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:
2026-08-10 16:14:34 +07:00
parent 6c0bb024a8
commit 3b919adc56
3 changed files with 65 additions and 13 deletions
+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 zones 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();
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB