feat(dashboard): console phase 2 - self-hosted basemap, basin stations, markers
CI / Format & lint (push) Successful in 24s
CI / Test suite (push) Successful in 1m9s
Docs / Validate documentation (push) Successful in 14s

- Basemap: src/static/basemap/ping-basin.pmtiles, a Protomaps extract of the
  Ping basin (zoom 0-12, 15 MB, OpenStreetMap data), drawn by
  protomaps-leaflet 5.1.0 (unpkg, SRI) in its light/dark style with labels in
  the page language. It replaces the dimmed OSM raster tiles, whose baked-in
  labels were the noisiest part of the screen; OSM stays as the fallback.
  The browser reads the file with range requests (ServeDir answers 206;
  dev_proxy.py now does too). The map stays within the basin.
- Basin stations: a second list next to the RID gauges and a map layer of
  diamonds, from /api/hii/waterlevel/latest (falling back to
  /sensors/thaiwater), coloured by ThaiWater's bank-height classes (over the
  bank = danger, 70-100 % = watch). Gauges RID already lists appear once.
  The panel shows level (m MSL), % of bank height, distance to and height of
  the bank top, gauge reading and agency; selection in the URL (#hii-<id>).
- custom-markers.json as pins from zoom 12, with the flood zone they are in.

Refresh instructions for the basemap in src/static/basemap/README.md.
This commit is contained in:
grabowski committed 2026-10-19 16:58:21 +07:00
1 parent 301a629b42
commit 71cc1b0ca7
6 files changed
+408 -34

No files matched your search

+14 -14
View File
@@ -1,6 +1,6 @@
# Dashboard redesign: the console # 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. one and replaces it in phase 4.
- **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the - **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the
@@ -50,9 +50,9 @@ One screen, no page scroll on a desktop:
| Area | Content | | 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 | | 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 | | 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: Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Rain 24 h and Flood zones, one 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 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 | | 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 | | Status bar | data credits, API, source code |
**Narrower screens:** **Narrower screens:**
@@ -63,7 +63,7 @@ One screen, no page scroll on a desktop:
| < 860 px | One column: verdict, map, list, panel | | < 860 px | One column: verdict, map, list, panel |
**Behaviour:** **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. - Arrow keys move through the list.
- Data refreshes every 5 minutes. - 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 | | `refresh-button` | Removed: refresh every 5 min, age in the top bar | 1 |
| `lang-toggle`, `theme-toggle` | EN / ไทย segmented control, icon button | 1 | | `lang-toggle`, `theme-toggle` | EN / ไทย segmented control, icon button | 1 |
| `error`, `loading` | Map overlay with retry; "Loading" in the list | 1 | | `error`, `loading` | Map overlay with retry; "Loading" in the list | 1 |
| `thaiwater-sensors`, `sensors-*` | List tab + map layer | 2 | | `thaiwater-sensors`, `sensors-*` | Basin stations list + map layer | 2 |
| custom markers | Map layer | 2 | | custom markers | Pins from zoom 12, popup with their flood zone | 2 |
| `history-card`, `history-*` | Panel history with ranges | 3 | | `history-card`, `history-*` | Panel history with ranges | 3 |
| `skill-panel`, `skill-*` | Panel section for P.1 | 3 | | `skill-panel`, `skill-*` | Panel section for P.1 | 3 |
| `db-stats`, `db-*` | Status-bar popover | 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"). - **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. - **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. - **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 ## 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). - Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
- Alerts drawer (ntfy topics); URL selection; 5-minute refresh. - Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
- `tests/test_console.py`; `dev_proxy.py` serves `src/static` with proper content types. - `tests/test_console.py`; `dev_proxy.py` serves `src/static` with proper content types.
2. **Map and basin.** 2. **Done — 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. - **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:** check that ServeDir answers HTTP range requests (PMTiles needs them); add range support to `dev_proxy.py`. - **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too.
- **ThaiWater/HII stations:** - **ThaiWater/HII stations:** the Basin stations list and map layer, from `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`.
- A tab in the list. - **Custom markers:** `custom-markers.json` as pins, with their flood-zone popup.
- 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.** 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. - **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`. - **P.1 forecast skill** ("Is the model getting better?") as a panel section, from `/api/forecast/skill`.
+16 -2
View File
@@ -8,6 +8,7 @@ console preview at /static/console.html), everything else the live server."""
import http.server import http.server
import mimetypes import mimetypes
import os import os
import re
import sys import sys
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -38,7 +39,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
) )
if ctype.startswith("text/"): if ctype.startswith("text/"):
ctype += "; charset=utf-8" 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 return
try: try:
req = urllib.request.Request( req = urllib.request.Request(
@@ -57,9 +68,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
self._send(e.code, "application/json", e.read()) 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_response(code)
self.send_header("Content-Type", ctype) 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("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.end_headers() self.end_headers()
+20
View File
@@ -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 <https://build-metadata.protomaps.dev/builds.json>:
```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.
Binary file not shown.
+341 -17
View File
@@ -130,11 +130,24 @@
.key i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; } .key i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.key i.line { width: 16px; height: 3px; border-radius: 2px; } .key i.line { width: 16px; height: 3px; border-radius: 2px; }
.key i.dot { border-radius: 50%; } .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); } .mapwrap { position: relative; min-height: 0; background: var(--bg); }
#map { position: absolute; inset: 0; background: var(--bg); } #map { position: absolute; inset: 0; background: var(--bg); }
.leaflet-container { font-family: inherit; } .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 { background: var(--panel); color: var(--text); border-color: var(--line) !important; }
.leaflet-bar a:hover { background: var(--panel-2); } .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; } .leaflet-control-attribution { background: color-mix(in srgb, var(--panel) 85%, transparent) !important; color: var(--muted) !important; }
@@ -294,16 +307,29 @@
<div class="ws"> <div class="ws">
<section class="pane list" data-i18n-aria="list.title" aria-label="Gauges"> <section class="pane list" data-i18n-aria="list.title" aria-label="Gauges">
<div class="pane-head"> <div class="pane-head">
<h2 class="label"><span data-i18n="list.title">Gauges</span><span id="list-count"></span></h2> <div class="seg wide" id="list-tabs" role="tablist" data-i18n-aria="tab.group" aria-label="Station lists">
<button type="button" role="tab" data-tab="rid" aria-selected="true"><span data-i18n="tab.rid">RID gauges</span><span class="count" id="count-rid"></span></button>
<button type="button" role="tab" data-tab="basin" aria-selected="false"><span data-i18n="tab.basin">Basin stations</span><span class="count" id="count-basin"></span></button>
</div>
<input type="search" class="filter" id="station-search" data-i18n-placeholder="list.filter" data-i18n-aria="list.filter" placeholder="Filter gauges…" aria-label="Filter gauges…"> <input type="search" class="filter" id="station-search" data-i18n-placeholder="list.filter" data-i18n-aria="list.filter" placeholder="Filter gauges…" aria-label="Filter gauges…">
<div class="tabs" id="sorts" role="group" data-i18n-aria="sort.group" aria-label="Sort"> <div class="tabs" id="sorts" role="group" data-i18n-aria="sort.group" aria-label="Sort">
<button type="button" data-sort="river" aria-pressed="true" data-i18n="sort.river">River order</button> <button type="button" data-sort="river" aria-pressed="true" data-i18n="sort.river">River order</button>
<button type="button" data-sort="risk" aria-pressed="false" data-i18n="sort.risk">Closest to warning level</button> <button type="button" data-sort="risk" aria-pressed="false" data-i18n="sort.risk">Closest to warning level</button>
<button type="button" data-sort="rising" aria-pressed="false" data-i18n="sort.rising">Rising</button> <button type="button" data-sort="rising" aria-pressed="false" data-i18n="sort.rising">Rising</button>
</div> </div>
<div class="tabs" id="sorts-basin" role="group" data-i18n-aria="sort.group" aria-label="Sort" hidden>
<button type="button" data-bsort="bank" aria-pressed="true" data-i18n="sort.bank">Fullest first</button>
<button type="button" data-bsort="name" aria-pressed="false" data-i18n="sort.name">By name</button>
</div>
</div> </div>
<div class="rows" id="rows"><div class="list-msg" data-i18n="list.loading">Loading gauges…</div></div> <div class="rows" id="rows"><div class="list-msg" data-i18n="list.loading">Loading gauges…</div></div>
<div class="list-foot"> <div class="list-foot" id="foot-basin" hidden>
<span class="key"><i class="dia" style="background:var(--normal)"></i><span data-i18n="legend.bank.normal">Up to 70 % of bank height</span></span>
<span class="key"><i class="dia" style="background:var(--watch)"></i><span data-i18n="legend.bank.high">High 70–100 %</span></span>
<span class="key"><i class="dia" style="background:var(--danger)"></i><span data-i18n="legend.bank.over">Over the bank</span></span>
<span class="key"><i class="dia" style="background:var(--nodata)"></i><span data-i18n="status.nodata">No data</span></span>
</div>
<div class="list-foot" id="foot-rid">
<span class="key"><i style="background:var(--normal)"></i><span data-i18n="status.normal">Normal</span></span> <span class="key"><i style="background:var(--normal)"></i><span data-i18n="status.normal">Normal</span></span>
<span class="key"><i style="background:var(--watch)"></i><span data-i18n="legend.watch">Within 0.5 m</span></span> <span class="key"><i style="background:var(--watch)"></i><span data-i18n="legend.watch">Within 0.5 m</span></span>
<span class="key"><i style="background:var(--warning)"></i><span data-i18n="status.warning">Warning</span></span> <span class="key"><i style="background:var(--warning)"></i><span data-i18n="status.warning">Warning</span></span>
@@ -315,6 +341,7 @@
<section class="mapwrap"> <section class="mapwrap">
<div id="map" role="application" data-i18n-aria="map.aria" aria-label="Map of the Ping River gauges"></div> <div id="map" role="application" data-i18n-aria="map.aria" aria-label="Map of the Ping River gauges"></div>
<div class="layers" role="group" data-i18n-aria="layer.group" aria-label="Map layers"> <div class="layers" role="group" data-i18n-aria="layer.group" aria-label="Map layers">
<button type="button" id="layer-basin" aria-pressed="false" data-i18n="layer.basin">Basin stations</button>
<button type="button" id="layer-rain" aria-pressed="false" data-i18n="layer.rain">Rain 24 h</button> <button type="button" id="layer-rain" aria-pressed="false" data-i18n="layer.rain">Rain 24 h</button>
<button type="button" id="layer-zones" aria-pressed="false" data-i18n="layer.zones">Flood zones</button> <button type="button" id="layer-zones" aria-pressed="false" data-i18n="layer.zones">Flood zones</button>
</div> </div>
@@ -324,6 +351,12 @@
<span class="key"><i class="line" style="background:var(--river-2);height:2px"></i><span data-i18n="legend.tributary">Tributaries</span></span> <span class="key"><i class="line" style="background:var(--river-2);height:2px"></i><span data-i18n="legend.tributary">Tributaries</span></span>
<span class="key"><i class="dot" style="background:var(--normal)"></i><span data-i18n="legend.gauge">Gauge · colour = status</span></span> <span class="key"><i class="dot" style="background:var(--normal)"></i><span data-i18n="legend.gauge">Gauge · colour = status</span></span>
</div> </div>
<div id="legend-basin" hidden>
<span class="label" data-i18n="layer.basin">Basin stations</span>
<span class="key"><i class="dia" style="background:var(--normal)"></i><span data-i18n="legend.basin">ThaiWater/HII · colour = % of bank height</span></span>
<span class="key"><i class="dia" style="background:var(--watch)"></i><span data-i18n="legend.bank.high">High 70–100 %</span></span>
<span class="key"><i class="dia" style="background:var(--danger)"></i><span data-i18n="legend.bank.over">Over the bank</span></span>
</div>
<div id="legend-rain" hidden> <div id="legend-rain" hidden>
<span class="label" data-i18n="layer.rain">Rain 24 h</span> <span class="label" data-i18n="layer.rain">Rain 24 h</span>
<span class="key"><i class="dot" style="background:#b7a6e3"></i><span data-i18n="legend.rain.light">Light 0.1–10 mm</span></span> <span class="key"><i class="dot" style="background:#b7a6e3"></i><span data-i18n="legend.rain.light">Light 0.1–10 mm</span></span>
@@ -377,13 +410,15 @@
<div class="scrim" id="scrim"></div> <div class="scrim" id="scrim"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
<script src="https://unpkg.com/protomaps-leaflet@5.1.0/dist/protomaps-leaflet.js" integrity="sha384-pAKrddYWxjFAIJwatOtmmtq9ebz3ZwluHCEg3pqOMpOporUofLqz/l5hs+0LAWaH" crossorigin="anonymous"></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const state = { lang: 'th', stations: [], byCode: new Map(), selected: null, sort: 'river', map: null, tiles: null, markers: new Map(), const state = { lang: 'th', stations: [], byCode: new Map(), selected: null, sort: 'river', map: null, tiles: null, markers: new Map(),
rivers: null, riverLayer: null, rain: [], rainLayer: null, zones: null, zoneLayer: null, zonesUserHidden: false, fitted: false, rivers: null, riverLayer: null, rain: [], rainLayer: null, zones: null, zoneLayer: null, zonesUserHidden: false, fitted: false,
p1Stages: null, alerts: null, lastLoad: null }; p1Stages: null, alerts: null, lastLoad: null, tab: 'rid', bsort: 'bank', basin: [], basinById: new Map(), basinOn: false,
basinLayer: null, basinMarkers: new Map(), pois: null, poiLayer: null };
// ---- Localisation --------------------------------------------------- // ---- Localisation ---------------------------------------------------
// Thai first: this is a flood warning for people living on this river. // Thai first: this is a flood warning for people living on this river.
@@ -415,7 +450,6 @@
'age.night': 'overnight pause', 'age.night': 'overnight pause',
'age.night.tip': 'The Royal Irrigation Department publishes the night hours together, usually around 07:00.', 'age.night.tip': 'The Royal Irrigation Department publishes the night hours together, usually around 07:00.',
'list.title': 'Gauges', 'list.title': 'Gauges',
'list.count': (a, b) => `${a} of ${b} reporting`,
'list.filter': 'Filter by code or name', 'list.filter': 'Filter by code or name',
'list.loading': 'Loading gauges…', 'list.loading': 'Loading gauges…',
'list.empty': 'No gauge matches the filter', 'list.empty': 'No gauge matches the filter',
@@ -538,6 +572,38 @@
'alerts.t.outlook.d': 'Experimental: the forecast model gives a 50 % or higher chance that P.1 reaches its warning level within 24 h. Up to about 13 h earlier than the gauge, but it can be wrong.', 'alerts.t.outlook.d': 'Experimental: the forecast model gives a 50 % or higher chance that P.1 reaches its warning level within 24 h. Up to about 13 h earlier than the gauge, but it can be wrong.',
'alerts.t.status': 'Monitor status', 'alerts.t.status': 'Monitor status',
'alerts.t.status.d': 'Gauge feed stale / recovered. For people who rely on the dashboard.', 'alerts.t.status.d': 'Gauge feed stale / recovered. For people who rely on the dashboard.',
'tab.group': 'Station lists',
'tab.rid': 'RID gauges',
'tab.basin': 'Basin stations',
'sort.bank': 'Fullest first',
'sort.name': 'By name',
'layer.basin': 'Basin stations',
'legend.basin': 'ThaiWater/HII · colour = % of bank height',
'legend.bank.normal': 'Up to 70 % of bank height',
'legend.bank.high': 'High 70–100 %',
'legend.bank.over': 'Over the bank',
'bank.over': 'Over the bank',
'bank.high': 'High water',
'bank.normal': 'Normal',
'bank.low': 'Low water',
'bank.critical': 'Critically low',
'bank.nodata': 'No data',
'b.ofbank': 'of bank',
'b.level': 'Water level',
'b.msl': 'm MSL',
'b.bank': 'Of bank height',
'b.tobank': 'To bank top',
'b.banklevel': 'Bank top',
'b.gauge': 'Gauge reading',
'b.agency': 'Agency',
'b.scale': 'Water level as a share of the bank height',
'b.banktop': 'bank top',
'b.note': 'ThaiWater/HII telemetry. This site keeps the latest reading only: no history or forecast for this station.',
'b.link.json': 'Latest readings, all stations (JSON)',
'poi.label': 'Marker',
'poi.outside': 'Outside the mapped flood zones',
'poi.zone': 'Flood zone',
'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} m`,
'foot.data': 'Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo', 'foot.data': 'Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo',
'foot.preview': 'Preview of the new dashboard', 'foot.preview': 'Preview of the new dashboard',
'foot.classic': 'current dashboard', 'foot.classic': 'current dashboard',
@@ -569,7 +635,6 @@
'age.night': 'งดรายงานช่วงกลางคืน', 'age.night': 'งดรายงานช่วงกลางคืน',
'age.night.tip': 'กรมชลประทานเผยแพร่ข้อมูลช่วงกลางคืนพร้อมกัน โดยปกติราว 07:00 น.', 'age.night.tip': 'กรมชลประทานเผยแพร่ข้อมูลช่วงกลางคืนพร้อมกัน โดยปกติราว 07:00 น.',
'list.title': 'สถานีวัดน้ำ', 'list.title': 'สถานีวัดน้ำ',
'list.count': (a, b) => `รายงาน ${a} จาก ${b}`,
'list.filter': 'ค้นหาด้วยรหัสหรือชื่อ', 'list.filter': 'ค้นหาด้วยรหัสหรือชื่อ',
'list.loading': 'กำลังโหลดสถานี…', 'list.loading': 'กำลังโหลดสถานี…',
'list.empty': 'ไม่พบสถานีที่ตรงกับคำค้น', 'list.empty': 'ไม่พบสถานีที่ตรงกับคำค้น',
@@ -692,6 +757,38 @@
'alerts.t.outlook.d': 'ทดลอง: แบบจำลองพยากรณ์ให้โอกาส 50% ขึ้นไปที่ P.1 จะถึงระดับเตือนภัยภายใน 24 ชม. เร็วกว่าสถานีวัดได้ถึงราว 13 ชม. แต่อาจผิดพลาดได้', 'alerts.t.outlook.d': 'ทดลอง: แบบจำลองพยากรณ์ให้โอกาส 50% ขึ้นไปที่ P.1 จะถึงระดับเตือนภัยภายใน 24 ชม. เร็วกว่าสถานีวัดได้ถึงราว 13 ชม. แต่อาจผิดพลาดได้',
'alerts.t.status': 'สถานะระบบ', 'alerts.t.status': 'สถานะระบบ',
'alerts.t.status.d': 'ข้อมูลสถานีล่าช้า / กลับมาปกติ สำหรับผู้ที่พึ่งพาแดชบอร์ด', 'alerts.t.status.d': 'ข้อมูลสถานีล่าช้า / กลับมาปกติ สำหรับผู้ที่พึ่งพาแดชบอร์ด',
'tab.group': 'รายการสถานี',
'tab.rid': 'สถานีกรมชลฯ',
'tab.basin': 'สถานีในลุ่มน้ำ',
'sort.bank': 'น้ำเต็มตลิ่งมากสุด',
'sort.name': 'ตามชื่อ',
'layer.basin': 'สถานีในลุ่มน้ำ',
'legend.basin': 'ThaiWater/HII · สีตาม % ของความสูงตลิ่ง',
'legend.bank.normal': 'ไม่เกิน 70% ของความสูงตลิ่ง',
'legend.bank.high': 'น้ำมาก 70–100%',
'legend.bank.over': 'ล้นตลิ่ง',
'bank.over': 'ล้นตลิ่ง',
'bank.high': 'น้ำมาก',
'bank.normal': 'น้ำปกติ',
'bank.low': 'น้ำน้อย',
'bank.critical': 'น้ำน้อยวิกฤต',
'bank.nodata': 'ไม่มีข้อมูล',
'b.ofbank': 'ของตลิ่ง',
'b.level': 'ระดับน้ำ',
'b.msl': 'ม.รทก.',
'b.bank': 'เทียบความสูงตลิ่ง',
'b.tobank': 'ถึงระดับตลิ่ง',
'b.banklevel': 'ระดับตลิ่ง',
'b.gauge': 'ระดับตามไม้วัด',
'b.agency': 'หน่วยงาน',
'b.scale': 'ระดับน้ำเทียบกับความสูงตลิ่ง',
'b.banktop': 'ระดับตลิ่ง',
'b.note': 'ข้อมูลโทรมาตรจาก ThaiWater/HII เว็บไซต์นี้เก็บเฉพาะค่าล่าสุด ไม่มีข้อมูลย้อนหลังหรือพยากรณ์ของสถานีนี้',
'b.link.json': 'ค่าล่าสุดทุกสถานี (JSON)',
'poi.label': 'หมุด',
'poi.outside': 'อยู่นอกพื้นที่น้ำท่วมที่ทำแผนที่ไว้',
'poi.zone': 'พื้นที่น้ำท่วม',
'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} ม.`,
'foot.data': 'ข้อมูล: กรมชลประทาน · ThaiWater/HII · Open-Meteo', 'foot.data': 'ข้อมูล: กรมชลประทาน · ThaiWater/HII · Open-Meteo',
'foot.preview': 'ตัวอย่างแดชบอร์ดใหม่', 'foot.preview': 'ตัวอย่างแดชบอร์ดใหม่',
'foot.classic': 'แดชบอร์ดปัจจุบัน', 'foot.classic': 'แดชบอร์ดปัจจุบัน',
@@ -738,6 +835,7 @@
state.lang = lang; state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ } try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
applyTranslations(); applyTranslations();
setBasemap();
renderAll(); renderAll();
} }
@@ -786,6 +884,22 @@
return 'normal'; return 'normal';
} }
// ThaiWater's own classes for the water level as a share of the bank
// height; on the map and in the list over the bank is danger, 70-100 %
// is watch, the rest normal.
function bankClass(p) {
if (p == null) return 'nodata';
if (p > 100) return 'over';
if (p > 70) return 'high';
if (p > 30) return 'normal';
if (p > 10) return 'low';
return 'critical';
}
function bankStatus(b) {
if (b.bank == null || !b.time || Date.now() - b.time.getTime() > 24 * 3600e3) return 'nodata';
return b.bank > 100 ? 'danger' : b.bank > 70 ? 'watch' : 'normal';
}
// City verdict from P.1's level and its 24 h forecast. City flooding // City verdict from P.1's level and its 24 h forecast. City flooding
// starts at stage 1 (P.1 3.70 m), the official inundation stages. // starts at stage 1 (P.1 3.70 m), the official inundation stages.
function cityVerdict() { function cityVerdict() {
@@ -811,11 +925,12 @@
async function load() { async function load() {
try { try {
const [stations, latest, forecast, rain] = await Promise.all([ const [stations, latest, forecast, rain, hii] = await Promise.all([
json(fetch('/stations')), json(fetch('/stations')),
json(fetch('/measurements/latest?limit=500')), json(fetch('/measurements/latest?limit=500')),
json(fetch('/forecast')).catch(() => []), json(fetch('/forecast')).catch(() => []),
json(fetch('/api/hii/rainfall/latest')).catch(() => []), json(fetch('/api/hii/rainfall/latest')).catch(() => []),
json(fetch('/api/hii/waterlevel/latest')).catch(() => []),
]); ]);
if (!state.rivers) state.rivers = await json(fetch('/static/ping-river-network.geojson')).catch(() => null); if (!state.rivers) state.rivers = await json(fetch('/static/ping-river-network.geojson')).catch(() => null);
// 72 h per gauge: sparkline, 6 h change, panel chart // 72 h per gauge: sparkline, 6 h change, panel chart
@@ -864,10 +979,34 @@
const p1 = state.byCode.get('P.1'); const p1 = state.byCode.get('P.1');
state.p1Stages = p1 && p1.fc[24] && Array.isArray(p1.fc[24].stages) ? p1.fc[24].stages : null; state.p1Stages = p1 && p1.fc[24] && Array.isArray(p1.fc[24].stages) ? p1.fc[24].stages : null;
state.rain = Array.isArray(rain) ? rain : []; state.rain = Array.isArray(rain) ? rain : [];
// Basin stations: the collected HII feed, or the live ThaiWater
// passthrough while it has no data. RID gauges listed by both are
// shown once, as RID gauges.
let basin = (Array.isArray(hii) ? hii : []).map((r) => ({
id: `hii-${r.station_id}`, code: r.rid_code || r.oldcode || `HII-${r.station_id}`, nameEn: r.name_en, nameTh: r.name_th,
lat: r.latitude, lon: r.longitude, agency: r.agency, river: r.river_name, msl: r.wl_msl, offset: r.offset_msl,
bank: r.storage_percent, toBank: r.diff_wl_bank, bankMsl: r.min_bank_msl, time: parseTs(r.timestamp) }));
if (!basin.length) {
const tw = await json(fetch('/sensors/thaiwater')).catch(() => []);
basin = (Array.isArray(tw) ? tw : []).map((r) => ({
id: `tw-${r.station_code}`, code: r.station_code, nameEn: r.station_name, nameTh: r.station_name,
lat: r.latitude, lon: r.longitude, agency: r.agency, river: r.river_name, msl: r.water_level_msl, offset: r.offset_msl,
bank: r.bank_percent, toBank: r.distance_to_bank, bankMsl: null, time: parseTs(r.timestamp) }));
}
const ridCodes = new Set(stations.map((st) => st.station_code));
state.basin = basin
.filter((b) => !ridCodes.has(b.code) && Number.isFinite(b.lat) && Number.isFinite(b.lon))
.map((b) => ({ ...b, msl: b.msl == null ? null : Number(b.msl), bank: b.bank == null ? null : Number(b.bank),
toBank: b.toBank == null ? null : Number(b.toBank), offset: b.offset == null ? null : Number(b.offset),
bankMsl: b.bankMsl == null ? null : Number(b.bankMsl) }))
.map((b) => ({ ...b, status: bankStatus(b) }));
state.basinById = new Map(state.basin.map((b) => [b.id, b]));
state.lastLoad = Date.now(); state.lastLoad = Date.now();
if (!state.selected || !state.byCode.has(state.selected)) { const known = (id) => state.byCode.has(id) || state.basinById.has(id);
if (!state.selected || !known(state.selected)) {
const fromHash = decodeURIComponent(location.hash.slice(1)); const fromHash = decodeURIComponent(location.hash.slice(1));
state.selected = state.byCode.has(fromHash) ? fromHash : 'P.1'; state.selected = known(fromHash) ? fromHash : 'P.1';
if (state.basinById.has(state.selected)) setTab('basin', false);
} }
$('error').classList.remove('show'); $('error').classList.remove('show');
renderAll(); renderAll();
@@ -914,7 +1053,8 @@
]; ];
$('kpis').innerHTML = kpis.map((k) => `<div class="kpi ${k.cls || ''}" data-p="${k.p}" title="${escapeHtml(k.tip || '')}"> $('kpis').innerHTML = kpis.map((k) => `<div class="kpi ${k.cls || ''}" data-p="${k.p}" title="${escapeHtml(k.tip || '')}">
<span class="label">${escapeHtml(k.label)}</span><b class="num">${k.value}</b></div>`).join(''); <span class="label">${escapeHtml(k.label)}</span><b class="num">${k.value}</b></div>`).join('');
$('list-count').textContent = list.length ? t('list.count', reporting, list.length) : ''; $('count-rid').textContent = String(list.length);
$('count-basin').textContent = String(state.basin.length);
} }
// ---- Gauge list ----------------------------------------------------- // ---- Gauge list -----------------------------------------------------
@@ -939,7 +1079,29 @@
return d >= 0 ? t('list.below', d.toFixed(2)) : t('list.above', (-d).toFixed(2)); return d >= 0 ? t('list.below', d.toFixed(2)) : t('list.above', (-d).toFixed(2));
} }
function renderBasinList() {
const query = $('station-search').value.trim().toLowerCase();
let list = state.basin.filter((b) => !query || `${b.code} ${b.nameEn} ${b.nameTh} ${b.river || ''} ${b.agency || ''}`.toLowerCase().includes(query));
list = state.bsort === 'name'
? [...list].sort((a, b) => nameOf(a).localeCompare(nameOf(b), loc()))
: [...list].sort((a, b) => (b.bank ?? -1) - (a.bank ?? -1));
if (!list.length) { $('rows').innerHTML = `<div class="list-msg">${escapeHtml(t('list.empty'))}</div>`; return; }
$('rows').innerHTML = list.map((b) => {
const old = b.time && Date.now() - b.time.getTime() > STALE_AFTER_MIN * 60000;
const note = old ? t('list.old', Math.round((Date.now() - b.time.getTime()) / 3600e3)) : [b.river, b.agency].filter(Boolean).join(' · ');
const color = css(`--${b.status}`);
return `<button type="button" class="row basin" data-code="${escapeHtml(b.id)}" aria-current="${b.id === state.selected}">
<span class="bar" style="background:${color}" title="${escapeHtml(t(`bank.${bankClass(b.bank)}`))}"></span>
<span class="code">${escapeHtml(b.code)}</span>
<span class="name"><b>${escapeHtml(nameOf(b))}</b><small class="${old ? 'old' : ''}">${escapeHtml(note)}</small></span>
<span class="bankbar" aria-hidden="true"><i style="width:${Math.min(100, Math.max(0, b.bank ?? 0)) * 0.9}%;background:${color}"></i><em style="left:63%"></em><em style="left:90%"></em></span>
<span class="lv num"><b>${b.bank == null ? '–' : `${b.bank.toFixed(0)}%`}</b><small class="flat">${escapeHtml(t('b.ofbank'))}</small></span>
</button>`;
}).join('');
}
function renderList() { function renderList() {
if (state.tab === 'basin') { renderBasinList(); return; }
const query = $('station-search').value.trim().toLowerCase(); const query = $('station-search').value.trim().toLowerCase();
let list = state.stations.filter((s) => !query || `${s.code} ${s.nameEn} ${s.nameTh}`.toLowerCase().includes(query)); let list = state.stations.filter((s) => !query || `${s.code} ${s.nameEn} ${s.nameTh}`.toLowerCase().includes(query));
const gap = (s) => (s.warn == null || s.level == null ? Infinity : s.warn - s.level); const gap = (s) => (s.warn == null || s.level == null ? Infinity : s.warn - s.level);
@@ -963,14 +1125,33 @@
// ---- Map ------------------------------------------------------------ // ---- Map ------------------------------------------------------------
function initMap() { function initMap() {
if (!window.L) throw new Error(t('error.leaflet')); if (!window.L) throw new Error(t('error.leaflet'));
const map = L.map('map', { zoomControl: true, attributionControl: true, minZoom: 7 }).setView([18.75, 98.95], 9); const map = L.map('map', { zoomControl: true, attributionControl: true, minZoom: 8, maxZoom: 16,
maxBounds: [[16.3, 97.2], [20.7, 100.6]], maxBoundsViscosity: 0.7 }).setView([18.75, 98.95], 9);
map.createPane('zones').style.zIndex = 380; map.createPane('zones').style.zIndex = 380;
map.createPane('rivers').style.zIndex = 390; map.createPane('rivers').style.zIndex = 390;
map.createPane('rain').style.zIndex = 420; map.createPane('rain').style.zIndex = 420;
map.createPane('basin').style.zIndex = 440;
map.createPane('pois').style.zIndex = 445;
map.createPane('gauges').style.zIndex = 450; map.createPane('gauges').style.zIndex = 450;
state.tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18, attribution: '&copy; OpenStreetMap contributors' }).addTo(map);
state.map = map; state.map = map;
setBasemap();
map.on('zoomend', syncPois);
}
// The basemap is a Protomaps extract of the Ping basin served by this
// site (src/static/basemap, zoom 0-12, drawn larger beyond that): no tile
// provider, labels in the page language, a light and a dark style. The
// OSM raster tiles are the fallback if the renderer did not load.
const BASEMAP = '/static/basemap/ping-basin.pmtiles';
function setBasemap() {
if (!state.map) return;
if (state.tiles) state.map.removeLayer(state.tiles);
const dark = document.documentElement.dataset.theme === 'dark';
state.tiles = window.protomapsL
? protomapsL.leafletLayer({ url: BASEMAP, flavor: dark ? 'dark' : 'light', lang: state.lang, maxDataZoom: 12,
attribution: '<a href="https://protomaps.com" target="_blank" rel="noopener">Protomaps</a> &copy; <a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>' })
: L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, className: 'osm-tiles', attribution: '&copy; OpenStreetMap contributors' });
state.tiles.addTo(state.map);
} }
function fitGauges() { function fitGauges() {
@@ -1055,6 +1236,79 @@
state.rainLayer = group.addTo(state.map); state.rainLayer = group.addTo(state.map);
} }
// Basin stations (ThaiWater/HII): diamonds, so they never read as RID gauges
function renderBasin() {
if (!state.map) return;
if (state.basinLayer) { state.map.removeLayer(state.basinLayer); state.basinLayer = null; }
state.basinMarkers = new Map();
$('layer-basin').setAttribute('aria-pressed', String(state.basinOn));
$('legend-basin').hidden = !state.basinOn;
if (!state.basinOn) return;
const group = L.layerGroup();
state.basin.forEach((b) => {
const selected = b.id === state.selected;
const icon = L.divIcon({ className: 'bs-wrap', iconSize: [16, 16], iconAnchor: [8, 8],
html: `<span class="bs${selected ? ' sel' : ''}" style="--c:${css(`--${b.status}`)}"></span>` });
const marker = L.marker([b.lat, b.lon], { icon, pane: 'basin', keyboard: false, zIndexOffset: selected ? 1000 : 0 })
.bindTooltip(`${escapeHtml(b.code)} · ${escapeHtml(nameOf(b))}${b.bank == null ? '' : ` · ${b.bank.toFixed(0)}%`}`, { direction: 'top', offset: [0, -6] })
.on('click', () => select(b.id, false));
marker.addTo(group);
state.basinMarkers.set(b.id, marker);
});
state.basinLayer = group.addTo(state.map);
}
// The team's own markers (custom-markers.json): shown from zoom 12, with
// the flood zone they are in
const PIN = '<svg width="18" height="24" viewBox="0 0 18 24" aria-hidden="true"><path d="M9 23s7-7.4 7-13A7 7 0 0 0 2 10c0 5.6 7 13 7 13z" style="fill:var(--accent);stroke:var(--panel);stroke-width:1.5"/><circle cx="9" cy="10" r="2.6" style="fill:var(--panel)"/></svg>';
async function loadPois() {
state.pois = await json(fetch('/static/custom-markers.json')).catch(() => []);
if (!state.zones) state.zones = await json(fetch('/static/flood-zones-p1.geojson')).catch(() => null);
if (!state.map || !Array.isArray(state.pois)) return;
const group = L.layerGroup();
state.pois.forEach((poi) => {
if (!Number.isFinite(poi.lat) || !Number.isFinite(poi.lon)) return;
const icon = L.divIcon({ className: 'poi-wrap', html: PIN, iconSize: [18, 24], iconAnchor: [9, 23], popupAnchor: [0, -20] });
L.marker([poi.lat, poi.lon], { icon, pane: 'pois', title: poi.name }).bindPopup(() => poiPopup(poi)).addTo(group);
});
state.poiLayer = group;
syncPois();
}
function syncPois() {
if (!state.poiLayer) return;
const show = state.map.getZoom() >= 12;
if (show && !state.map.hasLayer(state.poiLayer)) state.poiLayer.addTo(state.map);
if (!show && state.map.hasLayer(state.poiLayer)) state.map.removeLayer(state.poiLayer);
}
function pointInRing(lat, lon, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1];
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function zoneOfPoint(lat, lon) {
if (!state.zones) return null;
let best = null;
state.zones.features.forEach((f) => {
const g = f.geometry;
const polygons = g.type === 'Polygon' ? [g.coordinates] : g.type === 'MultiPolygon' ? g.coordinates : [];
if (polygons.some((rings) => rings.length && pointInRing(lat, lon, rings[0])) && (!best || f.properties.zone < best.properties.zone)) best = f;
});
return best;
}
function poiPopup(poi) {
const zone = zoneOfPoint(poi.lat, poi.lon);
let body = `<div style="color:var(--muted)">${escapeHtml(t('poi.outside'))}</div>`;
if (zone) {
const st = zoneStage(zone.properties.zone);
body = `<div class="pgrid"><div><span>${escapeHtml(t('poi.zone'))}</span><b>${escapeHtml(t('poi.zone.value', zone.properties.zone, Number(zone.properties.level).toFixed(2)))}</b></div>
<div><span>${escapeHtml(t('zone.chance24'))}</span><b>${st ? pct(st.p_exceed) : '–'}</b></div></div>`;
}
return `<div class="popup"><div class="label">${escapeHtml(t('poi.label'))}</div><h3>${escapeHtml(poi.name)}</h3>${poi.note ? `<div style="color:var(--muted)">${escapeHtml(poi.note)}</div>` : ''}${body}</div>`;
}
// Chiang Mai inundation zones, digitised from the municipal flood map. // Chiang Mai inundation zones, digitised from the municipal flood map.
// Zone 1 floods first (deep red), zone 7 last; a zone the river has // Zone 1 floods first (deep red), zone 7 last; a zone the river has
// reached is drawn as water. // reached is drawn as water.
@@ -1179,7 +1433,54 @@
</div></div>`; </div></div>`;
} }
function bankScale(b) {
if (b.bank == null) return '';
const hi = Math.max(115, b.bank + 5);
const x = (v) => Math.max(0, Math.min(100, (v / hi) * 100));
return `<div class="scale-wrap" role="img" aria-label="${escapeHtml(t('b.scale'))}">
<div class="scale">
<i style="width:${x(70)}%;background:color-mix(in srgb, var(--normal) 22%, transparent)"></i>
<i style="width:${x(100) - x(70)}%;background:color-mix(in srgb, var(--watch) 28%, transparent)"></i>
<i style="flex:1;background:color-mix(in srgb, var(--danger) 36%, transparent)"></i>
</div>
<div class="scale-mark" style="left:${x(b.bank)}%"><em style="${x(b.bank) > 60 ? 'right:4px' : 'left:4px'}">${b.bank.toFixed(0)}%</em></div>
<div class="scale-ticks">
<span style="left:${x(70)}%;transform:translateX(-50%);color:var(--watch-ink)">70%</span>
<span style="left:${x(100)}%;transform:translateX(-50%);color:var(--danger-ink)">100% ${escapeHtml(t('b.banktop'))}</span>
</div></div>`;
}
function renderBasinDetail(b) {
const el = $('detail');
const cls = bankClass(b.bank);
const gauge = b.offset != null && b.msl != null ? b.msl - b.offset : null;
const cell = (label, value, unit, sub = '') => `<div><span class="label">${escapeHtml(t(label))}</span><b class="num">${value}${unit ? `<small>${escapeHtml(unit)}</small>` : ''}</b>${sub ? `<span class="sub">${escapeHtml(sub)}</span>` : ''}</div>`;
el.innerHTML = `
<div class="d-head">
<div class="sub">${escapeHtml(otherNameOf(b))}</div>
<h1><span>${escapeHtml(b.code)} ${escapeHtml(nameOf(b))}</span><span class="badge" style="color:${css(`--${b.status}-ink`)}">${escapeHtml(t(`bank.${cls}`))}</span></h1>
<div class="sub" style="margin-top:3px">${escapeHtml([b.river, b.agency].filter(Boolean).join(' · '))}</div>
<div class="sub">${b.time ? escapeHtml(t('d.reading', fmtWhen(b.time), ago(b.time))) : escapeHtml(t('d.noreading'))}</div>
</div>
<div class="d-grid">
<div><span class="label">${escapeHtml(t('b.level'))}</span><b class="lg num">${b.msl == null ? '–' : b.msl.toFixed(2)}<small>${escapeHtml(t('b.msl'))}</small></b></div>
${cell('b.bank', b.bank == null ? '–' : b.bank.toFixed(0), '%')}
${cell('b.tobank', b.toBank == null ? '–' : Math.abs(b.toBank).toFixed(2), 'm', b.toBank == null ? '' : t(b.toBank >= 0 ? 'd.below' : 'd.above'))}
${cell('b.banklevel', b.bankMsl == null ? '–' : b.bankMsl.toFixed(2), t('b.msl'))}
${cell('b.gauge', gauge == null ? '–' : gauge.toFixed(2), gauge == null ? '' : 'm')}
${cell('b.agency', escapeHtml(b.agency || '–'), '')}
</div>
${b.bank == null ? '' : `<div class="sect" style="padding-top:6px">${bankScale(b)}</div>`}
<div class="sect"><p class="note" style="margin:0">${escapeHtml(t('b.note'))}</p></div>
<div class="sect"><h3 class="label"><span>${escapeHtml(t('d.data'))}</span></h3><div class="links">
<a href="/api/hii/waterlevel/latest" target="_blank" rel="noopener">${escapeHtml(t('b.link.json'))}</a>
<a href="https://www.thaiwater.net/" target="_blank" rel="noopener">ThaiWater</a>
<a href="/docs" target="_blank" rel="noopener">${escapeHtml(t('d.link.api'))}</a></div></div>`;
}
function renderDetail() { function renderDetail() {
const basinStation = state.basinById.get(state.selected);
if (basinStation) { renderBasinDetail(basinStation); return; }
const s = state.byCode.get(state.selected); const s = state.byCode.get(state.selected);
const el = $('detail'); const el = $('detail');
if (!s) { el.innerHTML = ''; return; } if (!s) { el.innerHTML = ''; return; }
@@ -1249,12 +1550,26 @@
} }
// ---- Selection ------------------------------------------------------ // ---- Selection ------------------------------------------------------
function setTab(tab, render = true) {
state.tab = tab;
document.querySelectorAll('#list-tabs button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === tab)));
$('sorts').hidden = tab !== 'rid';
$('sorts-basin').hidden = tab !== 'basin';
$('foot-rid').hidden = tab !== 'rid';
$('foot-basin').hidden = tab !== 'basin';
if (tab === 'basin' && !state.basinOn) { state.basinOn = true; if (render) renderBasin(); }
if (render) renderList();
}
function select(code, fly = true) { function select(code, fly = true) {
if (!state.byCode.has(code)) return; const basin = state.basinById.has(code);
if (!basin && !state.byCode.has(code)) return;
state.selected = code; state.selected = code;
history.replaceState(null, '', `#${encodeURIComponent(code)}`); history.replaceState(null, '', `#${encodeURIComponent(code)}`);
renderList(); renderMarkers(); renderDetail(); if (basin && state.tab !== 'basin') setTab('basin', false);
const marker = state.markers.get(code); if (!basin && state.tab !== 'rid') setTab('rid', false);
renderList(); renderMarkers(); renderBasin(); renderDetail();
const marker = state.markers.get(code) || state.basinMarkers.get(code);
if (fly && marker && state.map) state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 10), { duration: 0.5 }); if (fly && marker && state.map) state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 10), { duration: 0.5 });
const row = document.querySelector(`.row[data-code="${CSS.escape(code)}"]`); const row = document.querySelector(`.row[data-code="${CSS.escape(code)}"]`);
if (row) row.scrollIntoView({ block: 'nearest' }); if (row) row.scrollIntoView({ block: 'nearest' });
@@ -1263,7 +1578,7 @@
function renderAll() { function renderAll() {
if (!state.stations.length) return; if (!state.stations.length) return;
renderTop(); renderList(); renderRivers(); renderMarkers(); renderRain(); autoZones(); renderDetail(); renderTop(); renderList(); renderRivers(); renderMarkers(); renderBasin(); renderRain(); autoZones(); renderDetail();
} }
// ---- Alerts (ntfy) --------------------------------------------------- // ---- Alerts (ntfy) ---------------------------------------------------
@@ -1310,8 +1625,16 @@
document.documentElement.dataset.theme = next; document.documentElement.dataset.theme = next;
try { localStorage.setItem('ping-river-theme', next); } catch (e) { /* private mode */ } try { localStorage.setItem('ping-river-theme', next); } catch (e) { /* private mode */ }
document.querySelector('meta[name="theme-color"]').content = css('--bg'); document.querySelector('meta[name="theme-color"]').content = css('--bg');
setBasemap();
renderAll(); renderAll();
}); });
document.querySelectorAll('#list-tabs button').forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab)));
document.querySelectorAll('#sorts-basin button').forEach((b) => b.addEventListener('click', () => {
state.bsort = b.dataset.bsort;
document.querySelectorAll('#sorts-basin button').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
renderList();
}));
$('layer-basin').addEventListener('click', () => { state.basinOn = !state.basinOn; renderBasin(); });
$('verdict').addEventListener('click', () => select('P.1')); $('verdict').addEventListener('click', () => select('P.1'));
$('station-search').addEventListener('input', renderList); $('station-search').addEventListener('input', renderList);
document.querySelectorAll('#sorts button').forEach((b) => b.addEventListener('click', () => { document.querySelectorAll('#sorts button').forEach((b) => b.addEventListener('click', () => {
@@ -1369,6 +1692,7 @@
try { initMap(); } catch (error) { $('error-message').textContent = error.message; $('error').classList.add('show'); } try { initMap(); } catch (error) { $('error-message').textContent = error.message; $('error').classList.add('show'); }
load(); load();
loadAlerts(); loadAlerts();
loadPois();
window.setInterval(load, 5 * 60 * 1000); window.setInterval(load, 5 * 60 * 1000);
})(); })();
</script> </script>
+17 -1
View File
@@ -52,7 +52,9 @@ def test_every_fetched_path_is_routed():
def test_static_files_it_loads_exist(): def test_static_files_it_loads_exist():
for path in re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2))", _html()): for path in re.findall(
r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html()
):
assert (STATIC / path).is_file(), path assert (STATIC / path).is_file(), path
@@ -96,5 +98,19 @@ def test_fonts_are_self_hosted_with_their_licence():
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file() assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
def test_basemap_is_self_hosted():
html = _html()
assert "/static/basemap/ping-basin.pmtiles" in html
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
# the attribution OpenStreetMap's licence requires
assert "openstreetmap.org/copyright" in html
def test_basin_stations_have_a_fallback_feed():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" in html
def test_preview_is_not_indexed(): def test_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html() assert '<meta name="robots" content="noindex">' in _html()