Files
Northern-Thailand-Ping-Rive…/docs/DASHBOARD_REDESIGN.md
T
grabowski a610abcdb8
CI / Format & lint (push) Successful in 44s
CI / Test suite (push) Successful in 49s
Docs / Validate documentation (push) Successful in 18s
feat(dashboard): CMU's 2024 flood marks as a map layer
CMU Water Center marked the October 2024 high-water line at ~4,900 places in
Chiang Mai and Lamphun (depth above the road while P.1 stood at 5.30 m).

- src/static/flood-marks-2024.json (position and depth, 110 KB), built by
  scripts/build_flood_marks.py from CMU's GeoJSON.
- "Flood marks 2024" layer: canvas dots from zoom 12 in CMU's five depth
  classes, legend row, CMU credit; turning it on below zoom 12 flies to the
  city. A mark's popup gives the depth and its flood zone.
- Zone and marker popups name the nearest mark within 250 m (loaded with the
  zones).
- No depth estimate for other P.1 levels: the marks do not scale one-to-one
  with P.1 (marks in the 5.30 m zone stood a median 65 cm deep in 2024).
- The layer bar wraps on narrow maps; popups pan clear of it.
- Docs: DATA_SOURCES 5.1, DASHBOARD_REDESIGN, README.
2026-10-26 14:30:35 +07:00

14 KiB
Raw Blame History

Dashboard redesign: the console

Status: phase 4 done (2026-10-20). The console is the dashboard: it replaced the old page at / (src/static/dashboard.html; the server reads it at start, so a change needs a restart). Phase 5 (polish) is open.

  • Locally: uv run python scripts/dev_proxy.py 8765, then http://localhost:8765/. The API is proxied to production.
  • Rules (tests/test_dashboard.py):
    • every path it fetches is a Rust route;
    • every string exists in EN and TH, and Thai is the default;
    • no emoji;
    • timestamps are Bangkok wall-clock time;
    • a replay or a simulation is never shown as live data.

Why: the old 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, Flood zones and Flood marks 2024, 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 five city flood 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

Old page (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, the old dashboard's key, so the choice carried over).

    • 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 classes of statusOf for P.1 — flooding (the Ping over its banks) from 4.20 m, high water from 3.70 m; "flooding likely" at danger ≥ 50 %, "high water likely" at warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m. (Until 2026-10-25 it said flooding from 3.70 m, after the older municipal map.)

  • 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: src/static/flood-zones-p1.geojson, CMU Water Center's flood hazard map of the October 2024 flood (built by scripts/build_flood_zones.py from CMU's KML): five disjoint zones, zone k floods first when P.1 reaches stage k (4.30, 4.50, 4.70, 5.00, 5.30 m), in CMU's colours from deep red to yellow, credited in the map attribution. Shown automatically while P.1 is over its banks (danger level) or stage 1 is at least 50 % likely within 24 h, unless hidden by the user. A point's zone (team markers) honours the holes: dry islands inside a zone are outside it.

  • 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 old dashboard's "All time" for P.1 end in October 2024.

  • Issued forecasts: the history's violet line is the model's 24 h peak for every hour (/api/forecast/history, source = model only: a heuristic row is the level itself and drew a dip into the line). The nightly gaps were RID's overnight batch; the collector now forecasts those hours when they arrive (docs/FLOOD_FORECASTING.md, Serving).

  • 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.

  • Replay of October 2024: src/static/flood-2024-all.json (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age.

  • Simulation: ?demo_level=4.4 pins P.1 at 4.40 m; ?demo_rise=1 raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live.

  • Flood marks 2024: src/static/flood-marks-2024.json, CMU Water Center's ~4,900 marks of the October 2024 high-water line (depth above the road at P.1 5.30 m; scripts/build_flood_marks.py), on a canvas from zoom 12 in CMU's five depth classes; turning the layer on below zoom 12 flies to the city. A mark's popup gives the depth and its flood zone; zone and marker popups name the nearest mark within 250 m (the marks load with the zones). Off by default; also available in the replay, since it is 2024 data. Popups pan clear of the layer bar, which wraps to two rows on phones.

  • 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 (now tests/test_dashboard.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. Done — 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 became dashboard.html, with the old page's title, description, Open Graph and JSON-LD tags; the robots noindex is gone. tests/test_dashboard.py is the console's tests plus the old ones that still apply (one P.1 level, SEO and indexing files).
    • 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 rows without capacity (discharge_percent) are ThaiWater's copy of the gauge, which fills hours RID has not published yet (its night hours); their discharge comes from a different rating, about half of RID's at P.1. The panel shows the last RID discharge, and the history chart drops the discharge of those rows for gauges RID reports discharge for.
  • P.84 spike. A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.