feat(dashboard): CMU's 2024 flood marks as a map layer
CI / Format & lint (push) Successful in 44s
CI / Test suite (push) Successful in 49s
Docs / Validate documentation (push) Successful in 18s

CMU Water Center marked the October 2024 high-water line at ~4,900 places in
Chiang Mai and Lamphun (depth above the road while P.1 stood at 5.30 m).

- src/static/flood-marks-2024.json (position and depth, 110 KB), built by
  scripts/build_flood_marks.py from CMU's GeoJSON.
- "Flood marks 2024" layer: canvas dots from zoom 12 in CMU's five depth
  classes, legend row, CMU credit; turning it on below zoom 12 flies to the
  city. A mark's popup gives the depth and its flood zone.
- Zone and marker popups name the nearest mark within 250 m (loaded with the
  zones).
- No depth estimate for other P.1 levels: the marks do not scale one-to-one
  with P.1 (marks in the 5.30 m zone stood a median 65 cm deep in 2024).
- The layer bar wraps on narrow maps; popups pan clear of it.
- Docs: DATA_SOURCES 5.1, DASHBOARD_REDESIGN, README.
This commit is contained in:
grabowski committed 2026-10-26 14:30:35 +07:00
1 parent 701bb87d34
commit a610abcdb8
6 files changed
+191 -10

No files matched your search

