feat: replay shows when the model would have detected and warned
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 13s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s

The 2024 snapshot now carries a model track: p_warning and predicted
24 h peak from HGB heads trained ONLY on pre-flood data (an honest
backtest, cutoff 2024-08-31), plus its calibration sigma. During replay:

- the map clock shows the system's state at each moment: 'model: no
  flood expected' -> 'elevated risk' -> amber 'MODEL ALERT - flooding
  within 24 h likely (predicted peak X m)' -> red 'FLOODING' once the
  river actually crosses stage 1. First alert fires 24 Sep 01:00, a
  full day before the water crossed the flood line on 25 Sep.
- the flood-risk outlook stage chips re-render each frame from the
  historic model prediction (sigmoid over the stage levels), so the
  whole outlook panel time-travels with the map.
This commit is contained in:
2026-08-10 18:07:57 +07:00
parent d015420b66
commit 199b0e3483
2 changed files with 53 additions and 12 deletions
+52 -11
View File
@@ -137,6 +137,10 @@
} }
.replay-clock strong { display: block; font-size: 1.65rem; font-weight: 800; letter-spacing: -.02em; white-space: nowrap; } .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; } .replay-clock span { font-size: .78rem; opacity: .85; }
.replay-alert { display: block; margin-top: 5px; font-size: .82rem; font-weight: 800; min-height: 1.1em; }
.replay-alert.note { color: #9fc9e8; }
.replay-alert.warn { color: #f5c542; }
.replay-alert.flood { color: #ff8a7a; }
.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; }
@@ -221,7 +225,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> <div class="replay-clock" id="replay-clock"><strong id="replay-clock-time"></strong><span id="replay-clock-sub"></span><span class="replay-alert" id="replay-alert"></span></div>
</article> </article>
<aside class="side-card"> <aside class="side-card">
@@ -833,6 +837,39 @@
$('p1-peak').textContent = `⏪ 2024 flood replay · ${when} · P.1 at ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`; $('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-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`; $('replay-clock-sub').textContent = `P.1 ${p1Text} · basin flow ${Math.round(totalFlow).toLocaleString()} m³/s`;
// model track: what the forecast system (trained pre-flood) said at this moment
const model = data.model || {};
const pWarn = model.p_warn24 ? model.p_warn24[frame] : null;
const predMax = model.pred_max24 ? model.pred_max24[frame] : null;
// drive the flood-risk outlook chips from the historic model prediction
if (predMax != null && zoneData) {
const sigma = model.sigma24 || 0.17;
const stages = zoneData.features
.map((f) => ({
stage: f.properties.zone,
level: Number(f.properties.level),
p_exceed: 1 / (1 + Math.exp(-(predMax - f.properties.level) / sigma)),
}))
.filter((s, idx, arr) => arr.findIndex((o) => o.stage === s.stage) === idx)
.sort((a, b) => a.stage - b.stage);
state.p1Stages = stages;
renderStageChips(stages, 24);
$('p1-outlook').style.display = 'block';
}
const alertEl = $('replay-alert');
if (state.p1Now != null && state.p1Now >= 3.70) {
alertEl.textContent = '🌊 FLOODING · river above stage 1 (3.70 m)';
alertEl.className = 'replay-alert flood';
} else if (pWarn != null && pWarn >= 0.5) {
alertEl.textContent = `⚠ MODEL ALERT · flooding within 24 h likely${predMax != null ? ` · predicted peak ${predMax.toFixed(2)} m` : ''}`;
alertEl.className = 'replay-alert warn';
} else if (pWarn != null && pWarn >= 0.2) {
alertEl.textContent = `model: elevated flood risk (${Math.round(pWarn * 100)}% within 24 h)`;
alertEl.className = 'replay-alert note';
} else {
alertEl.textContent = pWarn == null ? '' : 'model: no flood expected';
alertEl.className = 'replay-alert note';
}
i += 2; // 2 h per frame (~40 s total) i += 2; // 2 h per frame (~40 s total)
if (i >= n + 40) endReplay(); // hold the last frame briefly if (i >= n + 40) endReplay(); // hold the last frame briefly
}, 120); }, 120);
@@ -875,6 +912,19 @@
return '#1e8b60'; return '#1e8b60';
} }
function renderStageChips(stages, horizonHours) {
const strip = $('p1-stages');
strip.replaceChildren();
(stages || []).forEach((s) => {
const chip = document.createElement('div');
chip.className = 'stage-chip';
chip.style.background = stageColor(s.p_exceed);
chip.title = `Stage ${s.stage}: river at ${s.level.toFixed(2)} m — ${Math.round(s.p_exceed * 100)}% within ${horizonHours} h`;
chip.innerHTML = `${Math.round(s.p_exceed * 100)}%<small>S${s.stage} · ${s.level.toFixed(2)} m</small>`;
strip.appendChild(chip);
});
}
function renderP1Outlook(rows) { function renderP1Outlook(rows) {
const card = $('p1-outlook'); const card = $('p1-outlook');
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));
@@ -902,16 +952,7 @@
restyleFloodZones(); restyleFloodZones();
$('p1-peak').textContent = (simulated ? `⚠ SIMULATION: P.1 at ${demoLevel.toFixed(2)} m (real: ${Number(row.current_level).toFixed(2)} m)` : $('p1-peak').textContent = (simulated ? `⚠ SIMULATION: P.1 at ${demoLevel.toFixed(2)} m (real: ${Number(row.current_level).toFixed(2)} m)` :
`Now ${Number(row.current_level).toFixed(2)} m`) + ` · predicted peak next ${row.horizon_hours} h: ${Number(row.predicted_max_level).toFixed(2)} m`; `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'); renderStageChips(row.stages, row.horizon_hours);
strip.replaceChildren();
row.stages.forEach((s) => {
const chip = document.createElement('div');
chip.className = 'stage-chip';
chip.style.background = stageColor(s.p_exceed);
chip.title = `Stage ${s.stage}: river at ${s.level.toFixed(2)} m — ${Math.round(s.p_exceed * 100)}% within ${row.horizon_hours} h`;
chip.innerHTML = `${Math.round(s.p_exceed * 100)}%<small>S${s.stage} · ${s.level.toFixed(2)} m</small>`;
strip.appendChild(chip);
});
card.style.display = 'block'; card.style.display = 'block';
} }
File diff suppressed because one or more lines are too long