705 lines
20 KiB
JavaScript
705 lines
20 KiB
JavaScript
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);
|
|
});
|