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
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:
+99
-45
@@ -50,6 +50,8 @@
|
||||
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-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 {
|
||||
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);
|
||||
@@ -127,6 +129,14 @@
|
||||
width: 26px; height: 26px; display: grid; place-items: center; font-size: 19px;
|
||||
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-station { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; }
|
||||
.forecast-station strong { font-size: .8rem; }
|
||||
@@ -181,7 +191,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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="refresh-button" type="button">↻ Refresh</button>
|
||||
</div>
|
||||
@@ -211,6 +221,7 @@
|
||||
</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="replay-clock" id="replay-clock"><strong id="replay-clock-time">—</strong><span id="replay-clock-sub"></span></div>
|
||||
</article>
|
||||
|
||||
<aside class="side-card">
|
||||
@@ -346,15 +357,23 @@
|
||||
return 'flow-surge';
|
||||
}
|
||||
|
||||
function renderRiverNetwork(riverNetwork, stations, readings) {
|
||||
if (!riverNetwork) return;
|
||||
const gauges = stations
|
||||
function riverGauges(stations, readings) {
|
||||
return stations
|
||||
.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 }))
|
||||
.filter((g) => g.q != null)
|
||||
.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, {
|
||||
style: (f) => ({ color: '#e3f4f8', weight: riverWeight(flowBySegment.get(f)) + 4.5, opacity: .8, lineCap: 'round' })
|
||||
}).addTo(state.map);
|
||||
@@ -371,6 +390,41 @@
|
||||
flow.bringToBack();
|
||||
casing.bringToBack();
|
||||
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) {
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
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() {
|
||||
const button = $('replay-2024');
|
||||
if (state.replayTimer) { // stop a running replay
|
||||
window.clearInterval(state.replayTimer);
|
||||
state.replayTimer = null;
|
||||
state.p1Now = state.p1RealNow;
|
||||
restyleFloodZones();
|
||||
button.textContent = '▶ Replay Oct 2024 flood';
|
||||
$('p1-peak').textContent = state.p1PeakText || '';
|
||||
return;
|
||||
}
|
||||
if (state.replayTimer) { endReplay(); return; }
|
||||
button.textContent = 'Loading 2024 data…';
|
||||
try {
|
||||
// pre-extracted static snapshot (15 kB); fall back to the live history API
|
||||
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}`);
|
||||
const rows = await response.json();
|
||||
frames = rows.filter((row) => {
|
||||
const t = row.timestamp;
|
||||
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;
|
||||
const response = await fetch('/static/flood-2024-all.json');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
const n = data.timestamps.length;
|
||||
if (n < 10) throw new Error('snapshot empty');
|
||||
state.zonesUserHidden = false;
|
||||
await showFloodZones(false);
|
||||
button.textContent = '⏹ Stop replay';
|
||||
setLiveIndicator('replay', '⏪ 2024 REPLAY');
|
||||
$('replay-clock').style.display = 'block';
|
||||
let i = 0;
|
||||
state.replayTimer = window.setInterval(() => {
|
||||
const frame = frames[Math.min(i, frames.length - 1)];
|
||||
state.p1Now = Number(frame.water_level);
|
||||
const when = new Date(frame.timestamp).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit' });
|
||||
$('p1-peak').textContent = `⏪ 2024 flood replay · ${when} · P.1 at ${state.p1Now.toFixed(2)} m`;
|
||||
const frame = Math.min(i, n - 1);
|
||||
const readings = new Map();
|
||||
let totalFlow = 0;
|
||||
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();
|
||||
i += 1; // 1 h per frame (~45 s total)
|
||||
if (i >= frames.length + 25) { // hold the last frame briefly
|
||||
window.clearInterval(state.replayTimer);
|
||||
state.replayTimer = null;
|
||||
state.p1Now = state.p1RealNow;
|
||||
restyleFloodZones();
|
||||
button.textContent = '▶ Replay Oct 2024 flood';
|
||||
$('p1-peak').textContent = state.p1PeakText || '';
|
||||
}
|
||||
const ts = new Date(data.timestamps[frame]);
|
||||
const when = ts.toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit' });
|
||||
const p1Text = state.p1Now == null ? '—' : state.p1Now.toFixed(2) + ' m';
|
||||
$('p1-peak').textContent = `⏪ 2024 flood replay · ${when} · P.1 at ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`;
|
||||
$('replay-clock-time').textContent = ts.toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
$('replay-clock-sub').textContent = `P.1 ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`;
|
||||
i += 2; // 2 h per frame (~40 s total)
|
||||
if (i >= n + 40) endReplay(); // hold the last frame briefly
|
||||
}, 120);
|
||||
} catch (error) {
|
||||
button.textContent = `Replay unavailable: ${error.message}`;
|
||||
@@ -835,6 +888,7 @@
|
||||
const demoLevel = Number(params.get('demo_level'));
|
||||
const simulated = Number.isFinite(demoLevel) && demoLevel > 0;
|
||||
if (simulated) state.p1Now = demoLevel;
|
||||
if ((simulated || params.has('demo_rise')) && !state.replayTimer) setLiveIndicator('sim', '⚠ SIMULATION');
|
||||
if (params.has('demo_rise') && !state.demoRiseTimer) {
|
||||
let level = state.p1Now ?? 2.2;
|
||||
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
Reference in New Issue
Block a user