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
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:
@@ -216,6 +216,14 @@
|
||||
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high > 250</div>
|
||||
<div class="legend-row"><i class="line-swatch" style="background:#69b7d0"></i> River · no nearby gauge</div>
|
||||
<div class="legend-row"><i class="line-swatch" style="background:linear-gradient(90deg,#1e8b60,#087da5,#d99018,#cc4b37)"></i> River · gauge colour, dashes = flow</div>
|
||||
<div class="legend-title" style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px">Rainfall · 24 h
|
||||
<label style="font-weight:650;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="rain-toggle" checked style="accent-color:#086b96">show</label>
|
||||
</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#74c1e4;border-radius:50%"></i> Light 0.1–10 mm</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#2f96c4;border-radius:50%"></i> Moderate 10–35</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#086b96;border-radius:50%"></i> Heavy 35–90</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#d99018;border-radius:50%"></i> Very heavy 90–150</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#cc4b37;border-radius:50%"></i> Extreme > 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
@@ -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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import secrets
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -536,6 +537,73 @@ async def get_thaiwater_sensors():
|
||||
raise HTTPException(status_code=502, detail="ThaiWater API unavailable")
|
||||
|
||||
|
||||
def _hii_engine():
|
||||
"""Engine of the HII store, or None when collection is disabled."""
|
||||
collector = app_state.get("hii_collector")
|
||||
if not collector:
|
||||
return None
|
||||
store = collector.store
|
||||
if not store.engine and not store.connect():
|
||||
return None
|
||||
return store.engine
|
||||
|
||||
|
||||
def _hii_rows(sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Run a read query against the hii_* tables, JSON-normalizing numerics."""
|
||||
engine = _hii_engine()
|
||||
if engine is None:
|
||||
return []
|
||||
from sqlalchemy import text
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = [dict(row._mapping) for row in conn.execute(text(sql), params)]
|
||||
for row in rows:
|
||||
for key, value in row.items():
|
||||
if isinstance(value, Decimal):
|
||||
row[key] = float(value)
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/hii/rainfall/latest")
|
||||
async def get_hii_rainfall_latest(hours: int = Query(26, ge=1, le=168)):
|
||||
"""Latest rainfall reading per HII station (Ping basin, collected hourly)."""
|
||||
increment_counter("api_requests", labels={"endpoint": "hii_rainfall"})
|
||||
sql = """
|
||||
SELECT s.id AS station_id, s.oldcode, s.name_en, s.name_th,
|
||||
s.latitude, s.longitude, s.agency,
|
||||
m.timestamp, m.rain_1h, m.rain_24h
|
||||
FROM hii_rainfall m
|
||||
JOIN hii_rain_stations s ON s.id = m.station_id
|
||||
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
|
||||
FROM hii_rainfall WHERE timestamp >= :cutoff
|
||||
GROUP BY station_id) latest
|
||||
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(hours=hours)
|
||||
return await asyncio.to_thread(_hii_rows, sql, {"cutoff": cutoff})
|
||||
|
||||
|
||||
@app.get("/api/hii/waterlevel/latest")
|
||||
async def get_hii_waterlevel_latest(hours: int = Query(26, ge=1, le=168)):
|
||||
"""Latest water-level reading per HII station (Ping basin, m MSL)."""
|
||||
increment_counter("api_requests", labels={"endpoint": "hii_waterlevel"})
|
||||
sql = """
|
||||
SELECT s.id AS station_id, s.oldcode, s.rid_code, s.name_en, s.name_th,
|
||||
s.latitude, s.longitude, s.agency, s.river_name,
|
||||
s.offset_msl, s.min_bank_msl, s.is_key_station,
|
||||
m.timestamp, m.wl_msl, m.discharge, m.storage_percent,
|
||||
m.situation_level, m.diff_wl_bank
|
||||
FROM hii_waterlevel m
|
||||
JOIN hii_wl_stations s ON s.id = m.station_id
|
||||
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
|
||||
FROM hii_waterlevel WHERE timestamp >= :cutoff
|
||||
GROUP BY station_id) latest
|
||||
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(hours=hours)
|
||||
return await asyncio.to_thread(_hii_rows, sql, {"cutoff": cutoff})
|
||||
|
||||
|
||||
@app.get("/measurements/history/{station_code}")
|
||||
async def get_postgres_history(
|
||||
station_code: str,
|
||||
|
||||
Reference in New Issue
Block a user