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:
+5
-1
@@ -45,7 +45,11 @@ class Config:
|
||||
)
|
||||
|
||||
# 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
|
||||
# Default to localhost; set VM_HOST in the environment for real deployments
|
||||
|
||||
@@ -519,9 +519,9 @@ class SQLAdapter(DatabaseAdapter):
|
||||
"station_code": row[1],
|
||||
"station_name_en": row[2],
|
||||
"station_name_th": row[3],
|
||||
"water_level": float(row[4]) if row[4] else None,
|
||||
"discharge": float(row[5]) if row[5] else None,
|
||||
"discharge_percent": float(row[6]) if row[6] else None,
|
||||
"water_level": float(row[4]) if row[4] is not None else None,
|
||||
"discharge": float(row[5]) if row[5] is not None else None,
|
||||
"discharge_percent": float(row[6]) if row[6] is not None else None,
|
||||
"status": row[7],
|
||||
}
|
||||
)
|
||||
@@ -573,9 +573,9 @@ class SQLAdapter(DatabaseAdapter):
|
||||
"station_code": row[1],
|
||||
"station_name_en": row[2],
|
||||
"station_name_th": row[3],
|
||||
"water_level": float(row[4]) if row[4] else None,
|
||||
"discharge": float(row[5]) if row[5] else None,
|
||||
"discharge_percent": float(row[6]) if row[6] else None,
|
||||
"water_level": float(row[4]) if row[4] is not None else None,
|
||||
"discharge": float(row[5]) if row[5] is not None else None,
|
||||
"discharge_percent": float(row[6]) if row[6] is not None else None,
|
||||
"status": row[7],
|
||||
}
|
||||
)
|
||||
@@ -618,9 +618,9 @@ class SQLAdapter(DatabaseAdapter):
|
||||
"station_id": row[1],
|
||||
"station_code": row[2] 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,
|
||||
"discharge": float(row[5]) if row[5] else None,
|
||||
"discharge_percent": float(row[6]) if row[6] else None,
|
||||
"water_level": float(row[4]) if row[4] is not None else None,
|
||||
"discharge": float(row[5]) if row[5] is not None else None,
|
||||
"discharge_percent": float(row[6]) if row[6] is not None else None,
|
||||
"status": row[7],
|
||||
}
|
||||
)
|
||||
|
||||
+60
-10
@@ -114,8 +114,15 @@
|
||||
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; } }
|
||||
.flow-line { animation: riverMove 1.8s linear infinite; }
|
||||
@keyframes riverMove { to { stroke-dashoffset: -30; } }
|
||||
.flow-line { animation: riverMove 3s linear infinite; }
|
||||
.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); }
|
||||
.popup { min-width: 190px; }
|
||||
.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">
|
||||
<div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div>
|
||||
<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 & dash speed follow live discharge</span></div>
|
||||
<div class="legend">
|
||||
<div class="legend-title">Flow status</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low < 25 m³/s</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25–100</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100–250</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
|
||||
@@ -273,22 +282,63 @@
|
||||
</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;
|
||||
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, {
|
||||
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);
|
||||
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);
|
||||
casing.bringToBack();
|
||||
flow.bringToBack();
|
||||
casing.bringToBack();
|
||||
state.layers.push(casing, flow);
|
||||
}
|
||||
|
||||
function renderMap(stations, readings, riverNetwork) {
|
||||
clearLayers();
|
||||
renderRiverNetwork(riverNetwork);
|
||||
renderRiverNetwork(riverNetwork, stations, readings);
|
||||
const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude));
|
||||
const bounds = [];
|
||||
|
||||
@@ -358,8 +408,8 @@
|
||||
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' })),
|
||||
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: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', 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 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+17
-6
@@ -454,7 +454,7 @@ async def get_postgres_history(
|
||||
hours: int = Query(168, ge=1),
|
||||
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}"
|
||||
now = time.monotonic()
|
||||
with HISTORY_CACHE_LOCK:
|
||||
@@ -463,10 +463,9 @@ async def get_postgres_history(
|
||||
return cached[1]
|
||||
try:
|
||||
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()
|
||||
if db_config["type"] == "postgresql":
|
||||
history = PostgresHistory(db_config["connection_string"])
|
||||
data = await asyncio.to_thread(
|
||||
history.station_history,
|
||||
station_code,
|
||||
@@ -474,14 +473,26 @@ async def get_postgres_history(
|
||||
end_time,
|
||||
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:
|
||||
HISTORY_CACHE[cache_key] = (now, data)
|
||||
return data
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error))
|
||||
except Exception as error:
|
||||
logger.error(f"Error fetching PostgreSQL history: {error}")
|
||||
raise HTTPException(status_code=502, detail="PostgreSQL history unavailable")
|
||||
logger.error(f"Error fetching measurement history: {error}")
|
||||
raise HTTPException(status_code=502, detail="Measurement history unavailable")
|
||||
|
||||
|
||||
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
||||
|
||||
Reference in New Issue
Block a user