Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6480c98647 | ||
|
|
31e9d056aa | ||
|
|
326db85114 | ||
|
|
ead74ebdec | ||
|
|
f99ae01626 | ||
|
|
a804923290 |
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user