const state = { range: "24h", 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", }; 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 })); } 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 computeRange(range, tz) { const now = new Date(); let start = null; let end = null; let axisStart = null; let axisEnd = null; switch (range) { case "6h": { end = now; start = new Date(now.getTime() - 6 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "24h": { start = startOfDay(now, tz); end = new Date(start.getTime() + 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "24h-last": { end = now; start = new Date(now.getTime() - 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "72h-last": { end = now; start = new Date(now.getTime() - 72 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "72h": { end = startOfDay(now, tz); start = new Date(end.getTime() - 72 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "7d-last": { end = now; start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "7d": { start = startOfWeekSunday(now, tz); end = new Date(start.getTime() + 7 * 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"); if (range) { state.range = range; } 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 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("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 updateText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } 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 computeRainProbability(latest, pressureTrend1h) { if (!latest) { return null; } let prob = 0.1; if (pressureTrend1h !== null && pressureTrend1h !== undefined) { if (pressureTrend1h <= -3.0) { prob += 0.5; } else if (pressureTrend1h <= -2.0) { prob += 0.35; } else if (pressureTrend1h <= -1.0) { prob += 0.2; } else if (pressureTrend1h <= -0.5) { prob += 0.1; } } if (latest.rh !== null && latest.rh !== undefined) { if (latest.rh >= 95) { prob += 0.2; } else if (latest.rh >= 90) { prob += 0.15; } else if (latest.rh >= 85) { prob += 0.1; } } if (latest.wind_m_s !== null && latest.wind_m_s !== undefined && latest.wind_m_s >= 6) { prob += 0.05; } prob = Math.max(0.05, Math.min(0.95, prob)); let label = "Low"; if (prob >= 0.6) { label = "High"; } else if (prob >= 0.35) { label = "Medium"; } return { prob, 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 updateSiteMeta(site, model, tzLabel) { 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}`; } } 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); 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); 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 lastPressureTrend = lastNonNull(obsFiltered, "pressure_trend_1h"); const rainProb = computeRainProbability(latest, lastPressureTrend); if (rainProb) { updateText("live-rain-prob", `${Math.round(rainProb.prob * 100)}% (${rainProb.label})`); } else { updateText("live-rain-prob", "--"); } 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.y1 = { position: "right", ticks: { color: "#a4c4c4" }, grid: { drawOnChartArea: false }, }; const rainIncs = computeRainIncrements(obsFiltered); const rainHourly = computeHourlySums(rainIncs, state.tz); const rainTitle = document.getElementById("chart-rain-title"); if (rainTitle) { rainTitle.textContent = "Rain (obs hourly sum vs forecast)"; } const rainChart = { data: { datasets: [ { type: "line", label: "obs hourly sum (mm)", data: rainHourly, borderColor: colors.rain, yAxisID: "y", }, { type: "bar", label: "forecast precip (mm)", data: series(forecast, "precip_mm"), backgroundColor: colors.forecast, yAxisID: "y", }, ], }, options: rainOptions, }; upsertChart("chart-rain", rainChart); 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 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("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); });