Compare commits

..
Author SHA1 Message Date
root 6480c98647 Merge PR #110: feat(frontend): mobile-friendly temperature charts with a fixed readout
Release / Image (frontend) (push) Successful in 58s
Release / Image (gateway) (push) Successful in 33s
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Release / Image (backend) (push) Successful in 1m20s
2026-10-26 19:11:44 +01:00
rootandClaude Opus 5.5 31e9d056aa feat(frontend): mobile-first temperature charts with a readout above the plot
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
Replace the floating recharts tooltip, which covered the plot on a phone, with
toggle chips and a readout row above a plain SVG chart that scrubs by touch.
The four series are CWU, Bufor CO, Do pompy and Wyjście pompy; indoor and
outdoor temperature are dropped from the charts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:08:27 +01:00
9 changed files with 301 additions and 123 deletions

No files matched your search

@@ -97,6 +97,15 @@ class InfluxHistoryRepositoryTest {
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
}
@Test
void asksForGroundInAndPumpOutletFromTheirLegacyFields() throws IOException {
repository(TIMEOUT).history(Series.GROUND_IN_TEMP, FROM, TO, 360);
repository(TIMEOUT).history(Series.PUMP_OUTLET_TEMP, FROM, TO, 360);
assertTrue(requests.get(0).get("q").startsWith("SELECT mean(\"Temp in\") FROM \"POMPA\""), requests.get(0).get("q"));
assertTrue(requests.get(1).get("q").startsWith("SELECT mean(\"Temp pc out\") FROM \"POMPA\""), requests.get(1).get("q"));
}
@Test
void asksTheElectricityDatabaseForGridPower() throws IOException {
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
+4
View File
@@ -32,6 +32,10 @@ checks the gateway's rules first (levels from the reserve to 100 %, starts incre
Podgląd opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Temperature charts (`TimeChart.tsx`, plain SVG; recharts stays for Statystyki and Energia) are mobile-first: toggle chips and a readout row above the plot
(latest values, or those under the finger while scrubbing; `touch-action: pan-y` keeps vertical swipes for the page) instead of a floating tooltip.
`TemperatureChart.tsx` is the four-series chart (CWU, Bufor CO, Do pompy, Wyjście pompy) of Podgląd and Historia > Wykresy.
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+13 -26
View File
@@ -1,57 +1,44 @@
import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { Program } from "./Program";
import { Stats } from "./Stats";
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { TemperatureChart } from "./TemperatureChart";
import { TimeChart, toRows, type Range } from "./TimeChart";
import type { History as HistoryData } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
// Ids as in Series.java.
// Other series than the temperatures; ids as in Series.java.
const SERIES = {
outdoor_temp: ["Temperatura na zewnątrz", "#3b82f6"],
indoor_temp: ["Temperatura w domu", "#10b981"],
co_buffer_temp: ["Bufor CO", "#ef4444"],
cwu_temp: ["Ciepła woda (CWU)", "#f59e0b"],
ground_in_temp: ["Gruntowe: do pompy", "#22a6c9"],
ground_out_temp: ["Gruntowe: z pompy", "#2f7fe0"],
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
pv_power: ["Produkcja PV", "#f59e0b"],
load_power: ["Wyjście zapasowe falownika", "#10b981"],
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
temperatures: ["Temperatury"],
ground_out_temp: ["Gruntowe: z pompy", "var(--color-s3)"],
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "var(--color-s5)"],
pv_power: ["Produkcja PV", "var(--color-s1)"],
load_power: ["Wyjście zapasowe falownika", "var(--color-s2)"],
battery_soc: ["Bateria (naładowanie)", "var(--color-s5)"],
} as const;
type SeriesId = keyof typeof SERIES;
const RANGES: Record<Range, string> = { "24h": "24 godziny", "7d": "7 dni" };
function Chart({ series, range }: { series: SeriesId; range: Range }) {
function Chart({ series, range }: { series: Exclude<SeriesId, "temperatures">; range: Range }) {
// One poller per choice, so an answer is never shown for another one.
const { data, offline } = usePoll<HistoryData>(`api/history?series=${series}&range=${range}`);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const values = data.points.map((p) => p.v);
const digits = data.unit === "°C" ? 1 : 0;
const [name, colour] = SERIES[series];
return (
<>
{offline && <Freshness offline />}
{data.points.length < 2 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
<>
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
unit={data.unit} range={range} height={320} area />
<p className="mt-2 text-sm text-soft">
Najmniej {number(Math.min(...values), digits)} {data.unit} · najwięcej {number(Math.max(...values), digits)} {data.unit}.
Dotknij wykresu, aby zobaczyć wartość.
</p>
</>
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
unit={data.unit} range={range} />
)}
</>
);
}
function Charts() {
const [series, setSeries] = useState<SeriesId>("cwu_temp");
const [series, setSeries] = useState<SeriesId>("temperatures");
const [range, setRange] = useState<Range>("24h");
return (
<Card>
@@ -62,7 +49,7 @@ function Charts() {
</select>
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
</div>
<Chart series={series} range={range} />
{series === "temperatures" ? <TemperatureChart range={range} /> : <Chart series={series} range={range} />}
</Card>
);
}
+3 -33
View File
@@ -4,8 +4,8 @@ import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
import { TemperatureChart } from "./TemperatureChart";
import type { Energy, HeatPump, PiSensor, PumpState, Weather } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
import { EnergyToday, gridText } from "./Energy";
import { TemperatureDetails } from "./Details";
@@ -23,14 +23,6 @@ export const PUMP_NAMES = {
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
} as const;
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
const TEMPERATURE_LINES: Line[] = [
{ key: "cwu_temp", name: "CWU", colour: "#f59e0b" },
{ key: "co_buffer_temp", name: "Bufor CO", colour: "#ef4444" },
{ key: "indoor_temp", name: "W domu", colour: "#10b981" },
{ key: "outdoor_temp", name: "Na zewnątrz", colour: "#3b82f6", dashed: true },
];
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
@@ -145,28 +137,6 @@ function Devices({ hp }: { hp: HeatPump }) {
);
}
function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
const indoor = usePoll<History>("api/history?series=indoor_temp&range=24h", HISTORY_MS);
const outdoor = usePoll<History>("api/history?series=outdoor_temp&range=24h", HISTORY_MS);
const all = [cwu, co, indoor, outdoor].map((p) => p.data);
if (all.some((d) => !d)) return null;
return toRows(Object.fromEntries(all.map((d) => [d!.series, d!.points])));
}
function TemperatureChart() {
const rows = useTemperatureHistory();
return (
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2">
<Legend lines={TEMPERATURE_LINES} />
{rows == null ? <Loading /> : rows.length < 2
? <p className="text-soft">Za mało danych w tym okresie.</p>
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range="24h" />}
</Card>
);
}
function NoReading() {
return (
<Card>
@@ -217,7 +187,7 @@ export function Overview() {
<TemperatureDetails hp={hp} pi={pi} />
</div>
<EnergyToday e={e} link />
<TemperatureChart />
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2"><TemperatureChart range="24h" /></Card>
</div>
)}
</>
+24
View File
@@ -0,0 +1,24 @@
import { usePoll } from "./poll";
import { TimeChart, toRows, type Line, type Range } from "./TimeChart";
import type { History } from "./types";
import { Loading } from "./ui";
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
// Ids as in Series.java; colours are the series tokens of index.css.
export const TEMPERATURE_LINES: Line[] = [
{ key: "cwu_temp", name: "Ciepła woda", colour: "var(--color-s1)" },
{ key: "co_buffer_temp", name: "Bufor CO", colour: "var(--color-s2)" },
{ key: "ground_in_temp", name: "Do pompy", colour: "var(--color-s3)" },
{ key: "pump_outlet_temp", name: "Wyjście pompy", colour: "var(--color-s4)" },
];
export function TemperatureChart({ range }: { range: Range }) {
const polls = TEMPERATURE_LINES.map((l) => usePoll<History>(`api/history?series=${l.key}&range=${range}`, HISTORY_MS));
const data = polls.map((p) => p.data);
if (data.some((d) => !d)) return <Loading />;
const rows = toRows(Object.fromEntries(data.map((d) => [d!.series, d!.points])));
return rows.length < 2
? <p className="py-8 text-soft">Za mało danych w tym okresie.</p>
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range={range} />;
}
+95
View File
@@ -0,0 +1,95 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { TimeChart, type Line, type Row } from "./TimeChart";
import { TemperatureChart } from "./TemperatureChart";
import { serve } from "./fixtures";
const T0 = Date.parse("2026-10-26T08:00:00Z");
const MIN = 60000;
const rows: Row[] = Array.from({ length: 25 }, (_, i) => ({ t: T0 + i * 30 * MIN, a: 20 + i, b: 50 - i }));
const lines: Line[] = [
{ key: "a", name: "Ciepła woda", colour: "red" },
{ key: "b", name: "Bufor CO", colour: "blue" },
];
const readout = () => screen.getByRole("group", { name: "Odczyt" });
const plot = () => screen.getByRole("img", { name: "Wykres temperatur" }).parentElement!;
function mount() {
// jsdom has no layout: the plot is given the width of a phone.
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 390 });
render(<TimeChart rows={rows} lines={lines} unit="°C" range="24h" />);
const box = plot();
box.getBoundingClientRect = () => ({ left: 0, width: 390 }) as DOMRect;
return box;
}
describe("TimeChart", () => {
it("reads the latest values until the plot is touched", () => {
mount();
expect(readout()).toHaveTextContent("Ciepła woda44,0 °C");
expect(readout()).toHaveTextContent("Bufor CO26,0 °C");
expect(readout()).toHaveTextContent("Ostatni odczyt");
expect(screen.queryByTestId("cursor")).toBeNull();
});
it("reads the values under the finger and goes back to the latest on release", () => {
const box = mount();
fireEvent.pointerDown(box, { clientX: 6 });
expect(readout()).toHaveTextContent("Ciepła woda20,0 °C");
expect(readout()).toHaveTextContent("Bufor CO50,0 °C");
expect(screen.getByTestId("cursor")).toBeInTheDocument();
fireEvent.pointerMove(box, { clientX: 200 });
expect(readout()).not.toHaveTextContent("Ostatni odczyt");
expect(readout()).not.toHaveTextContent("Ciepła woda20,0 °C");
fireEvent.pointerUp(box);
expect(readout()).toHaveTextContent("Ciepła woda44,0 °C");
expect(screen.queryByTestId("cursor")).toBeNull();
});
it("lets the page scroll vertically and never uses a floating tooltip", () => {
const box = mount();
expect(box.style.touchAction).toBe("pan-y");
expect(document.querySelector("[role=tooltip]")).toBeNull();
});
it("hides and shows a series with its chip, and keeps at least one", async () => {
mount();
const a = screen.getByRole("button", { name: "Ciepła woda" });
const b = screen.getByRole("button", { name: "Bufor CO" });
await userEvent.click(a);
expect(a).toHaveAttribute("aria-pressed", "false");
expect(within(readout()).queryByText("Ciepła woda")).toBeNull();
await userEvent.click(b);
expect(b).toHaveAttribute("aria-pressed", "true");
expect(within(readout()).getByText("Bufor CO")).toBeInTheDocument();
await userEvent.click(a);
expect(within(readout()).getByText("Ciepła woda")).toBeInTheDocument();
});
it("is as wide as its container, not wider", () => {
mount();
expect(screen.getByRole("img", { name: "Wykres temperatur" })).toHaveAttribute("width", "390");
});
});
describe("TemperatureChart", () => {
it("asks for ground in and pump outlet beside CWU and the buffer, not for indoor or outdoor", async () => {
const answer = { series: "x", unit: "°C", points: [{ t: "2026-10-26T08:00:00Z", v: 30 }, { t: "2026-10-26T09:00:00Z", v: 31 }] };
const fetch = serve({ "api/history": answer });
render(<TemperatureChart range="24h" />);
expect(await screen.findByRole("button", { name: "Do pompy" })).toBeInTheDocument();
const urls = fetch.mock.calls.map(([url]) => String(url));
for (const s of ["cwu_temp", "co_buffer_temp", "ground_in_temp", "pump_outlet_temp"]) {
expect(urls).toContain(`api/history?series=${s}&range=24h`);
}
expect(urls.join()).not.toMatch(/indoor_temp|outdoor_temp/);
});
});
+142 -56
View File
@@ -1,4 +1,4 @@
import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { useLayoutEffect, useRef, useState, type PointerEvent } from "react";
import { dayAndHour, hour, number, warsaw } from "./format";
import type { Point } from "./types";
@@ -60,66 +60,152 @@ export function valueTicks(min: number, max: number): number[] {
export interface Line {
key: string;
name: string;
colour: string;
dashed?: boolean;
colour: string; // a CSS colour, normally a series token of index.css
}
export function TimeChart({ rows, lines, unit, range, height = 260, area = false }: {
rows: Row[]; lines: Line[]; unit: string; range: Range; height?: number; area?: boolean;
}) {
const times = rows.map((r) => r.t);
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
const values = rows.flatMap((r) => lines.map((l) => r[l.key])).filter((v): v is number => v != null);
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
const MARGIN = { top: 10, bottom: 24, left: 6 };
const CHAR_PX = 7; // of a 12 px axis label
interface Layout {
width: number;
height: number;
right: number; // room for the value labels, from their length
x: (t: number) => number;
y: (v: number) => number;
}
const segments = (rows: Row[], key: string): Row[][] =>
rows.reduce<Row[][]>((all, row) => {
if (row[key] == null) return all.length && all[all.length - 1].length ? [...all, []] : all;
if (!all.length) return [[row]];
all[all.length - 1].push(row);
return all;
}, []);
const path = (part: Row[], key: string, { x, y }: Layout) =>
part.map((r, i) => `${i ? "L" : "M"}${x(r.t).toFixed(1)} ${y(r[key] as number).toFixed(1)}`).join("");
const latest = (rows: Row[], key: string) => rows.findLast((r) => r[key] != null);
/** The row nearest to time t among those that have a value for one of the keys. */
function nearest(rows: Row[], keys: string[], t: number): Row | undefined {
const candidates = rows.filter((r) => keys.some((k) => r[k] != null));
return candidates.reduce<Row | undefined>((best, r) => (!best || Math.abs(r.t - t) < Math.abs(best.t - t) ? r : best), undefined);
}
function useWidth(ref: React.RefObject<HTMLDivElement | null>) {
const [width, setWidth] = useState(340);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => element.clientWidth > 0 && setWidth(element.clientWidth);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return width;
}
/**
* A touch-first line chart in plain SVG (recharts keeps the readout in a floating tooltip that covers
* the plot on a phone). Above the plot: toggle chips and a readout row that shows the latest values or,
* while a finger or the mouse is on the plot, the values at that time. `touch-action: pan-y` leaves vertical
* swipes to the page, so only horizontal drags scrub.
*/
export function TimeChart({ rows, lines, unit, range }: { rows: Row[]; lines: Line[]; unit: string; range: Range }) {
const box = useRef<HTMLDivElement>(null);
const width = useWidth(box);
const [hidden, setHidden] = useState<string[]>([]);
const [cursor, setCursor] = useState<Row | null>(null);
const shown = lines.filter((l) => !hidden.includes(l.key));
const keys = shown.map((l) => l.key);
const digits = unit === "°C" ? 1 : 0;
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
const times = rows.map((r) => r.t);
const t0 = Math.min(...times);
const t1 = Math.max(...times);
const values = rows.flatMap((r) => keys.map((k) => r[k])).filter((v): v is number => v != null);
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
const label = (v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1);
const right = Math.max(...valueAxis.map((v) => label(v).length)) * CHAR_PX + 6;
const height = width < 480 ? 260 : 300;
const [lo, hi] = [valueAxis[0], valueAxis[valueAxis.length - 1]];
const layout: Layout = {
width, height, right,
x: (t) => MARGIN.left + ((t - t0) / (t1 - t0)) * (width - MARGIN.left - right),
y: (v) => MARGIN.top + ((hi - v) / (hi - lo)) * (height - MARGIN.top - MARGIN.bottom),
};
const ticks = timeTicks(t0, t1, range);
const tickText = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
const toggle = (key: string) => {
if (hidden.includes(key)) setHidden(hidden.filter((k) => k !== key));
else if (shown.length > 1) setHidden([...hidden, key]);
};
const scrub = (e: PointerEvent<HTMLDivElement>) => {
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
const fraction = (x - MARGIN.left) / (width - MARGIN.left - right);
setCursor(nearest(rows, keys, t0 + Math.min(1, Math.max(0, fraction)) * (t1 - t0)) ?? null);
};
const release = () => setCursor(null);
const lastTime = Math.max(...keys.map((k) => latest(rows, k)?.t ?? 0));
const readoutRow = cursor ?? rows.find((r) => r.t === lastTime);
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`fill-${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={l.colour} stopOpacity={0.35} />
<stop offset="1" stopColor={l.colour} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
ticks={valueAxis} domain={[valueAxis[0], valueAxis[valueAxis.length - 1]]}
tickFormatter={(v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1)} />
<Tooltip
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
/>
{lines.map((l) => (area
? <Area key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
fill={`url(#fill-${l.key})`} dot={false} isAnimationActive={false} connectNulls={false} />
: <Line key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
strokeDasharray={l.dashed ? "5 5" : undefined} dot={false} isAnimationActive={false} connectNulls={false} />
<div>
<div role="group" aria-label="Wielkości na wykresie" className="mb-2 flex flex-wrap gap-2">
{lines.map((l) => (
<button key={l.key} aria-pressed={!hidden.includes(l.key)} onClick={() => toggle(l.key)}
className="flex min-h-11 items-center gap-2 rounded-full border-2 border-edge px-3 text-sm font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:border-current aria-pressed:text-ink">
<span aria-hidden="true" className="size-3 rounded-full" style={{ background: l.colour, opacity: hidden.includes(l.key) ? 0.35 : 1 }} />
{l.name}
</button>
))}
</div>
<div role="group" aria-label="Odczyt" className="mb-2 rounded-xl bg-page px-3 py-2">
<p className="text-sm font-semibold">
{cursor ? dayAndHour(new Date(cursor.t)) : <>Ostatni odczyt: {dayAndHour(new Date(lastTime))}</>}
</p>
<dl className="mt-1 grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
{shown.map((l) => (
<div key={l.key}>
<dt className="flex items-center gap-1.5 text-xs text-soft">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ background: l.colour }} />
{l.name}
</dt>
<dd className="font-bold tabular-nums">{readoutRow?.[l.key] != null ? `${number(readoutRow[l.key], digits)} ${unit}` : "—"}</dd>
</div>
))}
</ComposedChart>
</ResponsiveContainer>
</dl>
</div>
<div ref={box} className="w-0 min-w-full select-none" style={{ touchAction: "pan-y", WebkitTouchCallout: "none" }}
onPointerDown={scrub} onPointerMove={scrub} onPointerUp={release} onPointerCancel={release} onPointerLeave={release}>
<svg width={width} height={height} role="img" aria-label="Wykres temperatur" className="block">
{valueAxis.map((v) => (
<g key={v}>
<line x1={MARGIN.left} x2={width - right} y1={layout.y(v)} y2={layout.y(v)} stroke="var(--color-edge)" />
<text x={width - right + 6} y={layout.y(v)} dy="0.32em" fontSize={12} fill="var(--color-soft)">{label(v)}</text>
</g>
))}
{ticks.map((t) => (
<text key={t} x={layout.x(t)} y={height - 6} fontSize={12} fill="var(--color-soft)" textAnchor="middle">{tickText(t)}</text>
))}
{shown.map((l) => segments(rows, l.key).map((part) => (
<path key={`${l.key}-${part[0].t}`} d={path(part, l.key, layout)} fill="none" stroke={l.colour} strokeWidth={2.5}
strokeLinejoin="round" strokeLinecap="round" />
)))}
{cursor && (
<g data-testid="cursor">
<line x1={layout.x(cursor.t)} x2={layout.x(cursor.t)} y1={MARGIN.top} y2={height - MARGIN.bottom} stroke="var(--color-ink)" strokeWidth={1.5} />
{shown.filter((l) => cursor[l.key] != null).map((l) => (
<circle key={l.key} cx={layout.x(cursor.t)} cy={layout.y(cursor[l.key] as number)} r={4.5} fill={l.colour} stroke="var(--color-panel)" strokeWidth={2} />
))}
</g>
)}
</svg>
</div>
</div>
);
}
export function Legend({ lines }: { lines: Line[] }) {
return (
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-soft">
{lines.map((l) => (
<li key={l.key} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden="true">
<line x1="1" y1="3" x2="17" y2="3" stroke={l.colour} strokeWidth="3" strokeLinecap="round"
strokeDasharray={l.dashed ? "4 3" : undefined} />
</svg>
{l.name}
</li>
))}
</ul>
);
}
+11
View File
@@ -23,6 +23,12 @@
--color-brine: #22a6c9;
--color-off: #b9c5bf;
--color-alarm: #d92d20;
/* Chart series (TimeChart): distinct in hue and lightness, readable on the panel. */
--color-s1: #d97706;
--color-s2: #dc2626;
--color-s3: #0891b2;
--color-s4: #7c3aed;
--color-s5: #2563eb;
}
:root { color-scheme: light; }
@@ -40,6 +46,11 @@
--color-side: #08170f;
--color-side-hi: #17402f;
--color-off: #4b5a52;
--color-s1: #fbbf24;
--color-s2: #f87171;
--color-s3: #22d3ee;
--color-s4: #a78bfa;
--color-s5: #60a5fa;
}
body {
-8
View File
@@ -93,14 +93,6 @@ House meter SDM630 (`electricity.PRAD2`): `total_power_active` is **+ import /
Deye grid registers. Checked 2026-10-23 in InfluxDB: −2.5 to −5.5 kW between 11:00 and 15:00 local
while PV exported, +645 W at night matching Deye's 640 W import.
Link quirks seen from the gateway on the Pi (2026-10-24/25), both harmless to the readings:
- The meter's RS485 converter (`192.168.0.10`, MAC `d8:b0:4c:bc:7a:8e`) sends late TCP ACKs from
port 502 with the source address `76.85.0.32` (TTL 255, so from the LAN, not the internet).
ufw drops them, about 1800 `[UFW BLOCK]` journal lines a day; the polls themselves succeed.
- The Solarman logger resets our connection about every 3 h, always near :12
(`deye is down: Connection reset`); the next poll a minute later works again.
Considered and rejected (2026-10-23): pointing the logger's second server ("Server B") at our
backend. Push frames are poorly documented binary data every ~5 min, need a TCP server with
acks, and a wrong setting could cut off the Solarman cloud my father uses. Local FC3 polling