Files
Northern-Thailand-Ping-Rive…/src/static/dashboard.html
T
grabowski 6112c681a7
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 24s
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 12s
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Generate API Documentation (push) Successful in 8s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
docs: render of actual vs predicted through the 2024 flood; adaptive replay speed
docs/img/backtest-2024-p1.png: observed P.1 level vs the 24h-ahead
predicted peak issued at each hour by a model trained only on pre-flood
data, with the warning-probability panel below (first alert 24 Sep
01:00, 24 h before flooding began). Embedded in FLOOD_FORECASTING.md's
headline-validation section with an honest reading, including the
~0.4 m peak under-prediction.

Replay pacing is now adaptive: 4 h/frame through quiet days, 2 h when
risk is elevated, 1 h (hour-by-hour) while the model is alerting or the
river is near/above flood stage - so viewers can watch the detection
sequence unfold.
2026-08-10 18:10:48 +07:00

1019 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ping River Live Monitor</title>
<link rel="preconnect" href="https://unpkg.com">
<link rel="preconnect" href="https://tile.openstreetmap.org">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
<style>
:root {
--ink: #132b35;
--muted: #64777d;
--paper: #f3f7f5;
--card: #ffffff;
--river: #087da5;
--river-light: #38b4d5;
--mint: #dff4e8;
--green: #1e8b60;
--amber: #d99018;
--red: #cc4b37;
--border: #dce7e3;
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
color: var(--ink);
background:
radial-gradient(circle at 8% 0%, rgba(56, 180, 213, .12), transparent 25rem),
var(--paper);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
header {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
margin-bottom: 20px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
width: 48px; height: 48px; border-radius: 15px; display: grid; place-items: center;
color: white; font-size: 25px; background: linear-gradient(145deg, #0a91b9, #076787);
box-shadow: 0 10px 22px rgba(8, 125, 165, .25);
}
h1 { margin: 0; font-size: clamp(1.4rem, 2.5vw, 2rem); letter-spacing: -.035em; }
.subtitle { margin: 4px 0 0; color: var(--muted); font-size: .92rem; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.live-pill {
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
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);
}
button:hover { border-color: #a9c4bb; transform: translateY(-1px); }
button:disabled { opacity: .55; cursor: wait; transform: none; }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
.stat {
min-height: 112px; padding: 18px; background: var(--card); border: 1px solid var(--border);
border-radius: 17px; box-shadow: 0 6px 18px rgba(31,61,70,.045);
}
.stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; }
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
.map-card { position: relative; }
#station-map { height: 640px; width: 100%; background: #dcebea; }
.map-overlay {
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
}
.map-heading, .legend {
background: rgba(255,255,255,.93); backdrop-filter: blur(9px); border: 1px solid rgba(207,224,218,.9);
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
}
.map-heading strong { display: block; font-size: .9rem; }
.map-heading span { color: var(--muted); font-size: .72rem; }
.legend { font-size: .7rem; color: var(--muted); }
.legend-title { color: var(--ink); font-weight: 800; margin-bottom: 7px; }
.legend-row { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
.swatch { width: 9px; height: 9px; border-radius: 50%; }
.side-card { display: flex; flex-direction: column; max-height: 640px; }
.side-head { padding: 18px 18px 14px; border-bottom: 1px solid var(--border); }
.side-head h2 { margin: 0; font-size: 1rem; }
.side-head p { margin: 5px 0 0; color: var(--muted); font-size: .75rem; }
.station-list { overflow-y: auto; padding: 7px; }
.station-row {
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
}
.station-row:hover { background: #f1f7f5; transform: none; }
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
.station-name { overflow: hidden; }
.station-name strong, .station-name span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.station-name strong { font-size: .78rem; }
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: rgba(243,247,245,.88); }
.loading-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
.marker-wrap { background: none; border: 0; }
.flow-marker {
--marker-color: #087da5; --marker-size: 26px;
position: relative; width: var(--marker-size); height: var(--marker-size); display: grid; place-items: center;
border-radius: 50%; background: var(--marker-color); color: white; border: 3px solid white;
box-shadow: 0 4px 12px rgba(5,43,58,.35); font-size: 8px; font-weight: 900;
}
.flow-marker::before {
content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--marker-color);
opacity: .36; animation: pulse 2.2s ease-out infinite;
}
@keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } }
.flow-line { animation: riverMove 3s linear infinite; }
.flow-idle { animation-duration: 5.5s; }
.flow-slow { animation-duration: 3s; }
.flow-med { animation-duration: 1.9s; }
.flow-fast { animation-duration: 1.15s; }
.flow-surge { animation-duration: .7s; }
@keyframes riverMove { to { stroke-dashoffset: -40; } }
@media (prefers-reduced-motion: reduce) { .flow-line, .flow-marker::before { animation: none; } }
.line-swatch { width: 24px; height: 4px; border-radius: 2px; flex: none; }
.poi-marker {
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; }
.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; }
.forecast-station .fc-name { color: var(--muted); font-size: .68rem; margin: 2px 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.risk-chips { display: flex; gap: 6px; }
.risk-chip { flex: 1; text-align: center; border-radius: 8px; padding: 5px 4px; font-size: .64rem; font-weight: 800; color: white; }
.risk-chip span { display: block; font-weight: 650; font-size: .58rem; opacity: .85; }
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: #f7fbfa; }
.p1-outlook-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.p1-outlook-head strong { font-size: .88rem; }
.p1-peak { color: var(--muted); font-size: .76rem; }
.stage-strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.stage-chip { min-width: 74px; text-align: center; border-radius: 9px; padding: 6px 8px; font-size: .7rem; font-weight: 800; color: white; }
.stage-chip small { display: block; font-weight: 650; font-size: .6rem; opacity: .88; }
.zones-button { font-size: .72rem; padding: 7px 11px; }
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
.popup { min-width: 190px; }
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
.popup-th { color: var(--muted); font-size: .74rem; }
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
.popup-metric { background: #f1f7f5; padding: 8px; border-radius: 9px; }
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
@media (max-width: 900px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.workspace { grid-template-columns: 1fr; }
.side-card { max-height: 400px; }
}
@media (max-width: 560px) {
.shell { padding: 14px; }
header { align-items: flex-start; }
.subtitle, .live-pill { display: none; }
.stats { gap: 8px; }
.stat { min-height: 96px; padding: 14px; }
.stat-value { font-size: 1.25rem; }
#station-map { height: 540px; }
.map-overlay { left: 46px; }
.legend { display: none; }
}
</style>
</head>
<body>
<main class="shell">
<header>
<div class="brand">
<div class="brand-mark"></div>
<div>
<h1>Ping River Live Monitor</h1>
<p class="subtitle">Current water level and discharge across Northern Thailand</p>
</div>
</div>
<div class="header-actions">
<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>
</header>
<section class="stats" aria-label="River summary">
<article class="stat"><div class="stat-label">Reporting stations</div><div class="stat-value" id="station-count"></div><div class="stat-note">with current readings</div></article>
<article class="stat"><div class="stat-label">Combined discharge</div><div class="stat-value" id="total-flow"></div><div class="stat-note">sum of reported flows · m³/s</div></article>
<article class="stat"><div class="stat-label">Strongest flow</div><div class="stat-value" id="peak-flow"></div><div class="stat-note" id="peak-station">Awaiting station data</div></article>
<article class="stat"><div class="stat-label">Last updated</div><div class="stat-value" id="last-updated"></div><div class="stat-note" id="data-age">Loading latest readings</div></article>
</section>
<section class="workspace">
<article class="map-card">
<div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div>
<div class="map-overlay">
<div class="map-heading"><strong>Station flow map</strong><span>River width, colour &amp; dash speed follow live discharge</span></div>
<div class="legend">
<div class="legend-title">Flow status</div>
<div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low &lt; 25 m³/s</div>
<div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25100</div>
<div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100250</div>
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div>
<div class="legend-row"><i class="line-swatch" style="background:#69b7d0"></i> River · no nearby gauge</div>
<div class="legend-row"><i class="line-swatch" style="background:linear-gradient(90deg,#1e8b60,#087da5,#d99018,#cc4b37)"></i> River · gauge colour, dashes = flow</div>
</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="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">
<div class="side-head"><h2>Current station flow</h2><p>Select a station to locate it and load PostgreSQL history</p></div>
<div class="station-list" id="river-flow" aria-live="polite"></div>
<div class="side-head"><h2>Additional ThaiWater sensors</h2><p id="thaiwater-count">Loading Ping basin sensors…</p></div>
<div class="station-list" id="thaiwater-sensors" aria-live="polite"></div>
</aside>
</section>
<section class="map-card" id="forecast-card" style="margin-top:14px;padding:20px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
<div><h2 style="margin:0;font-size:1rem">Flood risk outlook <span style="color:var(--muted);font-weight:650;font-size:.72rem">· experimental</span></h2>
<p id="forecast-status" class="subtitle">Model probability of reaching warning / danger levels within 6, 12 and 24 hours</p></div>
</div>
<div class="p1-outlook" id="p1-outlook" style="display:none">
<div class="p1-outlook-head">
<div><strong>Chiang Mai city flood outlook · P.1 Nawarat Bridge</strong>
<div class="p1-peak" id="p1-peak"></div></div>
<button type="button" class="zones-button" id="zones-toggle">Show flood zones on map</button>
</div>
<div class="stage-strip" id="p1-stages"></div>
<div class="p1-peak" style="margin-top:7px">Chance the river reaches each official inundation stage within 24 h — city flooding begins at stage 1 (3.70 m); each stage floods additional districts.</div>
</div>
<div class="forecast-grid" id="forecast-grid"></div>
</section>
<section class="map-card" id="history-card" style="margin-top:14px;padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
<div><h2 id="history-title" style="margin:0;font-size:1rem">PostgreSQL history</h2><p id="history-status" class="subtitle">Select a RID flow station to load the last 7 days</p></div>
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white"><option value="24">24 hours</option><option value="168" selected>7 days</option><option value="720">30 days</option><option value="2160">90 days</option><option value="876000">All time</option></select>
</div>
<div style="height:260px;margin-top:14px;overflow:hidden;position:relative"><canvas id="history-chart" aria-label="Historical water level and discharge chart" style="display:block"></canvas></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
<script>
(function () {
'use strict';
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) {
if (flow == null || Number.isNaN(flow)) return '#7b8f94';
if (flow < 25) return '#1e8b60';
if (flow < 100) return '#087da5';
if (flow < 250) return '#d99018';
return '#cc4b37';
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[char]);
}
function latestByStation(measurements) {
const latest = new Map();
measurements.forEach((item) => {
const prior = latest.get(item.station_code);
if (!prior || new Date(item.timestamp) > new Date(prior.timestamp)) latest.set(item.station_code, item);
});
return latest;
}
function formatFlow(value) {
return value == null || Number.isNaN(Number(value)) ? 'No data' : `${Number(value).toFixed(1)} m³/s`;
}
function markerSize(flow) {
if (flow == null) return 24;
return Math.max(24, Math.min(42, 22 + Math.sqrt(Math.max(0, flow)) * 1.05));
}
function initMap() {
if (!window.L) throw new Error('The map library did not load. Check the internet connection.');
if (state.map) return;
state.map = L.map('station-map', { zoomControl: true, attributionControl: true }).setView([18.78, 98.98], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(state.map);
}
function clearLayers() {
state.layers.forEach((layer) => state.map.removeLayer(layer));
state.layers = [];
state.markers.clear();
}
function buildPopup(station, measurement) {
const flow = measurement ? measurement.discharge : null;
const level = measurement ? measurement.water_level : null;
const time = measurement ? new Date(measurement.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading available';
return `<div class="popup">
<div class="popup-code">${escapeHtml(station.station_code)}</div>
<h3>${escapeHtml(station.english_name)}</h3>
<div class="popup-th">${escapeHtml(station.thai_name)}</div>
<div class="popup-grid">
<div class="popup-metric"><span>Discharge</span><strong>${escapeHtml(formatFlow(flow))}</strong></div>
<div class="popup-metric"><span>Water level</span><strong>${level == null ? 'No data' : `${Number(level).toFixed(2)} m`}</strong></div>
</div>
<div class="popup-time">Reading: ${escapeHtml(time)}</div>
</div>`;
}
function nearestGaugeFlow(feature, gauges) {
const coords = feature.geometry && feature.geometry.coordinates;
if (!gauges.length || !coords || !coords.length) return null;
let best = Infinity, q = null;
const step = Math.max(1, Math.floor(coords.length / 5));
for (let i = 0; i < coords.length; i += step) {
const lon = coords[i][0], lat = coords[i][1];
gauges.forEach((g) => {
const d = (g.lat - lat) * (g.lat - lat) + (g.lon - lon) * (g.lon - lon);
if (d < best) { best = d; q = g.q; }
});
}
return best < 0.16 ? q : null; // only grade segments within ~0.4° (~45 km) of a gauge
}
function riverWeight(q) {
return q == null ? 2.5 : Math.max(3, Math.min(9, 2.5 + Math.sqrt(Math.max(0, q)) * .38));
}
function riverSpeedClass(q) {
if (q == null) return 'flow-idle';
if (q < 25) return 'flow-slow';
if (q < 100) return 'flow-med';
if (q < 250) return 'flow-fast';
return 'flow-surge';
}
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) }));
}
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);
const flow = L.geoJSON(riverNetwork, {
style: (f) => {
const q = flowBySegment.get(f);
return {
color: q == null ? '#69b7d0' : flowColor(q),
weight: riverWeight(q), opacity: .92, lineCap: 'round',
dashArray: '6 14', className: `flow-line ${riverSpeedClass(q)}`
};
}
}).addTo(state.map);
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) {
clearLayers();
renderRiverNetwork(riverNetwork, stations, readings);
const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude));
const bounds = [];
mapped.forEach((station) => {
const measurement = readings.get(station.station_code);
const flow = measurement && measurement.discharge != null ? Number(measurement.discharge) : null;
const color = flowColor(flow);
const size = markerSize(flow);
const icon = L.divIcon({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:${size}px">${escapeHtml(station.station_code.replace('P.', ''))}</div>`,
iconSize: [size, size], iconAnchor: [size / 2, size / 2], popupAnchor: [0, -size / 2]
});
const marker = L.marker([station.latitude, station.longitude], { icon, title: `${station.station_code} ${station.english_name}` })
.bindPopup(buildPopup(station, measurement))
.on('click', () => loadHistory(station.station_code))
.addTo(state.map);
state.layers.push(marker);
state.markers.set(station.station_code, marker);
bounds.push([station.latitude, station.longitude]);
});
if (!state.hasFit && bounds.length) {
state.map.fitBounds(bounds, { padding: [35, 35], maxZoom: 9 });
state.hasFit = true;
}
}
async function loadHistory(stationCode) {
state.selectedStation = stationCode;
state.historyRequestId++;
const reqId = state.historyRequestId;
$('history-title').textContent = `${stationCode} · PostgreSQL history`;
$('history-status').textContent = 'Loading historical measurements…';
try {
const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?hours=${$('history-range').value}`);
if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`);
const rows = await response.json();
if (reqId !== state.historyRequestId) return;
// Downsample to daily averages to prevent browser freezing with 50k+ points
const downsample = (data) => {
const buckets = {};
data.forEach((row) => {
const date = new Date(row.timestamp);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
const b = buckets[key];
if (row.discharge != null) b.discharge.push(row.discharge);
if (row.water_level != null) b.level.push(row.water_level);
});
return Object.values(buckets).map((b) => ({
timestamp: b.ts,
discharge: b.discharge.length ? b.discharge.reduce((a, c) => a + c, 0) / b.discharge.length : null,
water_level: b.level.length ? b.level.reduce((a, c) => a + c, 0) / b.level.length : null,
}));
};
const sampled = rows.length > 2000 ? downsample(rows) : rows;
// Safely clear existing chart instance
if (state.historyChart) { state.historyChart.destroy(); state.historyChart = null; }
// Clear any orphan Chart.js instance on the canvas
const existingChart = Chart.getChart($('history-chart'));
if (existingChart) existingChart.destroy();
state.historyChart = new Chart($('history-chart'), {
type: 'line',
data: {
labels: sampled.map((row) => new Date(row.timestamp).toLocaleString('en-TH', { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })),
datasets: [
{ label: 'Discharge (m³/s)', data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
{ label: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 }
]
},
options: {
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
interaction: { mode: 'index', intersect: false },
scales: {
flow: { type: 'linear', position: 'left' },
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } },
x: { ticks: { maxTicksLimit: 12 } }
},
plugins: {
legend: { display: true },
floodBands: { enabled: true }
}
},
plugins: [{
id: 'floodBands',
beforeDraw(chart, _args, pluginOptions) {
if (!pluginOptions.enabled || !chart.chartArea) return;
const levelScale = chart.scales?.level;
if (!levelScale || !Number.isFinite(levelScale.min) || !Number.isFinite(levelScale.max)) return;
const { ctx, chartArea } = chart;
const zones = [
{ min: levelScale.min, max: 3.0, color: 'rgba(30,139,96,.08)' },
{ min: 3.0, max: 4.5, color: 'rgba(217,144,24,.12)' },
{ min: 4.5, max: levelScale.max, color: 'rgba(204,75,55,.15)' },
];
ctx.save();
zones.forEach((z) => {
const min = Math.max(z.min, levelScale.min);
const max = Math.min(z.max, levelScale.max);
if (max <= min) return;
const top = levelScale.getPixelForValue(max);
const bottom = levelScale.getPixelForValue(min);
ctx.fillStyle = z.color;
ctx.fillRect(chartArea.left, top, chartArea.right - chartArea.left, bottom - top);
});
ctx.restore();
}
}]
});
$('history-status').textContent = rows.length ? `${rows.length} measurements (${sampled.length} daily) from PostgreSQL` : 'No PostgreSQL measurements in this period';
$('history-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
$('history-status').textContent = `History unavailable: ${error.message}`;
}
}
function renderList(stations, readings) {
const container = $('river-flow');
container.replaceChildren();
const ordered = [...stations].sort((a, b) => {
const af = readings.get(a.station_code)?.discharge;
const bf = readings.get(b.station_code)?.discharge;
return (bf == null ? -1 : Number(bf)) - (af == null ? -1 : Number(af));
});
ordered.forEach((station) => {
const measurement = readings.get(station.station_code);
const flow = measurement?.discharge == null ? null : Number(measurement.discharge);
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
row.innerHTML = `<span class="station-code" style="background:${flowColor(flow)}">${escapeHtml(station.station_code)}</span>
<span class="station-name"><strong>${escapeHtml(station.english_name)}</strong><span>${escapeHtml(station.thai_name)}</span></span>
<span class="flow-value">${flow == null ? '—' : flow.toFixed(1)}<span>m³/s</span></span>`;
row.addEventListener('click', () => {
const marker = state.markers.get(station.station_code);
if (marker) { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); }
loadHistory(station.station_code);
});
container.appendChild(row);
});
}
function renderThaiWaterSensors(sensors, existingCodes) {
const container = $('thaiwater-sensors');
container.replaceChildren();
const additional = sensors.filter((sensor) => !existingCodes.has(sensor.station_code));
$('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`;
additional.forEach((sensor) => {
const percent = sensor.bank_percent == null ? null : Number(sensor.bank_percent);
const color = percent == null ? '#7b8f94' : percent >= 100 ? '#cc4b37' : percent >= 80 ? '#d99018' : '#6c73b8';
const icon = L.divIcon({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`,
iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -11]
});
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL`;
const bank = sensor.distance_to_bank == null ? 'Unknown' : `${Number(sensor.distance_to_bank).toFixed(2)} m below bank`;
const marker = L.marker([sensor.latitude, sensor.longitude], { icon, title: `${sensor.station_code} ${sensor.station_name}` })
.bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(sensor.station_code)} · ThaiWater</div><h3>${escapeHtml(sensor.station_name)}</h3><div class="popup-th">${escapeHtml(sensor.river_name || 'Ping basin')} · ${escapeHtml(sensor.agency || '')}</div><div class="popup-grid"><div class="popup-metric"><span>Water level</span><strong>${escapeHtml(level)}</strong></div><div class="popup-metric"><span>Bank status</span><strong>${escapeHtml(bank)}</strong></div></div></div>`)
.addTo(state.map);
state.layers.push(marker);
state.markers.set(`thaiwater:${sensor.station_code}`, marker);
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
row.innerHTML = `<span class="station-code" style="background:${color}">${escapeHtml(sensor.station_code)}</span><span class="station-name"><strong>${escapeHtml(sensor.station_name)}</strong><span>${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater</span></span><span class="flow-value">${percent == null ? '—' : percent.toFixed(0) + '%'}<span>bank level</span></span>`;
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
container.appendChild(row);
});
}
function renderSummary(stations, readings) {
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
const flows = current.filter((m) => m.discharge != null).map((m) => ({ code: m.station_code, value: Number(m.discharge) }));
const total = flows.reduce((sum, item) => sum + item.value, 0);
const peak = flows.length ? flows.reduce((max, item) => item.value > max.value ? item : max) : null;
const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime()));
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
$('station-count').textContent = `${current.length} / ${stations.length}`;
$('total-flow').textContent = flows.length ? total.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '—';
$('peak-flow').textContent = peak ? peak.value.toFixed(1) : '—';
$('peak-station').textContent = peak ? `${peak.code} · m³/s` : 'No discharge reported';
$('last-updated').textContent = latest ? latest.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—';
if (latest) {
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
$('data-age').textContent = `${latest.toLocaleDateString([], { day: 'numeric', month: 'short' })} · ${minutes} min ago`;
} else $('data-age').textContent = 'No timestamp available';
}
async function loadDashboard() {
const refresh = $('refresh-button');
refresh.disabled = true;
$('loading').style.display = 'grid';
$('error').style.display = 'none';
try {
initMap();
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([
fetch('/stations'),
fetch('/measurements/latest?limit=500'),
fetch('/static/ping-river-network.geojson'),
fetch('/sensors/thaiwater')
]);
if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) {
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
}
const stations = await stationResponse.json();
const measurements = await measurementResponse.json();
const riverNetwork = await riverResponse.json();
const thaiWaterSensors = thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
const readings = latestByStation(measurements);
renderMap(stations, readings, riverNetwork);
renderList(stations, readings);
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code)));
loadCustomMarkers();
renderSummary(stations, readings);
$('loading').style.display = 'none';
loadForecasts(); // non-blocking; the card stays hidden until models are deployed
} catch (error) {
$('loading').style.display = 'none';
$('error').style.display = 'grid';
$('error-message').textContent = error.message;
console.error('Dashboard load failed:', error);
} finally {
refresh.disabled = false;
}
}
function riskColor(pWarning, pDanger) {
if (pDanger >= .5) return '#cc4b37';
if (pWarning >= .5 || pDanger >= .2) return '#d99018';
if (pWarning >= .2) return '#0a91b9';
return '#1e8b60';
}
// 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 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;
if (flooded) {
// river has reached this zone's trigger level: render as water
return { color: '#1b5e88', weight: 2, dashArray: null, fillColor: '#2f86c8', fillOpacity: .62 };
}
return {
color: '#8a5a2b',
weight: 1.2,
dashArray: '4 4',
fillColor: ZONE_COLORS[zone] || '#f9e3a1',
fillOpacity: .38 + .3 * 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 zones level now</div>' : ''}</div>`;
}
async function showFloodZones(fly) {
if (!state.map || zoneLayer) return;
const button = $('zones-toggle');
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);
if (button) button.textContent = 'Hide flood zones';
if (fly) {
state.map.flyToBounds(zoneLayer.getBounds(), { maxZoom: 13, duration: .8 });
document.getElementById('station-map').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} catch (error) {
if (button) button.textContent = 'Flood zones unavailable';
}
}
function toggleFloodZones() {
if (zoneLayer) {
state.map.removeLayer(zoneLayer);
zoneLayer = null;
state.zonesUserHidden = true; // don't auto-show again this session
const button = $('zones-toggle');
if (button) button.textContent = 'Show flood zones on map';
} else {
state.zonesUserHidden = false;
showFloodZones(true);
}
}
function restyleFloodZones() {
if (zoneLayer) zoneLayer.setStyle(zoneStyle);
// Flooding in progress: surface the zones even if the layer is off,
// unless the user explicitly hid it.
const anyFlooded = state.p1Now != null && (state.p1Stages || []).some((s) => state.p1Now >= s.level);
if (anyFlooded && !zoneLayer && !state.zonesUserHidden) showFloodZones(false);
}
function pointInRing(lat, lon, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function zoneOfPoint(lat, lon) {
if (!zoneData) return null;
let best = null;
zoneData.features.forEach((feature) => {
const geom = feature.geometry;
const polygons = geom.type === 'Polygon' ? [geom.coordinates] : geom.type === 'MultiPolygon' ? geom.coordinates : [];
const inside = polygons.some((rings) => rings.length && pointInRing(lat, lon, rings[0]));
if (inside && (!best || feature.properties.zone < best.properties.zone)) best = feature;
});
return best;
}
function poiPopupHtml(poi) {
const zone = zoneOfPoint(poi.lat, poi.lon);
let riskHtml = '<div class="popup-th">Outside the mapped flood zones</div>';
if (zone) {
const st = zoneStage(zone.properties.zone);
riskHtml = `<div class="popup-grid">
<div class="popup-metric"><span>Flood zone</span><strong>${zone.properties.zone} · P.1 ≥ ${Number(zone.properties.level).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>`;
}
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) { endReplay(); return; }
button.textContent = 'Loading 2024 data…';
try {
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 = 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();
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`;
// 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';
}
// Variable speed: race through quiet days, slow to hour-by-hour while
// the model is detecting and the flood unfolds, so the alert sequence
// (elevated risk -> model alert -> flooding) is readable.
const critical = (pWarn != null && pWarn >= 0.5) || (state.p1Now != null && state.p1Now >= 3.5);
const elevated = (pWarn != null && pWarn >= 0.2) || (state.p1Now != null && state.p1Now >= 2.8);
i += critical ? 1 : elevated ? 2 : 4;
if (i >= n + 40) endReplay(); // hold the last frame briefly
}, 120);
} catch (error) {
button.textContent = `Replay unavailable: ${error.message}`;
}
}
async function loadCustomMarkers() {
try {
const response = await fetch('/static/custom-markers.json');
if (!response.ok) return;
const pois = await response.json();
if (!Array.isArray(pois)) return;
if (!zoneData) {
try {
const zonesResponse = await fetch('/static/flood-zones-p1.geojson');
if (zonesResponse.ok) zoneData = await zonesResponse.json();
} catch (error) { /* zones optional for markers */ }
}
pois.forEach((poi) => {
if (!Number.isFinite(poi.lat) || !Number.isFinite(poi.lon)) return;
const icon = L.divIcon({
className: 'marker-wrap',
html: `<div class="poi-marker">${escapeHtml(poi.icon || '📍')}</div>`,
iconSize: [26, 26], iconAnchor: [13, 24], popupAnchor: [0, -22]
});
const marker = L.marker([poi.lat, poi.lon], { icon, title: poi.name })
.bindPopup(() => poiPopupHtml(poi))
.addTo(state.map);
state.layers.push(marker);
});
} catch (error) { /* markers are optional */ }
}
function stageColor(p) {
if (p >= .5) return '#cc4b37';
if (p >= .2) return '#d99018';
if (p >= .05) return '#0a91b9';
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));
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);
// Demo hooks: ?demo_level=4.4 pins a simulated P.1 level; ?demo_rise=1 animates
// the water from the real level up to the 2024 record (5.30 m).
const params = new URLSearchParams(location.search);
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(() => {
level = Math.min(level + 0.05, 5.30);
state.p1Now = level;
$('p1-peak').textContent = `⚠ SIMULATION: rising water · P.1 at ${level.toFixed(2)} m (real: ${Number(row.current_level).toFixed(2)} m)`;
restyleFloodZones();
if (level >= 5.30) window.clearInterval(state.demoRiseTimer);
}, 700);
}
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`;
renderStageChips(row.stages, row.horizon_hours);
card.style.display = 'block';
}
async function loadForecasts() {
const card = $('forecast-card');
try {
const response = await fetch('/forecast');
if (!response.ok) { card.style.display = 'none'; return; }
const rows = await response.json();
if (!Array.isArray(rows) || !rows.length) { card.style.display = 'none'; return; }
const byStation = new Map();
rows.forEach((row) => {
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
byStation.get(row.station_code).push(row);
});
renderP1Outlook(rows);
const grid = $('forecast-grid');
grid.replaceChildren();
const stations = [...byStation.entries()].map(([code, list]) => ({
code, list: list.sort((a, b) => a.horizon_hours - b.horizon_hours),
worst: Math.max(...list.map((r) => Math.max(r.p_danger ?? 0, (r.p_warning ?? 0) * .5)))
})).sort((a, b) => b.worst - a.worst);
stations.forEach(({ code, list }) => {
const first = list[0];
const cardEl = document.createElement('div');
cardEl.className = 'forecast-station';
const chips = list.map((r) => {
const pw = r.p_warning ?? 0, pd = r.p_danger ?? 0;
const pct = Math.round(Math.max(pw, pd) * 100);
const title = `+${r.horizon_hours}h · warning ${Math.round(pw * 100)}% · danger ${Math.round(pd * 100)}%` +
(r.predicted_max_level == null ? '' : ` · peak ~${Number(r.predicted_max_level).toFixed(2)} m`) +
(r.source === 'heuristic' ? ' · heuristic fallback' : '');
return `<div class="risk-chip" style="background:${riskColor(pw, pd)}" title="${escapeHtml(title)}">${pct}%<span>${r.horizon_hours}h</span></div>`;
}).join('');
cardEl.innerHTML = `<strong>${escapeHtml(code)}</strong>` +
`<div class="fc-name">${first.current_level == null ? '' : `now ${Number(first.current_level).toFixed(2)} m · `}peak risk next 24h</div>` +
`<div class="risk-chips">${chips}</div>`;
grid.appendChild(cardEl);
});
const asOf = rows[0].as_of ? new Date(rows[0].as_of).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : null;
$('forecast-status').textContent = `Probability of exceeding warning / danger level within 6, 12 and 24 h` +
(asOf ? ` · based on readings up to ${asOf}` : '');
card.style.display = 'block';
} catch (error) {
card.style.display = 'none';
}
}
$('refresh-button').addEventListener('click', loadDashboard);
$('zones-toggle').addEventListener('click', toggleFloodZones);
$('replay-2024').addEventListener('click', replayFlood2024);
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
loadDashboard();
window.setInterval(loadDashboard, 5 * 60 * 1000);
})();
</script>
</body>
</html>