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

+475 -55
View File
@@ -36,7 +36,7 @@
--accent: #1d6fd6; --accent-soft: #e9f1fc; --on-accent: #ffffff;
--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;
--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);
}
[data-theme="dark"] {
@@ -45,7 +45,7 @@
--accent: #4c9bf0; --accent-soft: #14243a; --on-accent: #06101e;
--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;
--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);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -206,7 +206,39 @@
.scale-ticks span { position: absolute; white-space: nowrap; }
svg.chart { width: 100%; height: 180px; display: block; }
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; }
th { text-align: left; padding: 3px 0 6px; }
td { padding: 6px 0; border-top: 1px solid var(--line); }
@@ -380,6 +412,7 @@
<footer class="statusbar">
<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>
<nav>
<a href="/docs" target="_blank" rel="noopener" data-i18n="foot.api">API</a>
@@ -388,6 +421,11 @@
</nav>
</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">
<header>
<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(),
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,
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 ---------------------------------------------------
// 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.now': 'Now',
'd.peak24': '24 h peak',
'd.chart': 'Level · last 72 h and forecast',
'd.chart.unit': 'm',
'd.observed': 'Observed',
'd.forecast': 'Forecast peak',
'd.forecastzone': 'Forecast',
'd.nohistory': 'No readings in the last 72 hours',
'd.fc.title': 'Forecast · chance of reaching',
'd.fc.horizon': 'Within',
'd.fc.peak': 'Peak',
@@ -604,6 +640,54 @@
'poi.outside': 'Outside the mapped flood zones',
'poi.zone': 'Flood zone',
'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.preview': 'Preview of the new dashboard',
'foot.classic': 'current dashboard',
@@ -686,12 +770,9 @@
'd.scale': 'ระดับน้ำเทียบกับระดับเตือนภัยและระดับอันตราย',
'd.now': 'ขณะนี้',
'd.peak24': 'สูงสุด 24 ชม.',
'd.chart': 'ระดับน้ำ · 72 ชม. ล่าสุดและค่าพยากรณ์',
'd.chart.unit': 'ม.',
'd.observed': 'ค่าวัดจริง',
'd.forecast': 'ค่าพยากรณ์สูงสุด',
'd.forecastzone': 'พยากรณ์',
'd.nohistory': 'ไม่มีข้อมูลใน 72 ชั่วโมงที่ผ่านมา',
'd.fc.title': 'พยากรณ์ · โอกาสที่จะถึง',
'd.fc.horizon': 'ภายใน',
'd.fc.peak': 'สูงสุด',
@@ -789,6 +870,54 @@
'poi.outside': 'อยู่นอกพื้นที่น้ำท่วมที่ทำแผนที่ไว้',
'poi.zone': 'พื้นที่น้ำท่วม',
'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.preview': 'ตัวอย่างแดชบอร์ดใหม่',
'foot.classic': 'แดชบอร์ดปัจจุบัน',
@@ -837,6 +966,7 @@
applyTranslations();
setBasemap();
renderAll();
renderStats();
}
// ---- Formatting -----------------------------------------------------
@@ -1357,52 +1487,322 @@
return steps.find((s) => range / s <= 5) || 10;
}
function chart(s, width) {
const W = Math.max(280, width), H = 180, padL = 34, padR = 8, padT = 10, padB = 22;
const h = s.hist;
if (h.length < 2) return `<p class="note">${escapeHtml(t('d.nohistory'))}</p>`;
const t0 = h[0].t.getTime(), tNow = h[h.length - 1].t.getTime(), t1 = tNow + 24 * 3600e3;
const fc = [6, 12, 24].filter((x) => s.fc[x] && s.fc[x].predicted_max_level != null)
.map((x) => [tNow + x * 3600e3, Number(s.fc[x].predicted_max_level)]);
const values = h.map((r) => r.level).concat(fc.map((p) => p[1]));
let lo = Math.min(...values), hi = Math.max(...values);
// ---- History in the panel ---------------------------------------------
// Up to 7 days the chart ends in the current 24 h forecast. More than
// 2,500 readings are drawn as the daily mean with the daily low-high band:
// a flood is a peak, and a mean alone would flatten it. "All" asks for
// limit=100000: the API returns the oldest rows first and stops at 50,000.
const RANGES = [['3d', 72], ['7d', 168], ['30d', 720], ['90d', 2160], ['1y', 8760], ['all', 876000]];
// Bangkok has no daylight saving: its calendar day is plain arithmetic
// (Intl formatting per reading made "All", 70,000 rows, take a minute)
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;
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;
const X = (tm) => padL + ((tm - t0) / (t1 - t0)) * (W - padL - padR);
const Y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);
const X = (tm) => padL + ((tm - t0) / (t1 - t0 || 1)) * (W - padL - padR);
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);
let grid = '';
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>`;
}
for (let tm = Math.ceil(t0 / 3600e3) * 3600e3; tm <= t1; tm += 3600e3) {
const d = new Date(tm);
if (bkkHour(d) !== 0) continue;
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="${(X(tm) + 4).toFixed(1)}" y="${H - 6}">${escapeHtml(fmtDay(d))}</text>`;
g += `<line x1="${padL}" x2="${W - padR}" y1="${fBot}" y2="${fBot}" stroke="${line}"/>`
+ `<text x="${padL - 5}" y="${fTop + 8}" text-anchor="end">${Math.round(fMax / 1.12)}</text>`
+ `<text x="${padL - 5}" y="${fBot}" text-anchor="end">0</text>`
+ `<text x="${padL + 4}" y="${fTop + 8}">${escapeHtml(t('h.flow'))} m³/s</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('');
const area = `${line}L${X(tNow).toFixed(1)},${H - padB}L${X(t0).toFixed(1)},${H - padB}Z`;
const lastY = Y(h[h.length - 1].level);
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('');
const accent = css('--accent');
const warnLine = showWarn
? `<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"/>`
+ `<text x="${padL + 4}" y="${(Y(s.warn) - 4).toFixed(1)}" style="fill:${css('--warning-ink')}">${escapeHtml(t('d.warnlevel'))} ${m2(s.warn)}</text>` : '';
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="${escapeHtml(t('d.chart'))}">
<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>
<rect x="${X(tNow).toFixed(1)}" y="${padT}" width="${(X(t1) - X(tNow)).toFixed(1)}" height="${H - padT - padB}" fill="${css('--panel-2')}"/>
${grid}
<text x="${(X(tNow) + 5).toFixed(1)}" y="${padT + 11}">${escapeHtml(t('d.forecastzone'))}</text>
${warnLine}
<path d="${area}" fill="url(#area)"/>
<path d="${line}" fill="none" stroke="${accent}" stroke-width="1.8" stroke-linejoin="round"/>
<path d="${fline}" fill="none" stroke="${accent}" stroke-width="1.6" stroke-dasharray="4 3"/>
${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('')}
<circle cx="${X(tNow).toFixed(1)}" cy="${lastY.toFixed(1)}" r="3.5" fill="${accent}"/></svg>`;
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"/>`
+ `<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>`;
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"/>`
+ `<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>`;
// daily low-high band, per run of consecutive days
if (data.daily) {
let run = [];
const flush = () => {
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"/>`;
run = [];
};
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); });
flush();
}
const levelPath = path(pts, (x) => x.level, Y);
g += `<path d="${path(pts, (x) => x.flow, FY)}" fill="none" stroke="${css('--flow')}" stroke-width="1.3" stroke-linejoin="round"/>`
+ `<path d="${path(issued, (x) => x.v, Y)}" fill="none" stroke="${css('--issued')}" stroke-width="1.1" stroke-dasharray="3 3" opacity=".8"/>`
+ `<path d="${levelPath}" fill="none" stroke="${accent}" stroke-width="1.7" stroke-linejoin="round"/>`;
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) {
@@ -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>
${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>
<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>
${histSection()}
<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>
<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>
</div>
${stages}
${skillSection(s)}
<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="/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>`;
// the chart is drawn for the width its column actually has
const box = el.querySelector('.chart-box');
box.innerHTML = chart(s, box.clientWidth || 370);
drawHistSection(s);
$('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');
if (zb) zb.addEventListener('click', () => { state.zonesUserHidden = !!state.zoneLayer; setZones(!state.zoneLayer, !state.zoneLayer); });
}
@@ -1690,10 +2105,15 @@
applyTranslations();
document.querySelector('meta[name="theme-color"]').content = css('--bg');
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();
loadAlerts();
loadPois();
window.setInterval(load, 5 * 60 * 1000);
loadStats();
window.setInterval(() => { load(); loadStats(); }, 5 * 60 * 1000);
})();
</script>
</body>
+1 -1
View File
@@ -1438,7 +1438,7 @@
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}`;
: `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}`);
if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`);
const rows = await response.json();