more chart development
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
const state = {
|
||||
range: "24h",
|
||||
bucket: "5m",
|
||||
tz: "local",
|
||||
rangeStart: null,
|
||||
rangeEnd: null,
|
||||
charts: {},
|
||||
timer: null,
|
||||
};
|
||||
@@ -22,11 +25,25 @@ function formatNumber(value, digits) {
|
||||
return Number(value).toFixed(digits);
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "--";
|
||||
const dt = new Date(iso);
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "--";
|
||||
const dt = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return "--";
|
||||
return dt.toLocaleString();
|
||||
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) {
|
||||
@@ -36,6 +53,84 @@ function series(points, 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 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 "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 minMax(values) {
|
||||
let min = null;
|
||||
let max = null;
|
||||
@@ -75,20 +170,35 @@ function upsertChart(id, config) {
|
||||
state.charts[id] = new Chart(ctx, config);
|
||||
}
|
||||
|
||||
function baseOptions() {
|
||||
function baseOptions(range) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: "#d6f0f0" } },
|
||||
tooltip: { mode: "index", intersect: false },
|
||||
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 },
|
||||
ticks: {
|
||||
color: "#a4c4c4",
|
||||
maxTicksLimit: 6,
|
||||
callback: (value) => formatTick(value),
|
||||
},
|
||||
min: range && range.axisStart ? range.axisStart : undefined,
|
||||
max: range && range.axisEnd ? range.axisEnd : undefined,
|
||||
grid: { color: "rgba(123, 223, 242, 0.08)" },
|
||||
},
|
||||
y: {
|
||||
@@ -106,10 +216,11 @@ function baseOptions() {
|
||||
|
||||
function renderDashboard(data) {
|
||||
const latest = data.latest;
|
||||
updateText("site-meta", `${data.site} | model ${data.model}`);
|
||||
updateText("last-updated", `updated ${formatTime(data.generated_at)}`);
|
||||
const tzLabel = state.tz === "utc" ? "UTC" : "Local";
|
||||
updateText("site-meta", `${data.site} | model ${data.model} | ${tzLabel}`);
|
||||
updateText("last-updated", `updated ${formatDateTime(data.generated_at)}`);
|
||||
const forecastMeta = data.forecast && data.forecast.points && data.forecast.points.length
|
||||
? `forecast retrieved ${formatTime(data.forecast.retrieved_at)}`
|
||||
? `forecast retrieved ${formatDateTime(data.forecast.retrieved_at)}`
|
||||
: "forecast not available";
|
||||
updateText("forecast-meta", forecastMeta);
|
||||
|
||||
@@ -121,6 +232,8 @@ function renderDashboard(data) {
|
||||
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", "--");
|
||||
@@ -129,6 +242,8 @@ function renderDashboard(data) {
|
||||
updateText("live-wdir", "--");
|
||||
updateText("live-uvi", "--");
|
||||
updateText("live-lux", "--");
|
||||
updateText("live-battery", "--");
|
||||
updateText("live-supercap", "--");
|
||||
}
|
||||
|
||||
const forecastUrl = document.getElementById("forecast-url");
|
||||
@@ -142,14 +257,21 @@ function renderDashboard(data) {
|
||||
}
|
||||
|
||||
const obs = data.observations || [];
|
||||
const forecast = (data.forecast && data.forecast.points) || [];
|
||||
const forecastAll = (data.forecast && data.forecast.points) || [];
|
||||
|
||||
const obsTemps = obs.map((p) => p.temp_c);
|
||||
const obsWinds = obs.map((p) => p.wind_m_s);
|
||||
const obsGusts = obs.map((p) => p.wind_gust_m_s);
|
||||
const obsRH = obs.map((p) => p.rh);
|
||||
const obsUvi = obs.map((p) => p.uvi);
|
||||
const obsLux = obs.map((p) => p.light_lux);
|
||||
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);
|
||||
@@ -189,17 +311,17 @@ function renderDashboard(data) {
|
||||
}
|
||||
updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--");
|
||||
|
||||
updateText("commentary", buildCommentary(latest, forecast));
|
||||
updateText("commentary", buildCommentary(latest, forecastAll));
|
||||
|
||||
const tempChart = {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs temp C", data: series(obs, "temp_c"), borderColor: colors.obs },
|
||||
{ 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(),
|
||||
options: baseOptions(range),
|
||||
};
|
||||
upsertChart("chart-temp", tempChart);
|
||||
|
||||
@@ -207,13 +329,13 @@ function renderDashboard(data) {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs wind m/s", data: series(obs, "wind_m_s"), borderColor: colors.obs },
|
||||
{ label: "obs gust m/s", data: series(obs, "wind_gust_m_s"), borderColor: colors.gust },
|
||||
{ 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(),
|
||||
options: baseOptions(range),
|
||||
};
|
||||
upsertChart("chart-wind", windChart);
|
||||
|
||||
@@ -221,15 +343,15 @@ function renderDashboard(data) {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs humidity %", data: series(obs, "rh"), borderColor: colors.humidity },
|
||||
{ label: "obs humidity %", data: series(obsFiltered, "rh"), borderColor: colors.humidity },
|
||||
{ label: "forecast rh %", data: series(forecast, "rh"), borderColor: colors.forecast },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
options: baseOptions(range),
|
||||
};
|
||||
upsertChart("chart-rh", rhChart);
|
||||
|
||||
const lightOptions = baseOptions();
|
||||
const lightOptions = baseOptions(range);
|
||||
lightOptions.scales.y1 = {
|
||||
position: "right",
|
||||
ticks: { color: "#a4c4c4" },
|
||||
@@ -240,14 +362,33 @@ function renderDashboard(data) {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "uvi", data: series(obs, "uvi"), borderColor: colors.uvi, yAxisID: "y" },
|
||||
{ label: "light lux", data: series(obs, "light_lux"), borderColor: colors.light, yAxisID: "y1" },
|
||||
{ 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 precipChart = {
|
||||
type: "bar",
|
||||
data: {
|
||||
@@ -255,7 +396,7 @@ function renderDashboard(data) {
|
||||
{ label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
options: baseOptions(range),
|
||||
};
|
||||
upsertChart("chart-precip", precipChart);
|
||||
}
|
||||
@@ -314,9 +455,15 @@ function buildCommentary(latest, forecast) {
|
||||
}
|
||||
|
||||
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" });
|
||||
@@ -342,6 +489,16 @@ function setupControls() {
|
||||
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;
|
||||
loadAndRender();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Reference in New Issue
Block a user