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
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:
+64
-13
@@ -247,7 +247,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
function flowColor(flow) {
|
function flowColor(flow) {
|
||||||
@@ -606,27 +606,75 @@
|
|||||||
return '#1e8b60';
|
return '#1e8b60';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Official Chiang Mai inundation map (keyed to P.1), georeferenced approximately.
|
// Chiang Mai inundation zones digitized from the official municipal map and
|
||||||
// Tune bounds if the river course in the scan drifts from the basemap.
|
// georeferenced to the OSM Ping centerline (~110 m median accuracy).
|
||||||
const FLOOD_ZONE_IMAGE = '/static/flood-zones-p1.jpg';
|
// Colors: susceptibility ramp — zone 1 floods first (deep red), zone 7 last.
|
||||||
const FLOOD_ZONE_BOUNDS = [[18.680, 98.925], [18.855, 99.105]];
|
const ZONE_COLORS = { 1: '#99201c', 2: '#c0392b', 3: '#d35400', 4: '#e67e22', 5: '#f39c12', 6: '#f5c542', 7: '#f9e3a1' };
|
||||||
let zoneOverlay = null;
|
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;
|
if (!state.map) return;
|
||||||
const button = $('zones-toggle');
|
const button = $('zones-toggle');
|
||||||
if (zoneOverlay) {
|
if (zoneLayer) {
|
||||||
state.map.removeLayer(zoneOverlay);
|
state.map.removeLayer(zoneLayer);
|
||||||
zoneOverlay = null;
|
zoneLayer = null;
|
||||||
if (button) button.textContent = 'Show flood zones on map';
|
if (button) button.textContent = 'Show flood zones on map';
|
||||||
} else {
|
return;
|
||||||
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 });
|
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';
|
if (button) button.textContent = 'Hide flood zones';
|
||||||
document.getElementById('station-map').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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) {
|
function stageColor(p) {
|
||||||
if (p >= .5) return '#cc4b37';
|
if (p >= .5) return '#cc4b37';
|
||||||
if (p >= .2) return '#d99018';
|
if (p >= .2) return '#d99018';
|
||||||
@@ -639,6 +687,9 @@
|
|||||||
const p1rows = rows.filter((r) => r.station_code === 'P.1' && Array.isArray(r.stages));
|
const p1rows = rows.filter((r) => r.station_code === 'P.1' && Array.isArray(r.stages));
|
||||||
if (!p1rows.length) { card.style.display = 'none'; return; }
|
if (!p1rows.length) { card.style.display = 'none'; return; }
|
||||||
const row = p1rows.reduce((best, r) => r.horizon_hours > best.horizon_hours ? r : best);
|
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`;
|
$('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');
|
const strip = $('p1-stages');
|
||||||
strip.replaceChildren();
|
strip.replaceChildren();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB |
Reference in New Issue
Block a user