feat(web): add history screen with touch line chart

Hand-written SVG; nice axis steps, min/max and a readout for the touched point.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-24 00:36:58 +02:00
1 parent df96cb73d1
commit d5328e40fc
2 files changed
+132

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { number, hour, weekday, dayAndHour } from "./format.js";
const W = 340, H = 220;
const MARGIN = { left: 48, right: 10, top: 12, bottom: 26 };
const Y_TICKS = 4; // about; the step is rounded
const X_TICKS = 4;
// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
function niceStep(rough) {
const magnitude = 10 ** Math.floor(Math.log10(rough));
return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
}
// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
export function lineChart(points, range) {
const times = points.map((p) => Date.parse(p.t));
const values = points.map((p) => p.v);
const [t0, t1] = [Math.min(...times), Math.max(...times)];
let [lo, hi] = [Math.min(...values), Math.max(...values)];
if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
const step = niceStep((hi - lo) / Y_TICKS);
[lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
const digits = step < 1 ? 1 : 0;
const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
const v = lo + i * step;
return `<line class="axis" x1="${MARGIN.left}" x2="${W - MARGIN.right}" y1="${y(v)}" y2="${y(v)}"/>
<text class="sub" x="${MARGIN.left - 6}" y="${y(v) + 4}" text-anchor="end">${number(v, digits)}</text>`;
}).join("");
const label = range === "7d" ? weekday : hour;
const xLabels = Array.from({ length: X_TICKS + 1 }, (_, i) => {
const t = t0 + ((t1 - t0) * i) / X_TICKS;
const anchor = i === 0 ? "start" : i === X_TICKS ? "end" : "middle";
return `<text class="sub" x="${x(t)}" y="${H - 6}" text-anchor="${anchor}">${label(new Date(t))}</text>`;
}).join("");
const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
const svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Wykres">
${yLabels}${xLabels}<path class="line" d="${path}"/>
<g id="cursor" hidden><line class="cursor" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>
<circle class="mark" r="5"/></g></svg>`;
return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
}
// Shows the point nearest to the finger or mouse in the readout element.
export function trackPointer(svg, readout, chart, { points, unit }) {
const cursor = svg.querySelector("#cursor");
const times = points.map((p) => Date.parse(p.t));
const show = (event) => {
const box = svg.getBoundingClientRect();
const px = ((event.clientX - box.left) / box.width) * W;
let best = 0;
times.forEach((time, i) => {
if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
});
const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
cursor.hidden = false;
cursor.querySelector("line").setAttribute("x1", cx);
cursor.querySelector("line").setAttribute("x2", cx);
cursor.querySelector("circle").setAttribute("cx", cx);
cursor.querySelector("circle").setAttribute("cy", cy);
readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
};
svg.addEventListener("pointerdown", show);
svg.addEventListener("pointermove", (event) => {
if (event.buttons || event.pointerType === "mouse") show(event);
});
}
+61
View File
@@ -0,0 +1,61 @@
import { getJson } from "./api.js";
import { number } from "./format.js";
import { lineChart, trackPointer } from "./chart.js";
// Ids as in Series.java.
const SERIES = {
outdoor_temp: "Temperatura na zewnątrz",
indoor_temp: "Temperatura w domu",
co_buffer_temp: "Bufor CO",
cwu_temp: "Ciepła woda (CWU)",
ground_in_temp: "Gruntowe: do pompy",
ground_out_temp: "Gruntowe: z pompy",
pump_outlet_temp: "Wyjście pompy ciepła",
grid_power: "Moc z sieci (+ pobór, − oddawanie)",
pv_power: "Produkcja PV",
load_power: "Zużycie domu",
battery_soc: "Bateria (naładowanie)",
};
const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
const choice = { series: "cwu_temp", range: "24h" };
let chart = null; // the one drawn by the last view, for the pointer handler
const options = () => Object.entries(SERIES)
.map(([id, name]) => `<option value="${id}"${id === choice.series ? " selected" : ""}>${name}</option>`).join("");
const rangeButtons = () => Object.entries(RANGES)
.map(([id, name]) => `<button data-range="${id}" aria-pressed="${id === choice.range}">${name}</button>`).join("");
function body(data) {
chart = null;
if (data.points.length < 2) return `<p class="muted">Za mało danych w tym okresie.</p>`;
chart = lineChart(data.points, choice.range);
const digits = data.unit === "°C" ? 1 : 0;
return `${chart.svg}
<p class="readout" id="readout">Dotknij wykresu, aby zobaczyć wartość.</p>
<p class="muted">Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}</p>`;
}
const view = (data) => `<section class="card"><h1>Historia</h1>
<select id="series" aria-label="Wielkość">${options()}</select>
<div class="seg">${rangeButtons()}</div>
${body(data)}</section>`;
function bind(root, data, reload) {
root.querySelector("#series").addEventListener("change", (event) => {
choice.series = event.target.value;
reload();
});
for (const button of root.querySelectorAll("[data-range]")) {
button.addEventListener("click", () => {
choice.range = button.dataset.range;
reload();
});
}
if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
}
const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
export default { load, view, bind };