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:
+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
Reference in New Issue
Block a user