From 2953690d5cd8f11748c51020b17ada401b5aa912 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Tue, 27 Jan 2026 17:16:36 +1100 Subject: [PATCH] more chart development --- cmd/ingestd/web.go | 50 ++++++++- cmd/ingestd/web/app.js | 215 ++++++++++++++++++++++++++++++++----- cmd/ingestd/web/index.html | 27 ++++- cmd/ingestd/web/styles.css | 13 ++- internal/db/series.go | 66 +++++++----- 5 files changed, 306 insertions(+), 65 deletions(-) diff --git a/cmd/ingestd/web.go b/cmd/ingestd/web.go index e2154a4..b44d49a 100644 --- a/cmd/ingestd/web.go +++ b/cmd/ingestd/web.go @@ -28,6 +28,8 @@ type dashboardResponse struct { Site string `json:"site"` Model string `json:"model"` OpenMeteoURL string `json:"open_meteo_url,omitempty"` + RangeStart time.Time `json:"range_start"` + RangeEnd time.Time `json:"range_end"` Observations []db.ObservationPoint `json:"observations"` Forecast db.ForecastSeries `json:"forecast"` Latest *db.ObservationPoint `json:"latest"` @@ -90,6 +92,8 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { if rangeStr == "" { rangeStr = "24h" } + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") bucket := r.URL.Query().Get("bucket") if bucket == "" { bucket = "5m" @@ -99,13 +103,37 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { return } - rangeDur, err := time.ParseDuration(rangeStr) - if err != nil || rangeDur <= 0 { - http.Error(w, "invalid range", http.StatusBadRequest) - return + var ( + start time.Time + end time.Time + 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 { http.Error(w, "failed to query observations", http.StatusInternalServerError) 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, Model: s.model, OpenMeteoURL: openMeteoURL, + RangeStart: start, + RangeEnd: end, Observations: observations, Forecast: forecast, Latest: latest, @@ -147,3 +177,13 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { 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") +} diff --git a/cmd/ingestd/web/app.js b/cmd/ingestd/web/app.js index d9a96db..8126602 100644 --- a/cmd/ingestd/web/app.js +++ b/cmd/ingestd/web/app.js @@ -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", () => { diff --git a/cmd/ingestd/web/index.html b/cmd/ingestd/web/index.html index 2760f17..a6bdcfe 100644 --- a/cmd/ingestd/web/index.html +++ b/cmd/ingestd/web/index.html @@ -17,9 +17,16 @@
Loading...
- - - +
+ + + + +
+
+ + +
@@ -58,6 +65,14 @@
Light Lux
--
+
+
Battery mV
+
--
+
+
+
Supercap V
+
--
+
@@ -112,6 +127,12 @@
+
+
Battery + Supercap
+
+ +
+
Precipitation (forecast)
diff --git a/cmd/ingestd/web/styles.css b/cmd/ingestd/web/styles.css index 1166ee9..761ecf5 100644 --- a/cmd/ingestd/web/styles.css +++ b/cmd/ingestd/web/styles.css @@ -53,7 +53,9 @@ body { .controls { display: flex; - gap: 10px; + gap: 12px; + flex-wrap: wrap; + align-items: center; } .btn { @@ -79,6 +81,15 @@ body { 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 { display: grid; grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.6fr); diff --git a/internal/db/series.go b/internal/db/series.go index a720c96..10148b5 100644 --- a/internal/db/series.go +++ b/internal/db/series.go @@ -19,6 +19,8 @@ type ObservationPoint struct { WindDirDeg *float64 `json:"wind_dir_deg,omitempty"` UVI *float64 `json:"uvi,omitempty"` LightLux *float64 `json:"light_lux,omitempty"` + BatteryMV *float64 `json:"battery_mv,omitempty"` + SupercapV *float64 `json:"supercap_v,omitempty"` } type ForecastPoint struct { @@ -38,38 +40,42 @@ type ForecastSeries struct { Points []ForecastPoint `json:"points"` } -func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSeconds int64) ([]ObservationPoint, error) { - if rangeSeconds <= 0 { - return nil, errors.New("range must be > 0") +func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, end time.Time) ([]ObservationPoint, error) { + if end.Before(start) || end.Equal(start) { + return nil, errors.New("invalid time range") } - table := "cagg_ws90_5m" + interval := "5 minutes" switch bucket { case "1m": - table = "cagg_ws90_1m" + interval = "1 minute" case "5m": - table = "cagg_ws90_5m" + interval = "5 minutes" default: return nil, fmt.Errorf("unsupported bucket: %s", bucket) } query := fmt.Sprintf(` SELECT - bucket, - temp_c_avg, - rh_avg, - wind_avg_ms_avg, - wind_gust_ms_max, - wind_dir_deg_avg, - uvi_max, - light_lux_max - FROM %s + time_bucket(INTERVAL '%s', ts) AS bucket, + avg(temperature_c) AS temp_c_avg, + avg(humidity) AS rh_avg, + avg(wind_avg_m_s) AS wind_avg_ms_avg, + max(wind_max_m_s) AS wind_gust_ms_max, + avg(wind_dir_deg) AS wind_dir_deg_avg, + max(uvi) AS uvi_max, + max(light_lux) AS light_lux_max, + avg(battery_mv) AS battery_mv_avg, + avg(supercap_v) AS supercap_v_avg + FROM observations_ws90 WHERE site = $1 - AND bucket >= now() - make_interval(secs => $2) + AND ts >= $2 + AND ts <= $3 + GROUP BY bucket 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 { return nil, err } @@ -78,11 +84,11 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSe points := make([]ObservationPoint, 0, 512) for rows.Next() { var ( - ts time.Time - temp, rh, wind, gust sql.NullFloat64 - dir, uvi, light sql.NullFloat64 + ts time.Time + temp, rh, wind, gust 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 } points = append(points, ObservationPoint{ @@ -94,6 +100,8 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSe WindDirDeg: nullFloatPtr(dir), UVI: nullFloatPtr(uvi), LightLux: nullFloatPtr(light), + BatteryMV: nullFloatPtr(battery), + SupercapV: nullFloatPtr(supercap), }) } if rows.Err() != nil { @@ -113,7 +121,9 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo wind_max_m_s, wind_dir_deg, uvi, - light_lux + light_lux, + battery_mv, + supercap_v FROM observations_ws90 WHERE site = $1 ORDER BY ts DESC @@ -121,11 +131,11 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo ` var ( - ts time.Time - temp, rh, wind, gust sql.NullFloat64 - dir, uvi, light sql.NullFloat64 + ts time.Time + temp, rh, wind, gust 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 errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -142,6 +152,8 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo WindDirDeg: nullFloatPtr(dir), UVI: nullFloatPtr(uvi), LightLux: nullFloatPtr(light), + BatteryMV: nullFloatPtr(battery), + SupercapV: nullFloatPtr(supercap), }, nil }