feat(dashboard): console phase 2 - self-hosted basemap, basin stations, markers
- 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:
1 parent
301a629b42
commit
71cc1b0ca7
6 files changed
+408
-34
No files matched your search
+14
-14
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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: '© 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> © <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: '© 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
@@ -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()
|
||||||
Reference in new issue
Block a user