feat: rainfall + HII water-level layers on the dashboard
Documentation / Build Sphinx Documentation (push) Successful in 1m3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 1m4s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
Documentation / Generate API Documentation (push) Successful in 23s
Documentation / Validate Documentation (push) Failing after 18s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Failing after 11m16s

New endpoints GET /api/hii/rainfall/latest and /api/hii/waterlevel/latest
serve the latest per-station rows from the hii_* tables. The map gains a
TWA-style rain layer: circle markers binned by TMD 24-h classes (blues
for light/moderate/heavy, site amber/red for very-heavy/extreme), with a
legend block and show/hide toggle. The ThaiWater sensor panel now
prefers the DB-backed HII feed (no API key required, 97+ stations,
ThaiWater storage-percent situation colors, gauge-datum conversion via
offset_msl) and falls back to the live /sensors/thaiwater passthrough.
This commit is contained in:
2026-08-11 15:26:58 +07:00
parent d72496f404
commit 4f3f19f6db
4 changed files with 219 additions and 7 deletions
+86 -7
View File
@@ -216,6 +216,14 @@
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div>
<div class="legend-row"><i class="line-swatch" style="background:#69b7d0"></i> River · no nearby gauge</div>
<div class="legend-row"><i class="line-swatch" style="background:linear-gradient(90deg,#1e8b60,#087da5,#d99018,#cc4b37)"></i> River · gauge colour, dashes = flow</div>
<div class="legend-title" style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px">Rainfall · 24 h
<label style="font-weight:650;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="rain-toggle" checked style="accent-color:#086b96">show</label>
</div>
<div class="legend-row"><i class="swatch" style="background:#74c1e4;border-radius:50%"></i> Light 0.110 mm</div>
<div class="legend-row"><i class="swatch" style="background:#2f96c4;border-radius:50%"></i> Moderate 1035</div>
<div class="legend-row"><i class="swatch" style="background:#086b96;border-radius:50%"></i> Heavy 3590</div>
<div class="legend-row"><i class="swatch" style="background:#d99018;border-radius:50%"></i> Very heavy 90150</div>
<div class="legend-row"><i class="swatch" style="background:#cc4b37;border-radius:50%"></i> Extreme &gt; 150</div>
</div>
</div>
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
@@ -270,7 +278,7 @@
<script>
(function () {
'use strict';
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null };
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null, rainLayer: null };
const $ = (id) => document.getElementById(id);
function flowColor(flow) {
@@ -281,6 +289,43 @@
return '#cc4b37';
}
// 24-h rain bins follow the TMD/ThaiWater classes: blues for ordinary rain,
// the site's warning amber/red once totals become flood-relevant.
function rainBin(mm) {
if (mm == null || Number.isNaN(mm) || mm <= 0) return { color: '#90a4a9', radius: 2.5, label: 'No rain', opacity: .45 };
if (mm <= 10) return { color: '#74c1e4', radius: 4.5, label: 'Light rain' };
if (mm <= 35) return { color: '#2f96c4', radius: 6, label: 'Moderate rain' };
if (mm <= 90) return { color: '#086b96', radius: 7.5, label: 'Heavy rain' };
if (mm <= 150) return { color: '#d99018', radius: 9.5, label: 'Very heavy rain' };
return { color: '#cc4b37', radius: 11.5, label: 'Extreme rain' };
}
function renderRainLayer(records) {
if (state.rainLayer) { state.map.removeLayer(state.rainLayer); state.rainLayer = null; }
if (!records || !records.length) return;
const group = L.layerGroup();
records.forEach((r) => {
if (!Number.isFinite(r.latitude) || !Number.isFinite(r.longitude)) return;
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
const bin = rainBin(mm);
const name = r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`;
const time = r.timestamp ? new Date(r.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading';
L.circleMarker([r.latitude, r.longitude], {
radius: bin.radius, color: '#ffffff', weight: 1.5,
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
}).bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(r.oldcode || 'Rain gauge')} · ${escapeHtml(bin.label)}</div>
<h3>${escapeHtml(name)}</h3><div class="popup-th">${escapeHtml(r.agency || 'HII')} rain gauge · ThaiWater</div>
<div class="popup-grid">
<div class="popup-metric"><span>Last 24 h</span><strong>${mm == null ? 'No data' : mm.toFixed(1) + ' mm'}</strong></div>
<div class="popup-metric"><span>Last hour</span><strong>${r.rain_1h == null ? '—' : Number(r.rain_1h).toFixed(1) + ' mm'}</strong></div>
</div>
<div class="popup-time">Reading: ${escapeHtml(time)}</div></div>`).addTo(group);
});
state.rainLayer = group;
const toggle = $('rain-toggle');
if (!toggle || toggle.checked) group.addTo(state.map);
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
@@ -596,13 +641,22 @@
$('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`;
additional.forEach((sensor) => {
const percent = sensor.bank_percent == null ? null : Number(sensor.bank_percent);
const color = percent == null ? '#7b8f94' : percent >= 100 ? '#cc4b37' : percent >= 80 ? '#d99018' : '#6c73b8';
// ThaiWater situation bands by % of bank capacity: over-bank red,
// high amber, normal green, low/critically-low ochre tones.
const color = percent == null ? '#7b8f94'
: percent > 100 ? '#cc4b37'
: percent > 70 ? '#d99018'
: percent > 30 ? '#1e8b60'
: percent > 10 ? '#b3973f'
: '#8a5a2b';
const icon = L.divIcon({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`,
iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -11]
});
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL`;
const gauge = sensor.offset_msl != null && sensor.water_level_msl != null
? ` (${(Number(sensor.water_level_msl) - Number(sensor.offset_msl)).toFixed(2)} m gauge)` : '';
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL${gauge}`;
const bank = sensor.distance_to_bank == null ? 'Unknown' : `${Number(sensor.distance_to_bank).toFixed(2)} m below bank`;
const marker = L.marker([sensor.latitude, sensor.longitude], { icon, title: `${sensor.station_code} ${sensor.station_name}` })
.bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(sensor.station_code)} · ThaiWater</div><h3>${escapeHtml(sensor.station_name)}</h3><div class="popup-th">${escapeHtml(sensor.river_name || 'Ping basin')} · ${escapeHtml(sensor.agency || '')}</div><div class="popup-grid"><div class="popup-metric"><span>Water level</span><strong>${escapeHtml(level)}</strong></div><div class="popup-metric"><span>Bank status</span><strong>${escapeHtml(bank)}</strong></div></div></div>`)
@@ -643,11 +697,12 @@
$('error').style.display = 'none';
try {
initMap();
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([
const [stationResponse, measurementResponse, riverResponse, hiiWlResponse, rainResponse] = await Promise.all([
fetch('/stations'),
fetch('/measurements/latest?limit=500'),
fetch('/static/ping-river-network.geojson'),
fetch('/sensors/thaiwater')
fetch('/api/hii/waterlevel/latest').catch(() => null),
fetch('/api/hii/rainfall/latest').catch(() => null)
]);
if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) {
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
@@ -655,11 +710,30 @@
const stations = await stationResponse.json();
const measurements = await measurementResponse.json();
const riverNetwork = await riverResponse.json();
const thaiWaterSensors = thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
const rainRecords = rainResponse && rainResponse.ok ? await rainResponse.json() : [];
// Prefer the DB-backed HII feed (no API key needed); fall back to the
// live ThaiWater passthrough when HII collection has no data yet.
let sensors = [];
const hiiRecords = hiiWlResponse && hiiWlResponse.ok ? await hiiWlResponse.json() : [];
if (hiiRecords.length) {
sensors = hiiRecords.map((r) => ({
station_code: r.rid_code || r.oldcode || `HII-${r.station_id}`,
station_name: r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`,
latitude: r.latitude, longitude: r.longitude,
water_level_msl: r.wl_msl, bank_percent: r.storage_percent,
distance_to_bank: r.diff_wl_bank, river_name: r.river_name,
agency: r.agency, situation_level: r.situation_level,
offset_msl: r.offset_msl, timestamp: r.timestamp
}));
} else {
const thaiWaterResponse = await fetch('/sensors/thaiwater').catch(() => null);
sensors = thaiWaterResponse && thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
}
const readings = latestByStation(measurements);
renderMap(stations, readings, riverNetwork);
renderRainLayer(rainRecords);
renderList(stations, readings);
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code)));
renderThaiWaterSensors(sensors, new Set(stations.map((station) => station.station_code)));
loadCustomMarkers();
renderSummary(stations, readings);
$('loading').style.display = 'none';
@@ -1046,6 +1120,11 @@
$('refresh-button').addEventListener('click', loadDashboard);
$('zones-toggle').addEventListener('click', toggleFloodZones);
$('rain-toggle').addEventListener('change', (event) => {
if (!state.rainLayer) return;
if (event.target.checked) state.rainLayer.addTo(state.map);
else state.map.removeLayer(state.rainLayer);
});
$('replay-2024').addEventListener('click', replayFlood2024);
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
loadDashboard();