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
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:
+52
-11
@@ -137,6 +137,10 @@
|
||||
}
|
||||
.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-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-station { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; }
|
||||
.forecast-station strong { font-size: .8rem; }
|
||||
@@ -221,7 +225,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>
|
||||
<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>
|
||||
|
||||
<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`;
|
||||
$('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`;
|
||||
// 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)
|
||||
if (i >= n + 40) endReplay(); // hold the last frame briefly
|
||||
}, 120);
|
||||
@@ -875,6 +912,19 @@
|
||||
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) {
|
||||
const card = $('p1-outlook');
|
||||
const p1rows = rows.filter((r) => r.station_code === 'P.1' && Array.isArray(r.stages));
|
||||
@@ -902,16 +952,7 @@
|
||||
restyleFloodZones();
|
||||
$('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`;
|
||||
const strip = $('p1-stages');
|
||||
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);
|
||||
});
|
||||
renderStageChips(row.stages, row.horizon_hours);
|
||||
card.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user