feat(dashboard): console phase 3 - history, forecast accuracy, data statistics
CI / Format & lint (push) Successful in 25s
CI / Test suite (push) Successful in 58s
Docs / Validate documentation (push) Successful in 11s

- History in the station panel (inline SVG): 3 d / 7 d / 30 d / 90 d / 1 y /
  all or a date range; level and discharge, the 24 h peaks as the model
  issued them, warning/danger bands, hover/touch values. Up to 7 d the chart
  ends in the current forecast. Above 2,500 readings it shows the daily mean
  with the daily low-high band, so a flood peak is not averaged away; gaps
  (RID's nightly pause) break the line instead of being bridged. Days are
  Bangkok days computed arithmetically: Intl per reading took a minute for
  "All" (67,000 rows), now 2 s.
- "How accurate is the forecast?" for P.1 (/api/forecast/skill), loaded when
  opened because a cold skill query takes up to 20 s.
- Data statistics (/api/stats) behind "2.7M readings since 2018" in the
  status bar.

fix(dashboard): "All time" history ended in October 2024. The history route
returns the oldest rows first and stops at 50,000 by default; P.1 has about
67,000 hours. Both dashboards now ask for limit=100000 (the route's maximum).
This commit is contained in:
grabowski committed 2026-10-19 17:12:39 +07:00
1 parent 71cc1b0ca7
commit ffd3030352
4 files changed
+498 -62

No files matched your search

+8 -6
View File
@@ -1,6 +1,6 @@
# Dashboard redesign: the console # Dashboard redesign: the console
Status: **phase 2 done** (2026-10-19). The new dashboard is built next to the current Status: **phase 3 done** (2026-10-19). The new dashboard is built next to the current
one and replaces it in phase 4. one and replaces it in phase 4.
- **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the - **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the
@@ -52,8 +52,8 @@ One screen, no page scroll on a desktop:
| Top bar | brand · **Chiang Mai verdict** (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts | | Top bar | brand · **Chiang Mai verdict** (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts |
| Left | two lists: **RID gauges** (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and **Basin stations** (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend | | Left | two lists: **RID gauges** (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and **Basin stations** (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend |
| Centre | map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h and Flood zones, the team's markers from zoom 12, one legend | | Centre | map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h and Flood zones, the team's markers from zoom 12, one legend |
| Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; 72 h chart with the forecast peaks; forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the seven inundation stages and the zones toggle; data links. A basin station shows level (m MSL), % of bank height with ThaiWater's class, distance to and height of the bank top, gauge reading, agency (latest reading only: no history is kept for them) | | Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the seven inundation stages, the zones toggle and "How accurate is the forecast?"; data links. A basin station shows level (m MSL), % of bank height with ThaiWater's class, distance to and height of the bank top, gauge reading, agency (latest reading only: no history is kept for them) |
| Status bar | data credits, API, source code | | Status bar | data credits, "2.7M readings since 2018" (opens the data statistics), API, source code |
**Narrower screens:** **Narrower screens:**
@@ -110,6 +110,8 @@ One screen, no page scroll on a desktop:
- **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered. - **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.
- **Flood zones:** shown automatically while P.1 is at or above a stage, unless hidden by the user. - **Flood zones:** shown automatically while P.1 is at or above a stage, unless hidden by the user.
- **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed. - **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed.
- **History:** above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for `limit=100000`: `/measurements/history` returns the oldest rows first and stops at its 50,000 default, which made the current dashboard's "All time" for P.1 end in October 2024 (fixed there too).
- **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.
- **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load. - **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load.
## Phases ## Phases
@@ -128,9 +130,9 @@ Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light
- **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too. - **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too.
- **ThaiWater/HII stations:** the Basin stations list and map layer, from `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`. - **ThaiWater/HII stations:** the Basin stations list and map layer, from `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`.
- **Custom markers:** `custom-markers.json` as pins, with their flood-zone popup. - **Custom markers:** `custom-markers.json` as pins, with their flood-zone popup.
3. **Station depth.** 3. **Done — Station depth.**
- **History in the panel:** range 24 h / 7 d / 30 d / 90 d / all / custom dates, level and discharge, issued 24 h forecasts overlaid, flood bands; Chart.js or SVG. - **History in the panel** (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values.
- **P.1 forecast skill** ("Is the model getting better?") as a panel section, from `/api/forecast/skill`. - **P.1 forecast accuracy** as a collapsible panel section, from `/api/forecast/skill`.
- **Data statistics:** a status-bar popover, from `/api/stats`. - **Data statistics:** a status-bar popover, from `/api/stats`.
4. **Modes and the swap.** 4. **Modes and the swap.**
- **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show. - **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show.
+475 -55
View File
@@ -36,7 +36,7 @@
--accent: #1d6fd6; --accent-soft: #e9f1fc; --on-accent: #ffffff; --accent: #1d6fd6; --accent-soft: #e9f1fc; --on-accent: #ffffff;
--normal: #16a34a; --watch: #d97706; --warning: #ea580c; --danger: #dc2626; --nodata: #9ca3af; --normal: #16a34a; --watch: #d97706; --warning: #ea580c; --danger: #dc2626; --nodata: #9ca3af;
--normal-ink: #15803d; --watch-ink: #b45309; --warning-ink: #c2410c; --danger-ink: #b91c1c; --nodata-ink: #6b7280; --normal-ink: #15803d; --watch-ink: #b45309; --warning-ink: #c2410c; --danger-ink: #b91c1c; --nodata-ink: #6b7280;
--river: #2f7dd1; --river-2: #8cbbe8; --tiles: grayscale(1) brightness(1.06) contrast(.8); --tiles-opacity: .75; --flow: #0d9488; --issued: #7c3aed; --river: #2f7dd1; --river-2: #8cbbe8; --tiles: grayscale(1) brightness(1.06) contrast(.8); --tiles-opacity: .75;
--shadow: 0 8px 28px rgba(17, 24, 39, .14); --shadow: 0 8px 28px rgba(17, 24, 39, .14);
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -45,7 +45,7 @@
--accent: #4c9bf0; --accent-soft: #14243a; --on-accent: #06101e; --accent: #4c9bf0; --accent-soft: #14243a; --on-accent: #06101e;
--normal: #34d399; --watch: #fbbf24; --warning: #fb923c; --danger: #f87171; --nodata: #5d6879; --normal: #34d399; --watch: #fbbf24; --warning: #fb923c; --danger: #f87171; --nodata: #5d6879;
--normal-ink: #34d399; --watch-ink: #fbbf24; --warning-ink: #fb923c; --danger-ink: #f87171; --nodata-ink: #8b95a7; --normal-ink: #34d399; --watch-ink: #fbbf24; --warning-ink: #fb923c; --danger-ink: #f87171; --nodata-ink: #8b95a7;
--river: #4c9bf0; --river-2: #2e5f96; --tiles: grayscale(1) invert(1) brightness(.62) contrast(.85); --tiles-opacity: .8; --flow: #2dd4bf; --issued: #a78bfa; --river: #4c9bf0; --river-2: #2e5f96; --tiles: grayscale(1) invert(1) brightness(.62) contrast(.85); --tiles-opacity: .8;
--shadow: 0 8px 28px rgba(0, 0, 0, .5); --shadow: 0 8px 28px rgba(0, 0, 0, .5);
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -206,7 +206,39 @@
.scale-ticks span { position: absolute; white-space: nowrap; } .scale-ticks span { position: absolute; white-space: nowrap; }
svg.chart { width: 100%; height: 180px; display: block; } svg.chart { width: 100%; height: 180px; display: block; }
svg.chart text { font-size: 10.5px; fill: var(--muted); font-family: inherit; } svg.chart text { font-size: 10.5px; fill: var(--muted); font-family: inherit; }
.chart-legend { display: flex; gap: 12px; font-size: 11.5px; color: var(--muted); margin-top: 4px; } .chart-legend { display: flex; gap: 4px 12px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); margin-top: 4px; }
.chart-box { position: relative; min-height: 250px; }
.chart-box svg { display: block; width: 100%; touch-action: pan-y; }
.chart-box svg text { font-size: 10.5px; fill: var(--muted); font-family: inherit; }
.chart-box svg text.halo { paint-order: stroke; stroke: var(--panel); stroke-width: 3px; stroke-linejoin: round; font-weight: 500; }
.tip { position: absolute; top: 4px; z-index: 5; pointer-events: none; padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel);
box-shadow: var(--shadow); font-size: 11.5px; line-height: 1.5; white-space: nowrap; }
.tip b { font-weight: 600; }
.tip span { color: var(--muted); }
.hist-ctl { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 8px; }
.hist-ctl .seg button { padding: 3px 8px; }
.dates { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); }
input[type="date"] { background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 6px; color: var(--text); font: inherit; font-size: 12px; padding: 2px 5px; }
[data-theme="dark"] input[type="date"] { color-scheme: dark; }
details.sect > summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; }
details.sect > summary::-webkit-details-marker { display: none; }
details.sect > summary::after { content: "+"; font-size: 15px; font-weight: 500; color: var(--muted); }
details.sect[open] > summary::after { content: "−"; }
details.sect[open] > summary { margin-bottom: 9px; }
.headline { font-weight: 600; margin-bottom: 4px; }
.headline.better { color: var(--normal-ink); } .headline.worse { color: var(--warning-ink); }
.tablewrap { overflow-x: auto; margin-top: 8px; }
table.small { font-size: 11.5px; }
table.small td, table.small th { padding-right: 10px; white-space: nowrap; }
table.small tr.current td { font-weight: 600; }
table.small tr.young td { color: var(--muted); }
.linklike { color: var(--text-2); font-size: 11.5px; text-decoration: underline dotted; text-underline-offset: 3px; }
.pop { position: fixed; z-index: 1500; left: 14px; bottom: 34px; width: min(420px, calc(100vw - 28px)); padding: 14px 16px; border: 1px solid var(--line);
border-radius: 10px; background: var(--panel); box-shadow: var(--shadow); }
.pop dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; }
.pop dt { color: var(--muted); }
.pop dd b { font-weight: 600; }
.pop dd small { display: block; color: var(--muted); font-size: 11.5px; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 3px 0 6px; } th { text-align: left; padding: 3px 0 6px; }
td { padding: 6px 0; border-top: 1px solid var(--line); } td { padding: 6px 0; border-top: 1px solid var(--line); }
@@ -380,6 +412,7 @@
<footer class="statusbar"> <footer class="statusbar">
<span data-i18n="foot.data">Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo</span> <span data-i18n="foot.data">Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo</span>
<button type="button" class="linklike" id="stats-button" aria-expanded="false" aria-controls="stats-pop" hidden></button>
<span class="preview"><span data-i18n="foot.preview">Preview of the new dashboard</span> · <a href="/" data-i18n="foot.classic">current dashboard</a></span> <span class="preview"><span data-i18n="foot.preview">Preview of the new dashboard</span> · <a href="/" data-i18n="foot.classic">current dashboard</a></span>
<nav> <nav>
<a href="/docs" target="_blank" rel="noopener" data-i18n="foot.api">API</a> <a href="/docs" target="_blank" rel="noopener" data-i18n="foot.api">API</a>
@@ -388,6 +421,11 @@
</nav> </nav>
</footer> </footer>
<div class="pop" id="stats-pop" role="dialog" aria-labelledby="stats-title" hidden>
<h3 class="label" id="stats-title" style="margin-bottom:10px" data-i18n="st.title">Data in this monitor</h3>
<dl id="stats-list"></dl>
</div>
<aside class="drawer" id="alerts" role="dialog" aria-modal="true" aria-labelledby="alerts-title"> <aside class="drawer" id="alerts" role="dialog" aria-modal="true" aria-labelledby="alerts-title">
<header> <header>
<h2 id="alerts-title" data-i18n="alerts.title">Flood alerts on your phone</h2> <h2 id="alerts-title" data-i18n="alerts.title">Flood alerts on your phone</h2>
@@ -418,7 +456,8 @@
const state = { lang: 'th', stations: [], byCode: new Map(), selected: null, sort: 'river', map: null, tiles: null, markers: new Map(), const state = { lang: 'th', stations: [], byCode: new Map(), selected: null, sort: 'river', map: null, tiles: null, markers: new Map(),
rivers: null, riverLayer: null, rain: [], rainLayer: null, zones: null, zoneLayer: null, zonesUserHidden: false, fitted: false, rivers: null, riverLayer: null, rain: [], rainLayer: null, zones: null, zoneLayer: null, zonesUserHidden: false, fitted: false,
p1Stages: null, alerts: null, lastLoad: null, tab: 'rid', bsort: 'bank', basin: [], basinById: new Map(), basinOn: false, p1Stages: null, alerts: null, lastLoad: null, tab: 'rid', bsort: 'bank', basin: [], basinById: new Map(), basinOn: false,
basinLayer: null, basinMarkers: new Map(), pois: null, poiLayer: null }; basinLayer: null, basinMarkers: new Map(), pois: null, poiLayer: null,
histRange: '3d', histFrom: '', histTo: '', histCache: new Map(), histReq: 0, skill: null, skillAt: 0, skillOpen: false, stats: null };
// ---- Localisation --------------------------------------------------- // ---- Localisation ---------------------------------------------------
// Thai first: this is a flood warning for people living on this river. // Thai first: this is a flood warning for people living on this river.
@@ -501,12 +540,9 @@
'd.scale': 'Level against the warning and danger levels', 'd.scale': 'Level against the warning and danger levels',
'd.now': 'Now', 'd.now': 'Now',
'd.peak24': '24 h peak', 'd.peak24': '24 h peak',
'd.chart': 'Level · last 72 h and forecast',
'd.chart.unit': 'm',
'd.observed': 'Observed', 'd.observed': 'Observed',
'd.forecast': 'Forecast peak', 'd.forecast': 'Forecast peak',
'd.forecastzone': 'Forecast', 'd.forecastzone': 'Forecast',
'd.nohistory': 'No readings in the last 72 hours',
'd.fc.title': 'Forecast · chance of reaching', 'd.fc.title': 'Forecast · chance of reaching',
'd.fc.horizon': 'Within', 'd.fc.horizon': 'Within',
'd.fc.peak': 'Peak', 'd.fc.peak': 'Peak',
@@ -604,6 +640,54 @@
'poi.outside': 'Outside the mapped flood zones', 'poi.outside': 'Outside the mapped flood zones',
'poi.zone': 'Flood zone', 'poi.zone': 'Flood zone',
'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} m`, 'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} m`,
'h.title': 'History',
'h.range': 'Period',
'h.r.3d': '3 d',
'h.r.7d': '7 d',
'h.r.30d': '30 d',
'h.r.90d': '90 d',
'h.r.1y': '1 y',
'h.r.all': 'All',
'h.from': 'From',
'h.to': 'To',
'h.loading': 'Loading…',
'h.count': (n) => `${n} readings`,
'h.daily': (n) => `daily mean and range of ${n} readings`,
'h.empty': 'No readings in this period',
'h.error': (msg) => `History unavailable: ${msg}`,
'h.level': 'Level',
'h.flow': 'Discharge',
'h.band': 'Daily low–high',
'h.issued': 'Forecast 24 h peak, as issued',
'h.mean': 'mean',
'sk.title': 'How accurate is the forecast?',
'skill.sub': (n, since) => `${n} verified 24 h forecasts for P.1 since ${since}.`,
'skill.explain': 'Every hour the deployed model’s 24 h peak forecast for P.1 is stored; once those 24 hours have passed it is compared with what the river did. Skill is how much better the model was than assuming the level stays where it is (0 = no better, 1 = perfect). A retrained model appears as a new row, so improvement, or its absence, is visible here rather than claimed.',
'skill.better': (v, prev, d) => `The current model ${v} is more accurate than ${prev}: its peak error is ${d} cm lower on the hours it has served.`,
'skill.worse': (v, prev, d) => `The current model ${v} has a higher peak error than ${prev} so far (+${d} cm).`,
'skill.caveat.quiet': 'Every verified hour so far was below 2 m, so this measures quiet-river accuracy only. The model is built and judged for flood onset (lead time before 3.70 m), which no quiet week can test: see the backtests in the documentation.',
'skill.caveat.regime': 'The versions served different weeks; the ≥ 2 m column compares them on the hours that matter.',
'skill.single': (v) => `Only ${v} has enough verified hours yet; the next retrain adds a row to compare.`,
'skill.young': (v, n, min) => `${v} has ${n} verified hours; a comparison needs ${min}.`,
'skill.none': 'No verified forecasts yet: the first appear 24 h after a model starts serving.',
'skill.col.version': 'Model',
'skill.col.period': 'Served',
'skill.col.n': 'Hours',
'skill.col.mae': 'Peak error',
'skill.col.skill': 'Skill',
'skill.col.high': '≥ 2 m error',
'skill.cm': (v) => `${v} cm`,
'skill.row.tip': (bias, pers) => `Bias ${bias} · error of assuming no change ${pers}`,
'st.button': (n, year) => `${n} readings since ${year}`,
'st.title': 'Data in this monitor',
'st.total': 'Readings stored',
'st.breakdown': (rid, hii, rain, om, res) => `${rid} RID · ${hii} ThaiWater/HII level · ${rain} rain · ${om} forecast rain · ${res} reservoir`,
'st.range': 'Period',
'st.days': (n) => `${n} days`,
'st.stations': 'Stations',
'st.stations.v': (rid, hii) => `${rid} RID · ${hii} ThaiWater/HII`,
'st.coverage': 'Coverage',
'st.coverage.v': 'of RID’s hourly readings recorded',
'foot.data': 'Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo', 'foot.data': 'Data: Royal Irrigation Department · ThaiWater/HII · Open-Meteo',
'foot.preview': 'Preview of the new dashboard', 'foot.preview': 'Preview of the new dashboard',
'foot.classic': 'current dashboard', 'foot.classic': 'current dashboard',
@@ -686,12 +770,9 @@
'd.scale': 'ระดับน้ำเทียบกับระดับเตือนภัยและระดับอันตราย', 'd.scale': 'ระดับน้ำเทียบกับระดับเตือนภัยและระดับอันตราย',
'd.now': 'ขณะนี้', 'd.now': 'ขณะนี้',
'd.peak24': 'สูงสุด 24 ชม.', 'd.peak24': 'สูงสุด 24 ชม.',
'd.chart': 'ระดับน้ำ · 72 ชม. ล่าสุดและค่าพยากรณ์',
'd.chart.unit': 'ม.',
'd.observed': 'ค่าวัดจริง', 'd.observed': 'ค่าวัดจริง',
'd.forecast': 'ค่าพยากรณ์สูงสุด', 'd.forecast': 'ค่าพยากรณ์สูงสุด',
'd.forecastzone': 'พยากรณ์', 'd.forecastzone': 'พยากรณ์',
'd.nohistory': 'ไม่มีข้อมูลใน 72 ชั่วโมงที่ผ่านมา',
'd.fc.title': 'พยากรณ์ · โอกาสที่จะถึง', 'd.fc.title': 'พยากรณ์ · โอกาสที่จะถึง',
'd.fc.horizon': 'ภายใน', 'd.fc.horizon': 'ภายใน',
'd.fc.peak': 'สูงสุด', 'd.fc.peak': 'สูงสุด',
@@ -789,6 +870,54 @@
'poi.outside': 'อยู่นอกพื้นที่น้ำท่วมที่ทำแผนที่ไว้', 'poi.outside': 'อยู่นอกพื้นที่น้ำท่วมที่ทำแผนที่ไว้',
'poi.zone': 'พื้นที่น้ำท่วม', 'poi.zone': 'พื้นที่น้ำท่วม',
'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} ม.`, 'poi.zone.value': (zone, lvl) => `${zone} · P.1 ≥ ${lvl} ม.`,
'h.title': 'ข้อมูลย้อนหลัง',
'h.range': 'ช่วงเวลา',
'h.r.3d': '3 วัน',
'h.r.7d': '7 วัน',
'h.r.30d': '30 วัน',
'h.r.90d': '90 วัน',
'h.r.1y': '1 ปี',
'h.r.all': 'ทั้งหมด',
'h.from': 'ตั้งแต่',
'h.to': 'ถึง',
'h.loading': 'กำลังโหลด…',
'h.count': (n) => `${n} ค่า`,
'h.daily': (n) => `ค่าเฉลี่ยและช่วงรายวันจาก ${n} ค่า`,
'h.empty': 'ไม่มีข้อมูลในช่วงเวลานี้',
'h.error': (msg) => `โหลดข้อมูลย้อนหลังไม่ได้: ${msg}`,
'h.level': 'ระดับน้ำ',
'h.flow': 'อัตราการไหล',
'h.band': 'ต่ำสุด–สูงสุดรายวัน',
'h.issued': 'ค่าพยากรณ์สูงสุด 24 ชม. ตามที่ออก',
'h.mean': 'เฉลี่ย',
'sk.title': 'พยากรณ์แม่นยำแค่ไหน?',
'skill.sub': (n, since) => `พยากรณ์ 24 ชม. ของ P.1 ที่ตรวจสอบแล้ว ${n} ครั้ง ตั้งแต่ ${since}`,
'skill.explain': 'ทุกชั่วโมงระบบบันทึกค่าพยากรณ์ระดับน้ำสูงสุดใน 24 ชม. ของ P.1 ไว้ เมื่อครบ 24 ชม. จึงนำมาเทียบกับระดับน้ำจริง "ทักษะ" คือแบบจำลองดีกว่าการสมมติว่าระดับน้ำคงที่มากเพียงใด (0 = ไม่ดีกว่า, 1 = สมบูรณ์แบบ) แบบจำลองที่ฝึกใหม่จะปรากฏเป็นแถวใหม่ จึงเห็นได้ว่าดีขึ้นจริงหรือไม่',
'skill.better': (v, prev, d) => `แบบจำลองปัจจุบัน ${v} แม่นยำกว่า ${prev}: ค่าคลาดเคลื่อนต่ำกว่า ${d} ซม. ในช่วงที่ให้บริการ`,
'skill.worse': (v, prev, d) => `แบบจำลองปัจจุบัน ${v} มีค่าคลาดเคลื่อนสูงกว่า ${prev} (+${d} ซม.)`,
'skill.caveat.quiet': 'ชั่วโมงที่ตรวจสอบทั้งหมดอยู่ต่ำกว่า 2 ม. จึงวัดได้เพียงความแม่นยำช่วงน้ำปกติ แบบจำลองถูกสร้างและประเมินสำหรับช่วงน้ำเริ่มท่วม (เวลาเตือนล่วงหน้าก่อน 3.70 ม.) ซึ่งสัปดาห์ปกติทดสอบไม่ได้ ดูผลทดสอบย้อนหลังในเอกสาร',
'skill.caveat.regime': 'แต่ละเวอร์ชันให้บริการคนละช่วงเวลา คอลัมน์ ≥ 2 ม. เปรียบเทียบเฉพาะชั่วโมงที่สำคัญ',
'skill.single': (v) => `มีเพียง ${v} ที่มีข้อมูลตรวจสอบเพียงพอ การฝึกครั้งถัดไปจะเพิ่มแถวให้เปรียบเทียบ`,
'skill.young': (v, n, min) => `${v} มีข้อมูลตรวจสอบ ${n} ชั่วโมง ต้องการอย่างน้อย ${min} เพื่อเปรียบเทียบ`,
'skill.none': 'ยังไม่มีพยากรณ์ที่ตรวจสอบได้ จะเริ่มมี 24 ชม. หลังแบบจำลองเริ่มทำงาน',
'skill.col.version': 'แบบจำลอง',
'skill.col.period': 'ช่วงเวลา',
'skill.col.n': 'ชั่วโมง',
'skill.col.mae': 'คลาดเคลื่อน',
'skill.col.skill': 'ทักษะ',
'skill.col.high': 'คลาดเคลื่อน ≥ 2 ม.',
'skill.cm': (v) => `${v} ซม.`,
'skill.row.tip': (bias, pers) => `อคติ ${bias} · คลาดเคลื่อนหากสมมติว่าระดับคงที่ ${pers}`,
'st.button': (n, year) => `${n} ค่า ตั้งแต่ปี ${year}`,
'st.title': 'ข้อมูลในระบบนี้',
'st.total': 'จำนวนข้อมูลที่จัดเก็บ',
'st.breakdown': (rid, hii, rain, om, res) => `${rid} กรมชลประทาน · ${hii} ระดับน้ำ ThaiWater/HII · ${rain} น้ำฝน · ${om} ฝนพยากรณ์ · ${res} อ่างเก็บน้ำ`,
'st.range': 'ช่วงเวลา',
'st.days': (n) => `${n} วัน`,
'st.stations': 'สถานี',
'st.stations.v': (rid, hii) => `${rid} กรมชลประทาน · ${hii} ThaiWater/HII`,
'st.coverage': 'ความครอบคลุม',
'st.coverage.v': 'ของค่ารายชั่วโมงของกรมชลฯ ที่บันทึกได้',
'foot.data': 'ข้อมูล: กรมชลประทาน · ThaiWater/HII · Open-Meteo', 'foot.data': 'ข้อมูล: กรมชลประทาน · ThaiWater/HII · Open-Meteo',
'foot.preview': 'ตัวอย่างแดชบอร์ดใหม่', 'foot.preview': 'ตัวอย่างแดชบอร์ดใหม่',
'foot.classic': 'แดชบอร์ดปัจจุบัน', 'foot.classic': 'แดชบอร์ดปัจจุบัน',
@@ -837,6 +966,7 @@
applyTranslations(); applyTranslations();
setBasemap(); setBasemap();
renderAll(); renderAll();
renderStats();
} }
// ---- Formatting ----------------------------------------------------- // ---- Formatting -----------------------------------------------------
@@ -1357,52 +1487,322 @@
return steps.find((s) => range / s <= 5) || 10; return steps.find((s) => range / s <= 5) || 10;
} }
function chart(s, width) { // ---- History in the panel ---------------------------------------------
const W = Math.max(280, width), H = 180, padL = 34, padR = 8, padT = 10, padB = 22; // Up to 7 days the chart ends in the current 24 h forecast. More than
const h = s.hist; // 2,500 readings are drawn as the daily mean with the daily low-high band:
if (h.length < 2) return `<p class="note">${escapeHtml(t('d.nohistory'))}</p>`; // a flood is a peak, and a mean alone would flatten it. "All" asks for
const t0 = h[0].t.getTime(), tNow = h[h.length - 1].t.getTime(), t1 = tNow + 24 * 3600e3; // limit=100000: the API returns the oldest rows first and stops at 50,000.
const fc = [6, 12, 24].filter((x) => s.fc[x] && s.fc[x].predicted_max_level != null) const RANGES = [['3d', 72], ['7d', 168], ['30d', 720], ['90d', 2160], ['1y', 8760], ['all', 876000]];
.map((x) => [tNow + x * 3600e3, Number(s.fc[x].predicted_max_level)]); // Bangkok has no daylight saving: its calendar day is plain arithmetic
const values = h.map((r) => r.level).concat(fc.map((p) => p[1])); // (Intl formatting per reading made "All", 70,000 rows, take a minute)
let lo = Math.min(...values), hi = Math.max(...values); const BKK = 7 * 3600e3;
const bkkParts = (d) => { const u = new Date(d.getTime() + BKK); return [u.getUTCFullYear(), u.getUTCMonth() + 1, u.getUTCDate()]; };
const bkkDay = (d) => bkkParts(d).map((v, k) => String(v).padStart(k ? 2 : 4, '0')).join('-');
function histQuery() {
if (state.histRange === 'custom') {
return [state.histFrom && `start=${state.histFrom}`, state.histTo && `end=${state.histTo}`].filter(Boolean).join('&') || 'hours=72';
}
const hours = (RANGES.find((r) => r[0] === state.histRange) || RANGES[0])[1];
return hours >= 876000 ? `hours=${hours}&limit=100000` : `hours=${hours}`;
}
function presetDates() {
if (state.histRange === 'custom') return;
const hours = (RANGES.find((r) => r[0] === state.histRange) || RANGES[0])[1];
state.histTo = bkkDay(new Date());
state.histFrom = hours >= 876000 ? String(state.stats?.first_timestamp || '2018-08-01').slice(0, 10) : bkkDay(new Date(Date.now() - hours * 3600e3));
}
function prepareHistory(rows, fcRows) {
const num = (v) => (v == null ? null : Number(v));
const pts = (Array.isArray(rows) ? rows : [])
.map((r) => ({ t: parseTs(r.timestamp), level: num(r.water_level), flow: num(r.discharge) }))
.filter((x) => x.t && (x.level != null || x.flow != null)).sort((a, b) => a.t - b.t);
let fc = (Array.isArray(fcRows) ? fcRows : []).filter((r) => r.predicted_max_level != null)
.map((r) => ({ t: parseTs(r.as_of), v: Number(r.predicted_max_level) })).filter((x) => x.t).sort((a, b) => a.t - b.t);
const daily = pts.length > 2500;
if (!daily) return { pts, fc, daily, n: pts.length };
const byDay = (list) => {
const m = new Map();
list.forEach((x) => { const k = Math.floor((x.t.getTime() + BKK) / 864e5); if (!m.has(k)) m.set(k, []); m.get(k).push(x); });
return [...m.entries()].map(([k, l]) => ({ t: new Date(k * 864e5 - BKK + 12 * 3600e3), l }));
};
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
const days = byDay(pts).map(({ t: day, l }) => {
const lv = l.map((x) => x.level).filter((v) => v != null), fl = l.map((x) => x.flow).filter((v) => v != null);
return { t: day, level: mean(lv), lo: lv.length ? Math.min(...lv) : null, hi: lv.length ? Math.max(...lv) : null, flow: mean(fl) };
});
// the highest 24 h peak forecast issued that day
fc = byDay(fc).map(({ t: day, l }) => ({ t: day, v: Math.max(...l.map((x) => x.v)) }));
return { pts: days, fc, daily, n: pts.length };
}
function getHistory(code) {
const q = histQuery();
// short ranges follow every refresh; long ones are fetched at most hourly
const fresh = state.histRange === '3d' || state.histRange === '7d' ? state.lastLoad : Math.floor(Date.now() / 3600e3);
const key = `${code}|${q}|${fresh}`;
if (!state.histCache.has(key)) {
if (state.histCache.size > 24) state.histCache.clear();
const promise = Promise.all([
json(fetch(`/measurements/history/${encodeURIComponent(code)}?${q}`)),
json(fetch(`/api/forecast/history/${encodeURIComponent(code)}?${q}&horizon=24`)).catch(() => []),
]).then(([rows, fc]) => prepareHistory(rows, fc));
promise.catch(() => state.histCache.delete(key));
state.histCache.set(key, promise);
}
return state.histCache.get(key);
}
function niceStep(range) {
const steps = [0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5];
return steps.find((x) => range / x <= 5) || 10;
}
// Day, week, month or year ticks at Bangkok midnights, thinned to fit
function xTicks(t0, t1, width) {
const span = (t1 - t0) / 864e5, ticks = [];
for (let tm = Math.floor((t0 + BKK) / 864e5) * 864e5 - BKK; tm <= t1; tm += 864e5) {
if (tm < t0) continue;
const d = new Date(tm);
const [, m, day] = bkkParts(d);
if (span <= 12) ticks.push({ t: tm, label: fmtDay(d) });
else if (span <= 70) { if (day % 7 === 1) ticks.push({ t: tm, label: fmtDay(d) }); }
else if (span <= 420) { if (day === 1) ticks.push({ t: tm, label: d.toLocaleDateString(loc(), { timeZone: TZ, month: 'short', ...(m === 1 ? { year: 'numeric' } : {}) }) }); }
else if (day === 1 && m === 1) ticks.push({ t: tm, label: d.toLocaleDateString(loc(), { timeZone: TZ, year: 'numeric' }) });
}
const every = Math.max(1, Math.ceil(ticks.length / Math.max(3, Math.floor((width - 50) / 64))));
return ticks.filter((_, k) => k % every === 0);
}
function drawHistory(box, s, data) {
const W = Math.max(280, Math.round(box.clientWidth || 370));
const padL = 38, padR = 8, padT = 8, levelH = 150, gap = 12, flowH = 58, axisH = 20;
const fTop = padT + levelH + gap, fBot = fTop + flowH, H = fBot + axisH;
const pts = data.pts;
if (pts.length < 2) { box.innerHTML = `<p class="note">${escapeHtml(t('h.empty'))}</p>`; return; }
const t0 = pts[0].t.getTime(), tLast = pts[pts.length - 1].t.getTime();
const ahead = state.histRange === '3d' || state.histRange === '7d';
const future = ahead ? [6, 12, 24].filter((x) => s.fc[x] && s.fc[x].predicted_max_level != null)
.map((x) => ({ t: tLast + x * 3600e3, v: Number(s.fc[x].predicted_max_level) })) : [];
const t1 = ahead ? tLast + 24 * 3600e3 : tLast;
const issued = data.fc.filter((x) => x.t.getTime() >= t0 && x.t.getTime() <= t1);
const values = pts.flatMap((x) => [x.level, x.lo, x.hi]).filter((v) => v != null).concat(future.map((x) => x.v), issued.map((x) => x.v));
if (!values.length) { box.innerHTML = `<p class="note">${escapeHtml(t('h.empty'))}</p>`; return; }
let lo = values.reduce((a, b) => Math.min(a, b), Infinity), hi = values.reduce((a, b) => Math.max(a, b), -Infinity);
const showWarn = s.warn != null && s.warn - hi < 1.0; const showWarn = s.warn != null && s.warn - hi < 1.0;
if (showWarn) hi = Math.max(hi, s.warn); if (showWarn) hi = Math.max(hi, s.warn);
const pad = Math.max(0.08, (hi - lo) * 0.15); const showDanger = showWarn && s.danger != null && s.danger - hi < 0.6;
if (showDanger) hi = Math.max(hi, s.danger);
const pad = Math.max(0.08, (hi - lo) * 0.12);
lo -= pad; hi += pad; lo -= pad; hi += pad;
const X = (tm) => padL + ((tm - t0) / (t1 - t0)) * (W - padL - padR); const X = (tm) => padL + ((tm - t0) / (t1 - t0 || 1)) * (W - padL - padR);
const Y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB); const Y = (v) => padT + (1 - (v - lo) / (hi - lo)) * levelH;
const flows = pts.map((x) => x.flow).filter((v) => v != null);
const fMax = flows.length ? flows.reduce((a, b) => Math.max(a, b), 0) * 1.12 || 1 : 1;
const FY = (v) => fBot - (v / fMax) * flowH;
const maxGap = data.daily ? 2.5 * 864e5 : 3.5 * 3600e3;
const path = (list, get, yf) => {
let d = '', prev = null;
list.forEach((x) => {
const v = get(x);
if (v == null) { prev = null; return; }
d += `${prev && x.t - prev.t <= maxGap ? 'L' : 'M'}${X(x.t.getTime()).toFixed(1)},${yf(v).toFixed(1)}`;
prev = x;
});
return d;
};
const accent = css('--accent'), line = css('--line');
let g = '';
// flood bands and threshold lines
if (showWarn) {
const top = showDanger ? Y(s.danger) : padT;
g += `<rect x="${padL}" y="${top.toFixed(1)}" width="${W - padL - padR}" height="${Math.max(0, Y(s.warn) - top).toFixed(1)}" fill="${css('--warning')}" opacity=".08"/>`;
if (showDanger) g += `<rect x="${padL}" y="${padT}" width="${W - padL - padR}" height="${Math.max(0, Y(s.danger) - padT).toFixed(1)}" fill="${css('--danger')}" opacity=".1"/>`;
}
const step = niceStep(hi - lo); const step = niceStep(hi - lo);
let grid = '';
for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) { for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) {
grid += `<line x1="${padL}" x2="${W - padR}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="${css('--line')}"/>` g += `<line x1="${padL}" x2="${W - padR}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="${line}"/>`
+ `<text x="${padL - 5}" y="${(Y(v) + 3.5).toFixed(1)}" text-anchor="end">${v.toFixed(step < 0.1 ? 2 : 1)}</text>`; + `<text x="${padL - 5}" y="${(Y(v) + 3.5).toFixed(1)}" text-anchor="end">${v.toFixed(step < 0.1 ? 2 : 1)}</text>`;
} }
for (let tm = Math.ceil(t0 / 3600e3) * 3600e3; tm <= t1; tm += 3600e3) { g += `<line x1="${padL}" x2="${W - padR}" y1="${fBot}" y2="${fBot}" stroke="${line}"/>`
const d = new Date(tm); + `<text x="${padL - 5}" y="${fTop + 8}" text-anchor="end">${Math.round(fMax / 1.12)}</text>`
if (bkkHour(d) !== 0) continue; + `<text x="${padL - 5}" y="${fBot}" text-anchor="end">0</text>`
grid += `<line x1="${X(tm).toFixed(1)}" x2="${X(tm).toFixed(1)}" y1="${padT}" y2="${H - padB}" stroke="${css('--line')}" stroke-dasharray="2 3"/>` + `<text x="${padL + 4}" y="${fTop + 8}">${escapeHtml(t('h.flow'))} m³/s</text>`;
+ `<text x="${(X(tm) + 4).toFixed(1)}" y="${H - 6}">${escapeHtml(fmtDay(d))}</text>`; xTicks(t0, t1, W).forEach((tk) => {
g += `<line x1="${X(tk.t).toFixed(1)}" x2="${X(tk.t).toFixed(1)}" y1="${padT}" y2="${fBot}" stroke="${line}" stroke-dasharray="2 3"/>`
+ (X(tk.t) > W - padR - 42
? `<text x="${(X(tk.t) - 3).toFixed(1)}" y="${H - 6}" text-anchor="end">${escapeHtml(tk.label)}</text>`
: `<text x="${(X(tk.t) + 3).toFixed(1)}" y="${H - 6}">${escapeHtml(tk.label)}</text>`);
});
if (ahead) {
g += `<rect x="${X(tLast).toFixed(1)}" y="${padT}" width="${(X(t1) - X(tLast)).toFixed(1)}" height="${levelH}" fill="${css('--panel-2')}" opacity=".85"/>`
+ `<text x="${(X(tLast) + 5).toFixed(1)}" y="${padT + 11}">${escapeHtml(t('d.forecastzone'))}</text>`;
} }
const line = h.map((r, i) => `${i ? 'L' : 'M'}${X(r.t.getTime()).toFixed(1)},${Y(r.level).toFixed(1)}`).join(''); if (showWarn) g += `<line x1="${padL}" x2="${W - padR}" y1="${Y(s.warn).toFixed(1)}" y2="${Y(s.warn).toFixed(1)}" stroke="${css('--warning')}" stroke-dasharray="5 4" stroke-width="1.3"/>`
const area = `${line}L${X(tNow).toFixed(1)},${H - padB}L${X(t0).toFixed(1)},${H - padB}Z`; + `<text class="halo" x="${padL + 4}" y="${(showDanger ? Y(s.warn) + 12 : Y(s.warn) - 4).toFixed(1)}" style="fill:${css('--warning-ink')}">${escapeHtml(t('d.warnlevel'))} ${m2(s.warn)}</text>`;
const lastY = Y(h[h.length - 1].level); if (showDanger) g += `<line x1="${padL}" x2="${W - padR}" y1="${Y(s.danger).toFixed(1)}" y2="${Y(s.danger).toFixed(1)}" stroke="${css('--danger')}" stroke-dasharray="5 4" stroke-width="1.3"/>`
const fline = `M${X(tNow).toFixed(1)},${lastY.toFixed(1)}` + fc.map((p) => `L${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(''); + `<text class="halo" x="${padL + 4}" y="${(Y(s.danger) - 4).toFixed(1)}" style="fill:${css('--danger-ink')}">${escapeHtml(t('d.dangerlevel'))} ${m2(s.danger)}</text>`;
const accent = css('--accent'); // daily low-high band, per run of consecutive days
const warnLine = showWarn if (data.daily) {
? `<line x1="${padL}" x2="${W - padR}" y1="${Y(s.warn).toFixed(1)}" y2="${Y(s.warn).toFixed(1)}" stroke="${css('--warning')}" stroke-dasharray="5 4" stroke-width="1.4"/>` let run = [];
+ `<text x="${padL + 4}" y="${(Y(s.warn) - 4).toFixed(1)}" style="fill:${css('--warning-ink')}">${escapeHtml(t('d.warnlevel'))} ${m2(s.warn)}</text>` : ''; const flush = () => {
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="${escapeHtml(t('d.chart'))}"> if (run.length > 1) g += `<path d="M${run.map((x) => `${X(x.t.getTime()).toFixed(1)},${Y(x.hi).toFixed(1)}`).join('L')}L${[...run].reverse().map((x) => `${X(x.t.getTime()).toFixed(1)},${Y(x.lo).toFixed(1)}`).join('L')}Z" fill="${accent}" opacity=".2"/>`;
<defs><linearGradient id="area" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${accent}" stop-opacity=".22"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></linearGradient></defs> run = [];
<rect x="${X(tNow).toFixed(1)}" y="${padT}" width="${(X(t1) - X(tNow)).toFixed(1)}" height="${H - padT - padB}" fill="${css('--panel-2')}"/> };
${grid} pts.forEach((x) => { if (x.hi == null || (run.length && x.t - run[run.length - 1].t > maxGap)) flush(); if (x.hi != null) run.push(x); });
<text x="${(X(tNow) + 5).toFixed(1)}" y="${padT + 11}">${escapeHtml(t('d.forecastzone'))}</text> flush();
${warnLine} }
<path d="${area}" fill="url(#area)"/> const levelPath = path(pts, (x) => x.level, Y);
<path d="${line}" fill="none" stroke="${accent}" stroke-width="1.8" stroke-linejoin="round"/> g += `<path d="${path(pts, (x) => x.flow, FY)}" fill="none" stroke="${css('--flow')}" stroke-width="1.3" stroke-linejoin="round"/>`
<path d="${fline}" fill="none" stroke="${accent}" stroke-width="1.6" stroke-dasharray="4 3"/> + `<path d="${path(issued, (x) => x.v, Y)}" fill="none" stroke="${css('--issued')}" stroke-width="1.1" stroke-dasharray="3 3" opacity=".8"/>`
${fc.map((p) => `<circle cx="${X(p[0]).toFixed(1)}" cy="${Y(p[1]).toFixed(1)}" r="2.8" fill="${css('--panel')}" stroke="${accent}" stroke-width="1.6"/>`).join('')} + `<path d="${levelPath}" fill="none" stroke="${accent}" stroke-width="1.7" stroke-linejoin="round"/>`;
<circle cx="${X(tNow).toFixed(1)}" cy="${lastY.toFixed(1)}" r="3.5" fill="${accent}"/></svg>`; if (future.length) {
const lastLevel = [...pts].reverse().find((x) => x.level != null);
g += `<path d="M${X(tLast).toFixed(1)},${Y(lastLevel ? lastLevel.level : future[0].v).toFixed(1)}${future.map((x) => `L${X(x.t).toFixed(1)},${Y(x.v).toFixed(1)}`).join('')}" fill="none" stroke="${accent}" stroke-width="1.6" stroke-dasharray="4 3"/>`
+ future.map((x) => `<circle cx="${X(x.t).toFixed(1)}" cy="${Y(x.v).toFixed(1)}" r="2.8" fill="${css('--panel')}" stroke="${accent}" stroke-width="1.6"/>`).join('');
}
g += `<g id="hx" style="display:none"><line y1="${padT}" y2="${fBot}" stroke="${css('--text-2')}" stroke-width="1"/>`
+ `<circle r="3.5" fill="${accent}" stroke="${css('--panel')}" stroke-width="1.5"/><circle r="3" fill="${css('--flow')}" stroke="${css('--panel')}" stroke-width="1.5"/></g>`;
const legend = [`<span class="key"><i class="line" style="background:var(--accent)"></i>${escapeHtml(t('d.observed'))}${data.daily ? ` (${escapeHtml(t('h.mean'))})` : ''}</span>`,
data.daily ? `<span class="key"><i style="background:var(--accent);opacity:.35;width:14px"></i>${escapeHtml(t('h.band'))}</span>` : '',
future.length ? `<span class="key"><i class="line" style="background:repeating-linear-gradient(90deg,var(--accent) 0 4px,transparent 4px 7px)"></i>${escapeHtml(t('d.forecast'))}</span>` : '',
issued.length ? `<span class="key"><i class="line" style="background:repeating-linear-gradient(90deg,var(--issued) 0 3px,transparent 3px 6px)"></i>${escapeHtml(t('h.issued'))}</span>` : '',
flows.length ? `<span class="key"><i class="line" style="background:var(--flow)"></i>${escapeHtml(t('h.flow'))}</span>` : ''].join('');
box.innerHTML = `<svg viewBox="0 0 ${W} ${H}" height="${H}" role="img" aria-label="${escapeHtml(t('h.title'))}">${g}</svg><div class="tip" hidden></div><div class="chart-legend">${legend}</div>`;
// Hover / touch: the nearest reading, with its forecast as issued
const svg = box.querySelector('svg'), tip = box.querySelector('.tip'), hx = svg.querySelector('#hx');
const [hl, hc1, hc2] = hx.children;
const nearest = (list, tm) => {
let a = 0, b = list.length - 1;
while (b - a > 1) { const mid = (a + b) >> 1; if (list[mid].t.getTime() < tm) a = mid; else b = mid; }
return Math.abs(list[a].t - tm) <= Math.abs(list[b].t - tm) ? list[a] : list[b];
};
const when = (d) => (data.daily ? d.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric' }) : fmtWhen(d));
svg.addEventListener('pointermove', (e) => {
const r = svg.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * W;
if (x < padL || x > W - padR) { hx.style.display = 'none'; tip.hidden = true; return; }
const tm = t0 + ((x - padL) / (W - padL - padR)) * (t1 - t0);
const pt = nearest(pts, Math.min(tm, tLast));
const px = X(pt.t.getTime());
hl.setAttribute('x1', px); hl.setAttribute('x2', px);
hc1.style.display = pt.level == null ? 'none' : ''; hc1.setAttribute('cx', px); if (pt.level != null) hc1.setAttribute('cy', Y(pt.level));
hc2.style.display = pt.flow == null ? 'none' : ''; hc2.setAttribute('cx', px); if (pt.flow != null) hc2.setAttribute('cy', FY(pt.flow));
hx.style.display = '';
const fc = issued.length ? nearest(issued, pt.t.getTime()) : null;
const fcNear = fc && Math.abs(fc.t - pt.t) <= (data.daily ? 864e5 / 2 : 3600e3);
tip.innerHTML = `<b>${escapeHtml(when(pt.t))}</b><br>`
+ `<span>${escapeHtml(t('h.level'))}</span> <b>${m2(pt.level)} m</b>${data.daily && pt.lo != null ? ` <span>(${m2(pt.lo)}–${m2(pt.hi)})</span>` : ''}<br>`
+ `<span>${escapeHtml(t('h.flow'))}</span> <b>${pt.flow == null ? '–' : `${pt.flow.toFixed(1)} m³/s`}</b>`
+ (fcNear ? `<br><span>${escapeHtml(t('h.issued'))}</span> <b>${m2(fc.v)} m</b>` : '');
tip.hidden = false;
const left = (px / W) * r.width, w = tip.offsetWidth;
const at = left + 12 + w > r.width ? left - w - 12 : left + 12;
tip.style.left = `${Math.max(0, Math.min(r.width - w, at))}px`;
});
svg.addEventListener('pointerleave', () => { hx.style.display = 'none'; tip.hidden = true; });
}
async function drawHistSection(s) {
const box = $('hist-box'), status = $('hist-status');
if (!box) return;
const req = ++state.histReq;
try {
const data = await getHistory(s.code);
if (req !== state.histReq || !box.isConnected) return;
drawHistory(box, s, data);
status.textContent = data.daily ? t('h.daily', data.n.toLocaleString(loc())) : t('h.count', data.n.toLocaleString(loc()));
} catch (error) {
if (req !== state.histReq || !box.isConnected) return;
box.innerHTML = `<p class="note">${escapeHtml(t('h.error', error.message))}</p>`;
status.textContent = '';
}
}
function histSection() {
if (state.histRange !== 'custom' && !state.histFrom) presetDates();
return `<div class="sect"><h3 class="label"><span>${escapeHtml(t('h.title'))}</span><span id="hist-status">${escapeHtml(t('h.loading'))}</span></h3>
<div class="hist-ctl">
<div class="seg" id="hist-range" role="group" aria-label="${escapeHtml(t('h.range'))}">${RANGES.map(([k]) =>
`<button type="button" data-r="${k}" aria-pressed="${state.histRange === k}">${escapeHtml(t(`h.r.${k}`))}</button>`).join('')}</div>
<span class="dates"><input type="date" id="hist-from" value="${escapeHtml(state.histFrom)}" aria-label="${escapeHtml(t('h.from'))}" title="${escapeHtml(t('h.from'))}">–<input type="date" id="hist-to" value="${escapeHtml(state.histTo)}" aria-label="${escapeHtml(t('h.to'))}" title="${escapeHtml(t('h.to'))}"></span>
</div>
<div class="chart-box" id="hist-box"></div></div>`;
}
// ---- Forecast accuracy (P.1) ------------------------------------------
// Loaded when the section is first opened: a cold skill query is slow.
function skillHtml(data) {
const versions = (data.versions || []).filter((v) => v.n > 0);
if (!versions.length) return `<p class="note" style="margin:0">${escapeHtml(t('skill.none'))}</p>`;
const cm = (v) => (v == null ? '–' : t('skill.cm', (v * 100).toFixed(1)));
const day = (v) => fmtDay(parseTs(v));
const total = versions.reduce((a, v) => a + v.n, 0);
const cur = data.current;
let head = t('skill.none'), cls = '';
if (data.trend && cur) {
const d = Math.abs(data.trend.mae_delta_m * 100).toFixed(1);
head = data.trend.better ? t('skill.better', cur.model_version, data.trend.previous_version, d) : t('skill.worse', cur.model_version, data.trend.previous_version, d);
cls = data.trend.better ? 'better' : 'worse';
} else if (cur && cur.enough_data) head = t('skill.single', cur.model_version);
else if (cur) head = t('skill.young', cur.model_version, cur.n, data.min_verified);
const anyHigh = versions.some((v) => v.above_2m_n > 0);
const compared = versions.filter((v) => v.enough_data).length > 1;
const caveat = !anyHigh ? t('skill.caveat.quiet') : compared ? t('skill.caveat.regime') : '';
const isCur = (v) => cur && v.model_version === cur.model_version;
const rows = [...versions].reverse().map((v) => `<tr class="${isCur(v) ? 'current' : ''}${v.enough_data ? '' : ' young'}" title="${escapeHtml(t('skill.row.tip', v.bias_m == null ? '–' : `${v.bias_m >= 0 ? '+' : ''}${(v.bias_m * 100).toFixed(1)} cm`, cm(v.persistence_mae_m)))}">
<td>${escapeHtml(v.model_version)}</td><td>${escapeHtml(day(v.first_issued))}–${escapeHtml(day(v.last_issued))}</td>
<td class="r num">${v.n.toLocaleString(loc())}</td><td class="r num">${escapeHtml(cm(v.mae_m))}</td>
<td class="r num">${v.skill == null ? '–' : v.skill.toFixed(2)}</td><td class="r num">${v.above_2m_n ? `${escapeHtml(cm(v.above_2m_mae_m))} (${v.above_2m_n})` : '–'}</td></tr>`).join('');
const cols = ['version', 'period', 'n', 'mae', 'skill', 'high'];
return `<p class="headline ${cls}">${escapeHtml(head)}</p>
<p class="note" style="margin:0">${escapeHtml(t('skill.sub', total.toLocaleString(loc()), day(versions[0].first_issued)))}${caveat ? ` ${escapeHtml(caveat)}` : ''}</p>
<div class="tablewrap"><table class="small"><thead><tr>${cols.map((c, k) => `<th class="label${k >= 2 ? ' r' : ''}">${escapeHtml(t(`skill.col.${c}`))}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>
<p class="note">${escapeHtml(t('skill.explain'))}</p>`;
}
async function loadSkill() {
const body = $('skill-body');
try {
if (!state.skill || Date.now() - state.skillAt > 15 * 60000) {
state.skill = await json(fetch('/api/forecast/skill?station_code=P.1&horizon=24'));
state.skillAt = Date.now();
}
const el = $('skill-body');
if (el) el.innerHTML = skillHtml(state.skill);
} catch (error) {
if (body && body.isConnected) body.innerHTML = `<p class="note" style="margin:0">${escapeHtml(t('h.error', error.message))}</p>`;
}
}
function skillSection(s) {
if (s.code !== 'P.1') return '';
return `<details class="sect" id="skill"${state.skillOpen ? ' open' : ''}><summary class="label">${escapeHtml(t('sk.title'))}</summary>
<div id="skill-body">${state.skill ? skillHtml(state.skill) : `<p class="note" style="margin:0">${escapeHtml(t('h.loading'))}</p>`}</div></details>`;
}
// ---- Data statistics (status bar) -------------------------------------
async function loadStats() {
try {
state.stats = await json(fetch('/api/stats'));
renderStats();
} catch (e) { /* the button stays hidden */ }
}
function renderStats() {
const st = state.stats;
if (!st) return;
const n = (v) => Number(v || 0).toLocaleString(loc());
const first = parseTs(st.first_timestamp), last = parseTs(st.last_timestamp);
const long = (d) => d.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric' });
const compact = new Intl.NumberFormat(loc(), { notation: 'compact', maximumFractionDigits: 1 }).format(st.total_measurements);
const button = $('stats-button');
button.textContent = t('st.button', compact, first.toLocaleDateString(loc(), { timeZone: TZ, year: 'numeric' }));
button.hidden = false;
$('stats-list').innerHTML = `
<dt>${escapeHtml(t('st.total'))}</dt><dd><b class="num">${n(st.total_measurements)}</b><small>${escapeHtml(t('st.breakdown', n(st.rid_measurements), n(st.hii_waterlevel_measurements), n(st.hii_rainfall_measurements), n(st.openmeteo_rain_measurements), n(st.reservoir_measurements)))}</small></dd>
<dt>${escapeHtml(t('st.range'))}</dt><dd><b>${escapeHtml(long(first))} – ${escapeHtml(long(last))}</b><small>${escapeHtml(t('st.days', n(st.days_spanned)))}</small></dd>
<dt>${escapeHtml(t('st.stations'))}</dt><dd><b class="num">${n(st.station_count)}</b><small>${escapeHtml(t('st.stations.v', n(st.rid_station_count), n(st.hii_station_count)))}</small></dd>
<dt>${escapeHtml(t('st.coverage'))}</dt><dd><b class="num">${st.coverage_percent == null ? '–' : `${Number(st.coverage_percent).toFixed(1)}%`}</b><small>${escapeHtml(t('st.coverage.v'))}</small></dd>`;
}
function openStats(open) {
$('stats-pop').hidden = !open;
$('stats-button').setAttribute('aria-expanded', String(open));
} }
function scale(s) { function scale(s) {
@@ -1525,10 +1925,7 @@
<div><span class="label">${escapeHtml(t('d.capacity'))}</span><b class="num">${s.cap == null ? '–' : `${Number(s.cap).toFixed(0)}<small>%</small>`}</b></div> <div><span class="label">${escapeHtml(t('d.capacity'))}</span><b class="num">${s.cap == null ? '–' : `${Number(s.cap).toFixed(0)}<small>%</small>`}</b></div>
</div> </div>
${s.warn != null ? `<div class="sect" style="padding-top:6px">${scale(s)}</div>` : ''} ${s.warn != null ? `<div class="sect" style="padding-top:6px">${scale(s)}</div>` : ''}
<div class="sect"><h3 class="label"><span>${escapeHtml(t('d.chart'))}</span><span>${escapeHtml(t('d.chart.unit'))}</span></h3> ${histSection()}
<div class="chart-box"></div>
<div class="chart-legend"><span class="key"><i class="line" style="background:var(--accent)"></i>${escapeHtml(t('d.observed'))}</span>
<span class="key"><i class="line" style="background:repeating-linear-gradient(90deg,var(--accent) 0 4px,transparent 4px 7px)"></i>${escapeHtml(t('d.forecast'))}</span></div></div>
<div class="sect"><h3 class="label"><span>${escapeHtml(t('d.fc.title'))}</span></h3> <div class="sect"><h3 class="label"><span>${escapeHtml(t('d.fc.title'))}</span></h3>
${rows.length ? `<table><thead><tr><th class="label">${escapeHtml(t('d.fc.horizon'))}</th><th class="label r">${escapeHtml(t('d.fc.peak'))}</th> ${rows.length ? `<table><thead><tr><th class="label">${escapeHtml(t('d.fc.horizon'))}</th><th class="label r">${escapeHtml(t('d.fc.peak'))}</th>
<th class="label">${escapeHtml(t('d.fc.warning'))}</th><th class="label">${escapeHtml(t('d.fc.danger'))}</th></tr></thead><tbody> <th class="label">${escapeHtml(t('d.fc.warning'))}</th><th class="label">${escapeHtml(t('d.fc.danger'))}</th></tr></thead><tbody>
@@ -1538,13 +1935,31 @@
<p class="note">${source}${source ? '<br>' : ''}${escapeHtml(t('d.disclaimer'))} <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener">ThaiWater</a> · <a href="https://www.tmd.go.th/" target="_blank" rel="noopener">TMD</a></p> <p class="note">${source}${source ? '<br>' : ''}${escapeHtml(t('d.disclaimer'))} <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener">ThaiWater</a> · <a href="https://www.tmd.go.th/" target="_blank" rel="noopener">TMD</a></p>
</div> </div>
${stages} ${stages}
${skillSection(s)}
<div class="sect"><h3 class="label"><span>${escapeHtml(t('d.data'))}</span></h3><div class="links"> <div class="sect"><h3 class="label"><span>${escapeHtml(t('d.data'))}</span></h3><div class="links">
<a href="/measurements/history/${encodeURIComponent(s.code)}?hours=168" target="_blank" rel="noopener">${escapeHtml(t('d.link.history'))}</a> <a href="/measurements/history/${encodeURIComponent(s.code)}?hours=168" target="_blank" rel="noopener">${escapeHtml(t('d.link.history'))}</a>
<a href="/api/forecast/history/${encodeURIComponent(s.code)}?hours=168&amp;horizon=24" target="_blank" rel="noopener">${escapeHtml(t('d.link.forecasts'))}</a> <a href="/api/forecast/history/${encodeURIComponent(s.code)}?hours=168&amp;horizon=24" target="_blank" rel="noopener">${escapeHtml(t('d.link.forecasts'))}</a>
<a href="/docs" target="_blank" rel="noopener">${escapeHtml(t('d.link.api'))}</a></div></div>`; <a href="/docs" target="_blank" rel="noopener">${escapeHtml(t('d.link.api'))}</a></div></div>`;
// the chart is drawn for the width its column actually has // the chart is drawn for the width its column actually has
const box = el.querySelector('.chart-box'); drawHistSection(s);
box.innerHTML = chart(s, box.clientWidth || 370); $('hist-range').addEventListener('click', (e) => {
const b = e.target.closest('button');
if (!b) return;
state.histRange = b.dataset.r;
presetDates();
renderDetail();
});
['hist-from', 'hist-to'].forEach((id) => $(id).addEventListener('change', () => {
state.histRange = 'custom';
state.histFrom = $('hist-from').value;
state.histTo = $('hist-to').value;
renderDetail();
}));
const sk = $('skill');
if (sk) {
sk.addEventListener('toggle', () => { state.skillOpen = sk.open; if (sk.open) loadSkill(); });
if (sk.open) loadSkill();
}
const zb = $('zones-from-panel'); const zb = $('zones-from-panel');
if (zb) zb.addEventListener('click', () => { state.zonesUserHidden = !!state.zoneLayer; setZones(!state.zoneLayer, !state.zoneLayer); }); if (zb) zb.addEventListener('click', () => { state.zonesUserHidden = !!state.zoneLayer; setZones(!state.zoneLayer, !state.zoneLayer); });
} }
@@ -1690,10 +2105,15 @@
applyTranslations(); applyTranslations();
document.querySelector('meta[name="theme-color"]').content = css('--bg'); document.querySelector('meta[name="theme-color"]').content = css('--bg');
try { initMap(); } catch (error) { $('error-message').textContent = error.message; $('error').classList.add('show'); } try { initMap(); } catch (error) { $('error-message').textContent = error.message; $('error').classList.add('show'); }
$('stats-button').addEventListener('click', (e) => { e.stopPropagation(); openStats($('stats-pop').hidden); });
document.addEventListener('click', (e) => { if (!$('stats-pop').hidden && !$('stats-pop').contains(e.target)) openStats(false); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') openStats(false); });
load(); load();
loadAlerts(); loadAlerts();
loadPois(); loadPois();
window.setInterval(load, 5 * 60 * 1000); loadStats();
window.setInterval(() => { load(); loadStats(); }, 5 * 60 * 1000);
})(); })();
</script> </script>
</body> </body>
+1 -1
View File
@@ -1438,7 +1438,7 @@
const startDate = $('history-start').value, endDate = $('history-end').value; const startDate = $('history-start').value, endDate = $('history-end').value;
const query = state.useDates && (startDate || endDate) const query = state.useDates && (startDate || endDate)
? [startDate && `start=${startDate}`, endDate && `end=${endDate}`].filter(Boolean).join('&') ? [startDate && `start=${startDate}`, endDate && `end=${endDate}`].filter(Boolean).join('&')
: `hours=${$('history-range').value}`; : `hours=${$('history-range').value}${$('history-range').value === '876000' ? '&limit=100000' : ''}`; // the API stops at 50,000 rows, oldest first
const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?${query}`); const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?${query}`);
if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`); if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`);
const rows = await response.json(); const rows = await response.json();
+14
View File
@@ -112,5 +112,19 @@ def test_basin_stations_have_a_fallback_feed():
assert "fetch('/sensors/thaiwater')" in html assert "fetch('/sensors/thaiwater')" in html
def test_history_reaches_today():
"""/measurements/history returns the oldest rows first and stops at its
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
assert "hours=${hours}&limit=100000" in _html()
dashboard = (STATIC / "dashboard.html").read_text(encoding="utf-8")
assert "&limit=100000" in dashboard
def test_skill_loads_only_when_opened():
html = _html()
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
assert "sk.addEventListener('toggle'" in html
def test_preview_is_not_indexed(): def test_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html() assert '<meta name="robots" content="noindex">' in _html()