fix: restore station telemetry and make river flow visualization data-driven

Station selection showed no history since 21ca844: Chart.js v4 datasets
had parsing:false with plain number arrays, drawing empty axes. Remove
the flag so the chart parses values again.

Backend hardening for the same flow:
- /measurements/history/{code} no longer 503s on non-Postgres configs;
  it falls back to the configured adapter (reversed to ascending order)
- DB_TYPE defaults to postgresql when POSTGRES_CONNECTION_STRING is set
  and DB_TYPE is unset, so the .env psql wins over the sqlite default
- zero readings (0.0) are no longer coerced to None, which would fail
  MeasurementResponse validation and 500 /measurements/latest

Map visualization:
- river segments are now colored, widened and dash-speed-animated by
  the discharge at the nearest gauge (same scale as the marker legend)
- fix z-order bug that drew the animated flow line behind its casing
- legend entries for river lines, reduced-motion fallback

River geometry: rebuild ping-river-network.geojson from Overpass
(110 -> 202 features), restoring missing Ping mainstem reaches through
the Bhumibol reservoir and the Tak-Kamphaeng Phet braided section
(unnamed waterway=river ways in OSM), with short synthetic connectors
(connector: true) bridging remaining sub-8 km holes.
This commit is contained in:
2026-08-10 10:30:05 +07:00
parent af1909db73
commit 49a3de0087
5 changed files with 99 additions and 34 deletions
+5 -1
View File
@@ -45,7 +45,11 @@ class Config:
) )
# Database configuration # Database configuration
DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower() # When DB_TYPE is not set explicitly, a configured Postgres connection wins over the sqlite default
DB_TYPE = os.getenv(
"DB_TYPE",
"postgresql" if os.getenv("POSTGRES_CONNECTION_STRING") else "sqlite",
).lower()
# VictoriaMetrics settings # VictoriaMetrics settings
# Default to localhost; set VM_HOST in the environment for real deployments # Default to localhost; set VM_HOST in the environment for real deployments
+9 -9
View File
@@ -519,9 +519,9 @@ class SQLAdapter(DatabaseAdapter):
"station_code": row[1], "station_code": row[1],
"station_name_en": row[2], "station_name_en": row[2],
"station_name_th": row[3], "station_name_th": row[3],
"water_level": float(row[4]) if row[4] else None, "water_level": float(row[4]) if row[4] is not None else None,
"discharge": float(row[5]) if row[5] else None, "discharge": float(row[5]) if row[5] is not None else None,
"discharge_percent": float(row[6]) if row[6] else None, "discharge_percent": float(row[6]) if row[6] is not None else None,
"status": row[7], "status": row[7],
} }
) )
@@ -573,9 +573,9 @@ class SQLAdapter(DatabaseAdapter):
"station_code": row[1], "station_code": row[1],
"station_name_en": row[2], "station_name_en": row[2],
"station_name_th": row[3], "station_name_th": row[3],
"water_level": float(row[4]) if row[4] else None, "water_level": float(row[4]) if row[4] is not None else None,
"discharge": float(row[5]) if row[5] else None, "discharge": float(row[5]) if row[5] is not None else None,
"discharge_percent": float(row[6]) if row[6] else None, "discharge_percent": float(row[6]) if row[6] is not None else None,
"status": row[7], "status": row[7],
} }
) )
@@ -618,9 +618,9 @@ class SQLAdapter(DatabaseAdapter):
"station_id": row[1], "station_id": row[1],
"station_code": row[2] or f"Station_{row[1]}", "station_code": row[2] or f"Station_{row[1]}",
"station_name_th": row[3] or f"Station {row[1]}", "station_name_th": row[3] or f"Station {row[1]}",
"water_level": float(row[4]) if row[4] else None, "water_level": float(row[4]) if row[4] is not None else None,
"discharge": float(row[5]) if row[5] else None, "discharge": float(row[5]) if row[5] is not None else None,
"discharge_percent": float(row[6]) if row[6] else None, "discharge_percent": float(row[6]) if row[6] is not None else None,
"status": row[7], "status": row[7],
} }
) )
+60 -10
View File
@@ -114,8 +114,15 @@
opacity: .36; animation: pulse 2.2s ease-out infinite; opacity: .36; animation: pulse 2.2s ease-out infinite;
} }
@keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } } @keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } }
.flow-line { animation: riverMove 1.8s linear infinite; } .flow-line { animation: riverMove 3s linear infinite; }
@keyframes riverMove { to { stroke-dashoffset: -30; } } .flow-idle { animation-duration: 5.5s; }
.flow-slow { animation-duration: 3s; }
.flow-med { animation-duration: 1.9s; }
.flow-fast { animation-duration: 1.15s; }
.flow-surge { animation-duration: .7s; }
@keyframes riverMove { to { stroke-dashoffset: -40; } }
@media (prefers-reduced-motion: reduce) { .flow-line, .flow-marker::before { animation: none; } }
.line-swatch { width: 24px; height: 4px; border-radius: 2px; flex: none; }
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); } .leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
.popup { min-width: 190px; } .popup { min-width: 190px; }
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; } .popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
@@ -171,13 +178,15 @@
<article class="map-card"> <article class="map-card">
<div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div> <div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div>
<div class="map-overlay"> <div class="map-overlay">
<div class="map-heading"><strong>Station flow map</strong><span>Marker size follows current discharge</span></div> <div class="map-heading"><strong>Station flow map</strong><span>River width, colour &amp; dash speed follow live discharge</span></div>
<div class="legend"> <div class="legend">
<div class="legend-title">Flow status</div> <div class="legend-title">Flow status</div>
<div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low &lt; 25 m³/s</div> <div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low &lt; 25 m³/s</div>
<div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25100</div> <div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25100</div>
<div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100250</div> <div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100250</div>
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div> <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> </div>
</div> </div>
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div> <div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
@@ -273,22 +282,63 @@
</div>`; </div>`;
} }
function renderRiverNetwork(riverNetwork) { function nearestGaugeFlow(feature, gauges) {
const coords = feature.geometry && feature.geometry.coordinates;
if (!gauges.length || !coords || !coords.length) return null;
let best = Infinity, q = null;
const step = Math.max(1, Math.floor(coords.length / 5));
for (let i = 0; i < coords.length; i += step) {
const lon = coords[i][0], lat = coords[i][1];
gauges.forEach((g) => {
const d = (g.lat - lat) * (g.lat - lat) + (g.lon - lon) * (g.lon - lon);
if (d < best) { best = d; q = g.q; }
});
}
return best < 0.16 ? q : null; // only grade segments within ~0.4° (~45 km) of a gauge
}
function riverWeight(q) {
return q == null ? 2.5 : Math.max(3, Math.min(9, 2.5 + Math.sqrt(Math.max(0, q)) * .38));
}
function riverSpeedClass(q) {
if (q == null) return 'flow-idle';
if (q < 25) return 'flow-slow';
if (q < 100) return 'flow-med';
if (q < 250) return 'flow-fast';
return 'flow-surge';
}
function renderRiverNetwork(riverNetwork, stations, readings) {
if (!riverNetwork) return; if (!riverNetwork) return;
const gauges = stations
.filter((s) => Number.isFinite(s.latitude) && Number.isFinite(s.longitude))
.map((s) => ({ lat: s.latitude, lon: s.longitude, q: readings.get(s.station_code)?.discharge }))
.filter((g) => g.q != null)
.map((g) => ({ lat: g.lat, lon: g.lon, q: Number(g.q) }));
const flowBySegment = new Map();
(riverNetwork.features || []).forEach((f) => flowBySegment.set(f, nearestGaugeFlow(f, gauges)));
const casing = L.geoJSON(riverNetwork, { const casing = L.geoJSON(riverNetwork, {
style: { color: '#d7f3f5', weight: 6, opacity: .72, lineCap: 'round' } style: (f) => ({ color: '#e3f4f8', weight: riverWeight(flowBySegment.get(f)) + 4.5, opacity: .8, lineCap: 'round' })
}).addTo(state.map); }).addTo(state.map);
const flow = L.geoJSON(riverNetwork, { const flow = L.geoJSON(riverNetwork, {
style: { color: '#087da5', weight: 2.5, opacity: .88, dashArray: '5 12', className: 'flow-line' } style: (f) => {
const q = flowBySegment.get(f);
return {
color: q == null ? '#69b7d0' : flowColor(q),
weight: riverWeight(q), opacity: .92, lineCap: 'round',
dashArray: '6 14', className: `flow-line ${riverSpeedClass(q)}`
};
}
}).addTo(state.map); }).addTo(state.map);
casing.bringToBack();
flow.bringToBack(); flow.bringToBack();
casing.bringToBack();
state.layers.push(casing, flow); state.layers.push(casing, flow);
} }
function renderMap(stations, readings, riverNetwork) { function renderMap(stations, readings, riverNetwork) {
clearLayers(); clearLayers();
renderRiverNetwork(riverNetwork); renderRiverNetwork(riverNetwork, stations, readings);
const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude)); const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude));
const bounds = []; const bounds = [];
@@ -358,8 +408,8 @@
data: { data: {
labels: sampled.map((row) => new Date(row.timestamp).toLocaleString('en-TH', { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })), labels: sampled.map((row) => new Date(row.timestamp).toLocaleString('en-TH', { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })),
datasets: [ datasets: [
{ label: 'Discharge (m³/s)', data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25, parsing: false }, { label: 'Discharge (m³/s)', data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
{ label: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25, parsing: false } { label: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 }
] ]
}, },
options: { options: {
File diff suppressed because one or more lines are too long
+17 -6
View File
@@ -454,7 +454,7 @@ async def get_postgres_history(
hours: int = Query(168, ge=1), hours: int = Query(168, ge=1),
limit: int = Query(50000, ge=1, le=100000), limit: int = Query(50000, ge=1, le=100000),
): ):
"""Get historical measurements for a station from PostgreSQL.""" """Get historical measurements for a station from the configured database."""
cache_key = f"{station_code}:{hours}:{limit}" cache_key = f"{station_code}:{hours}:{limit}"
now = time.monotonic() now = time.monotonic()
with HISTORY_CACHE_LOCK: with HISTORY_CACHE_LOCK:
@@ -463,10 +463,9 @@ async def get_postgres_history(
return cached[1] return cached[1]
try: try:
db_config = Config.get_database_config() db_config = Config.get_database_config()
if db_config["type"] != "postgresql":
raise RuntimeError("PostgreSQL is not configured")
history = PostgresHistory(db_config["connection_string"])
end_time = datetime.now() end_time = datetime.now()
if db_config["type"] == "postgresql":
history = PostgresHistory(db_config["connection_string"])
data = await asyncio.to_thread( data = await asyncio.to_thread(
history.station_history, history.station_history,
station_code, station_code,
@@ -474,14 +473,26 @@ async def get_postgres_history(
end_time, end_time,
limit, limit,
) )
else:
scraper = app_state["scraper"]
if not scraper or not scraper.db_adapter:
raise RuntimeError("Database not available")
rows = await asyncio.to_thread(
scraper.db_adapter.get_measurements_by_timerange,
end_time - timedelta(hours=hours),
end_time,
[station_code],
)
# adapter returns newest-first; keep the newest `limit` rows, chart wants ascending
data = list(reversed(rows[:limit]))
with HISTORY_CACHE_LOCK: with HISTORY_CACHE_LOCK:
HISTORY_CACHE[cache_key] = (now, data) HISTORY_CACHE[cache_key] = (now, data)
return data return data
except RuntimeError as error: except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error)) raise HTTPException(status_code=503, detail=str(error))
except Exception as error: except Exception as error:
logger.error(f"Error fetching PostgreSQL history: {error}") logger.error(f"Error fetching measurement history: {error}")
raise HTTPException(status_code=502, detail="PostgreSQL history unavailable") raise HTTPException(status_code=502, detail="Measurement history unavailable")
@app.get("/measurements/latest", response_model=List[MeasurementResponse]) @app.get("/measurements/latest", response_model=List[MeasurementResponse])