improve charts

This commit is contained in:
2026-01-28 08:51:41 +11:00
parent 940af3680f
commit 14b904cbad
4 changed files with 181 additions and 20 deletions

View File

@@ -27,7 +27,6 @@ type dashboardResponse struct {
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
Site string `json:"site"` Site string `json:"site"`
Model string `json:"model"` Model string `json:"model"`
OpenMeteoURL string `json:"open_meteo_url,omitempty"`
RangeStart time.Time `json:"range_start"` RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"` RangeEnd time.Time `json:"range_end"`
Observations []db.ObservationPoint `json:"observations"` 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.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`)) _, _ = 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))) mux.Handle("/", http.FileServer(http.FS(sub)))
srv := &http.Server{ 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) { func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -154,16 +169,10 @@ func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
openMeteoURL, err := providers.OpenMeteoRequestURL(s.site, s.model)
if err != nil {
log.Printf("web dashboard open-meteo url error: %v", err)
}
resp := dashboardResponse{ resp := dashboardResponse{
GeneratedAt: time.Now().UTC(), GeneratedAt: time.Now().UTC(),
Site: s.site.Name, Site: s.site.Name,
Model: s.model, Model: s.model,
OpenMeteoURL: openMeteoURL,
RangeStart: start, RangeStart: start,
RangeEnd: end, RangeEnd: end,
Observations: observations, Observations: observations,

View File

@@ -4,6 +4,7 @@ const state = {
tz: "local", tz: "local",
rangeStart: null, rangeStart: null,
rangeEnd: null, rangeEnd: null,
singleChartId: null,
charts: {}, charts: {},
timer: null, timer: null,
}; };
@@ -141,6 +142,80 @@ function computeRange(range, tz) {
return { start, end, axisStart, axisEnd }; 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) { function minMax(values) {
let min = null; let min = null;
let max = null; let max = null;
@@ -399,6 +474,8 @@ function renderDashboard(data) {
options: baseOptions(range), options: baseOptions(range),
}; };
upsertChart("chart-precip", precipChart); upsertChart("chart-precip", precipChart);
updateSingleChartMode();
} }
function buildCommentary(latest, forecast) { function buildCommentary(latest, forecast) {
@@ -486,6 +563,7 @@ function setupControls() {
btn.classList.add("active"); btn.classList.add("active");
state.range = btn.dataset.range; state.range = btn.dataset.range;
state.bucket = state.range === "6h" ? "1m" : "5m"; state.bucket = state.range === "6h" ? "1m" : "5m";
updateURLParams();
loadAndRender(); loadAndRender();
}); });
}); });
@@ -496,13 +574,25 @@ function setupControls() {
tzButtons.forEach((b) => b.classList.remove("active")); tzButtons.forEach((b) => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
state.tz = btn.dataset.tz; state.tz = btn.dataset.tz;
updateURLParams();
loadAndRender(); 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", () => { document.addEventListener("DOMContentLoaded", () => {
readStateFromURL();
syncControls();
setupControls(); setupControls();
setupShareLinks();
updateSingleChartMode();
loadAndRender(); loadAndRender();
if (state.timer) clearInterval(state.timer); if (state.timer) clearInterval(state.timer);
state.timer = setInterval(loadAndRender, 60 * 1000); state.timer = setInterval(loadAndRender, 60 * 1000);

View File

@@ -98,38 +98,56 @@
<div class="panel-meta" id="forecast-meta">--</div> <div class="panel-meta" id="forecast-meta">--</div>
</div> </div>
<div class="charts-grid"> <div class="charts-grid">
<div class="chart-card"> <div class="chart-card" data-chart="chart-temp">
<div class="chart-header">
<div class="chart-title">Temperature (obs vs forecast)</div> <div class="chart-title">Temperature (obs vs forecast)</div>
<button class="chart-link" data-chart="chart-temp" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-temp"></canvas> <canvas id="chart-temp"></canvas>
</div> </div>
</div> </div>
<div class="chart-card"> <div class="chart-card" data-chart="chart-wind">
<div class="chart-header">
<div class="chart-title">Wind (obs vs forecast)</div> <div class="chart-title">Wind (obs vs forecast)</div>
<button class="chart-link" data-chart="chart-wind" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-wind"></canvas> <canvas id="chart-wind"></canvas>
</div> </div>
</div> </div>
<div class="chart-card"> <div class="chart-card" data-chart="chart-rh">
<div class="chart-header">
<div class="chart-title">Humidity (obs vs forecast)</div> <div class="chart-title">Humidity (obs vs forecast)</div>
<button class="chart-link" data-chart="chart-rh" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-rh"></canvas> <canvas id="chart-rh"></canvas>
</div> </div>
</div> </div>
<div class="chart-card"> <div class="chart-card" data-chart="chart-light">
<div class="chart-header">
<div class="chart-title">UV Index and Light</div> <div class="chart-title">UV Index and Light</div>
<button class="chart-link" data-chart="chart-light" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-light"></canvas> <canvas id="chart-light"></canvas>
</div> </div>
</div> </div>
<div class="chart-card"> <div class="chart-card" data-chart="chart-power">
<div class="chart-header">
<div class="chart-title">Battery + Supercap</div> <div class="chart-title">Battery + Supercap</div>
<button class="chart-link" data-chart="chart-power" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-power"></canvas> <canvas id="chart-power"></canvas>
</div> </div>
</div> </div>
<div class="chart-card wide"> <div class="chart-card wide" data-chart="chart-precip">
<div class="chart-header">
<div class="chart-title">Precipitation (forecast)</div> <div class="chart-title">Precipitation (forecast)</div>
<button class="chart-link" data-chart="chart-precip" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas"> <div class="chart-canvas">
<canvas id="chart-precip"></canvas> <canvas id="chart-precip"></canvas>
</div> </div>

View File

@@ -221,12 +221,36 @@ body {
grid-column: span 2; grid-column: span 2;
} }
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.chart-title { .chart-title {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--muted); 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 { .chart-canvas {
@@ -243,6 +267,26 @@ canvas {
display: block; 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) { @media (max-width: 980px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;