diff --git a/docs/DASHBOARD_REDESIGN.md b/docs/DASHBOARD_REDESIGN.md index 58bc8c1..f3d5b8d 100644 --- a/docs/DASHBOARD_REDESIGN.md +++ b/docs/DASHBOARD_REDESIGN.md @@ -1,6 +1,6 @@ # Dashboard redesign: the console -Status: **phase 1 done** (2026-10-19). The new dashboard is built next to the current +Status: **phase 2 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 @@ -50,9 +50,9 @@ 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 | +| 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; 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. 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, API, source code | **Narrower screens:** @@ -63,7 +63,7 @@ One screen, no page scroll on a desktop: | < 860 px | One column: verdict, map, list, panel | **Behaviour:** -- Selection is in the URL (`#P.84`). +- 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. @@ -81,8 +81,8 @@ One screen, no page scroll on a desktop: | `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 | +| `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 | @@ -109,6 +109,8 @@ One screen, no page scroll on a desktop: - **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. +- **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 @@ -121,13 +123,11 @@ Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light - 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. +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. **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`. diff --git a/scripts/dev_proxy.py b/scripts/dev_proxy.py index 775de1d..d7f5f7d 100644 --- a/scripts/dev_proxy.py +++ b/scripts/dev_proxy.py @@ -8,6 +8,7 @@ console preview at /static/console.html), everything else the live server.""" import http.server import mimetypes import os +import re import sys import urllib.request from pathlib import Path @@ -38,7 +39,17 @@ class Handler(http.server.BaseHTTPRequestHandler): ) if ctype.startswith("text/"): ctype += "; charset=utf-8" - self._send(200, ctype, f.read_bytes()) + body = f.read_bytes() + # Byte ranges, like the server's ServeDir: PMTiles reads the + # basemap in pieces + m = re.fullmatch(r"bytes=(\d+)-(\d*)", self.headers.get("Range", "")) + if m: + start = int(m.group(1)) + end = min(int(m.group(2) or len(body) - 1), len(body) - 1) + extra = {"Content-Range": f"bytes {start}-{end}/{len(body)}"} + self._send(206, ctype, body[start : end + 1], extra) + return + self._send(200, ctype, body) return try: req = urllib.request.Request( @@ -57,9 +68,12 @@ class Handler(http.server.BaseHTTPRequestHandler): except urllib.error.HTTPError as e: self._send(e.code, "application/json", e.read()) - def _send(self, code, ctype, body): + def _send(self, code, ctype, body, extra=None): self.send_response(code) self.send_header("Content-Type", ctype) + self.send_header("Accept-Ranges", "bytes") + for name, value in (extra or {}).items(): + self.send_header(name, value) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() diff --git a/src/static/basemap/README.md b/src/static/basemap/README.md new file mode 100644 index 0000000..ff36e66 --- /dev/null +++ b/src/static/basemap/README.md @@ -0,0 +1,20 @@ +# Basemap + +`ping-basin.pmtiles` is the map under the console (`console.html`): a +[Protomaps](https://protomaps.com) vector extract of the Ping basin, zoom 0-12 +(drawn larger beyond that), 15 MB. The site serves it like any static file; +the browser reads it in pieces with HTTP range requests (ServeDir answers them, +and so does `scripts/dev_proxy.py`). No tile provider is involved. + +Map data © OpenStreetMap contributors (ODbL), shown in the map attribution. + +To refresh it (yearly is plenty), with [go-pmtiles](https://github.com/protomaps/go-pmtiles/releases) +and the newest daily build listed at : + +```bash +pmtiles extract https://build.protomaps.com/20261019.pmtiles ping-basin.pmtiles \ + --bbox=97.85,16.85,99.95,20.2 --maxzoom=12 +``` + +The renderer (`protomaps-leaflet` 5.1.0 from unpkg, pinned with SRI) draws it +in the page's language (`name:th` / `name:en`) with its `light` and `dark` styles. diff --git a/src/static/basemap/ping-basin.pmtiles b/src/static/basemap/ping-basin.pmtiles new file mode 100644 index 0000000..c91b0da Binary files /dev/null and b/src/static/basemap/ping-basin.pmtiles differ diff --git a/src/static/console.html b/src/static/console.html index 0ebf3aa..75efb00 100644 --- a/src/static/console.html +++ b/src/static/console.html @@ -130,11 +130,24 @@ .key i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; } .key i.line { width: 16px; height: 3px; border-radius: 2px; } .key i.dot { border-radius: 50%; } + .key i.dia { width: 7px; height: 7px; transform: rotate(45deg); border-radius: 1px; margin: 0 1px; } + .seg.wide { display: flex; width: 100%; } + .seg.wide button { flex: 1; padding: 6px 8px; font-size: 12.5px; } + .seg button[aria-selected="true"] { background: var(--panel-2); color: var(--text); } + .seg .count { margin-left: 6px; color: var(--muted); font-weight: 500; font-variant-numeric: tabular-nums; } + .row.basin { grid-template-columns: 4px 52px minmax(0, 1fr) 56px 50px; } + .row.basin .code { font-size: 11px; } + .bankbar { position: relative; height: 6px; border-radius: 3px; background: var(--panel-2); } + .bankbar i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; } + .bankbar em { position: absolute; top: -3px; bottom: -3px; width: 1px; background: var(--line-2); } + .bs-wrap, .poi-wrap { background: none; border: 0; } + .bs { display: block; width: 10px; height: 10px; margin: 3px; transform: rotate(45deg); border-radius: 2px; background: var(--c); box-shadow: 0 0 0 1.5px var(--panel); } + .bs.sel { width: 14px; height: 14px; margin: 1px; box-shadow: 0 0 0 2.5px var(--text); } .mapwrap { position: relative; min-height: 0; background: var(--bg); } #map { position: absolute; inset: 0; background: var(--bg); } .leaflet-container { font-family: inherit; } - .leaflet-tile-pane { filter: var(--tiles); opacity: var(--tiles-opacity); } + .osm-tiles { filter: var(--tiles); opacity: var(--tiles-opacity); } .leaflet-bar a { background: var(--panel); color: var(--text); border-color: var(--line) !important; } .leaflet-bar a:hover { background: var(--panel-2); } .leaflet-control-attribution { background: color-mix(in srgb, var(--panel) 85%, transparent) !important; color: var(--muted) !important; } @@ -294,16 +307,29 @@
-

Gauges

+
+ + +
+
Loading gauges…
-
+ +
Normal Within 0.5 m Warning @@ -315,6 +341,7 @@
+
@@ -324,6 +351,12 @@ Tributaries Gauge · colour = status
+