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:
1 parent
df96cb73d1
commit
d5328e40fc
2 files changed
+132
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user