diff --git a/CLAUDE.md b/CLAUDE.md index 52a96ae..749e5fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,10 @@ plus the monthly `water-monitor-retrain.timer` (Python, `.venv`, uv-managed). Up - Do not add Co-Authored-By trailers. - The dashboard is a single file, `src/static/dashboard.html`, EN + TH via the `t()` table: every user-visible string needs both languages. Every path it fetches must be - a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). + a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). Its + replacement, the console (`src/static/console.html`, preview at /static/console.html, + plan in docs/DASHBOARD_REDESIGN.md), follows the same rules plus no emoji + (tests/test_console.py); preview it with `uv run python scripts/dev_proxy.py`. - Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets -- -D warnings`, `cargo test`; minimum version 1.88 (`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read diff --git a/README.md b/README.md index 4d269f1..476b6c3 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ models/ trained bundles (gitignored) and evaluation results - [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room - [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy - [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust +- [docs/DASHBOARD_REDESIGN.md](docs/DASHBOARD_REDESIGN.md) — the new console dashboard (preview at `/static/console.html`): layout, rules, phases - [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources - Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/) diff --git a/docs/DASHBOARD_REDESIGN.md b/docs/DASHBOARD_REDESIGN.md new file mode 100644 index 0000000..58bc8c1 --- /dev/null +++ b/docs/DASHBOARD_REDESIGN.md @@ -0,0 +1,151 @@ +# Dashboard redesign: the console + +Status: **phase 1 done** (2026-10-19). The new dashboard is built next to the current +one and replaces it in phase 4. + +- **Preview:** , 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 + . 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 | gauge list: status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; filter; sort by river order, closest to the warning level, rising; status legend | +| Centre | map: Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Rain 24 h and Flood zones, one legend | +| Right | selected gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; 72 h chart with the forecast peaks; forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the seven inundation stages and the zones toggle; data links | +| Status bar | data credits, 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`). +- 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-*` | List tab + map layer | 2 | +| custom markers | Map layer | 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. + +## 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. **Map and basin.** + - **Basemap:** a self-hosted Protomaps extract of the Ping basin (PMTiles in `src/static/`, labels in Thai or English following the page language, light/dark themes). This replaces the dimmed OSM raster tiles, whose baked-in labels are the noisiest part of the screen. + - **Server:** check that ServeDir answers HTTP range requests (PMTiles needs them); add range support to `dev_proxy.py`. + - **ThaiWater/HII stations:** + - A tab in the list. + - A map layer: % of bank height, `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`. + - **Custom markers:** `custom-markers.json`, with their flood-zone popup. +3. **Station depth.** + - **History in the panel:** range 24 h / 7 d / 30 d / 90 d / all / custom dates, level and discharge, issued 24 h forecasts overlaid, flood bands; Chart.js or SVG. + - **P.1 forecast skill** ("Is the model getting better?") as a 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. diff --git a/scripts/dev_proxy.py b/scripts/dev_proxy.py index 793bf11..775de1d 100644 --- a/scripts/dev_proxy.py +++ b/scripts/dev_proxy.py @@ -1,8 +1,12 @@ """Serve the working-copy dashboard locally with API calls proxied to the live server, so browser-side changes can be checked against real data -before deploy. Usage: python scripts/dev_proxy.py [port]""" +before deploy. Usage: python scripts/dev_proxy.py [port] + +/ is src/static/dashboard.html, /static/... any file in src/static (e.g. the +console preview at /static/console.html), everything else the live server.""" import http.server +import mimetypes import os import sys import urllib.request @@ -30,8 +34,10 @@ class Handler(http.server.BaseHTTPRequestHandler): ctype = ( "application/json" if f.suffix in (".json", ".geojson") - else "application/octet-stream" + else mimetypes.guess_type(f.name)[0] or "application/octet-stream" ) + if ctype.startswith("text/"): + ctype += "; charset=utf-8" self._send(200, ctype, f.read_bytes()) return try: diff --git a/src/static/console.html b/src/static/console.html new file mode 100644 index 0000000..0ebf3aa --- /dev/null +++ b/src/static/console.html @@ -0,0 +1,1376 @@ + + + + + + Ping River Monitor · Chiang Mai water levels and flood outlook + + + + + + + + + + + + + + +
+
+ +
Ping River MonitorChiang Mai · Northern Thailand
+
+ +
+
+
+ + +
+ + +
+
+ +
+
+
+

Gauges

+ +
+ + + +
+
+
Loading gauges…
+
+ Normal + Within 0.5 m + Warning + Danger + No data +
+
+ +
+
+
+ + +
+
+
+ Ping River + Tributaries + Gauge · colour = status +
+ + +
+

+
+ + +
+ + + + +
+ + + + + diff --git a/src/static/fonts/README.md b/src/static/fonts/README.md new file mode 100644 index 0000000..b53835e --- /dev/null +++ b/src/static/fonts/README.md @@ -0,0 +1,13 @@ +# Fonts + +Self-hosted so the dashboard makes no request to Google. Both fonts are under the +SIL Open Font License 1.1 (`*-OFL.txt` next to them). + +| File | Font | Subset | +|---|---|---| +| `inter-latin.woff2` | Inter (variable, weight 400–700), | Latin | +| `noto-sans-thai-thai.woff2` | Noto Sans Thai (variable, weight 400–700), | Thai | + +Both files are Google Fonts' woff2 subsets (`fonts.googleapis.com/css2?family=Inter:wght@400..700` +and `family=Noto+Sans+Thai:wght@400..700`, fetched with a woff2-capable user agent). +The `unicode-range` of each `@font-face` in `console.html` matches its subset. diff --git a/src/static/fonts/inter-OFL.txt b/src/static/fonts/inter-OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/src/static/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/static/fonts/inter-latin.woff2 b/src/static/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/static/fonts/inter-latin.woff2 differ diff --git a/src/static/fonts/noto-sans-thai-OFL.txt b/src/static/fonts/noto-sans-thai-OFL.txt new file mode 100644 index 0000000..7fa8dcb --- /dev/null +++ b/src/static/fonts/noto-sans-thai-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/static/fonts/noto-sans-thai-thai.woff2 b/src/static/fonts/noto-sans-thai-thai.woff2 new file mode 100644 index 0000000..7930730 Binary files /dev/null and b/src/static/fonts/noto-sans-thai-thai.woff2 differ diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..4af7206 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,100 @@ +"""The console dashboard (src/static/console.html, docs/DASHBOARD_REDESIGN.md). + +Served at /static/console.html while it is built; it replaces dashboard.html +once it covers every feature. The rules are the dashboard's: every fetched +path is routed, every string has EN and TH, no emoji in the interface. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).parents[1] / "src" / "static" +CONSOLE = STATIC / "console.html" +ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs" + + +def _html() -> str: + return CONSOLE.read_text(encoding="utf-8") + + +def _body(html: str) -> str: + return html[html.index("") : html.index("