feat: vector flood-zone polygons replace the scanned-map overlay
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 25s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.12) (push) Failing after 33s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 16s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s

Digitize the 7 Chiang Mai inundation zones from the municipal map into
geojson polygons: pixels classified by legend color, vectorized via
marching squares, and georeferenced by a 6-parameter affine fitted
least-squares to the OSM Ping centerline (109 m median residual after
outlier-filtered refits - the scanned image overlay could never scale
correctly and is removed).

The dashboard renders the zones as a Leaflet geoJSON layer styled live
from the forecast: fill opacity scales with each zone's 24 h exceedance
probability, zones whose trigger level the river has already reached get
a solid red border, and each polygon's popup shows its trigger level and
live probability. Zone styles refresh with every forecast load.
This commit is contained in:
grabowski committed 2026-08-10 16:14:34 +07:00
1 parent 6c0bb024a8
commit 3b919adc56
3 files changed
+65 -13

No files matched your search

+64 -13
View File
@@ -247,7 +247,7 @@
<script>
(function () {
'use strict';
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0 };
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null };
const $ = (id) => document.getElementById(id);
function flowColor(flow) {
@@ -606,27 +606,75 @@
return '#1e8b60';
}
// Official Chiang Mai inundation map (keyed to P.1), georeferenced approximately.
// Tune bounds if the river course in the scan drifts from the basemap.
const FLOOD_ZONE_IMAGE = '/static/flood-zones-p1.jpg';
const FLOOD_ZONE_BOUNDS = [[18.680, 98.925], [18.855, 99.105]];
let zoneOverlay = null;
// Chiang Mai inundation zones digitized from the official municipal map and
// georeferenced to the OSM Ping centerline (~110 m median accuracy).
// Colors: susceptibility ramp — zone 1 floods first (deep red), zone 7 last.
const ZONE_COLORS = { 1: '#99201c', 2: '#c0392b', 3: '#d35400', 4: '#e67e22', 5: '#f39c12', 6: '#f5c542', 7: '#f9e3a1' };
let zoneLayer = null, zoneData = null;
function toggleFloodZones() {
function zoneStage(zone) {
return (state.p1Stages || []).find((s) => s.stage === zone) || null;
}
function zoneStyle(feature) {
const zone = feature.properties.zone;
const level = feature.properties.level;
const st = zoneStage(zone);
const p = st ? st.p_exceed : 0;
const flooded = state.p1Now != null && state.p1Now >= level;
return {
color: flooded ? '#7a1512' : '#8a5a2b',
weight: flooded ? 2.5 : 1,
dashArray: flooded ? null : '4 4',
fillColor: ZONE_COLORS[zone] || '#f9e3a1',
fillOpacity: flooded ? .55 : .16 + .38 * Math.min(1, p),
};
}
function zonePopupHtml(feature) {
const zone = feature.properties.zone;
const level = Number(feature.properties.level);
const st = zoneStage(zone);
const flooded = state.p1Now != null && state.p1Now >= level;
return `<div class="popup"><div class="popup-code">Flood zone ${zone}</div>
<h3>Floods when P.1 ≥ ${level.toFixed(2)} m</h3>
<div class="popup-grid">
<div class="popup-metric"><span>P.1 now</span><strong>${state.p1Now == null ? '—' : state.p1Now.toFixed(2) + ' m'}</strong></div>
<div class="popup-metric"><span>Chance within 24 h</span><strong>${st ? Math.round(st.p_exceed * 100) + '%' : '—'}</strong></div>
</div>
${flooded ? '<div class="popup-time" style="color:#8d2f22;font-weight:800">River is at or above this zone’s level now</div>' : ''}</div>`;
}
async function toggleFloodZones() {
if (!state.map) return;
const button = $('zones-toggle');
if (zoneOverlay) {
state.map.removeLayer(zoneOverlay);
zoneOverlay = null;
if (zoneLayer) {
state.map.removeLayer(zoneLayer);
zoneLayer = null;
if (button) button.textContent = 'Show flood zones on map';
} else {
zoneOverlay = L.imageOverlay(FLOOD_ZONE_IMAGE, FLOOD_ZONE_BOUNDS, { opacity: .62, interactive: false }).addTo(state.map);
state.map.flyToBounds(FLOOD_ZONE_BOUNDS, { maxZoom: 13, duration: .8 });
return;
}
try {
if (!zoneData) {
const response = await fetch('/static/flood-zones-p1.geojson');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
zoneData = await response.json();
}
zoneLayer = L.geoJSON(zoneData, { style: zoneStyle })
.bindPopup((layer) => zonePopupHtml(layer.feature))
.addTo(state.map);
state.map.flyToBounds(zoneLayer.getBounds(), { maxZoom: 13, duration: .8 });
if (button) button.textContent = 'Hide flood zones';
document.getElementById('station-map').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
if (button) button.textContent = 'Flood zones unavailable';
}
}
function restyleFloodZones() {
if (zoneLayer) zoneLayer.setStyle(zoneStyle);
}
function stageColor(p) {
if (p >= .5) return '#cc4b37';
if (p >= .2) return '#d99018';
@@ -639,6 +687,9 @@
const p1rows = rows.filter((r) => r.station_code === 'P.1' && Array.isArray(r.stages));
if (!p1rows.length) { card.style.display = 'none'; return; }
const row = p1rows.reduce((best, r) => r.horizon_hours > best.horizon_hours ? r : best);
state.p1Stages = row.stages;
state.p1Now = row.current_level == null ? null : Number(row.current_level);
restyleFloodZones();
$('p1-peak').textContent = `Now ${Number(row.current_level).toFixed(2)} m · predicted peak next ${row.horizon_hours} h: ${Number(row.predicted_max_level).toFixed(2)} m`;
const strip = $('p1-stages');
strip.replaceChildren();
+1
View File
@@ -0,0 +1 @@
{"type": "FeatureCollection", "source": "Digitized from the Chiang Mai municipal inundation map (flood extents of 2548/2554/2565 BE), georeferenced by least-squares fit to the OSM Ping centerline (~150-200 m accuracy)", "features": [{"type": "Feature", "properties": {"zone": 1, "level": 3.7}, "geometry": {"type": "Polygon", "coordinates": [[[98.98929538642234, 18.75826047362413], [98.98929290303863, 18.758974837838622], [98.99206971032511, 18.760760826878315], [98.99265438969589, 18.76192168533337], [98.99559020346827, 18.764154156536165], [98.99898426558495, 18.767993960305482], [98.99962277491225, 18.769065524743407], [98.99957778253433, 18.771744389541297], [99.0001649452888, 18.772190883781857], [99.00116010577094, 18.768172612752842], [99.00073443288606, 18.767458236460893], [99.00145051005072, 18.766743892375494], [99.00170585548737, 18.76513657993807], [99.00132269159673, 18.76245770306272], [98.99995430075874, 18.76093964035842], [98.99973898093262, 18.76129681642694], [98.99813649915508, 18.76040381586836], [98.99756314074662, 18.761118163979578], [98.99496411105946, 18.75951077102578], [98.99332408705511, 18.759153542621622], [98.99285838855971, 18.75968930269858], [98.99125590678219, 18.758796302140006], [98.99093292704298, 18.75933206624278], [98.98929538642234, 18.75826047362413]]]}}, {"type": "Feature", "properties": {"zone": 2, "level": 3.9}, "geometry": {"type": "Polygon", "coordinates": [[[98.99852988805186, 18.77040492694871], [98.99786515746004, 18.771744341231475], [98.9984310657174, 18.773173085763734], [98.99802803475716, 18.776209122604325], [98.9987391451544, 18.776923506947913], [98.99830850550214, 18.777637859084948], [98.99905219135881, 18.779245199703105], [98.99876427046273, 18.779959555865958], [99.00015143241411, 18.781209732493053], [98.99953556501143, 18.78388858118767], [99.0008802179686, 18.787103258398165], [99.00147125153428, 18.791568051848472], [99.00454233391176, 18.795943619720564], [99.00561644965875, 18.794872103592468], [99.00675819970827, 18.794872135799015], [99.00775474761791, 18.79558652819424], [99.00854094246881, 18.795229368228995], [99.00916316406641, 18.795854454529632], [99.00857103454453, 18.796836688718056], [99.00793004183352, 18.796479488494626], [99.00600209693309, 18.796836616253323], [99.00532604537895, 18.796300823969815], [99.00512701328253, 18.797104478175616], [99.00750575637616, 18.80049777562692], [99.00946379335231, 18.801747968357287], [99.00986821174007, 18.803444594940935], [99.00999960953395, 18.801569392400484], [99.0092884991367, 18.800855008056896], [99.00966530883244, 18.80022994993699], [99.01016358278726, 18.800587146134603], [99.01043521595363, 18.799426311834463], [99.00969153009694, 18.797818971216305], [99.0100683397927, 18.7971939130964], [99.01056661374751, 18.79755110929401], [99.01121008984222, 18.797193945302947], [99.01187482043404, 18.795854531020186], [99.0112111857984, 18.791746917664216], [99.0099717093706, 18.789068016633955], [99.00881615497507, 18.78790714207563], [99.0079247835948, 18.78772852586064], [99.00500774093582, 18.785228168580634], [99.00447192475418, 18.785406744537436], [99.00313859275933, 18.784067273893214], [99.00129318246366, 18.7812097646996], [99.00109027955601, 18.777995119695657], [99.00011250275979, 18.77701284122323], [98.99957420319444, 18.777905781394523], [98.99895198159685, 18.777280695093886], [98.99954411111872, 18.776298460905462], [99.00022016267286, 18.77683425318897], [99.00056191352547, 18.776030603008984], [98.99907205842841, 18.773530285987164], [98.99852988805186, 18.77040492694871]]]}}, {"type": "Feature", "properties": {"zone": 3, "level": 4.0}, "geometry": {"type": "Polygon", "coordinates": [[[99.00409591954332, 18.770405083955637], [99.00311317597969, 18.770851533912193], [99.0032884701953, 18.771744494212584], [99.00256990964695, 18.773173202512474], [99.0034212554167, 18.774601955096372], [99.00341628864929, 18.776030683525356], [99.00455307193143, 18.77745944416089], [99.00423009219222, 18.77799520826367], [99.00526169894498, 18.77888819271897], [99.00472339937964, 18.779781132890264], [99.00500635350832, 18.780495505156395], [99.00864831091056, 18.784871089131762], [99.01359350823192, 18.78897882326229], [99.01379254032835, 18.788175169056487], [99.0115189737641, 18.78531764778542], [99.01042469947619, 18.781924386566487], [99.00840151158118, 18.778888281286978], [99.00822870074927, 18.777280956772096], [99.00751759035201, 18.77656657242851], [99.0078055112481, 18.775852216265655], [99.00709440085085, 18.77513783192207], [99.00738232174693, 18.774423475759214], [99.00603021863866, 18.7733518911922], [99.00560951252119, 18.771208786471266], [99.00409591954332, 18.770405083955637]]]}}, {"type": "Feature", "properties": {"zone": 4, "level": 4.1}, "geometry": {"type": "Polygon", "coordinates": [[[99.02229190220848, 18.791122161480704], [99.02132296299087, 18.79272945378904], [99.02057927713419, 18.791122113170882], [99.01911454734545, 18.79165784506711], [99.01843849579133Line truncated
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB