From 14b904cbadaae7b72d959b5619356f20a8ccf54b Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Wed, 28 Jan 2026 08:51:41 +1100 Subject: [PATCH] improve charts --- cmd/ingestd/web.go | 23 +++++++--- cmd/ingestd/web/app.js | 90 ++++++++++++++++++++++++++++++++++++++ cmd/ingestd/web/index.html | 42 +++++++++++++----- cmd/ingestd/web/styles.css | 46 ++++++++++++++++++- 4 files changed, 181 insertions(+), 20 deletions(-) diff --git a/cmd/ingestd/web.go b/cmd/ingestd/web.go index e676171..1a6c277 100644 --- a/cmd/ingestd/web.go +++ b/cmd/ingestd/web.go @@ -27,7 +27,6 @@ type dashboardResponse struct { GeneratedAt time.Time `json:"generated_at"` 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"` @@ -53,6 +52,12 @@ func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, add w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) }) + mux.HandleFunc("/chart", func(w http.ResponseWriter, r *http.Request) { + serveIndex(w, sub) + }) + mux.HandleFunc("/chart/", func(w http.ResponseWriter, r *http.Request) { + serveIndex(w, sub) + }) mux.Handle("/", http.FileServer(http.FS(sub))) srv := &http.Server{ @@ -82,6 +87,16 @@ func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, add } } +func serveIndex(w http.ResponseWriter, sub fs.FS) { + b, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) +} + func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -154,16 +169,10 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { return } - openMeteoURL, err := providers.OpenMeteoRequestURL(s.site, s.model) - if err != nil { - log.Printf("web dashboard open-meteo url error: %v", err) - } - resp := dashboardResponse{ GeneratedAt: time.Now().UTC(), Site: s.site.Name, Model: s.model, - OpenMeteoURL: openMeteoURL, RangeStart: start, RangeEnd: end, Observations: observations, diff --git a/cmd/ingestd/web/app.js b/cmd/ingestd/web/app.js index c08fd41..06da823 100644 --- a/cmd/ingestd/web/app.js +++ b/cmd/ingestd/web/app.js @@ -4,6 +4,7 @@ const state = { tz: "local", rangeStart: null, rangeEnd: null, + singleChartId: null, charts: {}, timer: null, }; @@ -141,6 +142,80 @@ function computeRange(range, tz) { 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; @@ -399,6 +474,8 @@ function renderDashboard(data) { options: baseOptions(range), }; upsertChart("chart-precip", precipChart); + + updateSingleChartMode(); } function buildCommentary(latest, forecast) { @@ -486,6 +563,7 @@ function setupControls() { btn.classList.add("active"); state.range = btn.dataset.range; state.bucket = state.range === "6h" ? "1m" : "5m"; + updateURLParams(); loadAndRender(); }); }); @@ -496,13 +574,25 @@ function setupControls() { 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); diff --git a/cmd/ingestd/web/index.html b/cmd/ingestd/web/index.html index 95bc649..fa1aa9c 100644 --- a/cmd/ingestd/web/index.html +++ b/cmd/ingestd/web/index.html @@ -98,38 +98,56 @@
--
-
-
Temperature (obs vs forecast)
+
+
+
Temperature (obs vs forecast)
+ +
-
-
Wind (obs vs forecast)
+
+
+
Wind (obs vs forecast)
+ +
-
-
Humidity (obs vs forecast)
+
+
+
Humidity (obs vs forecast)
+ +
-
-
UV Index and Light
+
+
+
UV Index and Light
+ +
-
-
Battery + Supercap
+
+
+
Battery + Supercap
+ +
-
-
Precipitation (forecast)
+
+
+
Precipitation (forecast)
+ +
diff --git a/cmd/ingestd/web/styles.css b/cmd/ingestd/web/styles.css index 761ecf5..9a79966 100644 --- a/cmd/ingestd/web/styles.css +++ b/cmd/ingestd/web/styles.css @@ -221,12 +221,36 @@ body { grid-column: span 2; } +.chart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + .chart-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); - margin-bottom: 8px; +} + +.chart-link { + border: 1px solid rgba(123, 223, 242, 0.2); + background: transparent; + color: var(--accent); + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.chart-link:hover { + border-color: var(--accent); + color: var(--text); } .chart-canvas { @@ -243,6 +267,26 @@ canvas { display: block; } +.single-chart .panel.hero { + display: none; +} + +.single-chart .layout { + grid-template-columns: 1fr; +} + +.single-chart .charts { + grid-column: 1 / -1; +} + +.single-chart .chart-card { + display: none; +} + +.single-chart .chart-card.active { + display: block; +} + @media (max-width: 980px) { .layout { grid-template-columns: 1fr;