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", 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 computeRollingSum(points, windowMs) { const out = []; let sum = 0; let j = 0; const values = points.map((p) => ({ x: p.x, y: p.y == null ? 0 : p.y, })); for (let i = 0; i < values.length; i++) { const cur = values[i]; sum += cur.y; while (values[j].x < cur.x - windowMs) { sum -= values[j].y; j += 1; } out.push({ x: cur.x, y: sum }); } 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": { end = startOfDay(now, tz); start = new Date(end.getTime() - 72 * 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 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-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-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 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 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(forecast, "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(forecast, "wind_m_s"), borderColor: colors.forecast }, { label: "forecast gust m/s", data: series(forecast, "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(forecast, "rh"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-rh", rhChart); const lightOptions = baseOptions(range); lightOptions.scales.y1 = { position: "right", ticks: { color: "#a4c4c4" }, 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.y1 = { position: "right", ticks: { color: "#a4c4c4" }, 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 rainRolling = computeRollingSum(rainIncs, 24 * 60 * 60 * 1000); const rainHourly = computeHourlySums(rainIncs, state.tz); const rainTitle = document.getElementById("chart-rain-title"); const isHourly = state.range === "6h"; if (rainTitle) { rainTitle.textContent = isHourly ? "Rain (obs hourly vs forecast)" : "Rain (obs rolling 24h vs forecast)"; } const rainChart = { data: { datasets: [ { type: "line", label: isHourly ? "obs hourly sum (mm)" : "obs rolling 24h (mm)", data: isHourly ? rainHourly : rainRolling, 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); });