feat: full-basin 2024 flood replay with clock and demo indicator
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
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 14s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s

The replay now drives the entire map from a 120 kB all-station snapshot
(static/flood-2024-all.json: 649 hourly frames x 16 stations of level +
discharge, forward-filled on an aligned grid):

- station markers recolor/resize per frame from historic discharge
- river segments restyle per frame (color/width from the same
  nearest-gauge grading as live mode)
- flood zones flood/recede from P.1's historic level
- a large clock overlay on the map shows replay date/time, P.1 level
  and total basin flow
- the LIVE DATA pill switches to an amber '2024 REPLAY' (or
  'SIMULATION' for the demo_level/demo_rise hooks) and back on finish
- replay end or stop restores the live view via a full dashboard reload

Replaces the P.1-only snapshot (flood-2024-p1.json removed).
This commit is contained in:
2026-08-10 17:59:06 +07:00
parent 0b885e572d
commit d015420b66
3 changed files with 100 additions and 46 deletions
+98 -44
View File
@@ -50,6 +50,8 @@
background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem; background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem;
} }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); } .live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
.live-pill.demo { background: #fdeeda; color: #9a6200; }
.live-pill.demo .live-dot { background: #e6a23c; box-shadow: 0 0 0 5px rgba(230,162,60,.15); }
button { button {
border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink); border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink);
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05); padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
@@ -127,6 +129,14 @@
width: 26px; height: 26px; display: grid; place-items: center; font-size: 19px; width: 26px; height: 26px; display: grid; place-items: center; font-size: 19px;
filter: drop-shadow(0 3px 4px rgba(5,43,58,.45)); filter: drop-shadow(0 3px 4px rgba(5,43,58,.45));
} }
.replay-clock {
position: absolute; z-index: 650; left: 50%; transform: translateX(-50%); bottom: 20px;
background: rgba(19,43,53,.9); color: white; padding: 12px 26px; border-radius: 15px;
text-align: center; display: none; pointer-events: none; backdrop-filter: blur(6px);
box-shadow: 0 12px 30px rgba(9,30,38,.4);
}
.replay-clock strong { display: block; font-size: 1.65rem; font-weight: 800; letter-spacing: -.02em; white-space: nowrap; }
.replay-clock span { font-size: .78rem; opacity: .85; }
.forecast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 10px; margin-top: 14px; } .forecast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 10px; margin-top: 14px; }
.forecast-station { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; } .forecast-station { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; }
.forecast-station strong { font-size: .8rem; } .forecast-station strong { font-size: .8rem; }
@@ -181,7 +191,7 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="live-pill"><span class="live-dot"></span> LIVE DATA</div> <div class="live-pill" id="live-pill"><span class="live-dot"></span> <span id="live-pill-text">LIVE DATA</span></div>
<button id="replay-2024" type="button">▶ Replay Oct 2024 flood</button> <button id="replay-2024" type="button">▶ Replay Oct 2024 flood</button>
<button id="refresh-button" type="button">↻ Refresh</button> <button id="refresh-button" type="button">↻ Refresh</button>
</div> </div>
@@ -211,6 +221,7 @@
</div> </div>
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div> <div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
<div class="error-panel" id="error"><div><strong>Map data could not be loaded.</strong><br><span id="error-message"></span></div></div> <div class="error-panel" id="error"><div><strong>Map data could not be loaded.</strong><br><span id="error-message"></span></div></div>
<div class="replay-clock" id="replay-clock"><strong id="replay-clock-time"></strong><span id="replay-clock-sub"></span></div>
</article> </article>
<aside class="side-card"> <aside class="side-card">
@@ -346,15 +357,23 @@
return 'flow-surge'; return 'flow-surge';
} }
function renderRiverNetwork(riverNetwork, stations, readings) { function riverGauges(stations, readings) {
if (!riverNetwork) return; return stations
const gauges = stations
.filter((s) => Number.isFinite(s.latitude) && Number.isFinite(s.longitude)) .filter((s) => Number.isFinite(s.latitude) && Number.isFinite(s.longitude))
.map((s) => ({ lat: s.latitude, lon: s.longitude, q: readings.get(s.station_code)?.discharge })) .map((s) => ({ lat: s.latitude, lon: s.longitude, q: readings.get(s.station_code)?.discharge }))
.filter((g) => g.q != null) .filter((g) => g.q != null)
.map((g) => ({ lat: g.lat, lon: g.lon, q: Number(g.q) })); .map((g) => ({ lat: g.lat, lon: g.lon, q: Number(g.q) }));
const flowBySegment = new Map(); }
(riverNetwork.features || []).forEach((f) => flowBySegment.set(f, nearestGaugeFlow(f, gauges)));
function segmentFlows(riverNetwork, gauges) {
const flows = new Map();
(riverNetwork.features || []).forEach((f) => flows.set(f, nearestGaugeFlow(f, gauges)));
return flows;
}
function renderRiverNetwork(riverNetwork, stations, readings) {
if (!riverNetwork) return;
const flowBySegment = segmentFlows(riverNetwork, riverGauges(stations, readings));
const casing = L.geoJSON(riverNetwork, { const casing = L.geoJSON(riverNetwork, {
style: (f) => ({ color: '#e3f4f8', weight: riverWeight(flowBySegment.get(f)) + 4.5, opacity: .8, lineCap: 'round' }) style: (f) => ({ color: '#e3f4f8', weight: riverWeight(flowBySegment.get(f)) + 4.5, opacity: .8, lineCap: 'round' })
}).addTo(state.map); }).addTo(state.map);
@@ -371,6 +390,41 @@
flow.bringToBack(); flow.bringToBack();
casing.bringToBack(); casing.bringToBack();
state.layers.push(casing, flow); state.layers.push(casing, flow);
state.riverNet = riverNetwork;
state.riverStations = stations;
state.riverCasing = casing;
state.riverFlow = flow;
}
// Restyle the existing river layers for a different set of readings (used by
// the replay). Colors and widths update; dash-speed classes stay as rendered.
function restyleRiver(readings) {
if (!state.riverFlow || !state.riverNet) return;
const flows = segmentFlows(state.riverNet, riverGauges(state.riverStations, readings));
state.riverCasing.setStyle((f) => ({ color: '#e3f4f8', weight: riverWeight(flows.get(f)) + 4.5, opacity: .8 }));
state.riverFlow.setStyle((f) => {
const q = flows.get(f);
return { color: q == null ? '#69b7d0' : flowColor(q), weight: riverWeight(q), opacity: .92 };
});
}
function updateStationMarker(code, discharge) {
const marker = state.markers.get(code);
if (!marker) return;
const q = discharge == null ? null : Number(discharge);
const color = flowColor(q);
const size = markerSize(q);
marker.setIcon(L.divIcon({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:${size}px">${escapeHtml(code.replace('P.', ''))}</div>`,
iconSize: [size, size], iconAnchor: [size / 2, size / 2], popupAnchor: [0, -size / 2]
}));
}
function setLiveIndicator(mode, label) {
const pill = $('live-pill');
$('live-pill-text').textContent = label || 'LIVE DATA';
pill.classList.toggle('demo', mode !== 'live');
} }
function renderMap(stations, readings, riverNetwork) { function renderMap(stations, readings, riverNetwork) {
@@ -733,55 +787,54 @@
return `<div class="popup"><div class="popup-code">Marker</div><h3>${escapeHtml(poi.name)}</h3>${poi.note ? `<div class="popup-th">${escapeHtml(poi.note)}</div>` : ''}${riskHtml}</div>`; return `<div class="popup"><div class="popup-code">Marker</div><h3>${escapeHtml(poi.name)}</h3>${poi.note ? `<div class="popup-th">${escapeHtml(poi.note)}</div>` : ''}${riskHtml}</div>`;
} }
function endReplay() {
if (state.replayTimer) window.clearInterval(state.replayTimer);
state.replayTimer = null;
$('replay-2024').textContent = '▶ Replay Oct 2024 flood';
$('replay-clock').style.display = 'none';
setLiveIndicator('live');
loadDashboard(); // restore live markers, river, zones
}
async function replayFlood2024() { async function replayFlood2024() {
const button = $('replay-2024'); const button = $('replay-2024');
if (state.replayTimer) { // stop a running replay if (state.replayTimer) { endReplay(); return; }
window.clearInterval(state.replayTimer);
state.replayTimer = null;
state.p1Now = state.p1RealNow;
restyleFloodZones();
button.textContent = '▶ Replay Oct 2024 flood';
$('p1-peak').textContent = state.p1PeakText || '';
return;
}
button.textContent = 'Loading 2024 data…'; button.textContent = 'Loading 2024 data…';
try { try {
// pre-extracted static snapshot (15 kB); fall back to the live history API const response = await fetch('/static/flood-2024-all.json');
let frames;
const staticResponse = await fetch('/static/flood-2024-p1.json');
if (staticResponse.ok) {
frames = await staticResponse.json();
} else {
const response = await fetch('/measurements/history/P.1?hours=876000&limit=100000');
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const rows = await response.json(); const data = await response.json();
frames = rows.filter((row) => { const n = data.timestamps.length;
const t = row.timestamp; if (n < 10) throw new Error('snapshot empty');
return t >= '2024-09-15' && t <= '2024-10-12' && row.water_level != null;
});
}
if (frames.length < 10) throw new Error('no 2024 data in history');
state.p1RealNow = state.p1Now;
state.p1PeakText = $('p1-peak').textContent;
state.zonesUserHidden = false; state.zonesUserHidden = false;
await showFloodZones(false); await showFloodZones(false);
button.textContent = '⏹ Stop replay'; button.textContent = '⏹ Stop replay';
setLiveIndicator('replay', '⏪ 2024 REPLAY');
$('replay-clock').style.display = 'block';
let i = 0; let i = 0;
state.replayTimer = window.setInterval(() => { state.replayTimer = window.setInterval(() => {
const frame = frames[Math.min(i, frames.length - 1)]; const frame = Math.min(i, n - 1);
state.p1Now = Number(frame.water_level); const readings = new Map();
const when = new Date(frame.timestamp).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit' }); let totalFlow = 0;
$('p1-peak').textContent = `⏪ 2024 flood replay · ${when} · P.1 at ${state.p1Now.toFixed(2)} m`; Object.entries(data.stations).forEach(([code, series]) => {
const level = series.level[frame];
const discharge = series.discharge[frame];
readings.set(code, { water_level: level, discharge });
updateStationMarker(code, discharge);
if (discharge != null) totalFlow += discharge;
});
restyleRiver(readings);
const p1Level = data.stations['P.1'] ? data.stations['P.1'].level[frame] : null;
if (p1Level != null) state.p1Now = Number(p1Level);
restyleFloodZones(); restyleFloodZones();
i += 1; // 1 h per frame (~45 s total) const ts = new Date(data.timestamps[frame]);
if (i >= frames.length + 25) { // hold the last frame briefly const when = ts.toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit' });
window.clearInterval(state.replayTimer); const p1Text = state.p1Now == null ? '—' : state.p1Now.toFixed(2) + ' m';
state.replayTimer = null; $('p1-peak').textContent = `⏪ 2024 flood replay · ${when} · P.1 at ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`;
state.p1Now = state.p1RealNow; $('replay-clock-time').textContent = ts.toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
restyleFloodZones(); $('replay-clock-sub').textContent = `P.1 ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`;
button.textContent = '▶ Replay Oct 2024 flood'; i += 2; // 2 h per frame (~40 s total)
$('p1-peak').textContent = state.p1PeakText || ''; if (i >= n + 40) endReplay(); // hold the last frame briefly
}
}, 120); }, 120);
} catch (error) { } catch (error) {
button.textContent = `Replay unavailable: ${error.message}`; button.textContent = `Replay unavailable: ${error.message}`;
@@ -835,6 +888,7 @@
const demoLevel = Number(params.get('demo_level')); const demoLevel = Number(params.get('demo_level'));
const simulated = Number.isFinite(demoLevel) && demoLevel > 0; const simulated = Number.isFinite(demoLevel) && demoLevel > 0;
if (simulated) state.p1Now = demoLevel; if (simulated) state.p1Now = demoLevel;
if ((simulated || params.has('demo_rise')) && !state.replayTimer) setLiveIndicator('sim', '⚠ SIMULATION');
if (params.has('demo_rise') && !state.demoRiseTimer) { if (params.has('demo_rise') && !state.demoRiseTimer) {
let level = state.p1Now ?? 2.2; let level = state.p1Now ?? 2.2;
state.demoRiseTimer = window.setInterval(() => { state.demoRiseTimer = window.setInterval(() => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long