Compare commits

...
6 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
root 326db85114 Merge PR #106: fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 50s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:46:30 +01:00
rootandClaude Opus 5.5 ead74ebdec fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
The summary summed every item of the Panele PV group, including each
string's voltage and current, so 5.9 kW of PV showed as about 7 kW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:44:43 +01:00
root f99ae01626 Merge PR #105: fix: house consumption from the energy balance, Deye load is only its backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
2026-10-25 09:40:13 +01:00
rootandClaude Opus 5.5 a804923290 fix(energy): house consumption as a balance, not the Deye backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The Deye load registers cover only the inverter's backup output, so the
house showed 0 W while it drew 300 W. Now power is PV + battery + grid
(house meter, Deye grid as fallback) and today's energy is the day
counters balance. The backup output is exposed as backupOutputW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:35:30 +01:00
20 changed files with 481 additions and 144 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu",
group("Wyjście zapasowe falownika",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1),
field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0),
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
@@ -11,9 +11,20 @@ import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
/**
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
* The Deye "load" registers cover only its backup output, so the house is a balance:
* <ul>
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
* any of those readings is missing or stale;</li>
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
* (Deye day counters), null when any is missing.</li>
* </ul>
*/
public class EnergyService {
private final GatewayClient gateway;
@@ -30,17 +41,42 @@ public class EnergyService {
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources,
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
d.get("load_power_w"), today);
}
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
Double pv1 = deye.get("pv1_power_w");
Double pv2 = deye.get("pv2_power_w");
Double pvFronius = fronius.get("pv_power_w");
Double battery = deye.get("battery_power_w");
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
return null;
}
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
}
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
Double pv = deye.get("pv_today_kwh");
Double bought = deye.get("grid_bought_today_kwh");
Double sold = deye.get("grid_sold_today_kwh");
Double discharged = deye.get("battery_discharge_today_kwh");
Double charged = deye.get("battery_charge_today_kwh");
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
return null;
}
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
@@ -5,17 +5,18 @@ import java.util.List;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
* − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) {
}
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
/** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
}
}
@@ -280,7 +280,7 @@ class ApiServerTest {
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
"frequency", 50.01, "import_energy_active", 67514.27, "total_power_active", 388.78), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
@@ -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);
@@ -428,7 +428,8 @@ class ServicesTest {
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
assertNull(view.housePowerW());
assertNull(view.today().houseKwh());
}
@Test
@@ -441,6 +442,82 @@ class ServicesTest {
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
private static Map<String, Double> deye(double pv1, double pv2, double battery, double grid) {
return Map.of("pv1_power_w", pv1, "pv2_power_w", pv2, "battery_power_w", battery, "grid_power_w", grid,
"load_power_w", 0.0);
}
private static EnergyView house(Map<String, Double> deye, double froniusW, double meterW) throws IOException {
DeviceSnapshot meter = device("house-meter", "electricity", "PRAD2",
Map.of("total_power_active", meterW));
return energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("pv_power_w", froniusW)), meter);
}
@Test
void housePowerBalancesPvBatteryAndMeterWhileExporting() throws IOException {
EnergyView view = house(deye(2638.0, 3000.0, -15.0, -5768.0), 444.0, -5762.0);
assertEquals(305.0, view.housePowerW());
assertEquals(0.0, view.backupOutputW());
}
@Test
void housePowerAtNightIsImportPlusBatteryDischarge() throws IOException {
EnergyView view = house(deye(0.0, 0.0, 800.0, 1000.0), 0.0, 1000.0);
assertEquals(1800.0, view.housePowerW());
}
@Test
void housePowerIsNeverNegative() throws IOException {
assertEquals(0.0, house(deye(0.0, 0.0, -500.0, 100.0), 0.0, 100.0).housePowerW());
}
@Test
void housePowerFallsBackToDeyeGridWhenTheMeterIsStale() throws IOException {
Instant old = NOW.minusSeconds(3600);
var meter = new DeviceSnapshot("house-meter", true, old, null, List.of(new Measurement("electricity",
"PRAD2", Map.of("total_power_active", 9999.0), old)));
EnergyView view = energy(device("deye", "home", "deye", deye(1000.0, 0.0, 0.0, -600.0)),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0)), meter);
assertEquals(600.0, view.housePowerW());
}
@Test
void housePowerIsNullWhenASourceIsMissing() throws IOException {
Map<String, Double> deye = deye(1000.0, 0.0, 0.0, -600.0);
assertNull(energy(device("deye", "home", "deye", deye)).housePowerW());
assertNull(energy(device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
var noGrid = Map.of("pv1_power_w", 1.0, "pv2_power_w", 1.0, "battery_power_w", 1.0);
assertNull(energy(device("deye", "home", "deye", noGrid),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
}
@Test
void houseTodayIsPvPlusNetGridPlusNetBattery() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0, "battery_charge_today_kwh", 2.1);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertEquals(24.8, view.today().houseKwh());
}
@Test
void houseTodayIsNullWhenACounterIsMissing() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertNull(view.today().houseKwh());
}
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
return new EnergyDetailsService(gateway(devices), CLOCK).current();
}
@@ -532,8 +609,9 @@ class ServicesTest {
assertNull(view.gridPowerW());
assertNull(view.batteryPowerW());
assertNull(view.batterySocPct());
assertNull(view.loadPowerW());
assertNull(view.today().loadKwh());
assertNull(view.housePowerW());
assertNull(view.backupOutputW());
assertNull(view.today().houseKwh());
}
@Test
@@ -13,7 +13,7 @@
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
] },
{ "title": "Odbiór domu", "items": [
{ "title": "Wyjście zapasowe falownika", "items": [
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
] },
{ "title": "Panele PV", "items": [
@@ -22,7 +22,7 @@
] },
{ "title": "Dziś", "items": [
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Wyjście zapasowe", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
@@ -49,6 +49,7 @@
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Moc czynna", "items": [
{ "label": "Razem", "value": 389.0, "unit": "W", "digits": 0 },
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
] },
{ "title": "Napięcie", "items": [
@@ -8,6 +8,7 @@
"gridPowerW": null,
"batteryPowerW": null,
"batterySocPct": null,
"loadPowerW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
"housePowerW": null,
"backupOutputW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "houseKwh": null }
}
+3 -2
View File
@@ -8,6 +8,7 @@
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
"housePowerW": 364.78,
"backupOutputW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "houseKwh": 28.4 }
}
+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.
+31 -1
View File
@@ -109,6 +109,17 @@ describe("Energy details", () => {
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
});
it("shows the house consumption balance in the stat, the flow and the day donut", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
const stat = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(stat).toHaveTextContent("365 W");
expect(screen.getAllByText("365 W").length).toBeGreaterThan(1);
expect(screen.getAllByText("28,4 kWh").length).toBeGreaterThan(0);
});
it("dims the values of a source that has gone quiet", async () => {
justAfterFixture();
const e = example<EnergyData>("energy");
@@ -120,7 +131,7 @@ describe("Energy details", () => {
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
expect(stats).not.toHaveClass("opacity-60");
expect(stats).toHaveClass("opacity-60");
});
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
@@ -225,3 +236,22 @@ describe("Energy flow arrows", () => {
expect(screen.getByText("Słońce zasila dom: 2,5 kW")).toBeInTheDocument();
});
});
describe("Deye summary", () => {
it("adds only the string powers, not their voltages and currents", async () => {
const { deviceSummary } = await import("./Energy");
const item = (label: string, value: number, unit: string) => ({ label, value, unit, digits: 0 });
const deye = {
id: "deye", name: "Falownik Deye", time: null, stale: false,
groups: [
{ title: "Bateria", items: [item("Naładowanie", 99, "%")] },
{ title: "Panele PV", items: [
item("PV1 moc", 2870, "W"), item("PV1 napięcie", 497.7, "V"), item("PV1 prąd", 5.6, "A"),
item("PV2 moc", 3011, "W"), item("PV2 napięcie", 548.5, "V"), item("PV2 prąd", 5.3, "A"),
] },
],
};
expect(deviceSummary(deye, undefined)).toBe("Bateria 99 % · PV 5,9 kW");
});
});
+8 -7
View File
@@ -108,7 +108,7 @@ export function flowSummary(e: EnergyData) {
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
function quietNodes(e: EnergyData): string[] {
if (isStale(e.time)) return Object.keys(NODES);
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery", "house"] : ["pv", "house"]));
}
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
@@ -123,7 +123,7 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
<Node id="house" dim={dim("house")} value={power(e.housePowerW)} note="zużycie" />
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
</>
@@ -133,9 +133,9 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today;
const load = today?.loadKwh ?? null;
const house = today?.houseKwh ?? null;
const bought = today?.gridBoughtKwh ?? null;
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
const own = house != null && bought != null ? Math.max(0, house - bought) : null;
const parts = [
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
@@ -159,7 +159,7 @@ export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; li
</Pie>
</PieChart>
<div className="absolute inset-0 grid place-items-center text-center">
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
<div><p className="text-lg font-bold tabular-nums">{kwh(house)}</p><p className="text-xs text-soft">zużycie</p></div>
</div>
</div>
<div className="min-w-44 flex-1 text-sm">
@@ -200,7 +200,8 @@ const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
/** The main current values of a device, from the details and, for the house meter, from the flow. */
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
if (d.id === "deye") {
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
// The PV group also lists each string's voltage and current: only the powers add up.
const pv = reading(d, "Panele PV").filter((item) => item.unit === "W").reduce((sum, item) => sum + item.value, 0);
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
}
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
@@ -240,7 +241,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.housePowerW)} />
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
+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: ["Zużycie domu", "#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 {
+3 -2
View File
@@ -50,8 +50,9 @@ export interface Energy {
gridPowerW: number | null;
batteryPowerW: number | null;
batterySocPct: number | null;
loadPowerW: number | null;
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
housePowerW: number | null;
backupOutputW: number | null;
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; houseKwh: number | null };
}
export interface BatteryEnergy {