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 @@