@@ -337,15 +356,25 @@
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.
+ // 24-h rain bins follow the TMD/ThaiWater classes. Violet sequential ramp:
+ // distinct from the flow palette so a map dot is never ambiguous between
+ // "flow status" and "rainfall amount".
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' };
+ if (mm <= 10) return { color: '#b7a6e3', radius: 4.5, label: 'Light rain' };
+ if (mm <= 35) return { color: '#8f6fd2', radius: 6, label: 'Moderate rain' };
+ if (mm <= 90) return { color: '#6844b8', radius: 7.5, label: 'Heavy rain' };
+ if (mm <= 150) return { color: '#472a96', radius: 9.5, label: 'Very heavy 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) {
@@ -638,11 +667,17 @@
if (!pluginOptions.enabled || !chart.chartArea) return;
const levelScale = chart.scales?.level;
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 zones = [
- { min: levelScale.min, max: 3.0, color: 'rgba(30,139,96,.08)' },
- { min: 3.0, max: 4.5, color: 'rgba(217,144,24,.12)' },
- { min: 4.5, max: levelScale.max, color: 'rgba(204,75,55,.15)' },
+ { min: levelScale.min, max: thr.warning, color: 'rgba(30,139,96,.08)' },
+ { min: thr.warning, max: thr.danger, color: 'rgba(217,144,24,.12)' },
+ { min: thr.danger, max: levelScale.max, color: 'rgba(204,75,55,.15)' },
];
ctx.save();
zones.forEach((z) => {
@@ -696,7 +731,7 @@
const flow = measurement?.discharge == null ? null : Number(measurement.discharge);
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
- row.innerHTML = `
${escapeHtml(station.station_code)}
+ row.innerHTML = `
${escapeHtml(station.station_code)}
${escapeHtml(station.english_name)}${escapeHtml(station.thai_name)}
${flow == null ? '—' : flow.toFixed(1)}m³/s`;
row.addEventListener('click', () => {
@@ -744,12 +779,39 @@
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
const codeClass = sensor.station_code.length > 5 ? 'station-code long' : 'station-code';
- row.innerHTML = `
${escapeHtml(sensor.station_code)}${escapeHtml(sensor.station_name)}${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater${percent == null ? '—' : percent.toFixed(0) + '%'}of bank height`;
+ row.innerHTML = `
${escapeHtml(sensor.station_code)}${escapeHtml(sensor.station_name)}${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater${percent == null ? '—' : percent.toFixed(0) + '%'}of bank height`;
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
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) {
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()));
@@ -761,6 +823,8 @@
// Flow at the basin anchor (summing sequential mainstem gauges would
// double-count the same water, so no basin-wide total is shown).
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);
$('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
@@ -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) {
if (pDanger >= .5) return '#cc4b37';
if (pWarning >= .5 || pDanger >= .2) return '#d99018';
- if (pWarning >= .2) return '#0a91b9';
+ if (pWarning >= .2) return '#b3973f';
return '#1e8b60';
}
@@ -1097,7 +1163,7 @@
function stageColor(p) {
if (p >= .5) return '#cc4b37';
if (p >= .2) return '#d99018';
- if (p >= .05) return '#0a91b9';
+ if (p >= .05) return '#b3973f';
return '#1e8b60';
}
@@ -1107,7 +1173,7 @@
(stages || []).forEach((s) => {
const chip = document.createElement('div');
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.innerHTML = `${Math.round(s.p_exceed * 100)}%
S${s.stage} · ${s.level.toFixed(2)} m`;
strip.appendChild(chip);
@@ -1153,10 +1219,16 @@
const rows = await response.json();
if (!Array.isArray(rows) || !rows.length) { card.style.display = 'none'; return; }
const byStation = new Map();
+ state.stationThresholds = {};
rows.forEach((row) => {
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
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);
const grid = $('forecast-grid');
grid.replaceChildren();
@@ -1174,7 +1246,7 @@
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.source === 'heuristic' ? ' · heuristic fallback' : '');
- return `
${pct}%${r.horizon_hours}h
`;
+ return `
${pct}%${r.horizon_hours}h
`;
}).join('');
cardEl.innerHTML = `
${escapeHtml(code)}` +
`
${first.current_level == null ? '' : `now ${Number(first.current_level).toFixed(2)} m · `}peak risk next 24h
` +
diff --git a/src/web_api.py b/src/web_api.py
index 6c5b5e6..cb49ad5 100644
--- a/src/web_api.py
+++ b/src/web_api.py
@@ -267,6 +267,59 @@ async def background_scraping_task():
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