more chart development
This commit is contained in:
@@ -28,6 +28,8 @@ type dashboardResponse struct {
|
|||||||
Site string `json:"site"`
|
Site string `json:"site"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
OpenMeteoURL string `json:"open_meteo_url,omitempty"`
|
OpenMeteoURL string `json:"open_meteo_url,omitempty"`
|
||||||
|
RangeStart time.Time `json:"range_start"`
|
||||||
|
RangeEnd time.Time `json:"range_end"`
|
||||||
Observations []db.ObservationPoint `json:"observations"`
|
Observations []db.ObservationPoint `json:"observations"`
|
||||||
Forecast db.ForecastSeries `json:"forecast"`
|
Forecast db.ForecastSeries `json:"forecast"`
|
||||||
Latest *db.ObservationPoint `json:"latest"`
|
Latest *db.ObservationPoint `json:"latest"`
|
||||||
@@ -90,6 +92,8 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
if rangeStr == "" {
|
if rangeStr == "" {
|
||||||
rangeStr = "24h"
|
rangeStr = "24h"
|
||||||
}
|
}
|
||||||
|
startStr := r.URL.Query().Get("start")
|
||||||
|
endStr := r.URL.Query().Get("end")
|
||||||
bucket := r.URL.Query().Get("bucket")
|
bucket := r.URL.Query().Get("bucket")
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
bucket = "5m"
|
bucket = "5m"
|
||||||
@@ -99,13 +103,37 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rangeDur, err := time.ParseDuration(rangeStr)
|
var (
|
||||||
if err != nil || rangeDur <= 0 {
|
start time.Time
|
||||||
http.Error(w, "invalid range", http.StatusBadRequest)
|
end time.Time
|
||||||
return
|
err error
|
||||||
|
)
|
||||||
|
if startStr != "" || endStr != "" {
|
||||||
|
if startStr == "" || endStr == "" {
|
||||||
|
http.Error(w, "start and end required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, err = parseTimeParam(startStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid start", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end, err = parseTimeParam(endStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid end", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rangeDur, err := time.ParseDuration(rangeStr)
|
||||||
|
if err != nil || rangeDur <= 0 {
|
||||||
|
http.Error(w, "invalid range", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end = time.Now().UTC()
|
||||||
|
start = end.Add(-rangeDur)
|
||||||
}
|
}
|
||||||
|
|
||||||
observations, err := s.db.ObservationSeries(r.Context(), s.site.Name, bucket, int64(rangeDur.Seconds()))
|
observations, err := s.db.ObservationSeries(r.Context(), s.site.Name, bucket, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to query observations", http.StatusInternalServerError)
|
http.Error(w, "failed to query observations", http.StatusInternalServerError)
|
||||||
log.Printf("web dashboard observations error: %v", err)
|
log.Printf("web dashboard observations error: %v", err)
|
||||||
@@ -136,6 +164,8 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Site: s.site.Name,
|
Site: s.site.Name,
|
||||||
Model: s.model,
|
Model: s.model,
|
||||||
OpenMeteoURL: openMeteoURL,
|
OpenMeteoURL: openMeteoURL,
|
||||||
|
RangeStart: start,
|
||||||
|
RangeEnd: end,
|
||||||
Observations: observations,
|
Observations: observations,
|
||||||
Forecast: forecast,
|
Forecast: forecast,
|
||||||
Latest: latest,
|
Latest: latest,
|
||||||
@@ -147,3 +177,13 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("web dashboard encode error: %v", err)
|
log.Printf("web dashboard encode error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTimeParam(v string) (time.Time, error) {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
return time.Time{}, errors.New("unsupported time format")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const state = {
|
const state = {
|
||||||
range: "24h",
|
range: "24h",
|
||||||
bucket: "5m",
|
bucket: "5m",
|
||||||
|
tz: "local",
|
||||||
|
rangeStart: null,
|
||||||
|
rangeEnd: null,
|
||||||
charts: {},
|
charts: {},
|
||||||
timer: null,
|
timer: null,
|
||||||
};
|
};
|
||||||
@@ -22,11 +25,25 @@ function formatNumber(value, digits) {
|
|||||||
return Number(value).toFixed(digits);
|
return Number(value).toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso) {
|
function formatDateTime(value) {
|
||||||
if (!iso) return "--";
|
if (!value) return "--";
|
||||||
const dt = new Date(iso);
|
const dt = value instanceof Date ? value : new Date(value);
|
||||||
if (Number.isNaN(dt.getTime())) return "--";
|
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) {
|
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) {
|
function minMax(values) {
|
||||||
let min = null;
|
let min = null;
|
||||||
let max = null;
|
let max = null;
|
||||||
@@ -75,20 +170,35 @@ function upsertChart(id, config) {
|
|||||||
state.charts[id] = new Chart(ctx, config);
|
state.charts[id] = new Chart(ctx, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseOptions() {
|
function baseOptions(range) {
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: "index", intersect: false },
|
interaction: { mode: "index", intersect: false },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { labels: { color: "#d6f0f0" } },
|
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: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: "time",
|
type: "time",
|
||||||
time: { unit: "hour" },
|
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)" },
|
grid: { color: "rgba(123, 223, 242, 0.08)" },
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
@@ -106,10 +216,11 @@ function baseOptions() {
|
|||||||
|
|
||||||
function renderDashboard(data) {
|
function renderDashboard(data) {
|
||||||
const latest = data.latest;
|
const latest = data.latest;
|
||||||
updateText("site-meta", `${data.site} | model ${data.model}`);
|
const tzLabel = state.tz === "utc" ? "UTC" : "Local";
|
||||||
updateText("last-updated", `updated ${formatTime(data.generated_at)}`);
|
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
|
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";
|
: "forecast not available";
|
||||||
updateText("forecast-meta", forecastMeta);
|
updateText("forecast-meta", forecastMeta);
|
||||||
|
|
||||||
@@ -121,6 +232,8 @@ function renderDashboard(data) {
|
|||||||
updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`);
|
updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`);
|
||||||
updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`);
|
updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`);
|
||||||
updateText("live-lux", `${formatNumber(latest.light_lux, 0)}`);
|
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 {
|
} else {
|
||||||
updateText("live-temp", "--");
|
updateText("live-temp", "--");
|
||||||
updateText("live-rh", "--");
|
updateText("live-rh", "--");
|
||||||
@@ -129,6 +242,8 @@ function renderDashboard(data) {
|
|||||||
updateText("live-wdir", "--");
|
updateText("live-wdir", "--");
|
||||||
updateText("live-uvi", "--");
|
updateText("live-uvi", "--");
|
||||||
updateText("live-lux", "--");
|
updateText("live-lux", "--");
|
||||||
|
updateText("live-battery", "--");
|
||||||
|
updateText("live-supercap", "--");
|
||||||
}
|
}
|
||||||
|
|
||||||
const forecastUrl = document.getElementById("forecast-url");
|
const forecastUrl = document.getElementById("forecast-url");
|
||||||
@@ -142,14 +257,21 @@ function renderDashboard(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const obs = data.observations || [];
|
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 rangeStart = safeDate(data.range_start) || state.rangeStart;
|
||||||
const obsWinds = obs.map((p) => p.wind_m_s);
|
const rangeEnd = safeDate(data.range_end) || state.rangeEnd;
|
||||||
const obsGusts = obs.map((p) => p.wind_gust_m_s);
|
const range = { axisStart: rangeStart, axisEnd: rangeEnd };
|
||||||
const obsRH = obs.map((p) => p.rh);
|
|
||||||
const obsUvi = obs.map((p) => p.uvi);
|
const obsFiltered = filterRange(obs, rangeStart, rangeEnd);
|
||||||
const obsLux = obs.map((p) => p.light_lux);
|
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 fcTemps = forecast.map((p) => p.temp_c);
|
||||||
const fcWinds = forecast.map((p) => p.wind_m_s);
|
const fcWinds = forecast.map((p) => p.wind_m_s);
|
||||||
@@ -189,17 +311,17 @@ function renderDashboard(data) {
|
|||||||
}
|
}
|
||||||
updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--");
|
updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--");
|
||||||
|
|
||||||
updateText("commentary", buildCommentary(latest, forecast));
|
updateText("commentary", buildCommentary(latest, forecastAll));
|
||||||
|
|
||||||
const tempChart = {
|
const tempChart = {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
datasets: [
|
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 },
|
{ label: "forecast temp C", data: series(forecast, "temp_c"), borderColor: colors.forecast },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: baseOptions(),
|
options: baseOptions(range),
|
||||||
};
|
};
|
||||||
upsertChart("chart-temp", tempChart);
|
upsertChart("chart-temp", tempChart);
|
||||||
|
|
||||||
@@ -207,13 +329,13 @@ function renderDashboard(data) {
|
|||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: "obs wind m/s", data: series(obs, "wind_m_s"), borderColor: colors.obs },
|
{ label: "obs wind m/s", data: series(obsFiltered, "wind_m_s"), borderColor: colors.obs },
|
||||||
{ label: "obs gust m/s", data: series(obs, "wind_gust_m_s"), borderColor: colors.gust },
|
{ 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 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" },
|
{ label: "forecast gust m/s", data: series(forecast, "wind_gust_m_s"), borderColor: "#f7d79f" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: baseOptions(),
|
options: baseOptions(range),
|
||||||
};
|
};
|
||||||
upsertChart("chart-wind", windChart);
|
upsertChart("chart-wind", windChart);
|
||||||
|
|
||||||
@@ -221,15 +343,15 @@ function renderDashboard(data) {
|
|||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
datasets: [
|
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 },
|
{ label: "forecast rh %", data: series(forecast, "rh"), borderColor: colors.forecast },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: baseOptions(),
|
options: baseOptions(range),
|
||||||
};
|
};
|
||||||
upsertChart("chart-rh", rhChart);
|
upsertChart("chart-rh", rhChart);
|
||||||
|
|
||||||
const lightOptions = baseOptions();
|
const lightOptions = baseOptions(range);
|
||||||
lightOptions.scales.y1 = {
|
lightOptions.scales.y1 = {
|
||||||
position: "right",
|
position: "right",
|
||||||
ticks: { color: "#a4c4c4" },
|
ticks: { color: "#a4c4c4" },
|
||||||
@@ -240,14 +362,33 @@ function renderDashboard(data) {
|
|||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: "uvi", data: series(obs, "uvi"), borderColor: colors.uvi, yAxisID: "y" },
|
{ label: "uvi", data: series(obsFiltered, "uvi"), borderColor: colors.uvi, yAxisID: "y" },
|
||||||
{ label: "light lux", data: series(obs, "light_lux"), borderColor: colors.light, yAxisID: "y1" },
|
{ label: "light lux", data: series(obsFiltered, "light_lux"), borderColor: colors.light, yAxisID: "y1" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: lightOptions,
|
options: lightOptions,
|
||||||
};
|
};
|
||||||
upsertChart("chart-light", lightChart);
|
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 = {
|
const precipChart = {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
@@ -255,7 +396,7 @@ function renderDashboard(data) {
|
|||||||
{ label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip },
|
{ label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: baseOptions(),
|
options: baseOptions(range),
|
||||||
};
|
};
|
||||||
upsertChart("chart-precip", precipChart);
|
upsertChart("chart-precip", precipChart);
|
||||||
}
|
}
|
||||||
@@ -314,9 +455,15 @@ function buildCommentary(latest, forecast) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAndRender() {
|
async function loadAndRender() {
|
||||||
|
const range = computeRange(state.range, state.tz);
|
||||||
|
state.rangeStart = range.start;
|
||||||
|
state.rangeEnd = range.end;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
range: state.range,
|
range: state.range,
|
||||||
bucket: state.bucket,
|
bucket: state.bucket,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" });
|
const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" });
|
||||||
@@ -342,6 +489,16 @@ function setupControls() {
|
|||||||
loadAndRender();
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|||||||
@@ -17,9 +17,16 @@
|
|||||||
<div class="subtitle" id="site-meta">Loading...</div>
|
<div class="subtitle" id="site-meta">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn" data-range="6h">6h</button>
|
<div class="segmented" role="group" aria-label="time range">
|
||||||
<button class="btn active" data-range="24h">24h</button>
|
<button class="btn" data-range="6h">6h</button>
|
||||||
<button class="btn" data-range="72h">72h</button>
|
<button class="btn active" data-range="24h">24h</button>
|
||||||
|
<button class="btn" data-range="72h">72h</button>
|
||||||
|
<button class="btn" data-range="7d">7d</button>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="group" aria-label="timezone">
|
||||||
|
<button class="btn active" data-tz="local">Local</button>
|
||||||
|
<button class="btn" data-tz="utc">UTC</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -58,6 +65,14 @@
|
|||||||
<div class="label">Light Lux</div>
|
<div class="label">Light Lux</div>
|
||||||
<div class="value" id="live-lux">--</div>
|
<div class="value" id="live-lux">--</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">Battery mV</div>
|
||||||
|
<div class="value" id="live-battery">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">Supercap V</div>
|
||||||
|
<div class="value" id="live-supercap">--</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="callouts">
|
<div class="callouts">
|
||||||
@@ -112,6 +127,12 @@
|
|||||||
<canvas id="chart-light"></canvas>
|
<canvas id="chart-light"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">Battery + Supercap</div>
|
||||||
|
<div class="chart-canvas">
|
||||||
|
<canvas id="chart-power"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chart-card wide">
|
<div class="chart-card wide">
|
||||||
<div class="chart-title">Precipitation (forecast)</div>
|
<div class="chart-title">Precipitation (forecast)</div>
|
||||||
<div class="chart-canvas">
|
<div class="chart-canvas">
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ body {
|
|||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -79,6 +81,15 @@ body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid rgba(123, 223, 242, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(12, 22, 28, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.6fr);
|
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.6fr);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type ObservationPoint struct {
|
|||||||
WindDirDeg *float64 `json:"wind_dir_deg,omitempty"`
|
WindDirDeg *float64 `json:"wind_dir_deg,omitempty"`
|
||||||
UVI *float64 `json:"uvi,omitempty"`
|
UVI *float64 `json:"uvi,omitempty"`
|
||||||
LightLux *float64 `json:"light_lux,omitempty"`
|
LightLux *float64 `json:"light_lux,omitempty"`
|
||||||
|
BatteryMV *float64 `json:"battery_mv,omitempty"`
|
||||||
|
SupercapV *float64 `json:"supercap_v,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForecastPoint struct {
|
type ForecastPoint struct {
|
||||||
@@ -38,38 +40,42 @@ type ForecastSeries struct {
|
|||||||
Points []ForecastPoint `json:"points"`
|
Points []ForecastPoint `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSeconds int64) ([]ObservationPoint, error) {
|
func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, end time.Time) ([]ObservationPoint, error) {
|
||||||
if rangeSeconds <= 0 {
|
if end.Before(start) || end.Equal(start) {
|
||||||
return nil, errors.New("range must be > 0")
|
return nil, errors.New("invalid time range")
|
||||||
}
|
}
|
||||||
|
|
||||||
table := "cagg_ws90_5m"
|
interval := "5 minutes"
|
||||||
switch bucket {
|
switch bucket {
|
||||||
case "1m":
|
case "1m":
|
||||||
table = "cagg_ws90_1m"
|
interval = "1 minute"
|
||||||
case "5m":
|
case "5m":
|
||||||
table = "cagg_ws90_5m"
|
interval = "5 minutes"
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported bucket: %s", bucket)
|
return nil, fmt.Errorf("unsupported bucket: %s", bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
bucket,
|
time_bucket(INTERVAL '%s', ts) AS bucket,
|
||||||
temp_c_avg,
|
avg(temperature_c) AS temp_c_avg,
|
||||||
rh_avg,
|
avg(humidity) AS rh_avg,
|
||||||
wind_avg_ms_avg,
|
avg(wind_avg_m_s) AS wind_avg_ms_avg,
|
||||||
wind_gust_ms_max,
|
max(wind_max_m_s) AS wind_gust_ms_max,
|
||||||
wind_dir_deg_avg,
|
avg(wind_dir_deg) AS wind_dir_deg_avg,
|
||||||
uvi_max,
|
max(uvi) AS uvi_max,
|
||||||
light_lux_max
|
max(light_lux) AS light_lux_max,
|
||||||
FROM %s
|
avg(battery_mv) AS battery_mv_avg,
|
||||||
|
avg(supercap_v) AS supercap_v_avg
|
||||||
|
FROM observations_ws90
|
||||||
WHERE site = $1
|
WHERE site = $1
|
||||||
AND bucket >= now() - make_interval(secs => $2)
|
AND ts >= $2
|
||||||
|
AND ts <= $3
|
||||||
|
GROUP BY bucket
|
||||||
ORDER BY bucket ASC
|
ORDER BY bucket ASC
|
||||||
`, table)
|
`, interval)
|
||||||
|
|
||||||
rows, err := d.Pool.Query(ctx, query, site, rangeSeconds)
|
rows, err := d.Pool.Query(ctx, query, site, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,11 +84,11 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSe
|
|||||||
points := make([]ObservationPoint, 0, 512)
|
points := make([]ObservationPoint, 0, 512)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
ts time.Time
|
ts time.Time
|
||||||
temp, rh, wind, gust sql.NullFloat64
|
temp, rh, wind, gust sql.NullFloat64
|
||||||
dir, uvi, light sql.NullFloat64
|
dir, uvi, light, battery, supercap sql.NullFloat64
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light); err != nil {
|
if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
points = append(points, ObservationPoint{
|
points = append(points, ObservationPoint{
|
||||||
@@ -94,6 +100,8 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSe
|
|||||||
WindDirDeg: nullFloatPtr(dir),
|
WindDirDeg: nullFloatPtr(dir),
|
||||||
UVI: nullFloatPtr(uvi),
|
UVI: nullFloatPtr(uvi),
|
||||||
LightLux: nullFloatPtr(light),
|
LightLux: nullFloatPtr(light),
|
||||||
|
BatteryMV: nullFloatPtr(battery),
|
||||||
|
SupercapV: nullFloatPtr(supercap),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if rows.Err() != nil {
|
if rows.Err() != nil {
|
||||||
@@ -113,7 +121,9 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
wind_max_m_s,
|
wind_max_m_s,
|
||||||
wind_dir_deg,
|
wind_dir_deg,
|
||||||
uvi,
|
uvi,
|
||||||
light_lux
|
light_lux,
|
||||||
|
battery_mv,
|
||||||
|
supercap_v
|
||||||
FROM observations_ws90
|
FROM observations_ws90
|
||||||
WHERE site = $1
|
WHERE site = $1
|
||||||
ORDER BY ts DESC
|
ORDER BY ts DESC
|
||||||
@@ -121,11 +131,11 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
`
|
`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ts time.Time
|
ts time.Time
|
||||||
temp, rh, wind, gust sql.NullFloat64
|
temp, rh, wind, gust sql.NullFloat64
|
||||||
dir, uvi, light sql.NullFloat64
|
dir, uvi, light, battery, supercap sql.NullFloat64
|
||||||
)
|
)
|
||||||
err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light)
|
err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -142,6 +152,8 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
WindDirDeg: nullFloatPtr(dir),
|
WindDirDeg: nullFloatPtr(dir),
|
||||||
UVI: nullFloatPtr(uvi),
|
UVI: nullFloatPtr(uvi),
|
||||||
LightLux: nullFloatPtr(light),
|
LightLux: nullFloatPtr(light),
|
||||||
|
BatteryMV: nullFloatPtr(battery),
|
||||||
|
SupercapV: nullFloatPtr(supercap),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user