improve charts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -98,38 +98,56 @@
|
||||
<div class="panel-meta" id="forecast-meta">--</div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Temperature (obs vs forecast)</div>
|
||||
<div class="chart-card" data-chart="chart-temp">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-temp"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Wind (obs vs forecast)</div>
|
||||
<div class="chart-card" data-chart="chart-wind">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-wind"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Humidity (obs vs forecast)</div>
|
||||
<div class="chart-card" data-chart="chart-rh">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-rh"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">UV Index and Light</div>
|
||||
<div class="chart-card" data-chart="chart-light">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-light"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Battery + Supercap</div>
|
||||
<div class="chart-card" data-chart="chart-power">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-power"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card wide">
|
||||
<div class="chart-title">Precipitation (forecast)</div>
|
||||
<div class="chart-card wide" data-chart="chart-precip">
|
||||
<div class="chart-header">
|
||||
<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">
|
||||
<canvas id="chart-precip"></canvas>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user