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>
|
||||
(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();
|
||||
|
||||
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