Files
Northern-Thailand-Ping-Rive…/docs/DASHBOARD_REDESIGN.md
T
grabowski ffd3030352
CI / Format & lint (push) Successful in 25s
CI / Test suite (push) Successful in 58s
Docs / Validate documentation (push) Successful in 11s
feat(dashboard): console phase 3 - history, forecast accuracy, data statistics
- History in the station panel (inline SVG): 3 d / 7 d / 30 d / 90 d / 1 y /
  all or a date range; level and discharge, the 24 h peaks as the model
  issued them, warning/danger bands, hover/touch values. Up to 7 d the chart
  ends in the current forecast. Above 2,500 readings it shows the daily mean
  with the daily low-high band, so a flood peak is not averaged away; gaps
  (RID's nightly pause) break the line instead of being bridged. Days are
  Bangkok days computed arithmetically: Intl per reading took a minute for
  "All" (67,000 rows), now 2 s.
- "How accurate is the forecast?" for P.1 (/api/forecast/skill), loaded when
  opened because a cold skill query takes up to 20 s.
- Data statistics (/api/stats) behind "2.7M readings since 2018" in the
  status bar.

fix(dashboard): "All time" history ended in October 2024. The history route
returns the oldest rows first and stops at 50,000 by default; P.1 has about
67,000 hours. Both dashboards now ask for limit=100000 (the route's maximum).
2026-10-19 17:12:39 +07:00

11 KiB
Raw Blame History

Dashboard redesign: the console

Status: phase 3 done (2026-10-19). The new dashboard is built next to the current one and replaces it in phase 4.

  • Preview: https://water.buildfor.life/static/console.html, after git pull on the server (static files are read per request, no restart). It is not indexed (noindex) and links back to the current dashboard.
  • Locally: uv run python scripts/dev_proxy.py 8765, then http://localhost:8765/static/console.html. The API is proxied to production.
  • Rules: the same as for dashboard.html, plus no emoji (tests/test_console.py):
    • every path it fetches is a Rust route;
    • every string exists in EN and TH, and Thai is the default;
    • timestamps are Bangkok wall-clock time.

Why: the current page

Observed on 2026-10-19 at 1440 px and on a 390 px phone.

  • No clear first answer. "Is Chiang Mai flooding?" is a thin green banner above four KPI cards of equal weight. Two of them answer nothing for a resident: "Most stressed gauge 16%" and "Chiang Mai river flow 53.9" (no unit in the number). At the bottom there are five more (2,700,706 datapoints, 3,002 days, …).
  • The colours answer the wrong question. Markers and river lines are coloured by discharge class (m³/s), not by distance to the warning level, and the station list shows discharge.
  • The map is noisy. OSM terrain with Thai labels, numbered markers and animated dashes. The legend covers about a third of the map, and more than half of the first phone screen.
  • The header is busy. It has six buttons with emoji (↻ 🌙 🔔 ▶); on a phone they wrap into three rows.
  • Internal detail on the main page. The model-skill table lists seven model hashes; the history card is an empty 360 px box until a station is clicked.
  • Inconsistent styling. A gradient background, several card and pill styles, three greens.

Decision

Two directions were sketched on 2026-10-19 against live data (throwaway HTML, not kept):

  • A: a public information page.
  • B: an operations console.

The user chose B.

Layout

One screen, no page scroll on a desktop:

Area Content
Top bar brand · Chiang Mai verdict (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts
Left two lists: RID gauges (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and Basin stations (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend
Centre map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h and Flood zones, the team's markers from zoom 12, one legend
Right selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the seven inundation stages, the zones toggle and "How accurate is the forecast?"; data links. A basin station shows level (m MSL), % of bank height with ThaiWater's class, distance to and height of the bank top, gauge reading, agency (latest reading only: no history is kept for them)
Status bar data credits, "2.7M readings since 2018" (opens the data statistics), API, source code

Narrower screens:

Width Layout
< 1280 px The panel moves below list and map, in columns
< 860 px One column: verdict, map, list, panel

Behaviour:

  • Selection is in the URL (#P.84, #hii-1198299); opening a basin station switches to its list.
  • Arrow keys move through the list.
  • Data refreshes every 5 minutes.

Where every current element goes

Current (dashboard.html id) Console Phase
flood-verdict, verdict-* Verdict in the top bar 1
station-count, last-updated, data-age, live-pill Top-bar KPIs (reporting, data age) 1
river-flow, peak-station (P.1 flow, most stressed gauge) Discharge and capacity in the panel; "Closest to warning level" sort 1
station-map, map-legend, legend-toggle, rain-toggle, zones-toggle Map, layer buttons, one legend 1
side list, station-search Gauge list with filter and sorts 1
forecast-card, p1-outlook, p1-peak, p1-stages, forecast-grid Panel: forecast table for every gauge, stages for P.1 1
alerts-panel, alerts-* Alerts drawer 1
refresh-button Removed: refresh every 5 min, age in the top bar 1
lang-toggle, theme-toggle EN / ไทย segmented control, icon button 1
error, loading Map overlay with retry; "Loading" in the list 1
thaiwater-sensors, sensors-* Basin stations list + map layer 2
custom markers Pins from zoom 12, popup with their flood zone 2
history-card, history-* Panel history with ranges 3
skill-panel, skill-* Panel section for P.1 3
db-stats, db-* Status-bar popover 3
replay-2024, replay-*, demo hooks Replay/simulation mode with banner and mode pill 4

Design rules

  • Type: Inter (Latin) and Noto Sans Thai, self-hosted in src/static/fonts/ (75 KB, OFL). Numbers use tabular figures.

  • Colours: CSS variables for light and dark, with dark the default; a saved choice wins (ping-river-theme, shared with the current dashboard).

    • Accent: #1d6fd6 light, #4c9bf0 dark.
    • Status: colours are used only for status, each with an -ink variant for text (WCAG AA on white).
  • One status rule (statusOf) for the map, list and panel. It is display-only; the notifier keeps its own transitions.

    Status Rule
    Normal none of the rules below
    Watch within 0.5 m of the warning level, or 24 h warning chance ≥ 25 %
    Warning level ≥ threshold_warning
    Danger level ≥ threshold_danger
    No data no reading for 24 h
  • City verdict: the current dashboard's rule — flooding from P.1 ≥ 3.70 m; "likely" at danger ≥ 50 % or warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m.

  • Data age: "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's FEED_NIGHT_UNTIL_HOUR.

  • Discharge: comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00").

  • Rain layer: off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.

  • Flood zones: shown automatically while P.1 is at or above a stage, unless hidden by the user.

  • Basin stations: ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed.

  • History: above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for limit=100000: /measurements/history returns the oldest rows first and stops at its 50,000 default, which made the current dashboard's "All time" for P.1 end in October 2024 (fixed there too).

  • Forecast accuracy: loaded when the section is opened (a cold /api/forecast/skill query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.

  • Basemap: src/static/basemap/ping-basin.pmtiles, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its light/dark style with name:th/name:en labels. Refresh instructions in src/static/basemap/README.md. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load.

Phases

Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light and dark; EN and TH.

  1. Done — Console shell with live data.
    • Layout, design tokens, fonts, EN/TH.
    • Verdict and KPIs, gauge list with sparklines and sorts.
    • Map with status markers, rain and flood-zone layers.
    • Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
    • Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
    • tests/test_console.py; dev_proxy.py serves src/static with proper content types.
  2. Done — Map and basin.
    • Basemap: the self-hosted Protomaps extract replaces the dimmed OSM raster tiles, whose baked-in labels were the noisiest part of the screen. Labels follow the page language; light and dark styles.
    • Server: ServeDir answers HTTP range requests (checked: 206); dev_proxy.py now does too.
    • ThaiWater/HII stations: the Basin stations list and map layer, from /api/hii/waterlevel/latest, falling back to /sensors/thaiwater.
    • Custom markers: custom-markers.json as pins, with their flood-zone popup.
  3. Done — Station depth.
    • History in the panel (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values.
    • P.1 forecast accuracy as a collapsible panel section, from /api/forecast/skill.
    • Data statistics: a status-bar popover, from /api/stats.
  4. Modes and the swap.
    • October 2024 replay: banner, clock, markers and verdict follow the replay; zones auto-show.
    • Demo hooks: ?demo_level=, ?demo_rise=. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live.
    • The swap: console.html becomes dashboard.html; the old file and its tests go. tests/test_console.py absorbs the remaining checks of tests/test_dashboard.py (one P.1 level, SEO tags; the robots noindex is removed).
    • Docs: README screenshot and the CLAUDE.md rule.
  5. Polish.
    • Phone layout pass.
    • Native-speaker review of the Thai strings.
    • Keyboard and screen reader pass.
    • Lighthouse ≥ 90 (performance, accessibility).
    • No console errors.

Data notes from building phase 1

  • Negative levels. P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings.
  • P.1 discharge disagrees between endpoints. At 15:00 on 2026-10-19, /measurements/latest had level 1.30 m with no discharge, while /measurements/history had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The console shows the last RID discharge. Worth checking where the 26.0 comes from.
  • P.84 spike. A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.