const state = { range: "24h", mode: "current", bucket: "5m", tz: "local", rangeStart: null, rangeEnd: null, singleChartId: null, charts: {}, timer: null, }; const colors = { obs: "#7bdff2", forecast: "#f4b942", gust: "#ff7d6b", humidity: "#7ee081", pressure: "#8fb8de", uvi: "#f4d35e", light: "#b8f2e6", precip: "#4ea8de", rain: "#4ea8de", rainStart: "#f77f00", }; const RAIN_HOURLY_THRESHOLD_MM = 0.1; function formatNumber(value, digits) { if (value === null || value === undefined) { return "--"; } return Number(value).toFixed(digits); } function formatDateTime(value) { if (!value) return "--"; const dt = value instanceof Date ? value : new Date(value); if (Number.isNaN(dt.getTime())) return "--"; const opts = { dateStyle: "medium", timeStyle: "short" }; if (state.tz === "utc") { return new Intl.DateTimeFormat(undefined, { ...opts, timeZone: "UTC" }).format(dt); } return new Intl.DateTimeFormat(undefined, opts).format(dt); } function formatTick(value) { const dt = new Date(value); if (Number.isNaN(dt.getTime())) return ""; const opts = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }; if (state.tz === "utc") { return new Intl.DateTimeFormat(undefined, { ...opts, timeZone: "UTC" }).format(dt); } return new Intl.DateTimeFormat(undefined, opts).format(dt); } function series(points, key) { return points.map((p) => { const t = new Date(p.ts).getTime(); return { x: Number.isNaN(t) ? null : t, y: p[key] === undefined ? null : p[key], }; }); } function filterRange(points, start, end) { if (!start || !end) return points; const min = start.getTime(); const max = end.getTime(); return points.filter((p) => { if (!p.ts) return false; const t = new Date(p.ts).getTime(); return !Number.isNaN(t) && t >= min && t <= max; }); } function safeDate(value) { if (!value) return null; const dt = value instanceof Date ? value : new Date(value); return Number.isNaN(dt.getTime()) ? null : dt; } function hourBucketMs(ts, tz) { const d = new Date(ts); if (Number.isNaN(d.getTime())) return null; if (tz === "utc") { return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours()); } return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); } function computeRainIncrements(points) { const out = []; let prev = null; for (const p of points) { const t = new Date(p.ts).getTime(); if (Number.isNaN(t)) continue; if (p.rain_mm === null || p.rain_mm === undefined) { out.push({ x: t, y: null }); continue; } let delta = null; if (prev !== null) { delta = p.rain_mm - prev; if (delta < 0) delta = 0; } prev = p.rain_mm; out.push({ x: t, y: delta }); } return out; } function computeHourlySums(points, tz) { const buckets = new Map(); for (const p of points) { if (p.y == null) continue; const bucket = hourBucketMs(p.x, tz); if (bucket === null) continue; buckets.set(bucket, (buckets.get(bucket) || 0) + p.y); } return Array.from(buckets.entries()) .sort((a, b) => a[0] - b[0]) .map(([x, y]) => ({ x, y: y < RAIN_HOURLY_THRESHOLD_MM ? 0 : y })); } function clampRainSeries(points, key) { return points.map((p) => { const v = p[key]; if (v == null) { return { ...p }; } return { ...p, [key]: v < 0.1 ? 0 : v }; }); } function startOfDay(date, tz) { if (tz === "utc") { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function startOfWeekSunday(date, tz) { const day = tz === "utc" ? date.getUTCDay() : date.getDay(); const base = startOfDay(date, tz); const diff = day; return new Date(base.getTime() - diff * 24 * 60 * 60 * 1000); } function startOfMonth(date, tz) { if (tz === "utc") { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); } return new Date(date.getFullYear(), date.getMonth(), 1); } function addMonths(date, months, tz) { if (tz === "utc") { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)); } return new Date(date.getFullYear(), date.getMonth() + months, 1); } function computeRange(range, tz) { const now = new Date(); let start = null; let end = null; let axisStart = null; let axisEnd = null; let mode = state.mode; if (range.endsWith("-last")) { mode = "last"; range = range.replace(/-last$/, ""); } switch (range) { case "6h": { if (mode === "current") { const hour = tz === "utc" ? now.getUTCHours() : now.getHours(); const block = Math.floor(hour / 6) * 6; if (tz === "utc") { start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), block)); } else { start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), block); } end = new Date(start.getTime() + 6 * 60 * 60 * 1000); } else { end = now; start = new Date(now.getTime() - 6 * 60 * 60 * 1000); } axisStart = start; axisEnd = end; break; } case "24h": { if (mode === "current") { start = startOfDay(now, tz); end = new Date(start.getTime() + 24 * 60 * 60 * 1000); } else { end = now; start = new Date(now.getTime() - 24 * 60 * 60 * 1000); } axisStart = start; axisEnd = end; break; } case "72h": { if (mode === "current") { end = startOfDay(now, tz); start = new Date(end.getTime() - 72 * 60 * 60 * 1000); } else { end = now; start = new Date(now.getTime() - 72 * 60 * 60 * 1000); } axisStart = start; axisEnd = end; break; } case "7d": { if (mode === "current") { start = startOfWeekSunday(now, tz); end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000); } else { end = now; start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); } axisStart = start; axisEnd = end; break; } case "1m": { if (mode === "current") { start = startOfMonth(now, tz); end = addMonths(start, 1, tz); } else { end = now; start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); } axisStart = start; axisEnd = end; break; } default: { end = now; start = new Date(now.getTime() - 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; } } return { start, end, axisStart, axisEnd }; } function readStateFromURL() { const url = new URL(window.location.href); const range = url.searchParams.get("range"); const tz = url.searchParams.get("tz"); const mode = url.searchParams.get("mode"); if (range) { if (range.endsWith("-last")) { state.range = range.replace(/-last$/, ""); state.mode = "last"; } else { state.range = range; } } if (mode === "current" || mode === "last") { state.mode = mode; } else if (range === "6h" && !mode) { state.mode = "last"; } if (tz === "utc" || tz === "local") { state.tz = tz; } state.bucket = state.range === "6h" ? "1m" : "5m"; const chartParam = url.searchParams.get("chart"); const chartMatch = url.pathname.match(/^\/chart\/([^/]+)/); if (chartMatch && chartMatch[1]) { state.singleChartId = chartMatch[1]; } else if (chartParam) { state.singleChartId = chartParam; } } function syncControls() { const rangeButtons = document.querySelectorAll(".btn[data-range]"); rangeButtons.forEach((btn) => { btn.classList.toggle("active", btn.dataset.range === state.range); }); const modeButtons = document.querySelectorAll(".btn[data-mode]"); modeButtons.forEach((btn) => { btn.classList.toggle("active", btn.dataset.mode === state.mode); }); const tzButtons = document.querySelectorAll(".btn[data-tz]"); tzButtons.forEach((btn) => { btn.classList.toggle("active", btn.dataset.tz === state.tz); }); } function updateSingleChartMode() { const cards = document.querySelectorAll(".chart-card"); if (!state.singleChartId) { document.body.classList.remove("single-chart"); cards.forEach((card) => card.classList.remove("active")); return; } document.body.classList.add("single-chart"); cards.forEach((card) => { const id = card.dataset.chart; card.classList.toggle("active", id === state.singleChartId); }); } function buildChartURL(chartId) { const url = new URL(window.location.origin + "/chart/" + chartId); url.searchParams.set("range", state.range); url.searchParams.set("mode", state.mode); url.searchParams.set("tz", state.tz); return url.toString(); } function setupShareLinks() { const buttons = document.querySelectorAll(".chart-link"); buttons.forEach((btn) => { btn.addEventListener("click", async () => { const chartId = btn.dataset.chart; if (!chartId) return; const url = buildChartURL(chartId); try { await navigator.clipboard.writeText(url); btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Share"; }, 1200); } catch (err) { window.prompt("Copy chart URL:", url); } }); }); } function minMax(values) { let min = null; let max = null; for (const v of values) { if (v === null || v === undefined) continue; if (min === null || v < min) min = v; if (max === null || v > max) max = v; } return { min, max }; } function sum(values) { let total = 0; let seen = false; for (const v of values) { if (v === null || v === undefined) continue; total += v; seen = true; } return seen ? total : null; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function updateText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } function observationBucketSummary() { if (state.range === "6h") { return "obs bucket 1m (6h view)"; } return "obs bucket 5m (TimescaleDB-aligned)"; } function describeBarometer(pressure, trend) { if (pressure === null || pressure === undefined) { return "No barometer data yet."; } let pressureLabel = "normal"; if (pressure <= 1000) { pressureLabel = "very low"; } else if (pressure < 1008) { pressureLabel = "low"; } else if (pressure >= 1024) { pressureLabel = "high"; } let trendLabel = "steady"; if (trend !== null && trend !== undefined) { if (trend <= -2.0) { trendLabel = "falling fast"; } else if (trend <= -0.5) { trendLabel = "falling"; } else if (trend >= 2.0) { trendLabel = "rising fast"; } else if (trend >= 0.5) { trendLabel = "rising"; } } let outlook = "Stable; no strong rain signal."; if (trend !== null && trend !== undefined) { if (trend <= -1.0 && pressure <= 1008) { outlook = "Unsettled; rain more likely."; } else if (trend <= -1.0) { outlook = "Unsettled; rain possible."; } else if (trend >= 1.0 && pressure >= 1020) { outlook = "Improving; rain less likely."; } else if (trend >= 1.0) { outlook = "Improving; rain chance easing."; } } else if (pressure <= 1005) { outlook = "Low pressure; rain possible."; } else if (pressure >= 1022) { outlook = "Fair weather likely."; } if (trend === null || trend === undefined) { return `Pressure ${formatNumber(pressure, 1)} hPa (${pressureLabel}); trend unavailable. ${outlook}`; } const trendText = `${trend >= 0 ? "+" : ""}${trend.toFixed(1)} hPa/hr`; return `Pressure ${formatNumber(pressure, 1)} hPa (${pressureLabel}), ${trendLabel} (${trendText}). ${outlook}`; } function lastNonNull(points, key) { for (let i = points.length - 1; i >= 0; i -= 1) { const v = points[i][key]; if (v !== null && v !== undefined) { return v; } } return null; } function classifyRainProbability(prob) { if (prob >= 0.6) return "High"; if (prob >= 0.35) return "Medium"; return "Low"; } function computeDewPointC(tempC, rh) { if (tempC === null || tempC === undefined || rh === null || rh === undefined) { return null; } const safeRh = clamp(rh, 1, 100); const a = 17.625; const b = 243.04; const gamma = Math.log(safeRh / 100) + (a * tempC) / (b + tempC); return (b * gamma) / (a - gamma); } function computeRainProbabilityFromInputs(tempC, rh, pressureHpa) { if (tempC === null || tempC === undefined || rh === null || rh === undefined || pressureHpa === null || pressureHpa === undefined) { return null; } const dewPointC = computeDewPointC(tempC, rh); if (dewPointC === null) { return null; } const saturationSpread = Math.max(0, tempC - dewPointC); const humidityFactor = clamp((rh - 55) / 45, 0, 1); const pressureFactor = clamp((1016 - pressureHpa) / 18, 0, 1); const saturationFactor = clamp((6 - saturationSpread) / 6, 0, 1); const score = 0.45 * humidityFactor + 0.35 * pressureFactor + 0.2 * saturationFactor; const prob = clamp(0.02 + 0.93 * score, 0.02, 0.98); return { prob, label: classifyRainProbability(prob) }; } function computeRainProbability(latest) { if (!latest) { return null; } return computeRainProbabilityFromInputs(latest.temp_c, latest.rh, latest.pressure_hpa); } function buildRainProbabilitySeries(points) { const out = []; for (const p of points) { const t = new Date(p.ts).getTime(); if (Number.isNaN(t)) continue; const rp = computeRainProbabilityFromInputs(p.temp_c, p.rh, p.pressure_hpa); out.push({ x: t, y: rp ? Math.round(rp.prob * 1000) / 10 : null, }); } return out; } function buildRainProbabilitySeriesFromPredictions(points) { return points.map((p) => { const t = new Date(p.ts).getTime(); if (Number.isNaN(t)) { return { x: null, y: null }; } if (p.probability === null || p.probability === undefined) { return { x: t, y: null }; } return { x: t, y: Math.round(Number(p.probability) * 1000) / 10, }; }); } function thresholdSeries(range, threshold) { if (!range || !range.axisStart || !range.axisEnd || threshold === null || threshold === undefined) { return []; } const y = Math.round(Number(threshold) * 1000) / 10; return [ { x: range.axisStart.getTime(), y }, { x: range.axisEnd.getTime(), y }, ]; } function predictionAgeMinutes(prediction) { if (!prediction || !prediction.ts) return null; const ts = new Date(prediction.ts).getTime(); if (Number.isNaN(ts)) return null; return (Date.now() - ts) / (60 * 1000); } function updateWeatherIcons(latest, rainProb) { const sunEl = document.getElementById("live-icon-sun"); const cloudEl = document.getElementById("live-icon-cloud"); const rainEl = document.getElementById("live-icon-rain"); const textEl = document.getElementById("live-weather-text"); [sunEl, cloudEl, rainEl].forEach((el) => { if (el) el.classList.remove("active"); }); if (!latest) { if (textEl) textEl.textContent = "--"; return; } const prob = rainProb ? rainProb.prob : null; const rh = latest.rh; const pressure = latest.pressure_hpa; const uvi = latest.uvi; let sunActive = false; let cloudActive = false; let rainActive = false; let label = "Partly cloudy"; if (prob !== null && prob >= 0.6) { rainActive = true; cloudActive = true; label = "Rain likely"; } else if ( (prob !== null && prob >= 0.35) || (rh !== null && rh !== undefined && rh >= 80) || (pressure !== null && pressure !== undefined && pressure <= 1008) ) { cloudActive = true; label = "Cloudy"; } else if ( uvi !== null && uvi !== undefined && uvi >= 4 && rh !== null && rh !== undefined && rh < 75 && pressure !== null && pressure !== undefined && pressure >= 1012 ) { sunActive = true; label = "Sunny"; } else { sunActive = true; cloudActive = true; } if (sunEl) sunEl.classList.toggle("active", sunActive); if (cloudEl) cloudEl.classList.toggle("active", cloudActive); if (rainEl) rainEl.classList.toggle("active", rainActive); if (textEl) textEl.textContent = label; } function extendForecastTo(points, endTime) { if (!points || !points.length || !endTime) { return points; } const last = points[points.length - 1]; const lastTs = new Date(last.ts).getTime(); if (Number.isNaN(lastTs)) { return points; } const endTs = endTime.getTime(); if (endTs <= lastTs) { return points; } const maxGapMs = 75 * 60 * 1000; if (endTs-lastTs > maxGapMs) { return points; } return [...points, { ...last, ts: new Date(endTs).toISOString() }]; } function formatBuildStamp(build) { if (!build) { return "build --"; } const parts = []; const rawBuildTime = build.build_time; if (rawBuildTime && rawBuildTime !== "unknown") { const dt = new Date(rawBuildTime); if (Number.isNaN(dt.getTime())) { parts.push(rawBuildTime); } else { parts.push(formatDateTime(dt)); } } else { parts.push("--"); } if (build.git_commit && build.git_commit !== "unknown") { parts.push(`commit ${build.git_commit}`); } if (build.version && build.version !== "dev") { parts.push(`version ${build.version}`); } return `build ${parts.join(", ")}`; } function updateSiteMeta(site, model, tzLabel, build) { const home = document.getElementById("site-home"); const suffix = document.getElementById("site-meta-suffix"); if (home) { home.textContent = site || "--"; home.setAttribute("href", "/"); } if (suffix) { suffix.textContent = ` | model ${model || "--"} | ${tzLabel} | ${formatBuildStamp(build)}`; } } function upsertChart(id, config) { const ctx = document.getElementById(id); if (!ctx) return; if (state.charts[id]) { state.charts[id].data = config.data; state.charts[id].options = config.options; state.charts[id].update(); return; } state.charts[id] = new Chart(ctx, config); } function baseOptions(range) { return { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: "#d6f0f0" } }, tooltip: { mode: "index", intersect: false, callbacks: { title: (items) => { if (!items.length) return ""; return formatDateTime(new Date(items[0].parsed.x)); }, }, }, }, scales: { x: { type: "time", time: { unit: "hour" }, ticks: { color: "#a4c4c4", maxTicksLimit: 6, callback: (value) => formatTick(value), }, min: range && range.axisStart ? range.axisStart.getTime() : undefined, max: range && range.axisEnd ? range.axisEnd.getTime() : undefined, grid: { color: "rgba(123, 223, 242, 0.08)" }, }, y: { ticks: { color: "#a4c4c4" }, grid: { color: "rgba(123, 223, 242, 0.08)" }, }, }, elements: { line: { tension: 0.2, borderWidth: 2 }, point: { radius: 0, hitRadius: 8 }, }, spanGaps: true, }; } function renderDashboard(data) { const latest = data.latest; const tzLabel = state.tz === "utc" ? "UTC" : "Local"; updateSiteMeta(data.site, data.model, tzLabel, data.build); updateText("last-updated", `updated ${formatDateTime(data.generated_at)}`); const forecastMeta = data.forecast && data.forecast.points && data.forecast.points.length ? `forecast retrieved ${formatDateTime(data.forecast.retrieved_at)}` : "forecast not available"; updateText("forecast-meta", `${forecastMeta} | ${observationBucketSummary()}`); if (latest) { updateText("live-temp", `${formatNumber(latest.temp_c, 2)} C`); updateText("live-rh", `${formatNumber(latest.rh, 0)} %`); updateText("live-pressure", `${formatNumber(latest.pressure_hpa, 1)} hPa`); updateText("live-wind", `${formatNumber(latest.wind_m_s, 2)}`); updateText("live-gust", `${formatNumber(latest.wind_gust_m_s, 2)}`); updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`); updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`); updateText("live-lux", `${formatNumber(latest.light_lux, 0)}`); updateText("live-battery", `${formatNumber(latest.battery_mv, 0)}`); updateText("live-supercap", `${formatNumber(latest.supercap_v, 2)}`); } else { updateText("live-temp", "--"); updateText("live-rh", "--"); updateText("live-pressure", "--"); updateText("live-wind", "--"); updateText("live-gust", "--"); updateText("live-wdir", "--"); updateText("live-uvi", "--"); updateText("live-lux", "--"); updateText("live-battery", "--"); updateText("live-supercap", "--"); } const obs = data.observations || []; const forecastAll = (data.forecast && data.forecast.points) || []; const rangeStart = safeDate(data.range_start) || state.rangeStart; const rangeEnd = safeDate(data.range_end) || state.rangeEnd; const range = { axisStart: rangeStart, axisEnd: rangeEnd }; const obsFiltered = filterRange(obs, rangeStart, rangeEnd); const forecast = filterRange(forecastAll, rangeStart, rangeEnd); const forecastLine = extendForecastTo(forecast, rangeEnd); const rainPredictions = filterRange(data.rain_predictions || [], rangeStart, rangeEnd); const latestRainPrediction = data.latest_rain_prediction || null; const latestPredictionAgeMin = predictionAgeMinutes(latestRainPrediction); const modelPredictionFresh = latestPredictionAgeMin !== null && latestPredictionAgeMin <= 90; const lastPressureTrend = lastNonNull(obsFiltered, "pressure_trend_1h"); const modelRainProb = modelPredictionFresh && latestRainPrediction && latestRainPrediction.probability !== null && latestRainPrediction.probability !== undefined ? { prob: Number(latestRainPrediction.probability), label: classifyRainProbability(Number(latestRainPrediction.probability)), source: "model", } : null; const heuristicRainProb = computeRainProbability(latest); const rainProb = modelRainProb || heuristicRainProb; if (rainProb) { const sourceLabel = rainProb.source === "model" ? "model" : "heuristic"; updateText("live-rain-prob", `${Math.round(rainProb.prob * 100)}% (${rainProb.label}, ${sourceLabel})`); } else if (latestRainPrediction && latestRainPrediction.probability !== null && latestRainPrediction.probability !== undefined) { const stalePct = Math.round(Number(latestRainPrediction.probability) * 100); updateText("live-rain-prob", `${stalePct}% (stale model)`); } else { updateText("live-rain-prob", "--"); } updateWeatherIcons(latest, rainProb); updateText("baro-outlook", describeBarometer(latest ? latest.pressure_hpa : null, lastPressureTrend)); const obsTemps = obsFiltered.map((p) => p.temp_c); const obsWinds = obsFiltered.map((p) => p.wind_m_s); const obsGusts = obsFiltered.map((p) => p.wind_gust_m_s); const obsRH = obsFiltered.map((p) => p.rh); const obsUvi = obsFiltered.map((p) => p.uvi); const obsLux = obsFiltered.map((p) => p.light_lux); const fcTemps = forecast.map((p) => p.temp_c); const fcWinds = forecast.map((p) => p.wind_m_s); const fcGusts = forecast.map((p) => p.wind_gust_m_s); const fcRH = forecast.map((p) => p.rh); const fcPrecip = forecast.map((p) => p.precip_mm); const obsTempSummary = minMax(obsTemps); const obsWindSummary = minMax(obsWinds); const obsUviSummary = minMax(obsUvi); const obsLuxSummary = minMax(obsLux); const forecastTempSummary = minMax(fcTemps); const forecastPrecipTotal = sum(fcPrecip); const obsParts = []; if (obsTempSummary.min !== null) { obsParts.push(`temp_c ${obsTempSummary.min.toFixed(1)} to ${obsTempSummary.max.toFixed(1)}`); } if (obsWindSummary.max !== null) { obsParts.push(`wind_max ${obsWindSummary.max.toFixed(1)} m/s`); } if (obsUviSummary.max !== null) { obsParts.push(`uvi_max ${obsUviSummary.max.toFixed(1)}`); } if (obsLuxSummary.max !== null) { obsParts.push(`lux_max ${obsLuxSummary.max.toFixed(0)}`); } updateText("obs-summary", obsParts.length ? obsParts.join(" | ") : "--"); const forecastParts = []; if (forecastTempSummary.min !== null) { forecastParts.push(`temp_c ${forecastTempSummary.min.toFixed(1)} to ${forecastTempSummary.max.toFixed(1)}`); } if (forecastPrecipTotal !== null) { forecastParts.push(`precip_total ${forecastPrecipTotal.toFixed(1)} mm`); } updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--"); updateText("commentary", buildCommentary(latest, forecastAll)); const tempChart = { type: "line", data: { datasets: [ { label: "obs temp C", data: series(obsFiltered, "temp_c"), borderColor: colors.obs }, { label: "forecast temp C", data: series(forecastLine, "temp_c"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-temp", tempChart); const windChart = { type: "line", data: { datasets: [ { label: "obs wind m/s", data: series(obsFiltered, "wind_m_s"), borderColor: colors.obs }, { label: "obs gust m/s", data: series(obsFiltered, "wind_gust_m_s"), borderColor: colors.gust }, { label: "forecast wind m/s", data: series(forecastLine, "wind_m_s"), borderColor: colors.forecast }, { label: "forecast gust m/s", data: series(forecastLine, "wind_gust_m_s"), borderColor: "#f7d79f" }, ], }, options: baseOptions(range), }; upsertChart("chart-wind", windChart); const rhChart = { type: "line", data: { datasets: [ { label: "obs humidity %", data: series(obsFiltered, "rh"), borderColor: colors.humidity }, { label: "forecast rh %", data: series(forecastLine, "rh"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-rh", rhChart); const pressureChart = { type: "line", data: { datasets: [ { label: "obs pressure hPa", data: series(obsFiltered, "pressure_hpa"), borderColor: colors.pressure }, { label: "forecast msl hPa", data: series(forecastLine, "pressure_msl_hpa"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-pressure", pressureChart); const lightOptions = baseOptions(range); lightOptions.scales.y.ticks.color = colors.uvi; lightOptions.scales.y.title = { display: true, text: "UVI", color: colors.uvi }; lightOptions.scales.y1 = { position: "right", ticks: { color: colors.light }, title: { display: true, text: "Lux", color: colors.light }, grid: { drawOnChartArea: false }, }; const lightChart = { type: "line", data: { datasets: [ { label: "uvi", data: series(obsFiltered, "uvi"), borderColor: colors.uvi, yAxisID: "y" }, { label: "light lux", data: series(obsFiltered, "light_lux"), borderColor: colors.light, yAxisID: "y1" }, ], }, options: lightOptions, }; upsertChart("chart-light", lightChart); const powerOptions = baseOptions(range); powerOptions.scales.y.ticks.color = colors.obs; powerOptions.scales.y.title = { display: true, text: "Battery (mV)", color: colors.obs }; powerOptions.scales.y1 = { position: "right", ticks: { color: colors.forecast }, title: { display: true, text: "Supercap (V)", color: colors.forecast }, grid: { drawOnChartArea: false }, }; const powerChart = { type: "line", data: { datasets: [ { label: "battery mV", data: series(obsFiltered, "battery_mv"), borderColor: colors.obs, yAxisID: "y" }, { label: "supercap V", data: series(obsFiltered, "supercap_v"), borderColor: colors.forecast, yAxisID: "y1" }, ], }, options: powerOptions, }; upsertChart("chart-power", powerChart); const rainOptions = baseOptions(range); rainOptions.scales.y.min = 0; rainOptions.scales.y.ticks.color = colors.rain; rainOptions.scales.y.title = { display: true, text: "Observed Rain (mm)", color: colors.rain }; rainOptions.scales.y1 = { min: 0, position: "right", ticks: { color: colors.forecast }, title: { display: true, text: "Forecast Rain (mm)", color: colors.forecast }, grid: { drawOnChartArea: false }, }; const rainIncs = computeRainIncrements(obsFiltered); const rainHourly = computeHourlySums(rainIncs, state.tz); const latestRainHour = rainHourly.length ? rainHourly[rainHourly.length - 1].y : null; updateText("live-rain-hour", latestRainHour == null ? "--" : `${formatNumber(latestRainHour, 2)} mm`); const rainTitle = document.getElementById("chart-rain-title"); if (rainTitle) { rainTitle.textContent = `Rain (obs hourly sum from ${state.bucket} buckets vs forecast)`; } const forecastRain = clampRainSeries(forecastLine, "precip_mm"); const rainChart = { data: { datasets: [ { type: "line", label: `obs hourly sum (mm, ${state.bucket} buckets)`, data: rainHourly, borderColor: colors.rain, yAxisID: "y", }, { type: "bar", label: "forecast precip (mm)", data: series(forecastRain, "precip_mm"), backgroundColor: colors.forecast, yAxisID: "y1", }, ], }, options: rainOptions, }; upsertChart("chart-rain", rainChart); const rainProbOptions = baseOptions(range); rainProbOptions.scales.y.min = 0; rainProbOptions.scales.y.max = 100; rainProbOptions.scales.y.ticks.color = colors.rain; rainProbOptions.scales.y.ticks.callback = (value) => `${value}%`; rainProbOptions.scales.y.title = { display: true, text: "Probability (%)", color: colors.rain }; const rainProbChart = { type: "line", data: { datasets: [ { label: rainPredictions.length ? "model rain probability (%)" : "heuristic rain probability (%)", data: rainPredictions.length ? buildRainProbabilitySeriesFromPredictions(rainPredictions) : buildRainProbabilitySeries(obsFiltered), borderColor: colors.rain, backgroundColor: "rgba(78, 168, 222, 0.18)", fill: true, yAxisID: "y", }, { label: "decision threshold (%)", data: thresholdSeries(range, latestRainPrediction ? latestRainPrediction.threshold : null), borderColor: "#f4b942", borderDash: [6, 4], yAxisID: "y", }, ], }, options: rainProbOptions, }; upsertChart("chart-rain-prob", rainProbChart); updateSingleChartMode(); } function buildCommentary(latest, forecast) { if (!latest || !forecast || !forecast.length) { return "Waiting for forecast data..."; } const obsTs = new Date(latest.ts); if (Number.isNaN(obsTs.getTime())) { return "No valid observation timestamp yet."; } let nearest = null; let bestDiff = null; for (const p of forecast) { if (!p.ts) continue; const fcTs = new Date(p.ts); if (Number.isNaN(fcTs.getTime())) continue; const diff = Math.abs(obsTs - fcTs); if (bestDiff === null || diff < bestDiff) { bestDiff = diff; nearest = p; } } if (!nearest || bestDiff > 2 * 60 * 60 * 1000) { return "No nearby forecast point to compare yet."; } const parts = []; if (latest.temp_c !== null && nearest.temp_c !== null) { const delta = latest.temp_c - nearest.temp_c; parts.push(`temp ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} C`); } if (latest.wind_m_s !== null && nearest.wind_m_s !== null) { const delta = latest.wind_m_s - nearest.wind_m_s; parts.push(`wind ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); } if (latest.wind_gust_m_s !== null && nearest.wind_gust_m_s !== null) { const delta = latest.wind_gust_m_s - nearest.wind_gust_m_s; parts.push(`gust ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); } if (latest.rh !== null && nearest.rh !== null) { const delta = latest.rh - nearest.rh; parts.push(`rh ${delta >= 0 ? "+" : ""}${delta.toFixed(0)} %`); } if (!parts.length) { return "Not enough data to compute deviation yet."; } return `Now vs forecast: ${parts.join(", ")}`; } async function loadAndRender() { const range = computeRange(state.range, state.tz); state.rangeStart = range.start; state.rangeEnd = range.end; const params = new URLSearchParams({ range: state.range, bucket: state.bucket, start: range.start.toISOString(), end: range.end.toISOString(), }); try { const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" }); if (!resp.ok) { updateText("commentary", `Dashboard error ${resp.status}`); return; } const data = await resp.json(); renderDashboard(data); } catch (err) { updateText("commentary", "Dashboard fetch failed."); } } function setupControls() { const buttons = document.querySelectorAll(".btn[data-range]"); buttons.forEach((btn) => { btn.addEventListener("click", () => { buttons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.range = btn.dataset.range; state.bucket = state.range === "6h" ? "1m" : "5m"; updateURLParams(); loadAndRender(); }); }); const modeButtons = document.querySelectorAll(".btn[data-mode]"); modeButtons.forEach((btn) => { btn.addEventListener("click", () => { modeButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.mode = btn.dataset.mode; updateURLParams(); loadAndRender(); }); }); const tzButtons = document.querySelectorAll(".btn[data-tz]"); tzButtons.forEach((btn) => { btn.addEventListener("click", () => { tzButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.tz = btn.dataset.tz; updateURLParams(); loadAndRender(); }); }); } function updateURLParams() { const url = new URL(window.location.href); url.searchParams.set("range", state.range); url.searchParams.set("mode", state.mode); url.searchParams.set("tz", state.tz); history.replaceState({}, "", url); } document.addEventListener("DOMContentLoaded", () => { readStateFromURL(); syncControls(); setupControls(); setupShareLinks(); updateSingleChartMode(); loadAndRender(); if (state.timer) clearInterval(state.timer); state.timer = setInterval(loadAndRender, 60 * 1000); });