Files
Northern-Thailand-Ping-Rive…/src/static/dashboard.html
T
grabowski 9516857d44
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
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Generate API Documentation (push) Successful in 10s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 24s
Documentation / Build Sphinx Documentation (push) Successful in 19s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s
Documentation / Documentation Summary (push) Successful in 2s
feat: date-range picker on the station history panel; drop PostgreSQL naming
/measurements/history/{code} accepts optional start/end date params that
override the hours window (end date inclusive). The dashboard history
card gains from/to date inputs beside the quick-range dropdown —
explicit dates win, changing the dropdown clears them. All user-facing
'PostgreSQL history' labels renamed to 'Station history'.
2026-08-11 15:52:40 +07:00

1175 lines
70 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);
pointer-events: auto; /* .map-overlay disables events; re-enable for the legend's rain toggle */
}
.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-code.long { font-size: .55rem; word-break: break-all; line-height: 1.15; padding: 2px; text-align: center; }
.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; } /* per-segment duration set inline from discharge */
@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 class="legend-title" style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px">Rainfall · 24 h
<label style="font-weight:650;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="rain-toggle" checked style="accent-color:#086b96">show</label>
</div>
<div class="legend-row"><i class="swatch" style="background:#74c1e4;border-radius:50%"></i> Light 0.110 mm</div>
<div class="legend-row"><i class="swatch" style="background:#2f96c4;border-radius:50%"></i> Moderate 1035</div>
<div class="legend-row"><i class="swatch" style="background:#086b96;border-radius:50%"></i> Heavy 3590</div>
<div class="legend-row"><i class="swatch" style="background:#d99018;border-radius:50%"></i> Very heavy 90150</div>
<div class="legend-row"><i class="swatch" style="background:#cc4b37;border-radius:50%"></i> Extreme &gt; 150</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 its 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>
<button type="button" class="zones-button" id="forecast-expand" style="display:none;margin-top:12px">Show all station forecasts ▾</button>
<div class="forecast-grid" id="forecast-grid" style="display:none"></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">Station history</h2><p id="history-status" class="subtitle">Select a station to load the last 7 days</p></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<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>
<input type="date" id="history-start" title="From date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
<span style="color:var(--muted)"></span>
<input type="date" id="history-end" title="To date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
</div>
</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>
<section class="stats" id="db-stats" aria-label="Database statistics" style="margin-top:14px;display:none;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))">
<article class="stat"><div class="stat-label">Total datapoints</div><div class="stat-value" id="db-total"></div><div class="stat-note">measurements stored</div></article>
<article class="stat"><div class="stat-label">Date range</div><div class="stat-value" id="db-range" style="font-size:1.05rem;line-height:1.3;white-space:normal"></div><div class="stat-note">first to last record</div></article>
<article class="stat"><div class="stat-label">Days spanned</div><div class="stat-value" id="db-days"></div><div class="stat-note">between first and last</div></article>
<article class="stat"><div class="stat-label">Stations</div><div class="stat-value" id="db-stations"></div><div class="stat-note">distinct in database</div></article>
<article class="stat"><div class="stat-label">Coverage</div><div class="stat-value" id="db-coverage"></div><div class="stat-note">of hourly slots recorded</div></article>
</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, rainLayer: 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';
}
// 24-h rain bins follow the TMD/ThaiWater classes: blues for ordinary rain,
// the site's warning amber/red once totals become flood-relevant.
function rainBin(mm) {
if (mm == null || Number.isNaN(mm) || mm <= 0) return { color: '#90a4a9', radius: 2.5, label: 'No rain', opacity: .45 };
if (mm <= 10) return { color: '#74c1e4', radius: 4.5, label: 'Light rain' };
if (mm <= 35) return { color: '#2f96c4', radius: 6, label: 'Moderate rain' };
if (mm <= 90) return { color: '#086b96', radius: 7.5, label: 'Heavy rain' };
if (mm <= 150) return { color: '#d99018', radius: 9.5, label: 'Very heavy rain' };
return { color: '#cc4b37', radius: 11.5, label: 'Extreme rain' };
}
function renderRainLayer(records) {
if (state.rainLayer) { state.map.removeLayer(state.rainLayer); state.rainLayer = null; }
if (!records || !records.length) return;
const group = L.layerGroup();
records.forEach((r) => {
if (!Number.isFinite(r.latitude) || !Number.isFinite(r.longitude)) return;
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
const bin = rainBin(mm);
const name = r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`;
const time = r.timestamp ? new Date(r.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading';
L.circleMarker([r.latitude, r.longitude], {
radius: bin.radius, color: '#ffffff', weight: 1.5,
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
}).bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(r.oldcode || 'Rain gauge')} · ${escapeHtml(bin.label)}</div>
<h3>${escapeHtml(name)}</h3><div class="popup-th">${escapeHtml(r.agency || 'HII')} rain gauge · ThaiWater</div>
<div class="popup-grid">
<div class="popup-metric"><span>Last 24 h</span><strong>${mm == null ? 'No data' : mm.toFixed(1) + ' mm'}</strong></div>
<div class="popup-metric"><span>Last hour</span><strong>${r.rain_1h == null ? '—' : Number(r.rain_1h).toFixed(1) + ' mm'}</strong></div>
</div>
<div class="popup-time">Reading: ${escapeHtml(time)}</div></div>`).addTo(group);
});
state.rainLayer = group;
const toggle = $('rain-toggle');
if (!toggle || toggle.checked) group.addTo(state.map);
}
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));
}
// Continuous dash-animation period from discharge: calm water drifts (~5.5 s
// per cycle), a flood races (~0.5 s). Applied as inline animation-duration on
// each SVG path so it can be updated live (setStyle cannot change classes).
function flowDuration(q) {
if (q == null) return 5.5;
return Math.max(.5, Math.min(5.5, 260 / (Number(q) + 45)));
}
function applyFlowSpeed(layerGroup, flows) {
layerGroup.eachLayer((layer) => {
if (layer._path) layer._path.style.animationDuration = `${flowDuration(flows.get(layer.feature))}s`;
});
}
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'
};
}
}).addTo(state.map);
flow.bringToBack();
casing.bringToBack();
applyFlowSpeed(flow, flowBySegment);
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 };
});
applyFlowSpeed(state.riverFlow, flows);
}
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} · station history`;
$('history-status').textContent = 'Loading historical measurements…';
try {
// Explicit dates win over the quick-range dropdown
const startDate = $('history-start').value, endDate = $('history-end').value;
const query = startDate || endDate
? [startDate && `start=${startDate}`, endDate && `end=${endDate}`].filter(Boolean).join('&')
: `hours=${$('history-range').value}`;
const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?${query}`);
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 !== rows.length ? ` (${sampled.length} daily averages)` : ''}` : 'No 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))
.sort((a, b) => (Number(b.bank_percent) || -1) - (Number(a.bank_percent) || -1));
$('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);
// ThaiWater situation bands by % of bank capacity: over-bank red,
// high amber, normal green, low/critically-low ochre tones.
const color = percent == null ? '#7b8f94'
: percent > 100 ? '#cc4b37'
: percent > 70 ? '#d99018'
: percent > 30 ? '#1e8b60'
: percent > 10 ? '#b3973f'
: '#8a5a2b';
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 gauge = sensor.offset_msl != null && sensor.water_level_msl != null
? ` (${(Number(sensor.water_level_msl) - Number(sensor.offset_msl)).toFixed(2)} m gauge)` : '';
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL${gauge}`;
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';
const codeClass = sensor.station_code.length > 5 ? 'station-code long' : 'station-code';
row.innerHTML = `<span class="${codeClass}" 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, hiiWlResponse, rainResponse] = await Promise.all([
fetch('/stations'),
fetch('/measurements/latest?limit=500'),
fetch('/static/ping-river-network.geojson'),
fetch('/api/hii/waterlevel/latest').catch(() => null),
fetch('/api/hii/rainfall/latest').catch(() => null)
]);
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 rainRecords = rainResponse && rainResponse.ok ? await rainResponse.json() : [];
// Prefer the DB-backed HII feed (no API key needed); fall back to the
// live ThaiWater passthrough when HII collection has no data yet.
let sensors = [];
const hiiRecords = hiiWlResponse && hiiWlResponse.ok ? await hiiWlResponse.json() : [];
if (hiiRecords.length) {
sensors = hiiRecords.map((r) => ({
station_code: r.rid_code || r.oldcode || `HII-${r.station_id}`,
station_name: r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`,
latitude: r.latitude, longitude: r.longitude,
water_level_msl: r.wl_msl, bank_percent: r.storage_percent,
distance_to_bank: r.diff_wl_bank, river_name: r.river_name,
agency: r.agency, situation_level: r.situation_level,
offset_msl: r.offset_msl, timestamp: r.timestamp
}));
} else {
const thaiWaterResponse = await fetch('/sensors/thaiwater').catch(() => null);
sensors = thaiWaterResponse && thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
}
const readings = latestByStation(measurements);
renderMap(stations, readings, riverNetwork);
renderRainLayer(rainRecords);
renderList(stations, readings);
renderThaiWaterSensors(sensors, 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
loadDbStats(); // non-blocking; the strip stays hidden if /api/stats is unavailable
} 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;
let peakFlow = null;
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;
if (!peakFlow || discharge > peakFlow.value) peakFlow = { code, value: discharge };
}
});
// stat tiles follow the replay (restored by loadDashboard on finish)
$('total-flow').textContent = totalFlow.toLocaleString(undefined, { maximumFractionDigits: 1 });
$('peak-flow').textContent = peakFlow ? peakFlow.value.toFixed(1) : '—';
$('peak-station').textContent = peakFlow ? `${peakFlow.code} · m³/s · 2024 replay` : 'No discharge reported';
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}` : '');
// Only the P.1 city outlook is shown by default; the per-station
// grid stays collapsed behind the expander.
const expand = $('forecast-expand');
const others = stations.filter(({ code }) => code !== 'P.1').length;
expand.style.display = others ? 'inline-flex' : 'none';
grid.style.display = state.forecastExpanded ? 'grid' : 'none';
expand.textContent = state.forecastExpanded
? 'Hide station forecasts ▴'
: `Show all ${stations.length} station forecasts ▾`;
card.style.display = 'block';
} catch (error) {
card.style.display = 'none';
}
}
async function loadDbStats() {
const strip = $('db-stats');
try {
const response = await fetch('/api/stats');
if (!response.ok) { strip.style.display = 'none'; return; }
const stats = await response.json();
const fmtDate = (value) => new Date(value).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
$('db-total').textContent = Number(stats.total_measurements).toLocaleString();
$('db-range').textContent = `${fmtDate(stats.first_timestamp)} ${fmtDate(stats.last_timestamp)}`;
$('db-days').textContent = Number(stats.days_spanned).toLocaleString();
$('db-stations').textContent = String(stats.station_count);
$('db-coverage').textContent = stats.coverage_percent == null ? '—' : `${Number(stats.coverage_percent).toFixed(1)}%`;
strip.style.display = 'grid';
} catch (error) {
strip.style.display = 'none';
}
}
$('refresh-button').addEventListener('click', loadDashboard);
$('zones-toggle').addEventListener('click', toggleFloodZones);
$('forecast-expand').addEventListener('click', () => {
state.forecastExpanded = !state.forecastExpanded;
$('forecast-grid').style.display = state.forecastExpanded ? 'grid' : 'none';
const count = $('forecast-grid').children.length;
$('forecast-expand').textContent = state.forecastExpanded
? 'Hide station forecasts ▴'
: `Show all ${count} station forecasts ▾`;
});
$('rain-toggle').addEventListener('change', (event) => {
if (!state.rainLayer) return;
if (event.target.checked) state.rainLayer.addTo(state.map);
else state.map.removeLayer(state.rainLayer);
});
$('replay-2024').addEventListener('click', replayFlood2024);
$('history-range').addEventListener('change', () => {
$('history-start').value = ''; $('history-end').value = '';
if (state.selectedStation) loadHistory(state.selectedStation);
});
['history-start', 'history-end'].forEach((id) => $(id).addEventListener('change', () => {
if (state.selectedStation) loadHistory(state.selectedStation);
}));
loadDashboard();
window.setInterval(loadDashboard, 5 * 60 * 1000);
})();
</script>
</body>
</html>