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

+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.line { width: 16px; height: 3px; border-radius: 2px; }
.key i.dot { border-radius: 50%; }
.key i.dia { width: 7px; height: 7px; transform: rotate(45deg); border-radius: 1px; margin: 0 1px; }
.seg.wide { display: flex; width: 100%; }
.seg.wide button { flex: 1; padding: 6px 8px; font-size: 12.5px; }
.seg button[aria-selected="true"] { background: var(--panel-2); color: var(--text); }
.seg .count { margin-left: 6px; color: var(--muted); font-weight: 500; font-variant-numeric: tabular-nums; }
.row.basin { grid-template-columns: 4px 52px minmax(0, 1fr) 56px 50px; }
.row.basin .code { font-size: 11px; }
.bankbar { position: relative; height: 6px; border-radius: 3px; background: var(--panel-2); }
.bankbar i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; }
.bankbar em { position: absolute; top: -3px; bottom: -3px; width: 1px; background: var(--line-2); }
.bs-wrap, .poi-wrap { background: none; border: 0; }
.bs { display: block; width: 10px; height: 10px; margin: 3px; transform: rotate(45deg); border-radius: 2px; background: var(--c); box-shadow: 0 0 0 1.5px var(--panel); }
.bs.sel { width: 14px; height: 14px; margin: 1px; box-shadow: 0 0 0 2.5px var(--text); }
.mapwrap { position: relative; min-height: 0; background: var(--bg); }
#map { position: absolute; inset: 0; background: var(--bg); }
.leaflet-container { font-family: inherit; }
.leaflet-tile-pane { filter: var(--tiles); opacity: var(--tiles-opacity); }
.osm-tiles { filter: var(--tiles); opacity: var(--tiles-opacity); }
.leaflet-bar a { background: var(--panel); color: var(--text); border-color: var(--line) !important; }
.leaflet-bar a:hover { background: var(--panel-2); }
.leaflet-control-attribution { background: color-mix(in srgb, var(--panel) 85%, transparent) !important; color: var(--muted) !important; }
@@ -294,16 +307,29 @@
<div class="ws">
<section class="pane list" data-i18n-aria="list.title" aria-label="Gauges">
<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…">
<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="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>
</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 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(--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>
@@ -315,6 +341,7 @@
<section class="mapwrap">
<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">
<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-zones" aria-pressed="false" data-i18n="layer.zones">Flood zones</button>
</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="dot" style="background:var(--normal)"></i><span data-i18n="legend.gauge">Gauge · colour = status</span></span>
</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>
<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>
@@ -377,13 +410,15 @@
<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/protomaps-leaflet@5.1.0/dist/protomaps-leaflet.js" integrity="sha384-pAKrddYWxjFAIJwatOtmmtq9ebz3ZwluHCEg3pqOMpOporUofLqz/l5hs+0LAWaH" crossorigin="anonymous"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
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,
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 ---------------------------------------------------
// Thai first: this is a flood warning for people living on this river.
@@ -415,7 +450,6 @@
'age.night': 'overnight pause',
'age.night.tip': 'The Royal Irrigation Department publishes the night hours together, usually around 07:00.',
'list.title': 'Gauges',
'list.count': (a, b) => `${a} of ${b} reporting`,
'list.filter': 'Filter by code or name',
'list.loading': 'Loading gauges…',
'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.status': 'Monitor status',
'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.preview': 'Preview of the new dashboard',
'foot.classic': 'current dashboard',
@@ -569,7 +635,6 @@
'age.night': 'งดรายงานช่วงกลางคืน',
'age.night.tip': 'กรมชลประทานเผยแพร่ข้อมูลช่วงกลางคืนพร้อมกัน โดยปกติราว 07:00 น.',
'list.title': 'สถานีวัดน้ำ',
'list.count': (a, b) => `รายงาน ${a} จาก ${b}`,
'list.filter': 'ค้นหาด้วยรหัสหรือชื่อ',
'list.loading': 'กำลังโหลดสถานี…',
'list.empty': 'ไม่พบสถานีที่ตรงกับคำค้น',
@@ -692,6 +757,38 @@
'alerts.t.outlook.d': 'ทดลอง: แบบจำลองพยากรณ์ให้โอกาส 50% ขึ้นไปที่ P.1 จะถึงระดับเตือนภัยภายใน 24 ชม. เร็วกว่าสถานีวัดได้ถึงราว 13 ชม. แต่อาจผิดพลาดได้',
'alerts.t.status': 'สถานะระบบ',
'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.preview': 'ตัวอย่างแดชบอร์ดใหม่',
'foot.classic': 'แดชบอร์ดปัจจุบัน',
@@ -738,6 +835,7 @@
state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
applyTranslations();
setBasemap();
renderAll();
}
@@ -786,6 +884,22 @@
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
// starts at stage 1 (P.1 3.70 m), the official inundation stages.
function cityVerdict() {
@@ -811,11 +925,12 @@
async function load() {
try {
const [stations, latest, forecast, rain] = await Promise.all([
const [stations, latest, forecast, rain, hii] = await Promise.all([
json(fetch('/stations')),
json(fetch('/measurements/latest?limit=500')),
json(fetch('/forecast')).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);
// 72 h per gauge: sparkline, 6 h change, panel chart
@@ -864,10 +979,34 @@
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.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();
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));
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');
renderAll();
@@ -914,7 +1053,8 @@
];
$('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('');
$('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 -----------------------------------------------------
@@ -939,7 +1079,29 @@
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() {
if (state.tab === 'basin') { renderBasinList(); return; }
const query = $('station-search').value.trim().toLowerCase();
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);
@@ -963,14 +1125,33 @@
// ---- Map ------------------------------------------------------------
function initMap() {
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('rivers').style.zIndex = 390;
map.createPane('rain').style.zIndex = 420;
map.createPane('basin').style.zIndex = 440;
map.createPane('pois').style.zIndex = 445;
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;
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() {
@@ -1055,6 +1236,79 @@
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.
// Zone 1 floods first (deep red), zone 7 last; a zone the river has
// reached is drawn as water.
@@ -1179,7 +1433,54 @@
</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() {
const basinStation = state.basinById.get(state.selected);
if (basinStation) { renderBasinDetail(basinStation); return; }
const s = state.byCode.get(state.selected);
const el = $('detail');
if (!s) { el.innerHTML = ''; return; }
@@ -1249,12 +1550,26 @@
}
// ---- 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) {
if (!state.byCode.has(code)) return;
const basin = state.basinById.has(code);
if (!basin && !state.byCode.has(code)) return;
state.selected = code;
history.replaceState(null, '', `#${encodeURIComponent(code)}`);
renderList(); renderMarkers(); renderDetail();
const marker = state.markers.get(code);
if (basin && state.tab !== 'basin') setTab('basin', false);
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 });
const row = document.querySelector(`.row[data-code="${CSS.escape(code)}"]`);
if (row) row.scrollIntoView({ block: 'nearest' });
@@ -1263,7 +1578,7 @@
function renderAll() {
if (!state.stations.length) return;
renderTop(); renderList(); renderRivers(); renderMarkers(); renderRain(); autoZones(); renderDetail();
renderTop(); renderList(); renderRivers(); renderMarkers(); renderBasin(); renderRain(); autoZones(); renderDetail();
}
// ---- Alerts (ntfy) ---------------------------------------------------
@@ -1310,8 +1625,16 @@
document.documentElement.dataset.theme = next;
try { localStorage.setItem('ping-river-theme', next); } catch (e) { /* private mode */ }
document.querySelector('meta[name="theme-color"]').content = css('--bg');
setBasemap();
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'));
$('station-search').addEventListener('input', renderList);
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'); }
load();
loadAlerts();
loadPois();
window.setInterval(load, 5 * 60 * 1000);
})();
</script>