Files
Northern-Thailand-Ping-Rive…/src/static/dashboard.html
T
grabowski 811af1625b
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 15s
Documentation / Generate API Documentation (push) Successful in 9s
Documentation / Documentation Summary (push) Successful in 2s
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
feat: dashboard shows the live model version; DB stats count openmeteo_rain
The flood-outlook subtitle now reads '... · model hgb-v3+<sha> (trained
<date>)' from the forecast rows, so the deployed model is always visible
on the page. /api/stats adds openmeteo_rain_measurements (guarded — the
table may not exist on older DBs) to the whole-DB total, and the
Total-datapoints tile note gains a 'forecast rain' entry.
2026-08-12 17:35:23 +07:00

1388 lines
86 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 · Chiang Mai Water Levels &amp; Flood Forecast</title>
<meta name="description" content="Live water levels, discharge, rainfall and ML flood forecasts for the Ping River in Chiang Mai and Northern Thailand. Hourly data from 16 RID gauges and 400+ ThaiWater/HII stations, with official inundation-stage outlooks for Chiang Mai city.">
<link rel="canonical" href="https://water.buildfor.life/">
<meta name="theme-color" content="#f3f7f5">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8C%8A%3C/text%3E%3C/svg%3E">
<script defer src="https://stats.buildfor.life/script.js" data-website-id="00b2be73-8f5f-4400-9029-3be852eb08f7"></script>
<meta property="og:type" content="website">
<meta property="og:title" content="Ping River Live Monitor · Chiang Mai Flood Watch">
<meta property="og:description" content="Live water levels, rainfall and flood-risk forecasts for the Ping River basin, Northern Thailand.">
<meta property="og:url" content="https://water.buildfor.life/">
<meta property="og:site_name" content="Ping River Live Monitor">
<meta name="twitter:card" content="summary">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"Ping River Live Monitor","url":"https://water.buildfor.life/","description":"Live water levels, discharge, rainfall and flood forecasts for the Ping River, Chiang Mai, Thailand.","applicationCategory":"Weather","operatingSystem":"Web","offers":{"@type":"Offer","price":"0","priceCurrency":"THB"},"creator":{"@type":"Organization","name":"B4L","url":"https://buildfor.life"}}</script>
<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; min-height: 0; }
/* The RID flow list keeps at least ~5 rows even with the sensor list expanded */
#river-flow { flex: 1 1 auto; min-height: 280px; }
#thaiwater-sensors { flex: 1 1 auto; }
.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; }
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
#flood-verdict.ok { background: #e2f3ea; color: #0c5138; border-color: #bfe3d2; }
#flood-verdict.watch { background: #fdf1dc; color: #6b4a05; border-color: #f0d9a8; }
#flood-verdict.danger { background: #fbe3de; color: #7c1d10; border-color: #f2c0b6; }
#legend-toggle {
display: none; pointer-events: auto; background: rgba(255,255,255,.93); border: 1px solid rgba(207,224,218,.9);
border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink);
box-shadow: 0 7px 20px rgba(22,58,68,.12);
}
@media (max-width: 900px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.workspace { grid-template-columns: 1fr; }
/* No fixed cap: the sensors expander must stay reachable below the RID list */
.side-card { max-height: 70vh; }
#river-flow { min-height: 160px; }
}
@media (max-width: 560px) {
.shell { padding: 14px; }
header { align-items: flex-start; }
.subtitle { display: none; }
.live-pill { padding: 8px; }
#live-pill-text { display: none; } /* dot stays: live vs replay/simulation signal */
#replay-2024 { white-space: nowrap; padding: 9px 11px; font-size: .72rem; }
.stats { gap: 8px; }
.stat { min-height: 96px; padding: 14px; }
.stat-value { font-size: 1.25rem; }
#station-map { height: 540px; }
.map-overlay { left: 46px; }
#legend-toggle { display: inline-flex; }
.legend { display: none; }
.legend.open { display: block; max-height: 42vh; overflow-y: auto; }
}
</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 id="flood-verdict" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:15px 18px;border-radius:16px;border:1px solid;display:none">
<div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap">
<strong id="verdict-icon" style="font-size:1.2rem"></strong>
<div style="min-width:0;flex:1">
<strong id="verdict-text" style="font-size:.95rem"></strong>
<div id="verdict-sub" style="font-size:.72rem;margin-top:3px;opacity:.85">Experimental model — not an official warning · official alerts: <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener" style="color:inherit">ThaiWater ↗</a></div>
</div>
</div>
</section>
<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" id="station-count-note">RID gauges with current readings</div></article>
<article class="stat" title="Discharge: the volume of water flowing past the gauge each second (cubic meters per second)"><div class="stat-label">Chiang Mai river flow</div><div class="stat-value" id="total-flow"></div><div class="stat-note" id="cm-flow-note">P.1 Nawarat Bridge · m³/s</div></article>
<article class="stat" title="How full the river channel is at the busiest gauge — 100% means water reaches the top of the bank"><div class="stat-label">Most stressed gauge</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 style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;min-width:0">
<button type="button" id="legend-toggle" aria-expanded="false" aria-controls="map-legend">☰ Legend</button>
<div class="legend" id="map-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:#b7a6e3;border-radius:50%"></i> Light 0.110 mm</div>
<div class="legend-row"><i class="swatch" style="background:#8f6fd2;border-radius:50%"></i> Moderate 1035</div>
<div class="legend-row"><i class="swatch" style="background:#6844b8;border-radius:50%"></i> Heavy 3590</div>
<div class="legend-row"><i class="swatch" style="background:#472a96;border-radius:50%"></i> Very heavy 90150</div>
<div class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> Extreme &gt; 150</div>
<div class="legend-title" style="margin-top:10px">Other markers</div>
<div class="legend-row"><i class="swatch" style="background:#7b8f94"></i> Gauge · no recent data</div>
<div class="legend-row"><span style="font-weight:800;color:#0f6844">+</span> Water-level sensor · colour = % of bank height</div>
</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>
<input type="search" id="station-search" placeholder="Search stations · code, name, river…" aria-label="Search stations" style="margin-top:10px;width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white;font-size:.8rem">
</div>
<div class="station-list" id="river-flow" aria-live="polite"></div>
<div class="side-head" id="sensors-head" style="cursor:pointer" role="button" tabindex="0" aria-expanded="false" aria-controls="thaiwater-sensors" title="Show / hide the ThaiWater/HII station list"><h2>Additional basin stations <span id="sensors-arrow" style="color:var(--muted);font-size:.8rem"></span></h2><p id="thaiwater-count">Loading ThaiWater/HII stations…</p></div>
<div class="station-list" id="thaiwater-sensors" aria-live="polite" style="display:none"></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>
<p class="subtitle" style="margin-top:3px">Not an official warning — for official alerts see <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener">ThaiWater</a> and <a href="https://www.tmd.go.th/" target="_blank" rel="noopener">TMD</a></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">Last 24 hours</option><option value="168" selected>Last 7 days</option><option value="720">Last 30 days</option><option value="2160">Last 90 days</option><option value="876000">All time</option><option value="custom" hidden>Custom range</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 id="history-placeholder" style="position:absolute;inset:0;display:grid;place-items:center;color:var(--muted);font-size:.85rem;text-align:center;padding:20px">Click any station on the map or in the list to see its history</div></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" id="db-total-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" id="db-stations-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 RID hourly slots recorded</div></article>
</section>
<footer style="margin:22px 0 10px;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:.75rem">
<span>Open-source river monitoring for Northern Thailand · data from RID &amp; ThaiWater/HII</span>
<nav style="display:flex;gap:16px">
<a href="https://buildfor.life" target="_blank" rel="noopener" style="color:var(--ink);font-weight:700;text-decoration:none">buildfor.life ↗</a>
<a href="https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor" target="_blank" rel="noopener" style="color:var(--ink);font-weight:700;text-decoration:none">Source code ↗</a>
<a href="/docs" target="_blank" rel="noopener" style="color:var(--ink);font-weight:700;text-decoration:none">API docs ↗</a>
</nav>
</footer>
</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, useDates: false, forecastExpanded: false };
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. Violet sequential ramp:
// distinct from the flow palette so a map dot is never ambiguous between
// "flow status" and "rainfall amount".
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: '#b7a6e3', radius: 4.5, label: 'Light rain' };
if (mm <= 35) return { color: '#8f6fd2', radius: 6, label: 'Moderate rain' };
if (mm <= 90) return { color: '#6844b8', radius: 7.5, label: 'Heavy rain' };
if (mm <= 150) return { color: '#472a96', radius: 9.5, label: 'Very heavy rain' };
return { color: '#2a1668', radius: 11.5, label: 'Extreme rain' };
}
// Chips that carry text need contrast-safe pairings: slightly darkened
// fills for white ink, dark ink on the light amber/ochre fills.
const CHIP_BG = { '#1e8b60': '#0f6844', '#087da5': '#076d90', '#cc4b37': '#c23a25', '#7b8f94': '#5f7278' };
const CHIP_DARK_INK = { '#d99018': '#3a2a00', '#b3973f': '#241c04' };
function chipStyle(color) {
const ink = CHIP_DARK_INK[color];
return `background:${CHIP_BG[color] || color};color:${ink || '#ffffff'}`;
}
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.title = label || 'LIVE DATA'; // mobile shows the dot only
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…';
$('history-placeholder').style.display = 'none';
try {
// Hand-edited dates win; dropdown-driven loads use the precise
// trailing-hours window (the pickers just mirror it as dates).
const startDate = $('history-start').value, endDate = $('history-end').value;
const query = state.useDates && (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;
// Issued model predictions (recorded hourly by the server) —
// overlays what the model said would happen, when it said it.
let predSeries = null;
try {
const fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`);
const forecastRows = fr.ok ? await fr.json() : [];
if (forecastRows.length) {
const keyOf = (value) => {
const d = new Date(value);
return rows.length > 2000
? `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`
: `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${d.getUTCHours()}`;
};
const byKey = new Map();
forecastRows.forEach((r) => {
if (r.predicted_max_level == null) return;
const key = keyOf(r.as_of);
if (!byKey.has(key)) byKey.set(key, []);
byKey.get(key).push(Number(r.predicted_max_level));
});
predSeries = sampled.map((row) => {
const vals = byKey.get(keyOf(row.timestamp));
return vals ? vals.reduce((a, c) => a + c, 0) / vals.length : null;
});
if (!predSeries.some((v) => v != null)) predSeries = null;
}
} catch (error) { /* overlay is optional */ }
// 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 },
...(predSeries ? [{ label: 'Model 24 h peak (as issued)', data: predSeries, borderColor: '#7c4fd0', borderDash: [6, 4], yAxisID: 'level', pointRadius: 0, tension: .25, spanGaps: true }] : [])
]
},
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;
// Per-station thresholds from /forecast; P.1's official
// levels as fallback. No thresholds -> no bands (the old
// hardcoded 3.0/4.5 were wrong for most stations).
const thr = state.stationThresholds?.[state.selectedStation]
|| (state.selectedStation === 'P.1' ? { warning: 3.7, danger: 4.2 } : null);
if (!thr) return;
const { ctx, chartArea } = chart;
const zones = [
{ min: levelScale.min, max: thr.warning, color: 'rgba(30,139,96,.08)' },
{ min: thr.warning, max: thr.danger, color: 'rgba(217,144,24,.12)' },
{ min: thr.danger, 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';
} catch (error) {
$('history-status').textContent = `History unavailable: ${error.message}`;
}
}
function setSensorsOpen(open) {
state.sensorsOpen = open;
$('thaiwater-sensors').style.display = open ? '' : 'none';
$('sensors-arrow').textContent = open ? '▾' : '▸';
$('sensors-head').setAttribute('aria-expanded', String(open));
}
// Filters both station lists by any text on the row (code, names, river —
// Thai included, since it matches against the rendered text).
function applyStationSearch() {
const query = $('station-search').value.trim().toLowerCase();
if (query && !state.sensorsOpen) setSensorsOpen(true); // search reaches hidden stations
['river-flow', 'thaiwater-sensors'].forEach((id) => {
[...$(id).children].forEach((row) => {
row.style.display = !query || row.textContent.toLowerCase().includes(query) ? '' : 'none';
});
});
}
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="${chipStyle(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));
state.hiiReportingCount = additional.length;
$('thaiwater-count').textContent = `${additional.length} Ping basin stations · ThaiWater/HII 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="${chipStyle(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>of bank height</span></span>`;
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
container.appendChild(row);
});
}
// At-a-glance verdict from P.1 level + 24 h forecast. Level thresholds are
// the official Chiang Mai inundation stages (city flooding starts 3.70 m).
function updateFloodVerdict() {
const banner = $('flood-verdict');
const level = state.p1Now;
const fc = state.p1Forecast24; // {p_warning, p_danger, predicted_max_level} or null
if (level == null && !fc) { banner.style.display = 'none'; return; }
let cls, icon, text;
if (level != null && level >= 3.7) {
cls = 'danger'; icon = '🛑';
text = `Chiang Mai city flooding stage reached — river at ${level.toFixed(2)} m (flooding starts at 3.70 m)`;
} else if (fc && (fc.p_danger >= .5 || fc.p_warning >= .7)) {
cls = 'danger'; icon = '🛑';
text = `Flooding likely — the river may reach warning level within 24 h (now ${level == null ? '—' : level.toFixed(2) + ' m'}, warning at 3.70 m)`;
} else if ((fc && fc.p_warning >= .2) || (level != null && level >= 3.2)) {
cls = 'watch'; icon = '⚠️';
text = `Watch — river elevated at ${level == null ? '—' : level.toFixed(2) + ' m'}; ${fc ? Math.round(fc.p_warning * 100) + '% chance of reaching the 3.70 m warning level within 24 h' : 'monitor conditions'}`;
} else {
cls = 'ok'; icon = '✓';
text = `No Chiang Mai city flooding expected in the next 24 h — river at ${level == null ? '—' : level.toFixed(2) + ' m'}, flooding starts at 3.70 m`;
}
banner.className = cls;
$('verdict-icon').textContent = icon;
$('verdict-text').textContent = text;
banner.style.display = 'block';
}
function renderSummary(stations, readings) {
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
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}`;
if (state.hiiReportingCount != null) {
$('station-count-note').textContent = `RID gauges · +${state.hiiReportingCount} ThaiWater/HII reporting`;
}
// Flow at the basin anchor (summing sequential mainstem gauges would
// double-count the same water, so no basin-wide total is shown).
const p1 = readings.get('P.1');
if (p1?.water_level != null) state.p1Now = Number(p1.water_level);
updateFloodVerdict();
const p1Flow = p1?.discharge == null ? null : Number(p1.discharge);
$('total-flow').textContent = p1Flow == null ? '—' : p1Flow.toLocaleString(undefined, { maximumFractionDigits: 1 });
$('cm-flow-note').textContent = 'P.1 Nawarat Bridge · m³/s'; // capacity % lives in the next tile
const stressed = current
.filter((m) => m.discharge_percent != null)
.map((m) => ({ code: m.station_code, percent: Number(m.discharge_percent) }));
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
$('peak-station').textContent = worst ? `${worst.code} · of channel capacity` : 'No capacity data';
$('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)));
applyStationSearch(); // keep an active search applied across refreshes
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;
}
}
// Monotonic warning ramp (green -> ochre -> amber -> red); blue stays
// reserved for water quantities elsewhere on the page.
function riskColor(pWarning, pDanger) {
if (pDanger >= .5) return '#cc4b37';
if (pWarning >= .5 || pDanger >= .2) return '#d99018';
if (pWarning >= .2) return '#b3973f';
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)
const p1ReplayQ = data.stations['P.1'] ? data.stations['P.1'].discharge[frame] : null;
$('total-flow').textContent = p1ReplayQ == null ? '—' : Number(p1ReplayQ).toLocaleString(undefined, { maximumFractionDigits: 1 });
$('cm-flow-note').textContent = 'P.1 Nawarat Bridge · m³/s · 2024 replay';
$('peak-flow').textContent = peakFlow ? peakFlow.value.toFixed(0) : '—';
$('peak-station').textContent = peakFlow ? `${peakFlow.code} · strongest flow · 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 '#b3973f';
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.setAttribute('style', chipStyle(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();
state.stationThresholds = {};
rows.forEach((row) => {
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
byStation.get(row.station_code).push(row);
if (row.threshold_warning != null && row.threshold_danger != null) {
state.stationThresholds[row.station_code] = { warning: row.threshold_warning, danger: row.threshold_danger };
}
});
state.p1Forecast24 = rows.find((r) => r.station_code === 'P.1' && r.horizon_hours === 24) || null;
updateFloodVerdict();
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="${chipStyle(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;
const modelRow = rows.find((r) => r.source === 'model');
const trainedAt = modelRow?.trained_at ? new Date(modelRow.trained_at).toLocaleDateString([], { day: 'numeric', month: 'short' }) : null;
const modelInfo = modelRow?.model_version
? ` · model ${modelRow.model_version}${trainedAt ? ` (trained ${trainedAt})` : ''}`
: '';
$('forecast-status').textContent = `Probability of exceeding warning / danger level within 6, 12 and 24 h` +
(asOf ? ` · based on readings up to ${asOf}` : '') + modelInfo;
// 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' });
state.dbFirstDate = String(stats.first_timestamp).slice(0, 10); // feeds the All-time date range
$('db-total').textContent = Number(stats.total_measurements).toLocaleString();
if (stats.rid_measurements != null) {
const fmtNum = (value) => Number(value || 0).toLocaleString();
const openmeteo = stats.openmeteo_rain_measurements
? ` · ${fmtNum(stats.openmeteo_rain_measurements)} forecast rain`
: '';
$('db-total-note').textContent = `${fmtNum(stats.rid_measurements)} RID · ${fmtNum(stats.hii_waterlevel_measurements)} HII level · ${fmtNum(stats.hii_rainfall_measurements)} rain${openmeteo}`;
$('db-stations-note').textContent = `${stats.rid_station_count} RID · ${stats.hii_station_count} ThaiWater/HII`;
}
$('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';
$('forecast-expand').setAttribute('aria-expanded', String(state.forecastExpanded));
const count = $('forecast-grid').children.length;
$('forecast-expand').textContent = state.forecastExpanded
? 'Hide station forecasts ▴'
: `Show all ${count} station forecasts ▾`;
});
$('station-search').addEventListener('input', applyStationSearch);
$('sensors-head').addEventListener('click', () => setSensorsOpen(!state.sensorsOpen));
$('sensors-head').addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSensorsOpen(!state.sensorsOpen); }
});
$('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', () => {
if ($('history-range').value === 'custom') return;
state.useDates = false;
const hours = Number($('history-range').value);
// Mirror the chosen range into the date pickers (local dates)
const isoLocal = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const now = new Date();
$('history-end').value = isoLocal(now);
$('history-start').value = hours >= 876000
? (state.dbFirstDate || '2018-08-01') // All time: DB's first record
: isoLocal(new Date(now.getTime() - hours * 3600 * 1000));
if (state.selectedStation) loadHistory(state.selectedStation);
else $('history-status').textContent = 'Select a station first — click one on the map or in the list';
});
['history-start', 'history-end'].forEach((id) => $(id).addEventListener('change', () => {
state.useDates = true;
$('history-range').value = 'custom'; // dropdown no longer describes the window
if (state.selectedStation) loadHistory(state.selectedStation);
else $('history-status').textContent = 'Select a station first — click one on the map or in the list';
}));
$('legend-toggle').addEventListener('click', () => {
const open = $('map-legend').classList.toggle('open');
$('legend-toggle').setAttribute('aria-expanded', String(open));
});
loadDashboard();
// Skip auto-refresh while the 2024 replay is running (it would clobber replay state)
window.setInterval(() => { if (!state.replayTimer) loadDashboard(); }, 5 * 60 * 1000);
})();
</script>
</body>
</html>