feat: flood-verdict banner, violet rain palette, per-station bands, chip contrast, Umami
Documentation / Build Sphinx Documentation (push) Successful in 27s
Documentation / Documentation Summary (push) Successful in 3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Validate Documentation (push) Failing after 11s
Documentation / Generate API Documentation (push) Successful in 11s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 1m0s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (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 Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 30s

UI/UX review backlog round 2 plus analytics:
- at-a-glance verdict banner (role=status, aria-live) above the tiles:
  ok/watch/danger from P.1 level vs the official 3.70 m stage and the
  24 h forecast, with a not-an-official-warning disclaimer + ThaiWater
  link (also added to the forecast card)
- rainfall dots switch to a violet sequential ramp so they can never be
  confused with flow-status colors; legend gains 'Other markers' rows
  (grey no-data gauges, + bank-height sensors)
- history-chart flood bands now use each station's own /forecast
  thresholds (P.1 official fallback) instead of hardcoded 3.0/4.5
- chipStyle(): contrast-safe text on all code/risk/stage chips (dark ink
  on amber/ochre, darkened fills for white ink); risk/stage ramps drop
  the blue step for a monotonic green-ochre-amber-red escalation
- plain-language tooltips on the flow and capacity tiles
- Umami: client script tag plus opt-out server-side middleware posting
  api-request events (fire-and-forget, 3 s timeout, never blocks)
This commit is contained in:
2026-08-11 16:56:53 +07:00
parent 5848621c77
commit b89c7e1915
3 changed files with 161 additions and 24 deletions
+12
View File
@@ -87,6 +87,18 @@ class Config:
) )
HII_BASIN_CODE = int(os.getenv("HII_BASIN_CODE", "6")) # 6 = Ping Basin HII_BASIN_CODE = int(os.getenv("HII_BASIN_CODE", "6")) # 6 = Ping Basin
# Umami analytics (self-hosted). The website id is public (it ships in the
# dashboard <script> tag); server-side API tracking posts to /api/send.
UMAMI_API_URL = os.getenv("UMAMI_API_URL", "https://stats.buildfor.life/api/send")
UMAMI_WEBSITE_ID = os.getenv(
"UMAMI_WEBSITE_ID", "00b2be73-8f5f-4400-9029-3be852eb08f7"
)
UMAMI_TRACK_API = os.getenv("UMAMI_TRACK_API", "true").lower() in (
"1",
"true",
"yes",
)
# Scheduler settings # Scheduler settings
SCRAPING_INTERVAL_HOURS = int(os.getenv("SCRAPING_INTERVAL_HOURS", "1")) SCRAPING_INTERVAL_HOURS = int(os.getenv("SCRAPING_INTERVAL_HOURS", "1"))
+96 -24
View File
@@ -8,6 +8,7 @@
<link rel="canonical" href="https://water.buildfor.life/"> <link rel="canonical" href="https://water.buildfor.life/">
<meta name="theme-color" content="#f3f7f5"> <meta name="theme-color" content="#f3f7f5">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8C%8A%3C/text%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8C%8A%3C/text%3E%3C/svg%3E">
<script defer src="https://stats.buildfor.life/script.js" data-website-id="00b2be73-8f5f-4400-9029-3be852eb08f7"></script>
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Ping River Live Monitor · Chiang Mai Flood Watch"> <meta property="og:title" content="Ping River Live Monitor · Chiang Mai Flood Watch">
<meta property="og:description" content="Live water levels, rainfall and flood-risk forecasts for the Ping River basin, Northern Thailand."> <meta property="og:description" content="Live water levels, rainfall and flood-risk forecasts for the Ping River basin, Northern Thailand.">
@@ -177,6 +178,10 @@
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; } .popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; } .popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; } .popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
#flood-verdict.ok { background: #e2f3ea; color: #0c5138; border-color: #bfe3d2; }
#flood-verdict.watch { background: #fdf1dc; color: #6b4a05; border-color: #f0d9a8; }
#flood-verdict.danger { background: #fbe3de; color: #7c1d10; border-color: #f2c0b6; }
#legend-toggle { #legend-toggle {
display: none; pointer-events: auto; background: rgba(255,255,255,.93); border: 1px solid rgba(207,224,218,.9); display: none; pointer-events: auto; background: rgba(255,255,255,.93); border: 1px solid rgba(207,224,218,.9);
border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink); border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink);
@@ -224,10 +229,20 @@
</div> </div>
</header> </header>
<section id="flood-verdict" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:15px 18px;border-radius:16px;border:1px solid;display:none">
<div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap">
<strong id="verdict-icon" style="font-size:1.2rem"></strong>
<div style="min-width:0;flex:1">
<strong id="verdict-text" style="font-size:.95rem"></strong>
<div id="verdict-sub" style="font-size:.72rem;margin-top:3px;opacity:.85">Experimental model — not an official warning · official alerts: <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener" style="color:inherit">ThaiWater ↗</a></div>
</div>
</div>
</section>
<section class="stats" aria-label="River summary"> <section class="stats" aria-label="River summary">
<article class="stat"><div class="stat-label">Reporting stations</div><div class="stat-value" id="station-count"></div><div class="stat-note" id="station-count-note">RID gauges with current readings</div></article> <article class="stat"><div class="stat-label">Reporting stations</div><div class="stat-value" id="station-count"></div><div class="stat-note" id="station-count-note">RID gauges with current readings</div></article>
<article class="stat"><div class="stat-label">Chiang Mai river flow</div><div class="stat-value" id="total-flow"></div><div class="stat-note" id="cm-flow-note">P.1 Nawarat Bridge · m³/s</div></article> <article class="stat" title="Discharge: the volume of water flowing past the gauge each second (cubic meters per second)"><div class="stat-label">Chiang Mai river flow</div><div class="stat-value" id="total-flow"></div><div class="stat-note" id="cm-flow-note">P.1 Nawarat Bridge · m³/s</div></article>
<article class="stat"><div class="stat-label">Most stressed gauge</div><div class="stat-value" id="peak-flow"></div><div class="stat-note" id="peak-station">Awaiting station data</div></article> <article class="stat" title="How full the river channel is at the busiest gauge — 100% means water reaches the top of the bank"><div class="stat-label">Most stressed gauge</div><div class="stat-value" id="peak-flow"></div><div class="stat-note" id="peak-station">Awaiting station data</div></article>
<article class="stat"><div class="stat-label">Last updated</div><div class="stat-value" id="last-updated"></div><div class="stat-note" id="data-age">Loading latest readings</div></article> <article class="stat"><div class="stat-label">Last updated</div><div class="stat-value" id="last-updated"></div><div class="stat-note" id="data-age">Loading latest readings</div></article>
</section> </section>
@@ -249,11 +264,14 @@
<div class="legend-title" style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px">Rainfall · 24 h <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> <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>
<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:#b7a6e3;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:#8f6fd2;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:#6844b8;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:#472a96;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 class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> Extreme &gt; 150</div>
<div class="legend-title" style="margin-top:10px">Other markers</div>
<div class="legend-row"><i class="swatch" style="background:#7b8f94"></i> Gauge · no recent data</div>
<div class="legend-row"><span style="font-weight:800;color:#0f6844">+</span> Water-level sensor · colour = % of bank height</div>
</div> </div>
</div> </div>
</div> </div>
@@ -275,7 +293,8 @@
<section class="map-card" id="forecast-card" style="margin-top:14px;padding:20px;display:none"> <section class="map-card" id="forecast-card" style="margin-top:14px;padding:20px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
<div><h2 style="margin:0;font-size:1rem">Flood risk outlook <span style="color:var(--muted);font-weight:650;font-size:.72rem">· experimental</span></h2> <div><h2 style="margin:0;font-size:1rem">Flood risk outlook <span style="color:var(--muted);font-weight:650;font-size:.72rem">· experimental</span></h2>
<p id="forecast-status" class="subtitle">Model probability of reaching warning / danger levels within 6, 12 and 24 hours</p></div> <p id="forecast-status" class="subtitle">Model probability of reaching warning / danger levels within 6, 12 and 24 hours</p>
<p class="subtitle" style="margin-top:3px">Not an official warning — for official alerts see <a href="https://twa.thaiwater.net/" target="_blank" rel="noopener">ThaiWater</a> and <a href="https://www.tmd.go.th/" target="_blank" rel="noopener">TMD</a></p></div>
</div> </div>
<div class="p1-outlook" id="p1-outlook" style="display:none"> <div class="p1-outlook" id="p1-outlook" style="display:none">
<div class="p1-outlook-head"> <div class="p1-outlook-head">
@@ -337,15 +356,25 @@
return '#cc4b37'; return '#cc4b37';
} }
// 24-h rain bins follow the TMD/ThaiWater classes: blues for ordinary rain, // 24-h rain bins follow the TMD/ThaiWater classes. Violet sequential ramp:
// the site's warning amber/red once totals become flood-relevant. // distinct from the flow palette so a map dot is never ambiguous between
// "flow status" and "rainfall amount".
function rainBin(mm) { function rainBin(mm) {
if (mm == null || Number.isNaN(mm) || mm <= 0) return { color: '#90a4a9', radius: 2.5, label: 'No rain', opacity: .45 }; 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 <= 10) return { color: '#b7a6e3', radius: 4.5, label: 'Light rain' };
if (mm <= 35) return { color: '#2f96c4', radius: 6, label: 'Moderate rain' }; if (mm <= 35) return { color: '#8f6fd2', radius: 6, label: 'Moderate rain' };
if (mm <= 90) return { color: '#086b96', radius: 7.5, label: 'Heavy rain' }; if (mm <= 90) return { color: '#6844b8', radius: 7.5, label: 'Heavy rain' };
if (mm <= 150) return { color: '#d99018', radius: 9.5, label: 'Very heavy rain' }; if (mm <= 150) return { color: '#472a96', radius: 9.5, label: 'Very heavy rain' };
return { color: '#cc4b37', radius: 11.5, label: 'Extreme rain' }; return { color: '#2a1668', radius: 11.5, label: 'Extreme rain' };
}
// Chips that carry text need contrast-safe pairings: slightly darkened
// fills for white ink, dark ink on the light amber/ochre fills.
const CHIP_BG = { '#1e8b60': '#0f6844', '#087da5': '#076d90', '#cc4b37': '#c23a25', '#7b8f94': '#5f7278' };
const CHIP_DARK_INK = { '#d99018': '#3a2a00', '#b3973f': '#241c04' };
function chipStyle(color) {
const ink = CHIP_DARK_INK[color];
return `background:${CHIP_BG[color] || color};color:${ink || '#ffffff'}`;
} }
function renderRainLayer(records) { function renderRainLayer(records) {
@@ -638,11 +667,17 @@
if (!pluginOptions.enabled || !chart.chartArea) return; if (!pluginOptions.enabled || !chart.chartArea) return;
const levelScale = chart.scales?.level; const levelScale = chart.scales?.level;
if (!levelScale || !Number.isFinite(levelScale.min) || !Number.isFinite(levelScale.max)) return; if (!levelScale || !Number.isFinite(levelScale.min) || !Number.isFinite(levelScale.max)) return;
// Per-station thresholds from /forecast; P.1's official
// levels as fallback. No thresholds -> no bands (the old
// hardcoded 3.0/4.5 were wrong for most stations).
const thr = state.stationThresholds?.[state.selectedStation]
|| (state.selectedStation === 'P.1' ? { warning: 3.7, danger: 4.2 } : null);
if (!thr) return;
const { ctx, chartArea } = chart; const { ctx, chartArea } = chart;
const zones = [ const zones = [
{ min: levelScale.min, max: 3.0, color: 'rgba(30,139,96,.08)' }, { min: levelScale.min, max: thr.warning, color: 'rgba(30,139,96,.08)' },
{ min: 3.0, max: 4.5, color: 'rgba(217,144,24,.12)' }, { min: thr.warning, max: thr.danger, color: 'rgba(217,144,24,.12)' },
{ min: 4.5, max: levelScale.max, color: 'rgba(204,75,55,.15)' }, { min: thr.danger, max: levelScale.max, color: 'rgba(204,75,55,.15)' },
]; ];
ctx.save(); ctx.save();
zones.forEach((z) => { zones.forEach((z) => {
@@ -696,7 +731,7 @@
const flow = measurement?.discharge == null ? null : Number(measurement.discharge); const flow = measurement?.discharge == null ? null : Number(measurement.discharge);
const row = document.createElement('button'); const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row'; row.type = 'button'; row.className = 'station-row';
row.innerHTML = `<span class="station-code" style="background:${flowColor(flow)}">${escapeHtml(station.station_code)}</span> row.innerHTML = `<span class="station-code" style="${chipStyle(flowColor(flow))}">${escapeHtml(station.station_code)}</span>
<span class="station-name"><strong>${escapeHtml(station.english_name)}</strong><span>${escapeHtml(station.thai_name)}</span></span> <span class="station-name"><strong>${escapeHtml(station.english_name)}</strong><span>${escapeHtml(station.thai_name)}</span></span>
<span class="flow-value">${flow == null ? '—' : flow.toFixed(1)}<span>m³/s</span></span>`; <span class="flow-value">${flow == null ? '—' : flow.toFixed(1)}<span>m³/s</span></span>`;
row.addEventListener('click', () => { row.addEventListener('click', () => {
@@ -744,12 +779,39 @@
const row = document.createElement('button'); const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row'; row.type = 'button'; row.className = 'station-row';
const codeClass = sensor.station_code.length > 5 ? 'station-code long' : 'station-code'; const codeClass = sensor.station_code.length > 5 ? 'station-code long' : 'station-code';
row.innerHTML = `<span class="${codeClass}" style="background:${color}">${escapeHtml(sensor.station_code)}</span><span class="station-name"><strong>${escapeHtml(sensor.station_name)}</strong><span>${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater</span></span><span class="flow-value">${percent == null ? '—' : percent.toFixed(0) + '%'}<span>of bank height</span></span>`; row.innerHTML = `<span class="${codeClass}" style="${chipStyle(color)}">${escapeHtml(sensor.station_code)}</span><span class="station-name"><strong>${escapeHtml(sensor.station_name)}</strong><span>${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater</span></span><span class="flow-value">${percent == null ? '—' : percent.toFixed(0) + '%'}<span>of bank height</span></span>`;
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); }); row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
container.appendChild(row); container.appendChild(row);
}); });
} }
// At-a-glance verdict from P.1 level + 24 h forecast. Level thresholds are
// the official Chiang Mai inundation stages (city flooding starts 3.70 m).
function updateFloodVerdict() {
const banner = $('flood-verdict');
const level = state.p1Now;
const fc = state.p1Forecast24; // {p_warning, p_danger, predicted_max_level} or null
if (level == null && !fc) { banner.style.display = 'none'; return; }
let cls, icon, text;
if (level != null && level >= 3.7) {
cls = 'danger'; icon = '🛑';
text = `Chiang Mai city flooding stage reached — river at ${level.toFixed(2)} m (flooding starts at 3.70 m)`;
} else if (fc && (fc.p_danger >= .5 || fc.p_warning >= .7)) {
cls = 'danger'; icon = '🛑';
text = `Flooding likely — the river may reach warning level within 24 h (now ${level == null ? '—' : level.toFixed(2) + ' m'}, warning at 3.70 m)`;
} else if ((fc && fc.p_warning >= .2) || (level != null && level >= 3.2)) {
cls = 'watch'; icon = '⚠️';
text = `Watch — river elevated at ${level == null ? '—' : level.toFixed(2) + ' m'}; ${fc ? Math.round(fc.p_warning * 100) + '% chance of reaching the 3.70 m warning level within 24 h' : 'monitor conditions'}`;
} else {
cls = 'ok'; icon = '✓';
text = `No Chiang Mai city flooding expected in the next 24 h — river at ${level == null ? '—' : level.toFixed(2) + ' m'}, flooding starts at 3.70 m`;
}
banner.className = cls;
$('verdict-icon').textContent = icon;
$('verdict-text').textContent = text;
banner.style.display = 'block';
}
function renderSummary(stations, readings) { function renderSummary(stations, readings) {
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean); const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime())); const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime()));
@@ -761,6 +823,8 @@
// Flow at the basin anchor (summing sequential mainstem gauges would // Flow at the basin anchor (summing sequential mainstem gauges would
// double-count the same water, so no basin-wide total is shown). // double-count the same water, so no basin-wide total is shown).
const p1 = readings.get('P.1'); const p1 = readings.get('P.1');
if (p1?.water_level != null) state.p1Now = Number(p1.water_level);
updateFloodVerdict();
const p1Flow = p1?.discharge == null ? null : Number(p1.discharge); const p1Flow = p1?.discharge == null ? null : Number(p1.discharge);
$('total-flow').textContent = p1Flow == null ? '—' : p1Flow.toLocaleString(undefined, { maximumFractionDigits: 1 }); $('total-flow').textContent = p1Flow == null ? '—' : p1Flow.toLocaleString(undefined, { maximumFractionDigits: 1 });
$('cm-flow-note').textContent = 'P.1 Nawarat Bridge · m³/s'; // capacity % lives in the next tile $('cm-flow-note').textContent = 'P.1 Nawarat Bridge · m³/s'; // capacity % lives in the next tile
@@ -837,10 +901,12 @@
} }
} }
// Monotonic warning ramp (green -> ochre -> amber -> red); blue stays
// reserved for water quantities elsewhere on the page.
function riskColor(pWarning, pDanger) { function riskColor(pWarning, pDanger) {
if (pDanger >= .5) return '#cc4b37'; if (pDanger >= .5) return '#cc4b37';
if (pWarning >= .5 || pDanger >= .2) return '#d99018'; if (pWarning >= .5 || pDanger >= .2) return '#d99018';
if (pWarning >= .2) return '#0a91b9'; if (pWarning >= .2) return '#b3973f';
return '#1e8b60'; return '#1e8b60';
} }
@@ -1097,7 +1163,7 @@
function stageColor(p) { function stageColor(p) {
if (p >= .5) return '#cc4b37'; if (p >= .5) return '#cc4b37';
if (p >= .2) return '#d99018'; if (p >= .2) return '#d99018';
if (p >= .05) return '#0a91b9'; if (p >= .05) return '#b3973f';
return '#1e8b60'; return '#1e8b60';
} }
@@ -1107,7 +1173,7 @@
(stages || []).forEach((s) => { (stages || []).forEach((s) => {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'stage-chip'; chip.className = 'stage-chip';
chip.style.background = stageColor(s.p_exceed); chip.setAttribute('style', chipStyle(stageColor(s.p_exceed)));
chip.title = `Stage ${s.stage}: river at ${s.level.toFixed(2)} m — ${Math.round(s.p_exceed * 100)}% within ${horizonHours} h`; chip.title = `Stage ${s.stage}: river at ${s.level.toFixed(2)} m — ${Math.round(s.p_exceed * 100)}% within ${horizonHours} h`;
chip.innerHTML = `${Math.round(s.p_exceed * 100)}%<small>S${s.stage} · ${s.level.toFixed(2)} m</small>`; chip.innerHTML = `${Math.round(s.p_exceed * 100)}%<small>S${s.stage} · ${s.level.toFixed(2)} m</small>`;
strip.appendChild(chip); strip.appendChild(chip);
@@ -1153,10 +1219,16 @@
const rows = await response.json(); const rows = await response.json();
if (!Array.isArray(rows) || !rows.length) { card.style.display = 'none'; return; } if (!Array.isArray(rows) || !rows.length) { card.style.display = 'none'; return; }
const byStation = new Map(); const byStation = new Map();
state.stationThresholds = {};
rows.forEach((row) => { rows.forEach((row) => {
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []); if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
byStation.get(row.station_code).push(row); byStation.get(row.station_code).push(row);
if (row.threshold_warning != null && row.threshold_danger != null) {
state.stationThresholds[row.station_code] = { warning: row.threshold_warning, danger: row.threshold_danger };
}
}); });
state.p1Forecast24 = rows.find((r) => r.station_code === 'P.1' && r.horizon_hours === 24) || null;
updateFloodVerdict();
renderP1Outlook(rows); renderP1Outlook(rows);
const grid = $('forecast-grid'); const grid = $('forecast-grid');
grid.replaceChildren(); grid.replaceChildren();
@@ -1174,7 +1246,7 @@
const title = `+${r.horizon_hours}h · warning ${Math.round(pw * 100)}% · danger ${Math.round(pd * 100)}%` + const title = `+${r.horizon_hours}h · warning ${Math.round(pw * 100)}% · danger ${Math.round(pd * 100)}%` +
(r.predicted_max_level == null ? '' : ` · peak ~${Number(r.predicted_max_level).toFixed(2)} m`) + (r.predicted_max_level == null ? '' : ` · peak ~${Number(r.predicted_max_level).toFixed(2)} m`) +
(r.source === 'heuristic' ? ' · heuristic fallback' : ''); (r.source === 'heuristic' ? ' · heuristic fallback' : '');
return `<div class="risk-chip" style="background:${riskColor(pw, pd)}" title="${escapeHtml(title)}">${pct}%<span>${r.horizon_hours}h</span></div>`; return `<div class="risk-chip" style="${chipStyle(riskColor(pw, pd))}" title="${escapeHtml(title)}">${pct}%<span>${r.horizon_hours}h</span></div>`;
}).join(''); }).join('');
cardEl.innerHTML = `<strong>${escapeHtml(code)}</strong>` + cardEl.innerHTML = `<strong>${escapeHtml(code)}</strong>` +
`<div class="fc-name">${first.current_level == null ? '' : `now ${Number(first.current_level).toFixed(2)} m · `}peak risk next 24h</div>` + `<div class="fc-name">${first.current_level == null ? '' : `now ${Number(first.current_level).toFixed(2)} m · `}peak risk next 24h</div>` +
+53
View File
@@ -267,6 +267,59 @@ async def background_scraping_task():
await asyncio.sleep(60) # Wait a minute before retrying await asyncio.sleep(60) # Wait a minute before retrying
# Umami server-side API tracking (fire-and-forget; never blocks a response)
_UMAMI_TRACK_PREFIXES = (
"/api/",
"/measurements",
"/forecast",
"/stations",
"/sensors",
)
def _send_umami_event(path: str, method: str, status: int, host: str, user_agent: str):
try:
requests.post(
Config.UMAMI_API_URL,
json={
"type": "event",
"payload": {
"website": Config.UMAMI_WEBSITE_ID,
"url": path,
"hostname": host,
"name": "api-request",
"data": {"method": method, "status": status},
},
},
headers={"User-Agent": user_agent or "api-client"},
timeout=3,
)
except Exception:
pass # analytics must never affect API behavior
@app.middleware("http")
async def umami_api_tracking(request, call_next):
response = await call_next(request)
if (
Config.UMAMI_TRACK_API
and Config.UMAMI_WEBSITE_ID
and request.url.path.startswith(_UMAMI_TRACK_PREFIXES)
):
asyncio.get_event_loop().create_task(
asyncio.to_thread(
_send_umami_event,
request.url.path,
request.method,
response.status_code,
request.headers.get("host", "water.buildfor.life"),
request.headers.get("user-agent", ""),
)
)
return response
# API Routes # API Routes