diff --git a/backend/src/main/resources/web/chart.js b/backend/src/main/resources/web/chart.js new file mode 100644 index 0000000..1b04c36 --- /dev/null +++ b/backend/src/main/resources/web/chart.js @@ -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 ` + ${number(v, digits)}`; + }).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 `${label(new Date(t))}`; + }).join(""); + const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join(""); + + const svg = ` + ${yLabels}${xLabels} + `; + 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); + }); +} diff --git a/backend/src/main/resources/web/history.js b/backend/src/main/resources/web/history.js new file mode 100644 index 0000000..8db4418 --- /dev/null +++ b/backend/src/main/resources/web/history.js @@ -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]) => ``).join(""); + +const rangeButtons = () => Object.entries(RANGES) + .map(([id, name]) => ``).join(""); + +function body(data) { + chart = null; + if (data.points.length < 2) return `

Za mało danych w tym okresie.

`; + chart = lineChart(data.points, choice.range); + const digits = data.unit === "°C" ? 1 : 0; + return `${chart.svg} +

Dotknij wykresu, aby zobaczyć wartość.

+

Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}

`; +} + +const view = (data) => `

Historia

+ +
${rangeButtons()}
+ ${body(data)}
`; + +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 };