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
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.
1388 lines
86 KiB
HTML
1388 lines
86 KiB
HTML
<!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 & 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 & 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 < 25 m³/s</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25–100</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100–250</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high > 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.1–10 mm</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#8f6fd2;border-radius:50%"></i> Moderate 10–35</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#6844b8;border-radius:50%"></i> Heavy 35–90</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#472a96;border-radius:50%"></i> Very heavy 90–150</div>
|
||
<div class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> Extreme > 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 & 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) => ({
|
||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||
})[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: '© 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 zone’s 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>
|