+4 -4
View File
@@ -38,10 +38,10 @@ background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https
3.70 m. Everything about the model, including what did not work: 3.70 m. Everything about the model, including what did not work:
[docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md). [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md).
- **Shows it.** One screen: Chiang Mai's flood verdict, the gauges with their status - **Shows it.** One screen: Chiang Mai's flood verdict, the gauges with their status
and trend, a self-hosted map of the basin with rain and the city's inundation and trend, a self-hosted map of the basin with rain, the city's flood zones and
zones, and a panel per station with its history since 2018, the forecast and how the high-water marks of 2024, and a panel per station with its history since
accurate it has been. ThaiWater's basin stations, a replay of the 2024 flood, 2018, the forecast and how accurate it has been. ThaiWater's basin stations, a
Thai/English, dark/light. replay of the 2024 flood, Thai/English, dark/light.
- **Notifies.** Public push alerts over a self-hosted [ntfy](https://ntfy.sh): one - **Notifies.** Public push alerts over a self-hosted [ntfy](https://ntfy.sh): one
message when a gauge crosses its warning or danger level, one all-clear on the message when a gauge crosses its warning or danger level, one all-clear on the
way down, an opt-in early-warning topic from the model, nothing in between. way down, an opt-in early-warning topic from the model, nothing in between.
+2 -1
View File
@@ -51,7 +51,7 @@ One screen, no page scroll on a desktop:
|---|---| |---|---|
| 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 | 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 | | Left | two lists: **RID gauges** (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and **Basin stations** (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend |
| Centre | map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h and Flood zones, the team's markers from zoom 12, one legend | | 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, Flood zones and Flood marks 2024, the team's markers from zoom 12, one legend |
| Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the five city flood stages, the zones toggle and "How accurate is the forecast?"; 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) | | Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the five city flood stages, the zones toggle and "How accurate is the forecast?"; 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, "2.7M readings since 2018" (opens the data statistics), API, source code | | Status bar | data credits, "2.7M readings since 2018" (opens the data statistics), API, source code |
@@ -115,6 +115,7 @@ One screen, no page scroll on a desktop:
- **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip. - **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.
- **Replay of October 2024:** `src/static/flood-2024-all.json` (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age. - **Replay of October 2024:** `src/static/flood-2024-all.json` (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age.
- **Simulation:** `?demo_level=4.4` pins P.1 at 4.40 m; `?demo_rise=1` raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live. - **Simulation:** `?demo_level=4.4` pins P.1 at 4.40 m; `?demo_rise=1` raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live.
- **Flood marks 2024:** `src/static/flood-marks-2024.json`, CMU Water Center's ~4,900 marks of the October 2024 high-water line (depth above the road at P.1 5.30 m; `scripts/build_flood_marks.py`), on a canvas from zoom 12 in CMU's five depth classes; turning the layer on below zoom 12 flies to the city. A mark's popup gives the depth and its flood zone; zone and marker popups name the nearest mark within 250 m (the marks load with the zones). Off by default; also available in the replay, since it is 2024 data. Popups pan clear of the layer bar, which wraps to two rows on phones.
- **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. - **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
+8 -2
View File
@@ -250,7 +250,12 @@ and the province). A Next.js app; its data paths are in the page chunks
(`uv run scripts/build_flood_zones.py`). (`uv run scripts/build_flood_zones.py`).
- **Flood marks 2024** — `/data/New_floodMark_2025_fixed_like_pole.geojson` (4.9 MB): - **Flood marks 2024** — `/data/New_floodMark_2025_fixed_like_pole.geojson` (4.9 MB):
4,869 points in Chiang Mai and Lamphun with the October 2024 depth above the road 4,869 points in Chiang Mai and Lamphun with the October 2024 depth above the road
in cm (P.1 = 5.30 m), the marker's place and type. in cm (P.1 = 5.30 m), the marker's place and type. **In use:** the dashboard's
"Flood marks 2024" layer, `src/static/flood-marks-2024.json` (position and depth
only, 110 KB; `uv run python scripts/build_flood_marks.py`). The depth does not
scale one-to-one with P.1: marks in the zone that floods only at 5.30 m stood a
median 65 cm deep in 2024, so the site shows the 2024 depth and no estimate for
other levels.
- **Flood poles** — `/data/PoleCNX2025_v2.geojson`: 219 poles with ground level and - **Flood poles** — `/data/PoleCNX2025_v2.geojson`: 219 poles with ground level and
maximum flood level (m MSL) and the depth. maximum flood level (m MSL) and the depth.
- **City-reach gauges** (station page): P.67, P.103, P.1, P.104 (RID, `ST.16` - **City-reach gauges** (station page): P.67, P.103, P.1, P.104 (RID, `ST.16`
@@ -279,7 +284,8 @@ and the province). A Next.js app; its data paths are in the page chunks
| Open-Meteo rain | ✅ Hourly by ping-collector → `openmeteo_rain` (5 catchment points, past 5 days + 48 h forecast); the 2021+ archive trains the hgb-v3 rain features | — | | Open-Meteo rain | ✅ Hourly by ping-collector → `openmeteo_rain` (5 catchment points, past 5 days + 48 h forecast); the 2021+ archive trains the hgb-v3 rain features | — |
| HII API key (dams, forecasts) | ❌ | Contact HII for sanctioned access | | HII API key (dams, forecasts) | ❌ | Contact HII for sanctioned access |
| CMU CMFlood flood map (2024) | ✅ Static (2026-10-25): P.1 stages and the city's flood zones | Rebuild with `scripts/build_flood_zones.py` if CMU revises the map | | CMU CMFlood flood map (2024) | ✅ Static (2026-10-25): P.1 stages and the city's flood zones | Rebuild with `scripts/build_flood_zones.py` if CMU revises the map |
| CMU CMFlood marks, poles, city gauges | ❌ | See 5.1 | | CMU CMFlood flood marks 2024 | ✅ Static (2026-10-25): dashboard layer, nearest mark in zone and marker popups | Rebuild with `scripts/build_flood_marks.py` |
| CMU CMFlood poles, city gauges | ❌ | See 5.1 |
**Collector configuration** (ping-collector): HII, the dams and Open-Meteo are **Collector configuration** (ping-collector): HII, the dams and Open-Meteo are
fetched once per clock hour, also while RID is being retried; one-shot via fetched once per clock hour, also while RID is being retried; one-shot via
+71
View File
@@ -0,0 +1,71 @@
"""Build src/static/flood-marks-2024.json from CMU Water Center's flood marks.
After the October 2024 flood (P.1 peaked at 5.30 m on 5 October), CMU Water
Center marked the high-water line at about 5,000 places in Chiang Mai and
Lamphun (CMFlood, "Flood marks 2024"): the depth above the road surface, in
cm. This keeps position and depth only, in a compact array form:
uv run python scripts/build_flood_marks.py # fetch from CMU
uv run python scripts/build_flood_marks.py --geojson F # or a saved copy
"""
import argparse
import json
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "static" / "flood-marks-2024.json"
URL = "https://watercenter.cmu.ac.th/data/New_floodMark_2025_fixed_like_pole.geojson"
SOURCE = (
"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), "
"flood marks of October 2024, "
"https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark"
)
def load(path: Path | None) -> dict:
if path:
return json.loads(path.read_text(encoding="utf-8"))
req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode("utf-8"))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--geojson", type=Path, help="read CMU's GeoJSON from here")
args = ap.parse_args()
marks, skipped = [], 0
for f in load(args.geojson)["features"]:
p = f.get("properties") or {}
lon, lat, depth = p.get("longitude"), p.get("latitude"), p.get("water_level")
if lon is None or lat is None or depth is None:
skipped += 1
continue
# 18-19.5 N, 98.5-99.5 E: a swapped or empty coordinate is skipped
if not (18.0 < lat < 19.5 and 98.5 < lon < 99.5) or depth <= 0:
skipped += 1
continue
marks.append([round(lon, 5), round(lat, 5), round(depth)])
marks.sort(key=lambda m: (m[1], m[0]))
doc = {
"source": SOURCE,
"p1_level": 5.30,
"date": "2024-10-05",
"fields": ["lon", "lat", "depth_cm_above_road"],
"marks": marks,
}
OUT.write_text(
json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(
f"wrote {OUT.relative_to(REPO)}: {len(marks)} marks, {skipped} skipped, "
f"{OUT.stat().st_size / 1024:.0f} KB"
)
if __name__ == "__main__":
main()
+105 -3
View File
@@ -188,6 +188,7 @@
.st-label::before { display: none; } .st-label::before { display: none; }
[data-theme="dark"] .st-label { color: #e6eaf0; text-shadow: 0 0 3px #000, 0 0 3px #000, 0 0 3px #000; } [data-theme="dark"] .st-label { color: #e6eaf0; text-shadow: 0 0 3px #000, 0 0 3px #000, 0 0 3px #000; }
.layers { position: absolute; z-index: 800; right: 12px; top: 12px; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); } .layers { position: absolute; z-index: 800; right: 12px; top: 12px; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); }
.layers { flex-wrap: wrap; justify-content: flex-end; max-width: calc(100% - 64px); }
.layers button { padding: 5px 10px; border-radius: 5px; font-size: 12px; font-weight: 500; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; } .layers button { padding: 5px 10px; border-radius: 5px; font-size: 12px; font-weight: 500; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; }
.layers button::before { content: ""; width: 12px; height: 12px; border-radius: 3px; border: 1.5px solid var(--line-2); } .layers button::before { content: ""; width: 12px; height: 12px; border-radius: 3px; border: 1.5px solid var(--line-2); }
.layers button[aria-pressed="true"] { color: var(--text); } .layers button[aria-pressed="true"] { color: var(--text); }
@@ -404,6 +405,7 @@
<button type="button" id="layer-basin" aria-pressed="false" data-i18n="layer.basin">Basin stations</button> <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>
<button type="button" id="layer-marks" aria-pressed="false" data-i18n="layer.marks">Flood marks 2024</button>
</div> </div>
<div class="legend" id="legend"> <div class="legend" id="legend">
<div> <div>
@@ -431,6 +433,15 @@
<span class="key"><i style="background:#f5d547"></i><span data-i18n="legend.zones.last">Zone 5 last</span></span> <span class="key"><i style="background:#f5d547"></i><span data-i18n="legend.zones.last">Zone 5 last</span></span>
<span class="key"><i style="background:#2f86c8"></i><span data-i18n="legend.zones.flooded">River at or above the zone's level</span></span> <span class="key"><i style="background:#2f86c8"></i><span data-i18n="legend.zones.flooded">River at or above the zone's level</span></span>
</div> </div>
<div id="legend-marks" hidden>
<span class="label" data-i18n="layer.marks">Flood marks 2024</span>
<span class="key"><span data-i18n="legend.marks.unit">Depth above the road, cm:</span></span>
<span class="key"><i class="dot" style="background:#2e9e4f"></i><span class="num">0–40</span></span>
<span class="key"><i class="dot" style="background:#e6c229"></i><span class="num">41–80</span></span>
<span class="key"><i class="dot" style="background:#f08a24"></i><span class="num">81–120</span></span>
<span class="key"><i class="dot" style="background:#d7301f"></i><span class="num">121–160</span></span>
<span class="key"><i class="dot" style="background:#7b3294"></i><span class="num">&gt; 160</span></span>
</div>
</div> </div>
<div class="overlay" id="error"><div><strong id="error-message"></strong><br><button type="button" class="btn" id="retry" data-i18n="error.retry">Retry now</button></div></div> <div class="overlay" id="error"><div><strong id="error-message"></strong><br><button type="button" class="btn" id="retry" data-i18n="error.retry">Retry now</button></div></div>
</section> </section>
@@ -482,7 +493,7 @@
'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, marks: null, markLayer: null, fitted: false,
p1Stages: null, alerts: null, lastLoad: null, tab: 'rid', bsort: 'bank', basin: [], basinById: new Map(), basinOn: false, 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, basinLayer: null, basinMarkers: new Map(), pois: null, poiLayer: null,
mode: 'live', live: [], replay: null, replayData: null, simLevel: null, simRise: false, simTimer: null, mode: 'live', live: [], replay: null, replayData: null, simLevel: null, simRise: false, simTimer: null,
@@ -551,6 +562,15 @@
'layer.group': 'Map layers', 'layer.group': 'Map layers',
'layer.rain': 'Rain 24 h', 'layer.rain': 'Rain 24 h',
'layer.zones': 'Flood zones', 'layer.zones': 'Flood zones',
'layer.marks': 'Flood marks 2024',
'legend.marks.unit': 'Depth above the road, cm:',
'mark.title': 'Flood mark, October 2024',
'mark.depth': (cm) => `${cm} cm above the road`,
'mark.p1': 'The high-water line of the 2024 flood, when P.1 stood at 5.30 m (the record).',
'mark.nozone': 'Outside the mapped zones',
'mark.near': 'Flood mark 2024 nearby',
'mark.near.value': (cm, m) => `${cm} cm, ${m} m away`,
'mark.source': 'Surveyed by CMU Water Center',
'map.aria': 'Map of the Ping River gauges', 'map.aria': 'Map of the Ping River gauges',
'detail.aria': 'Selected gauge', 'detail.aria': 'Selected gauge',
'error.load': (msg) => `The data could not be loaded (${msg}). The page retries every 5 minutes.`, 'error.load': (msg) => `The data could not be loaded (${msg}). The page retries every 5 minutes.`,
@@ -810,6 +830,15 @@
'layer.group': 'ชั้นข้อมูลแผนที่', 'layer.group': 'ชั้นข้อมูลแผนที่',
'layer.rain': 'ฝน 24 ชม.', 'layer.rain': 'ฝน 24 ชม.',
'layer.zones': 'พื้นที่น้ำท่วม', 'layer.zones': 'พื้นที่น้ำท่วม',
'layer.marks': 'คราบน้ำท่วม 2567',
'legend.marks.unit': 'ความสูงน้ำจากผิวถนน (ซม.):',
'mark.title': 'เครื่องหมายระดับน้ำท่วม ตุลาคม 2567',
'mark.depth': (cm) => `สูงจากผิวถนน ${cm} ซม.`,
'mark.p1': 'ระดับน้ำท่วมสูงสุดปี 2567 ขณะที่ P.1 อยู่ที่ 5.30 ม. (สูงสุดเป็นประวัติการณ์)',
'mark.nozone': 'อยู่นอกพื้นที่ที่ทำแผนที่ไว้',
'mark.near': 'คราบน้ำท่วม 2567 ใกล้เคียง',
'mark.near.value': (cm, m) => `${cm} ซม. ห่าง ${m} ม.`,
'mark.source': 'สำรวจโดย ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มช.',
'map.aria': 'แผนที่สถานีวัดน้ำแม่น้ำปิง', 'map.aria': 'แผนที่สถานีวัดน้ำแม่น้ำปิง',
'detail.aria': 'สถานีที่เลือก', 'detail.aria': 'สถานีที่เลือก',
'error.load': (msg) => `โหลดข้อมูลไม่สำเร็จ (${msg}) หน้านี้จะลองใหม่ทุก 5 นาที`, 'error.load': (msg) => `โหลดข้อมูลไม่สำเร็จ (${msg}) หน้านี้จะลองใหม่ทุก 5 นาที`,
@@ -1538,17 +1567,21 @@
// ---- Map ------------------------------------------------------------ // ---- Map ------------------------------------------------------------
function initMap() { function initMap() {
if (!window.L) throw new Error(t('error.leaflet')); if (!window.L) throw new Error(t('error.leaflet'));
// pan popups clear of the layer bar, which wraps to two rows on phones
L.Popup.mergeOptions({ autoPanPaddingTopLeft: L.point(12, 100) });
const map = L.map('map', { zoomControl: true, attributionControl: true, minZoom: 8, maxZoom: 16, 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); 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('marks').style.zIndex = 430;
map.createPane('basin').style.zIndex = 440; map.createPane('basin').style.zIndex = 440;
map.createPane('pois').style.zIndex = 445; map.createPane('pois').style.zIndex = 445;
map.createPane('gauges').style.zIndex = 450; map.createPane('gauges').style.zIndex = 450;
state.map = map; state.map = map;
setBasemap(); setBasemap();
map.on('zoomend', syncPois); map.on('zoomend', syncPois);
map.on('zoomend', syncMarks);
} }
// The basemap is a Protomaps extract of the Ping basin served by this // The basemap is a Protomaps extract of the Ping basin served by this
@@ -1723,6 +1756,7 @@
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> 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>`; <div><span>${escapeHtml(t('zone.chance24'))}</span><b>${st ? pct(st.p_exceed) : '–'}</b></div></div>`;
} }
body += nearMarkHtml(poi.lat, poi.lon);
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>`; 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>`;
} }
@@ -1742,11 +1776,12 @@
const st = zoneStage(zone); const st = zoneStage(zone);
return { pane: 'zones', color: '#8a5a2b', weight: 1.1, dashArray: '4 4', fillColor: ZONE_COLORS[zone] || '#f5d547', fillOpacity: 0.34 + 0.3 * Math.min(1, st ? st.p_exceed : 0) }; return { pane: 'zones', color: '#8a5a2b', weight: 1.1, dashArray: '4 4', fillColor: ZONE_COLORS[zone] || '#f5d547', fillOpacity: 0.34 + 0.3 * Math.min(1, st ? st.p_exceed : 0) };
} }
function zonePopup(feature) { function zonePopup(feature, at) {
const zone = feature.properties.zone, level = Number(feature.properties.level); const zone = feature.properties.zone, level = Number(feature.properties.level);
const st = zoneStage(zone), now = p1Level(); const st = zoneStage(zone), now = p1Level();
return `<div class="popup"><div class="label">${escapeHtml(t('zone.title', zone))}</div><h3>${escapeHtml(t('zone.floods', level.toFixed(2)))}</h3> return `<div class="popup"><div class="label">${escapeHtml(t('zone.title', zone))}</div><h3>${escapeHtml(t('zone.floods', level.toFixed(2)))}</h3>
<div class="pgrid"><div><span>${escapeHtml(t('zone.p1now'))}</span><b>${m2(now)} m</b></div><div><span>${escapeHtml(t('zone.chance24'))}</span><b>${st ? pct(st.p_exceed) : '–'}</b></div></div> <div class="pgrid"><div><span>${escapeHtml(t('zone.p1now'))}</span><b>${m2(now)} m</b></div><div><span>${escapeHtml(t('zone.chance24'))}</span><b>${st ? pct(st.p_exceed) : '–'}</b></div></div>
${at ? nearMarkHtml(at.lat, at.lng) : ''}
${now != null && now >= level ? `<div class="pfoot" style="color:var(--danger-ink);font-weight:600">${escapeHtml(t('zone.reached'))}</div>` : ''}</div>`; ${now != null && now >= level ? `<div class="pfoot" style="color:var(--danger-ink);font-weight:600">${escapeHtml(t('zone.reached'))}</div>` : ''}</div>`;
} }
async function setZones(on, fly) { async function setZones(on, fly) {
@@ -1757,12 +1792,78 @@
if (on) { if (on) {
try { try {
if (!state.zones) state.zones = await json(fetch('/static/flood-zones-p1.geojson')); if (!state.zones) state.zones = await json(fetch('/static/flood-zones-p1.geojson'));
state.zoneLayer = L.geoJSON(state.zones, { style: zoneStyle, pane: 'zones', attribution: ZONE_SOURCE }).bindPopup((layer) => zonePopup(layer.feature)).addTo(state.map); state.zoneLayer = L.geoJSON(state.zones, { style: zoneStyle, pane: 'zones', attribution: ZONE_SOURCE })
.bindPopup((layer) => zonePopup(layer.feature, state.zoneLayer && state.zoneLayer.getPopup() ? state.zoneLayer.getPopup().getLatLng() : null)).addTo(state.map);
loadMarks(); // for the nearest flood mark in the zone popups
if (fly) state.map.flyToBounds(state.zoneLayer.getBounds(), { maxZoom: 13, duration: 0.7 }); if (fly) state.map.flyToBounds(state.zoneLayer.getBounds(), { maxZoom: 13, duration: 0.7 });
} catch (e) { $('layer-zones').setAttribute('aria-pressed', 'false'); $('legend-zones').hidden = true; } } catch (e) { $('layer-zones').setAttribute('aria-pressed', 'false'); $('legend-zones').hidden = true; }
} }
renderDetail(); renderDetail();
} }
// CMU Water Center's flood marks of October 2024 (scripts/build_flood_marks.py):
// the high-water line above the road at ~4,900 places while P.1 stood at
// 5.30 m, in CMU's depth classes. Drawn from zoom 12 (below that they are
// one blob); a 2024 fact, so also shown in the replay.
const MARK_BINS = [[40, '#2e9e4f'], [80, '#e6c229'], [120, '#f08a24'], [160, '#d7301f'], [Infinity, '#7b3294']];
const markColor = (cm) => MARK_BINS.find(([max]) => cm <= max)[1];
const MARK_SOURCE = '<a href="https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark" target="_blank" rel="noopener">CMU Water Center</a>';
async function loadMarks() {
if (!state.marks) state.marks = await json(fetch('/static/flood-marks-2024.json')).then((d) => (Array.isArray(d.marks) ? d.marks : null)).catch(() => null);
return state.marks;
}
// the closest mark within maxM metres (marks are [lon, lat, cm])
function nearestMark(lat, lon, maxM) {
if (!state.marks) return null;
const kx = 111320 * Math.cos((lat * Math.PI) / 180), ky = 110540;
let best = null, bestD = maxM;
state.marks.forEach((m) => {
const d = Math.hypot((m[0] - lon) * kx, (m[1] - lat) * ky);
if (d < bestD) { bestD = d; best = m; }
});
return best ? { cm: best[2], m: bestD } : null;
}
function nearMarkHtml(lat, lon) {
const near = nearestMark(lat, lon, 250);
if (!near) return '';
return `<div class="pgrid"><div><span>${escapeHtml(t('mark.near'))}</span><b>${escapeHtml(t('mark.near.value', near.cm, Math.max(10, Math.round(near.m / 10) * 10)))}</b></div></div>`;
}
function markPopup(m) {
const zone = zoneOfPoint(m[1], m[0]);
const where = zone ? t('poi.zone.value', zone.properties.zone, Number(zone.properties.level).toFixed(2)) : t('mark.nozone');
return `<div class="popup"><div class="label">${escapeHtml(t('mark.title'))}</div><h3>${escapeHtml(t('mark.depth', m[2]))}</h3>
<div style="color:var(--muted)">${escapeHtml(t('mark.p1'))}</div>
<div class="pgrid"><div><span>${escapeHtml(t('poi.zone'))}</span><b>${escapeHtml(where)}</b></div></div>
<div class="pfoot">${escapeHtml(t('mark.source'))}</div></div>`;
}
async function setMarks(on) {
if (!state.map) return;
if (state.markLayer) { if (state.map.hasLayer(state.markLayer)) state.map.removeLayer(state.markLayer); state.markLayer = null; }
$('layer-marks').setAttribute('aria-pressed', String(on));
$('legend-marks').hidden = !on;
if (!on) return;
const marks = await loadMarks();
if (!state.zones) state.zones = await json(fetch('/static/flood-zones-p1.geojson')).catch(() => null);
if (!marks || $('layer-marks').getAttribute('aria-pressed') !== 'true') {
$('layer-marks').setAttribute('aria-pressed', 'false');
$('legend-marks').hidden = true;
return;
}
const renderer = L.canvas({ pane: 'marks' });
const group = L.layerGroup([], { attribution: MARK_SOURCE });
marks.forEach((m) => {
L.circleMarker([m[1], m[0]], { renderer, pane: 'marks', radius: 3.5, weight: 0.6, color: 'rgba(0,0,0,0.45)', fillColor: markColor(m[2]), fillOpacity: 0.9 })
.bindPopup(() => markPopup(m)).addTo(group);
});
state.markLayer = group;
if (state.map.getZoom() < 12) state.map.flyTo([18.765, 99.0], 13, { duration: 0.7 });
syncMarks();
}
function syncMarks() {
if (!state.markLayer) return;
const show = state.map.getZoom() >= 12;
if (show && !state.map.hasLayer(state.markLayer)) state.markLayer.addTo(state.map);
if (!show && state.map.hasLayer(state.markLayer)) state.map.removeLayer(state.markLayer);
}
function autoZones() { function autoZones() {
// The river over its banks, or stage 1 likely within 24 h: show the // The river over its banks, or stage 1 likely within 24 h: show the
// zones unless the user hid them // zones unless the user hid them
@@ -2408,6 +2509,7 @@
try { localStorage.setItem(RAIN_KEY, on ? 'on' : 'off'); } catch (e) { /* private mode */ } try { localStorage.setItem(RAIN_KEY, on ? 'on' : 'off'); } catch (e) { /* private mode */ }
renderRain(); renderRain();
}); });
$('layer-marks').addEventListener('click', () => setMarks($('layer-marks').getAttribute('aria-pressed') !== 'true'));
$('layer-zones').addEventListener('click', () => { $('layer-zones').addEventListener('click', () => {
const on = $('layer-zones').getAttribute('aria-pressed') !== 'true'; const on = $('layer-zones').getAttribute('aria-pressed') !== 'true';
state.zonesUserHidden = !on; state.zonesUserHidden = !on;
+1
View File
@@ -0,0 +1 @@
{"source":"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), flood marks of October 2024, https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark","p1_level":5.3,"date":"2024-10-05","fields":["lon","lat","depth_cm_above_road"],"marks":[[98.94555,18.58911,131],[98.94474,18.59004,80],[98.95254,18.59092,35],[98.95091,18.59101,112],[98.95117,18.59133,96],[98.945,18.59134,78],[98.95099,18.5914,114],[98.95584,18.59243,43],[98.95553,18.59256,43],[98.95149,18.59275,156],[98.94926,18.59309,90],[98.94829,18.59436,101],[98.94542,18.59467,65],[98.9414,18.5957,73],[98.94884,18.59594,48],[98.94196,18.59609,63],[98.94342,18.59615,40],[98.94203,18.59616,57],[98.94863,18.59685,68],[98.94599,18.59707,40],[98.93865,18.59717,28],[98.94922,18.5972,76],[98.94842,18.59727,50],[98.94964,18.59769,65],[98.94784,18.59786,50],[98.94939,18.59791,52],[98.94599,18.59823,40],[98.94839,18.59839,37],[98.94795,18.59841,70],[98.94994,18.5985,53],[98.946,18.5986,28],[98.94831,18.59893,37],[98.94721,18.59895,30],[98.94985,18.59897,34],[98.94546,18.59921,38],[98.9493,18.59921,41],[98.94614,18.5994,40],[98.94458,18.59948,36],[98.95012,18.59976,68],[98.94604,18.5998,25],[98.94869,18.59982,32],[98.93946,18.6,26],[98.93307,18.60005,30],[98.94893,18.60033,32],[98.93826,18.60035,26],[98.94578,18.60047,40],[98.96498,18.60066,115],[98.93305,18.60128,58],[98.9535,18.60188,72],[98.96283,18.60198,45],[98.95473,18.60223,81],[98.93342,18.60291,27],[98.93735,18.60399,54],[98.93613,18.60457,32],[98.96806,18.60471,92],[98.93583,18.60479,58],[98.9342,18.60505,32],[98.94897,18.60509,40],[98.94952,18.60523,46],[98.94795,18.60572,63],[98.93452,18.6058,33],[98.93398,18.60581,27],[98.95078,18.60626,60],[98.96559,18.60645,58],[98.96518,18.60728,64],[98.96215,18.61133,54],[98.96336,18.61148,54],[98.96375,18.61238,51],[98.94076,18.61313,15],[98.96247,18.61369,20],[98.98529,18.61605,80],[98.98479,18.61629,73],[98.98601,18.61663,45],[98.96084,18.617,25],[98.98441,18.61702,90],[98.98533,18.61703,120],[98.98649,18.61815,50],[98.95499,18.61817,33],[98.95889,18.61887,35],[99.02179,18.61888,33],[98.98757,18.61924,30],[98.98717,18.61935,63],[98.98659,18.61985,76],[99.0227,18.62004,65],[98.98653,18.62013,79],[98.98854,18.62029,35],[98.9509,18.62036,87],[98.95268,18.62046,62],[99.02302,18.62054,46],[99.02241,18.62074,90],[98.95322,18.62094,96],[98.98795,18.62105,68],[98.95306,18.62117,92],[98.95485,18.62136,34],[99.02224,18.62139,90],[99.02348,18.62149,47],[98.94931,18.6217,83],[98.96125,18.62181,33],[98.98974,18.6219,10],[99.02205,18.62209,110],[98.95239,18.62212,118],[99.02371,18.62217,48],[98.95701,18.62224,20],[98.95168,18.62259,111],[99.02178,18.62272,91],[98.95117,18.62296,115],[99.02411,18.62323,31],[98.95138,18.62404,116],[98.97146,18.62411,26],[98.95172,18.62481,102],[99.02475,18.62491,34],[99.02096,18.62497,87],[98.95873,18.62517,23],[99.02063,18.62561,85],[99.02519,18.62589,18],[99.02027,18.62627,90],[99.02005,18.62656,96],[98.9579,18.62667,39],[99.02561,18.6269,32],[99.01972,18.627,96],[99.02061,18.62716,82],[99.02114,18.62764,52],[99.0192,18.62775,95],[99.03071,18.62795,51],[99.0221,18.6282,44],[98.97386,18.62837,27],[99.01877,18.62841,66],[98.94845,18.6285,73],[99.02274,18.6286,46],[98.97247,18.62869,31],[99.02858,18.62871,72],[99.0264,18.62887,43],[99.02042,18.629,100],[99.02338,18.62904,42],[99.027,18.62927,67],[98.9769,18.62936,43],[99.01764,18.62936,42],[99.02406,18.62941,37],[98.97457,18.62951,47],[99.01819,18.62959,47],[99.02118,18.6296,89],[99.02193,18.63011,89],[99.01882,18.63015,57],[98.95574,18.63018,30],[99.02446,18.63022,26],[99.02145,18.63047,70],[99.02672,18.63061,63],[99.01935,18.63069,65],[99.02963,18.63094,47],[99.02071,18.63096,63],[99.03878,18.63102,13],[98.96853,18.63112,36],[99.02138,18.6313,80],[99.01906,18.63131,36],[99.01855,18.63161,40],[99.02381,18.63165,47],[99.02153,18.63204,56],[99.023,18.63204,46],[99.03029,18.63226,38],[99.02,18.63245,30],[99.02705,18.63245,50],[99.03472,18.63255,18],[99.02185,18.63258,35],[99.0252,18.63336,50],[99.03113,18.63384,30],[99.02535,18.63435,41],[98.97782,18.6344,20],[99.03162,18.63441,47],[99.02745,18.63481,43],[98.98087,18.63494,46],[98.98229,18.63499,20],[99.02544,18.63523,40],[99.03221,18.63524,25],[99.02766,18.6359,47],[99.03261,18.63593,42],[99.05652,18.6362,13],[99.03907,18.63682,52],[98.98704,18.63693,30],[98.96901,18.63711,36],[99.0545,18.63725,9],[98.96356,18.6373,30],[99.03327,18.63757,37],[99.02802,18.63761,36],[98.99439,18.63798,30],[99.02567,18.63811,30],[98.98859,18.63813,36],[98.95419,18.63822,80],[98.98457,18.63837,32],[99.02846,18.63857,24],[98.99412,18.63862,34],[99.0342,18.63895,10],[98.96941,18.63904,30],[99.02548,18.63943,23],[99.03287,18.63954,17],[99.02997,18.6398,25],[99.02907,18.63982,11],[99.03358,18.64016,13],[98.99,18.64047,30],[99.02353,18.64072,41],[99.02159,18.64077,12],[98.99208,18.64086,52],[98.97228,18.64092,25],[99.034Line truncated