From ba5622f9b2cc032fe91961bc7aff5d32de985e97 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 18:11:22 +0200 Subject: [PATCH] Solar: Speicher-Ansicht mit Modulen und Zellen der BYD-Batterie Dritte Ansicht der Realtime-Karte (auch per Klick auf die Batterie): Kennzahlen (Ladestand mit kWh und voll/leer um, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, Durchsatz, Zustand), der Turm aus BMU und acht Modulen mit je 16 Zellbalken, das gewaehlte Modul mit Zellspannungen und Temperaturfuehlern, dazu Verlaeufe fuer Ladestand und Leistung (EnergyFlow) sowie Spreizung und Temperatur (byd) mit Zeitraum- wahl und Anzeige beim Ueberfahren. Neu: ajax/speicher.php. Nebenbei: Icon-Zeichen in energiefluss.js und im Katalog stehen als Escape (\uF...) statt als unsichtbare Zeichen im Quelltext. Co-Authored-By: Claude Opus 5 --- README.md | 14 +- ajax/speicher.php | 82 +++++ css/solar.css | 135 ++++++++ index.php | 2 +- js/solar/energiefluss.js | 19 +- js/solar/energieflussKatalog.js | 7 +- js/solar/solarMQTT.js | 7 + js/solar/speicher.js | 532 ++++++++++++++++++++++++++++++++ restricted/solar.php | 6 +- 9 files changed, 790 insertions(+), 14 deletions(-) create mode 100644 ajax/speicher.php create mode 100644 js/solar/speicher.js diff --git a/README.md b/README.md index 80287a2..f8f2351 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`). | Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT | |---|---|---|---|---| -| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `energieflussKatalog.js`, `energiefluss.js`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | +| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | | `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | | `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics | | `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — | @@ -342,6 +342,18 @@ des Medians liegt. Meldet ein Wechselrichter nichts, stehen seine Platten auf „ohne Daten“ statt „auffällig“. Den Verlauf je Platte gibt es nicht in der Datenbank; die Ansicht schreibt ihn ab dem Seitenaufruf mit (40 Minuten). +**Speicher-Ansicht** (Umschalter „Speicher“ oder Klick auf die Batterie) — +`speicher.js` zeigt die Batterie bis zur Zelle: Kennzahlen (Ladestand mit kWh +und „voll/leer um“, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, +Durchsatz mit gerechneten Zyklen, Zustand), den Turm aus BMU und Modulen mit +je 16 Zellbalken (Farbe und Höhe = Abweichung vom Mittel aller Zellen), das +gewählte Modul mit Zellspannungen und Temperaturfühlern sowie zwei +Verläufe aus `ajax/speicher.php` (Ladestand und Leistung aus `EnergyFlow`, +Spreizung und Temperatur aus `byd`). Live-Werte kommen über MQTT +`solarManager/byd/#` (`gatherBYDData.py`); fehlen sie, zeigt die Ansicht, was +der Wechselrichter weiß. Die Nennenergie je Modul (2,76 kWh, HVM) steht als +Konstante oben in `speicher.js` — die BMU meldet sie nicht. + **`skodaMQTT.js`** — Fahrzeugseite. Holt alles über `ajax/skoda.php?was=…` (live, ladungen, kurve, gesundheit, fahrten, strecke), zeichnet das Fahrzeug als SVG-Draufsicht mit Ladestand im Akku, Ladeknopf und Klimaknopf, und die diff --git a/ajax/speicher.php b/ajax/speicher.php new file mode 100644 index 0000000..fde2b7d --- /dev/null +++ b/ajax/speicher.php @@ -0,0 +1,82 @@ + "Nicht angemeldet."]); + exit; +} + +$was = $_GET["was"] ?? "verlauf"; + +try { + $db = solarDb(); + + if ($was === "verlauf") { + $stunden = max(1, min(24 * 90, intval($_GET["stunden"] ?? 24))); + $stuendlich = $stunden > 24 * 3; + $gruppe = $stuendlich ? "FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600" : "UNIX_TIMESTAMP(datetime)"; + + $soc = []; + $res = $db->query("SELECT $gruppe AS t, AVG(soc) AS soc, AVG(battP) AS p + FROM EnergyFlow + WHERE datetime >= NOW() - INTERVAL $stunden HOUR + GROUP BY t ORDER BY t"); + while ($res && $z = $res->fetch_assoc()) { + // battP > 0 heißt entladen; hinaus geht "positiv = laden", wie bei der BMU. + $soc[] = [(int)$z["t"], round((float)$z["soc"], 1), round(-(float)$z["p"])]; + } + + $bmu = []; + $res = $db->query("SELECT $gruppe AS t, AVG(spreizung) AS spr, MIN(t_min) AS tmin, MAX(t_max) AS tmax, AVG(soh) AS soh + FROM byd + WHERE datetime >= NOW() - INTERVAL $stunden HOUR + GROUP BY t ORDER BY t"); + while ($res && $z = $res->fetch_assoc()) { + $bmu[] = [(int)$z["t"], round((float)$z["spr"], 1), (int)$z["tmin"], (int)$z["tmax"], round((float)$z["soh"], 1)]; + } + + echo json_encode([ + "von" => time() - $stunden * 3600, "bis" => time(), + "soc" => $soc, "bmu" => $bmu, + ]); + exit; + } + + if ($was === "gesundheit") { + $tage = []; + $res = $db->query("SELECT DATE(datetime) AS tag, AVG(soh) AS soh, MAX(spreizung) AS spr, + MAX(geladen_kwh) AS geladen, MAX(entladen_kwh) AS entladen + FROM byd GROUP BY tag ORDER BY tag"); + while ($res && $z = $res->fetch_assoc()) { + $tage[] = [$z["tag"], round((float)$z["soh"], 1), (int)$z["spr"], + (float)$z["geladen"], (float)$z["entladen"]]; + } + echo json_encode(["tage" => $tage]); + exit; + } + + http_response_code(400); + echo json_encode(["error" => "Unbekannter Abschnitt."]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(["error" => "Verlauf nicht abrufbar: " . $e->getMessage()]); +} diff --git a/css/solar.css b/css/solar.css index cd322a5..ce4ef43 100644 --- a/css/solar.css +++ b/css/solar.css @@ -3733,6 +3733,141 @@ svg.anl-plan.anl-hoch { max-height: none; } .anl *, .anl-plan * { animation: none !important; transition: none !important; } } +/* ========================================================================== + Speicher-Ansicht (js/solar/speicher.js). Töne wie Übersicht und Anlage. + ========================================================================== */ +.spk { display: grid; gap: .9rem; } +.spk .spk-meldung { border-radius: .7rem; padding: .55rem .9rem; font-size: .88rem; background: rgba(232, 163, 58, .12); color: #e8a33a; } +.spk .spk-meldung.spk-fehler { background: rgba(229, 98, 79, .14); color: #e5624f; } +.spk .spk-fach { background: var(--ton-grund, #191d20); border-radius: .9rem; padding: .9rem 1rem; display: grid; gap: .7rem; align-content: start; min-width: 0; } +.spk .spk-kopf { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .4rem .8rem; } +.spk .spk-ueberschrift { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--ton-schrift-2, #9aa6ad); margin: 0; } +.spk .spk-stand { font-size: .78rem; color: var(--ton-schrift-3, #6f7a81); } +.spk .spk-leer { color: var(--ton-schrift-3, #6f7a81); font-size: .85rem; margin: .5rem 0; } + +/* Kennzahlen */ +.spk .spk-kennzahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .6rem; } +@media (max-width: 991.98px) { .spk .spk-kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.spk .spk-notiz { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); margin-top: -.4rem; } +.spk .spk-einheit { font-weight: 600; } +.spk .spk-kachel { position: relative; background: var(--ton-grund, #191d20); border-radius: .8rem; padding: .65rem .85rem .7rem; display: grid; gap: .1rem; overflow: hidden; } +.spk .spk-kachel-titel { font-size: .74rem; color: var(--ton-schrift-2, #9aa6ad); display: flex; align-items: center; gap: .4rem; } +.spk .spk-kachel-titel i { font-family: bootstrap-icons; font-style: normal; font-size: .9rem; color: #6cbe58; } +.spk .spk-kachel b { font-size: 1.45rem; font-weight: 600; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; line-height: 1.25; } +.spk .spk-kachel small { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.spk .spk-kachel.spk-gut b { color: #8fd37a; } +.spk .spk-kachel.spk-warm b { color: #e8a33a; } +.spk .spk-kachel.spk-warm .spk-kachel-titel i { color: #e8a33a; } +.spk .spk-kachel.spk-krit b, +.spk .spk-kachel.spk-krit .spk-kachel-titel i { color: #e5624f; } +.spk .spk-kachel-balken { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #2b3139; } +.spk .spk-kachel-balken span { display: block; height: 100%; background: linear-gradient(90deg, #4f8fb8, #6cbe58); transition: width 1.2s cubic-bezier(.2, .7, .2, 1); } + +/* Turm und Detail */ +.spk .spk-mitte { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: .9rem; align-items: start; } +@media (max-width: 991.98px) { .spk .spk-mitte { grid-template-columns: minmax(0, 1fr); } } +.spk .spk-turm { position: relative; display: grid; gap: 4px; } +.spk .spk-bmu { + display: flex; align-items: center; gap: .6rem; padding: .5rem .8rem; font-size: .82rem; + background: linear-gradient(#2c3238, #24292f); border: 1px solid #333a42; border-radius: .7rem .7rem .25rem .25rem; +} +.spk .spk-bmu b { font-weight: 600; } +.spk .spk-bmu span:last-child { color: var(--ton-schrift-3, #6f7a81); font-size: .74rem; margin-left: auto; } +.spk .spk-led { width: .55rem; height: .55rem; border-radius: 50%; background: #444b53; } +.spk .spk-led.an { background: #6cbe58; box-shadow: 0 0 .5rem #6cbe58; animation: spk-led 2.4s ease-in-out infinite; } +@keyframes spk-led { 50% { opacity: .55; } } +.spk .spk-modul { + all: unset; box-sizing: border-box; cursor: pointer; position: relative; overflow: hidden; + display: grid; grid-template-columns: 2.2rem minmax(0, 1fr) 7.4rem; gap: .7rem; align-items: center; + padding: .45rem .7rem; border-radius: .25rem; border: 1px solid #2f363d; + background: linear-gradient(90deg, #1f2429, #262c32 50%, #1f2429); + transition: border-color .2s, box-shadow .2s; + animation: spk-modul-auf .5s cubic-bezier(.2, .9, .3, 1.1) backwards; animation-delay: calc(var(--i) * 45ms); +} +@keyframes spk-modul-auf { from { opacity: 0; transform: translateY(8px); } } +.spk .spk-modul:hover { border-color: #46505a; } +.spk .spk-modul:focus-visible { outline: 2px solid #8fb8de; outline-offset: 1px; } +.spk .spk-modul.gewaehlt { border-color: #6cbe58; box-shadow: inset 3px 0 0 #6cbe58; } +.spk .spk-nr { font-weight: 600; color: var(--ton-schrift-2, #9aa6ad); font-size: .9rem; } +.spk .spk-zellen { display: grid; grid-template-columns: repeat(16, minmax(0, 1fr)); gap: 2px; height: 2rem; align-items: end; } +.spk .spk-zellen i { display: block; height: var(--h); background: var(--f); border-radius: 2px 2px 1px 1px; transition: height 1s cubic-bezier(.2, .7, .2, 1), background 1s; } +.spk .spk-werte { text-align: right; line-height: 1.25; font-variant-numeric: tabular-nums; } +.spk .spk-werte b { display: block; font-size: .8rem; font-weight: 600; } +.spk .spk-werte small { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); } +.spk .spk-sockel { height: .6rem; margin-top: -2px; border-radius: 0 0 .6rem .6rem; background: #1a1e22; border: 1px solid #2f363d; border-top: 0; } +/* Laden: Licht wandert nach oben durch die Module, Entladen nach unten. */ +.spk .spk-turm.spk-laden .spk-modul::after, +.spk .spk-turm.spk-entladen .spk-modul::after { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: linear-gradient(90deg, transparent, rgba(108, 190, 88, .16), transparent); + transform: translateX(-100%); animation: spk-strom 2.6s ease-in-out infinite; +} +.spk .spk-turm.spk-laden .spk-modul::after { animation-delay: calc((8 - var(--i)) * 180ms); } +.spk .spk-turm.spk-entladen .spk-modul::after { background: linear-gradient(90deg, transparent, rgba(232, 163, 58, .14), transparent); animation-delay: calc(var(--i) * 180ms); } +@keyframes spk-strom { 0% { transform: translateX(-100%); } 60%, 100% { transform: translateX(100%); } } +.spk.spk-pause *, +.spk-pause .spk * { animation-play-state: paused !important; } +.spk .spk-legende { display: flex; flex-wrap: wrap; gap: .3rem 1rem; font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-legende i { display: inline-block; width: .7rem; height: .7rem; border-radius: 2px; margin-right: .35rem; vertical-align: -.08rem; } + +.spk .spk-pille { font-size: .72rem; font-weight: 600; border-radius: 99px; padding: .1rem .55rem; } +.spk .spk-pille.ok { color: #6cbe58; background: rgba(108, 190, 88, .13); } +.spk .spk-pille.warn { color: #e8a33a; background: rgba(232, 163, 58, .14); } +.spk .spk-gross { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .8rem; } +.spk .spk-gross b { font-size: 2rem; font-weight: 600; line-height: 1.1; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; } +.spk .spk-gross span { color: var(--ton-schrift-2, #9aa6ad); font-size: .88rem; } +.spk .spk-zellchart { width: 100%; height: auto; display: block; } +.spk .spk-zelle { transform-box: fill-box; transform-origin: 50% 100%; animation: spk-zelle .6s cubic-bezier(.2, .9, .3, 1.1) backwards; animation-delay: calc(var(--j) * 25ms); } +.spk .spk-zelle.markiert { stroke: #fff; stroke-width: 1.5; } +@keyframes spk-zelle { from { transform: scaleY(0); } } +.spk .spk-gitter { stroke: #2a3037; stroke-width: 1; } +.spk .spk-mittel { stroke: #8d96a0; stroke-dasharray: 3 3; } +.spk .spk-achse { fill: var(--ton-schrift-3, #6f7a81); font-size: 10px; font-family: "Poppins", system-ui, sans-serif; } +.spk .spk-fuehler { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 3px; } +.spk .spk-fuehler span { text-align: center; font-size: .74rem; font-weight: 600; padding: .2rem 0; border-radius: .3rem; color: #15181c; background: var(--f); font-variant-numeric: tabular-nums; } +.spk .spk-paare { display: grid; grid-template-columns: auto 1fr; gap: .25rem .9rem; font-size: .86rem; margin: 0; } +.spk .spk-paare dt { color: var(--ton-schrift-2, #9aa6ad); font-weight: 400; } +.spk .spk-paare dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; } + +/* Verlauf */ +.spk .spk-diagramme { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .9rem; } +@media (max-width: 991.98px) { .spk .spk-diagramme { grid-template-columns: minmax(0, 1fr); } } +.spk .spk-zeitraum { display: inline-flex; gap: 2px; padding: 2px; border-radius: .5rem; background: #23282c; } +.spk .spk-zeitraum button { all: unset; cursor: pointer; font-size: .76rem; font-weight: 600; padding: .15rem .6rem; border-radius: .4rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-zeitraum button.aktiv { background: #2f363d; color: var(--ton-schrift, #e8ede9); } +.spk .spk-zeitraum button:focus-visible { outline: 2px solid #8fb8de; } +.spk .spk-diagramm { position: relative; min-height: 190px; } +.spk .spk-verlauf { display: block; width: 100%; height: auto; touch-action: pan-y; } +.spk .spk-flaeche { opacity: .22; } +.spk .spk-linie { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +.spk .spk-verlauf.neu .spk-linie { stroke-dasharray: 1 1; stroke-dashoffset: 1; animation: spk-zeichnen 1.2s cubic-bezier(.5, 0, .2, 1) forwards; animation-delay: calc(var(--i) * 150ms); } +.spk .spk-verlauf.neu .spk-flaeche { animation: spk-einblenden 1.2s backwards; } +@keyframes spk-zeichnen { to { stroke-dashoffset: 0; } } +@keyframes spk-einblenden { from { opacity: 0; } } +.spk .spk-zeiger { stroke: rgba(255, 255, 255, .35); stroke-width: 1; pointer-events: none; } +.spk .spk-tipp { + position: absolute; top: .4rem; z-index: 2; pointer-events: none; min-width: 9rem; + background: rgba(21, 24, 28, .94); border: 1px solid #333a42; border-radius: .55rem; padding: .4rem .6rem; + font-size: .76rem; display: grid; gap: .1rem; box-shadow: 0 .4rem 1rem rgba(0, 0, 0, .35); +} +.spk .spk-tipp b { font-weight: 600; margin-bottom: .1rem; } +.spk .spk-tipp span { display: flex; align-items: center; gap: .4rem; color: var(--ton-schrift-2, #9aa6ad); } +.spk .spk-tipp em { font-style: normal; margin-left: auto; padding-left: .8rem; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; } +.spk .spk-tipp i, +.spk .spk-reihen i { display: inline-block; width: .6rem; height: .6rem; border-radius: 2px; } +.spk .spk-reihen { display: flex; flex-wrap: wrap; gap: .2rem 1rem; font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); margin-top: .2rem; } +.spk .spk-reihen span { display: inline-flex; align-items: center; gap: .35rem; } +@media (max-width: 575.98px) { + .spk .spk-kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem; } + .spk .spk-kachel b { font-size: 1.2rem; } + .spk .spk-modul { grid-template-columns: 1.8rem minmax(0, 1fr) 5.6rem; gap: .45rem; padding: .4rem .5rem; } + .spk .spk-werte b { font-size: .72rem; } +} +@media (prefers-reduced-motion: reduce) { + .spk *, .spk *::after { animation: none !important; transition: none !important; } + .spk .spk-verlauf.neu .spk-linie { stroke-dashoffset: 0; } +} + /* --- Karte: Umschalter Übersicht / Anlage -------------------------------- */ .enf-umschalter { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--ton-radius-klein, .5rem); background: var(--ton-grund, #191d20); } .enf-umschalter .btn.btn-sm { border: 0; color: var(--ton-schrift-2, #9aa6ad); font-weight: 600; padding: .2rem .8rem; } diff --git a/index.php b/index.php index 5211273..79377ee 100644 --- a/index.php +++ b/index.php @@ -100,7 +100,7 @@ if (isset($_GET["addUser"])) { */ $pages = [ "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, - "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", + "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js", "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]], diff --git a/js/solar/energiefluss.js b/js/solar/energiefluss.js index a4ab0b0..95e7eac 100644 --- a/js/solar/energiefluss.js +++ b/js/solar/energiefluss.js @@ -109,14 +109,17 @@ /* Plaketten am Kreis: Zeichen aus Bootstrap Icons (liegt ohnehin im Dashboard). */ const ZEICHEN = { - eco: { glyph: "奈", farbe: "#6cbe58" }, // bi-leaf - uhr: { glyph: "", farbe: "#e8a33a" }, // bi-clock-fill - manuell: { glyph: "", farbe: "#c9a227" }, // bi-sliders - stecker: { glyph: "", farbe: "#8fb8de" }, // bi-plug-fill - schloss: { glyph: "", farbe: "#b9c0c8" }, // bi-lock-fill - heizung: { glyph: "", farbe: "#e5624f" }, // bi-fire - tank: { glyph: "", farbe: "#b9c0c8" }, // bi-fuel-pump-fill - warnung: { glyph: "", farbe: "#e5624f" }, // bi-exclamation-triangle-fill + // Codepunkte als Escape: die Zeichen selbst liegen im privaten Bereich + // (U+F90C sogar auf einem CJK-Kompatibilitaetszeichen) und sind im Editor + // unsichtbar oder werden beim Kopieren leicht verfaelscht. + eco: { glyph: "\uF90C", farbe: "#6cbe58" }, // bi-leaf + uhr: { glyph: "\uF291", farbe: "#e8a33a" }, // bi-clock-fill + manuell: { glyph: "\uF56B", farbe: "#c9a227" }, // bi-sliders + stecker: { glyph: "\uF4F6", farbe: "#8fb8de" }, // bi-plug-fill + schloss: { glyph: "\uF47A", farbe: "#b9c0c8" }, // bi-lock-fill + heizung: { glyph: "\uF7F6", farbe: "#e5624f" }, // bi-fire + tank: { glyph: "\uF83D", farbe: "#b9c0c8" }, // bi-fuel-pump-fill + warnung: { glyph: "\uF33A", farbe: "#e5624f" }, // bi-exclamation-triangle-fill }; /* ------------------------------------------------------------------------ diff --git a/js/solar/energieflussKatalog.js b/js/solar/energieflussKatalog.js index eff084f..592b745 100644 --- a/js/solar/energieflussKatalog.js +++ b/js/solar/energieflussKatalog.js @@ -121,6 +121,7 @@ const ENERGIEFLUSS_KATALOG = (function () { { id: "akku", name: "Batterie", symbol: "akku", farbe: "#6cbe58", eltern: "wr", x: 180, y: 630, skala: 15000, + klick: { ansicht: "speicher" }, lesen(m) { const p = m.zahl("solarManager/P_Akku"); const soc = m.zahl("solarManager/SOC"); @@ -289,9 +290,9 @@ const ENERGIEFLUSS_KATALOG = (function () { // Ids wie in wateringZones (solarMQTT.js) - dort werden Farbe, Text und // Tooltip gesetzt, hier nur die Stellen dafür angelegt. zonen: [ - { name: "Tröge", zeichen: "", status: "wateringTroughStatus", symbol: "wateringTroughIcon" }, - { name: "Vorn", zeichen: "", status: "wateringFrontStatus", symbol: "wateringFrontIcon" }, - { name: "Beete", zeichen: "", status: "wateringBedsStatus", symbol: "wateringBedsIcon" }, + { name: "Tröge", zeichen: "\uF180", status: "wateringTroughStatus", symbol: "wateringTroughIcon" }, + { name: "Vorn", zeichen: "\uF3CE", status: "wateringFrontStatus", symbol: "wateringFrontIcon" }, + { name: "Beete", zeichen: "\uF3CD", status: "wateringBedsStatus", symbol: "wateringBedsIcon" }, ], lesen() { return { wert: "" }; diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index 2a1f14d..88e3a25 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -148,6 +148,7 @@ function setFlowWidth(id, width) { // jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js). let energiefluss = null; let anlage = null; +let speicher = null; const solarSVG = { updateCnt: 99, @@ -166,6 +167,7 @@ const solarSVG = { if (energiefluss) energiefluss.aktualisieren(mqttData); // Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit. if (anlage) anlage.aktualisieren(mqttData); + if (speicher) speicher.aktualisieren(mqttData); } }; @@ -212,6 +214,11 @@ function energieflussStarten() { anlage = new Anlage(anlageBehaelter); anlage.zeichnen(); } + const speicherBehaelter = document.getElementById("speicher"); + if (speicherBehaelter && typeof Speicher !== "undefined") { + speicher = new Speicher(speicherBehaelter); + speicher.zeichnen(); + } } var chartSettings = { diff --git a/js/solar/speicher.js b/js/solar/speicher.js new file mode 100644 index 0000000..3f8278d --- /dev/null +++ b/js/solar/speicher.js @@ -0,0 +1,532 @@ +/* ========================================================================== + Speicher-Ansicht: die Batterie bis zur einzelnen Zelle. + + const s = new Speicher(behaelter); + s.zeichnen(); + s.aktualisieren(mqttData); // jede Sekunde, auch wenn verborgen + + Live kommt alles über MQTT: solarManager/SOC und P_Akku vom Wechselrichter, + solarManager/byd/# direkt aus der BMU (gatherBYDData.py im SolarManager - + Zellspannungen, Temperaturfühler, SOH, Zähler). Der Verlauf kommt aus + ajax/speicher.php. + + Ohne BMU-Daten (andere Batterie, Sammler aus) bleibt die Ansicht nützlich: + Ladestand, Leistung und ihr Verlauf stehen trotzdem da, Turm und + Zellwerte machen einem Hinweis Platz. + + Die Zellen sind durchgezählt wie bei der BMU: Modul m hat die Zellen + (m-1)*16+1 bis m*16 und die Fühler (m-1)*8+1 bis m*8. + ========================================================================== */ + +(function () { + "use strict"; + + // Nennenergie eines Moduls in kWh. HVM: 2,76; HVS: 2,56. Die BMU meldet die + // Zahl der Module, aber nicht diese Größe. + const KWH_JE_MODUL = 2.76; + const ZELLEN_JE_MODUL = 16; + const FUEHLER_JE_MODUL = 8; + const VERALTET_S = 600; + const NS = "http://www.w3.org/2000/svg"; + + const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ","); + const tausend = z => Math.round(z).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); + const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); + const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + + function leser(daten) { + return { + da(t) { return daten[t] !== undefined; }, + zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; }, + text(t) { return daten[t] === undefined ? "" : String(daten[t]); }, + json(t) { try { return JSON.parse(String(daten[t])); } catch (e) { return null; } }, + }; + } + + /** Abweichung -1..1 als Farbe: unter dem Mittel blau, im Mittel grün, darüber hellgrün. */ + function zellFarbe(t) { + const mix = (a, b, f) => a.map((x, i) => Math.round(x + (b[i] - x) * f)); + const blau = [79, 143, 184], gruen = [108, 190, 88], hell = [176, 222, 128]; + const c = t < 0 ? mix(gruen, blau, Math.min(1, -t)) : mix(gruen, hell, Math.min(1, t)); + return "rgb(" + c.join(",") + ")"; + } + function tempFarbe(t) { + const f = Math.max(0, Math.min(1, (t - 10) / 30)); + const kalt = [79, 143, 184], mild = [108, 190, 88], warm = [232, 163, 58]; + const c = f < 0.5 ? kalt.map((x, i) => Math.round(x + (mild[i] - x) * f * 2)) + : mild.map((x, i) => Math.round(x + (warm[i] - x) * (f - 0.5) * 2)); + return "rgb(" + c.join(",") + ")"; + } + + class Speicher { + constructor(behaelter, opt = {}) { + this.behaelter = behaelter; + this.kwhJeModul = opt.kwhJeModul || KWH_JE_MODUL; + this.modul = null; // gewähltes Modul (0-basiert), null = selbst wählen + this.zeitraum = 24; // Stunden + this.verlauf = null; + this.zuletzt = {}; + + new ResizeObserver(() => this.diagrammeZeichnen()).observe(behaelter); + this.sichtbar = false; + new IntersectionObserver(e => { + const vorher = this.sichtbar; + this.sichtbar = e[0].isIntersecting; + this.behaelter.classList.toggle("spk-pause", !this.sichtbar); + // Beim ersten Hinsehen den Verlauf holen - vorher kostet er nur. + if (this.sichtbar && !vorher && !this.verlauf) this.verlaufHolen(); + }).observe(behaelter); + setInterval(() => { if (this.sichtbar && !document.hidden) this.verlaufHolen(); }, 5 * 60000); + } + + /* --------------------------------------------------------- Zeichnen */ + zeichnen() { + this.behaelter.innerHTML = ` +
+ +
+
+
+

Module und Zellen

+
+
+ unter dem Mittel + im Mittel + darüber +
+
+
+
+
+
+
+

Ladestand und Leistung

+
+ + + +
+
+
+
+
+

Zellspreizung und Temperatur

+
+
+
+
`; + const $ = s => this.behaelter.querySelector(s); + this.dom = { + meldung: $(".spk-meldung"), kennzahlen: $(".spk-kennzahlen"), turm: $(".spk-turm"), + stand: $(".spk-stand"), detail: $(".spk-detail"), + soc: $('[data-diagramm="soc"]'), bmu: $('[data-diagramm="bmu"]'), + }; + this.behaelter.querySelector(".spk-zeitraum").addEventListener("click", ev => { + const b = ev.target.closest("[data-stunden]"); + if (!b) return; + this.zeitraum = Number(b.dataset.stunden); + b.parentElement.querySelectorAll("button").forEach(x => x.classList.toggle("aktiv", x === b)); + this.verlaufHolen(); + }); + this.dom.turm.addEventListener("click", ev => { + const b = ev.target.closest("[data-modul]"); + if (!b) return; + this.modul = Number(b.dataset.modul); + this.zuletzt.detail = null; + this.zuletzt.turm = null; + if (this.stand) this.anzeigen(); + }); + if (this.stand) this.anzeigen(); + } + + /* ---------------------------------------------------- Aktualisieren */ + aktualisieren(daten) { + const m = leser(daten); + const b = "solarManager/byd/"; + const bmu = m.da(b + "stand"); + const s = { + bmu, + socWr: m.zahl("solarManager/SOC"), + pWr: -m.zahl("solarManager/P_Akku"), // positiv = laden + grenze: m.zahl("solarManager/crgMaxPct"), + }; + if (bmu) { + Object.assign(s, { + ok: m.zahl(b + "ok") > 0, + stand: m.zahl(b + "stand"), + soc: m.zahl(b + "soc"), soh: m.zahl(b + "soh"), + spannung: m.zahl(b + "spannung"), strom: m.zahl(b + "strom"), + tMin: m.zahl(b + "tMin"), tMax: m.zahl(b + "tMax"), + zelleMin: m.zahl(b + "zelleMin"), zelleMax: m.zahl(b + "zelleMax"), + zelleMinNr: m.zahl(b + "zelleMinNr"), zelleMaxNr: m.zahl(b + "zelleMaxNr"), + spreizung: m.zahl(b + "spreizung"), ausgleich: m.zahl(b + "ausgleich"), + fehler: m.zahl(b + "fehler"), fehlertext: m.text(b + "fehlertext"), + geladen: m.zahl(b + "geladen"), entladen: m.zahl(b + "entladen"), + module: m.zahl(b + "module"), firmware: m.text(b + "firmware"), seriennummer: m.text(b + "seriennummer"), + zellenRoh: m.text(b + "zellen"), tempRoh: m.text(b + "temperaturen"), + }); + if (s.zellenRoh !== (this.stand && this.stand.zellenRoh)) { + s.zellen = m.json(b + "zellen") || []; + s.temps = m.json(b + "temperaturen") || []; + } else { + s.zellen = this.stand.zellen; + s.temps = this.stand.temps; + } + } + this.stand = s; + if (this.dom) this.anzeigen(); + } + + anzeigen() { + const s = this.stand; + const alt = s.bmu && Date.now() / 1000 - s.stand > VERALTET_S; + + // Meldung oben + let meldung = ""; + if (!s.bmu) meldung = "Von der Batteriesteuerung (BMU) kommen keine Daten – gezeigt wird, was der Wechselrichter weiß."; + else if (alt) meldung = "Die Werte der BMU sind veraltet: letzter Stand " + uhrzeit(new Date(s.stand * 1000)) + " Uhr."; + else if (s.fehler) meldung = "Die BMU meldet: " + s.fehlertext; + if (this.dom.meldung.textContent !== meldung) { + this.dom.meldung.textContent = meldung; + this.dom.meldung.hidden = !meldung; + this.dom.meldung.classList.toggle("spk-fehler", !!(s.bmu && s.fehler && !alt)); + } + + this.kennzahlenZeigen(s); + + // Laden/Entladen als Bewegung im Turm + this.dom.turm.classList.toggle("spk-laden", s.pWr > 50); + this.dom.turm.classList.toggle("spk-entladen", s.pWr < -50); + + if (s.bmu && s.zellen && s.zellen.length) { + const sig = s.zellenRoh + "|" + s.tempRoh + "|" + this.modul; + if (this.zuletzt.turm !== sig) { this.zuletzt.turm = sig; this.turmZeigen(s); } + const stand = "Messung " + uhrzeit(new Date(s.stand * 1000)); + if (this.dom.stand.textContent !== stand) this.dom.stand.textContent = stand; + } else if (this.zuletzt.turm !== "leer") { + this.zuletzt.turm = "leer"; + this.dom.turm.innerHTML = '

Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.

'; + this.dom.detail.innerHTML = ""; + } + } + + /* ------------------------------------------------------ Kennzahlen */ + kennzahlenZeigen(s) { + const soc = s.bmu ? s.soc : s.socWr; + const module = s.bmu && s.module ? s.module : 0; + const kwh = module * this.kwhJeModul; + const p = s.pWr; + let prognose = "in Ruhe"; + if (p > 50 && kwh) { + const h = kwh * (100 - soc) / 100 / (p / 1000); + prognose = soc >= 99.5 ? "voll" : "voll um " + uhrzeit(new Date(Date.now() + h * 3600000)); + } else if (p < -50 && kwh) { + const h = kwh * soc / 100 / (-p / 1000); + prognose = "leer um " + uhrzeit(new Date(Date.now() + h * 3600000)); + } + if (p > 50 && s.grenze > 0 && s.grenze < 100) prognose += " · begrenzt auf " + Math.round(s.grenze) + " %"; + + const kacheln = [ + { titel: "Ladestand", zeichen: "\uF187", wert: Math.round(soc) + " %", neben: kwh ? "≈ " + komma(kwh * soc / 100) + " von " + komma(kwh) + " kWh" : "", balken: soc }, + { titel: "Leistung", zeichen: p > 50 ? "\uF185" : "\uF46E", wert: (p > 50 ? "+" : p < -50 ? "−" : "") + komma(Math.abs(p) < 50 ? 0 : Math.abs(p) / 1000, 2) + " kW", + neben: (p > 50 ? "lädt · " : p < -50 ? "entlädt · " : "") + prognose, klasse: p > 50 ? "spk-gut" : p < -50 ? "spk-warm" : "" }, + ]; + if (s.bmu) { + const zyklen = kwh ? s.entladen / kwh : 0; + kacheln.push( + { titel: "Gesundheit", zeichen: "\uF76E", wert: komma(s.soh, 0) + " %", neben: "SOH laut BMU" }, + { titel: "Zellspreizung", zeichen: "\uF304", wert: tausend(s.spreizung) + " mV", + neben: komma(s.zelleMin / 1000, 3) + "–" + komma(s.zelleMax / 1000, 3) + " V", + klasse: s.spreizung > 30 ? "spk-warm" : "" }, + { titel: "Temperatur", zeichen: "\uF5CD", wert: Math.round(s.tMin) + "–" + Math.round(s.tMax) + " °C", neben: (s.temps || []).length + " Fühler" }, + { titel: "Spannung", zeichen: "\uF66B", wert: komma(s.spannung) + " V", neben: komma(s.strom) + " A" }, + { titel: "Durchsatz", zeichen: "\uF130", wert: tausend(s.entladen) + " kWh", + neben: "≈ " + tausend(zyklen) + " Zyklen · " + (s.geladen ? Math.round(s.entladen / s.geladen * 100) : 0) + " % zurück", + titelLang: "entladen seit Inbetriebnahme; Zyklen gerechnet mit " + komma(kwh) + " kWh; zurück = entladen je geladen" }, + { titel: "Zustand", zeichen: s.fehler ? "\uF33A" : "\uF26A", wert: s.fehler ? "Fehler" : "in Ordnung", + neben: (s.firmware || "") + (s.ausgleich ? " · gleicht " + s.ausgleich + " Zellen aus" : ""), klasse: s.fehler ? "spk-krit" : "spk-gut" }, + ); + } + const html = kacheln.map(k => ` +
+ ${k.zeichen}${k.titel} + ${schutz(k.wert)} + ${schutz(k.neben)} + ${k.balken !== undefined ? `` : ""} +
`).join(""); + if (this.zuletzt.kennzahlen === html) return; + // Beim ersten Mal ganz, danach Kachel für Kachel - der Balken soll gleiten, nicht springen. + const alte = this.dom.kennzahlen.children; + if (alte.length !== kacheln.length) { + this.dom.kennzahlen.innerHTML = html; + } else { + const neu = document.createElement("div"); + neu.innerHTML = html; + [...neu.children].forEach((k, i) => { + const a = alte[i]; + if (a.className !== k.className) a.className = k.className; + ["b", "small", ".spk-kachel-titel"].forEach(sel => { + if (a.querySelector(sel).innerHTML !== k.querySelector(sel).innerHTML) a.querySelector(sel).innerHTML = k.querySelector(sel).innerHTML; + }); + const bal = k.querySelector(".spk-kachel-balken span"); + if (bal) a.querySelector(".spk-kachel-balken span").style.width = bal.style.width; + }); + } + this.zuletzt.kennzahlen = html; + } + + /* ------------------------------------------------------------ Turm */ + turmZeigen(s) { + const z = s.zellen, t = s.temps || []; + const module = Math.max(1, Math.round(z.length / ZELLEN_JE_MODUL)); + const gueltig = z.filter(v => v > 2000 && v < 4500); + const mittel = gueltig.reduce((a, v) => a + v, 0) / (gueltig.length || 1); + const skala = Math.max(3, ...gueltig.map(v => Math.abs(v - mittel))); + this.mittel = mittel; this.skala = skala; + + const daten = []; + for (let i = 0; i < module; i++) { + const zellen = z.slice(i * ZELLEN_JE_MODUL, (i + 1) * ZELLEN_JE_MODUL); + const temps = t.slice(i * FUEHLER_JE_MODUL, (i + 1) * FUEHLER_JE_MODUL); + daten.push({ + zellen, temps, + min: Math.min(...zellen), max: Math.max(...zellen), + tMin: temps.length ? Math.min(...temps) : null, tMax: temps.length ? Math.max(...temps) : null, + }); + } + this.moduldaten = daten; + if (this.modul === null || this.modul >= module) { + // Ohne Wahl das Modul mit der größten Spreizung - dort gibt es am meisten zu sehen. + let best = 0; + daten.forEach((d, i) => { if (d.max - d.min > daten[best].max - daten[best].min) best = i; }); + this.modulAuto = best; + } + const gewaehlt = this.modul !== null && this.modul < module ? this.modul : this.modulAuto; + + // Oben das Modul 1 wie im Turm selbst: BMU oben, Module darunter. + const kopf = ` +
+ + BMU${schutz(s.seriennummer || "")} +
`; + const reihen = daten.map((d, i) => ` + `).join(""); + this.dom.turm.innerHTML = kopf + reihen + '
'; + this.detailZeigen(s, gewaehlt); + } + + detailZeigen(s, nr) { + const d = this.moduldaten[nr]; + const spr = d.max - d.min; + const mittel = this.mittel; + const lo = Math.min(d.min, mittel) - 3, hi = Math.max(d.max, mittel) + 3; + const B = 320, H = 150, L = 38, U = 20; + const X = j => L + j * ((B - L - 4) / d.zellen.length); + const bw = (B - L - 4) / d.zellen.length - 3; + const Y = v => 8 + (hi - v) / (hi - lo) * (H - U - 8); + const stufen = []; + const schritt = Math.max(1, Math.ceil((hi - lo) / 4)); + for (let v = Math.ceil(lo / schritt) * schritt; v <= hi; v += schritt) stufen.push(v); + + const balken = d.zellen.map((v, j) => { + const a = (v - mittel) / this.skala; + const nrGesamt = nr * ZELLEN_JE_MODUL + j + 1; + const markiert = nrGesamt === s.zelleMinNr || nrGesamt === s.zelleMaxNr; + return `Zelle ${nrGesamt}: ${tausend(v)} mV`; + }).join(""); + + const temps = d.temps.map((t, j) => `${t}°`).join(""); + const html = ` +

Modul ${nr + 1}

+ ${spr > 20 ? "ungleich" : "ausgeglichen"}
+
${spr} mVSpreizung der ${d.zellen.length} Zellen
+ + ${stufen.map(v => `${v}`).join("")} + + ${balken} + 1 + ${d.zellen.length} + +
Zellspannung in mV · gestrichelt: Mittel aller Zellen · weiß umrandet: tiefste und höchste Zelle der Batterie
+
${temps}
+
+
Modulspannung
${komma(d.zellen.reduce((a, v) => a + v, 0) / 1000)} V
+
Zellen
${komma(d.min / 1000, 3)} – ${komma(d.max / 1000, 3)} V
+
Temperatur
${d.tMin !== null ? d.tMin + " – " + d.tMax + " °C" : "–"}
+
Ganze Batterie
Zelle ${s.zelleMinNr} am tiefsten, ${s.zelleMaxNr} am höchsten
+
`; + this.dom.detail.innerHTML = html; + } + + /* -------------------------------------------------------- Verlauf */ + async verlaufHolen() { + try { + const antwort = await fetch("ajax/speicher.php?was=verlauf&stunden=" + this.zeitraum, { cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + this.verlauf = d; + this.diagrammeZeichnen(true); + } catch (e) { + if (this.dom) { + this.dom.soc.innerHTML = this.dom.bmu.innerHTML = '

Verlauf nicht abrufbar: ' + schutz(e.message) + "

"; + } + } + } + + diagrammeZeichnen(neu) { + if (!this.verlauf || !this.dom || !this.dom.soc.clientWidth) return; + const v = this.verlauf; + diagramm(this.dom.soc, { + von: v.von, bis: v.bis, neu, + links: { min: 0, max: 100, einheit: "%" }, + rechts: { einheit: "kW", symmetrisch: true }, + reihen: [ + { name: "Leistung", achse: "rechts", werte: v.soc.map(r => [r[0], r[2] / 1000]), flaeche: true, farbe: "#6cbe58", farbeNegativ: "#e8a33a", format: w => (w > 0 ? "+" : "") + komma(w, 2) + " kW" }, + { name: "Ladestand", achse: "links", werte: v.soc.map(r => [r[0], r[1]]), farbe: "#e3e8ec", format: w => komma(w, 0) + " %" }, + ], + }); + if (!v.bmu.length) { + this.dom.bmu.innerHTML = '

Noch keine Werte der BMU in diesem Zeitraum – sie werden seit 15.09.2026 alle 5 Minuten gespeichert.

'; + return; + } + // Die BMU wird erst seit 15.09.2026 mitgeschrieben: reichen die Werte + // nicht über den halben Zeitraum, beginnt die Achse kurz vor dem ersten. + const erster = v.bmu[0][0]; + const bmuVon = erster - v.von > (v.bis - v.von) / 2 ? Math.max(v.von, erster - 1800) : v.von; + diagramm(this.dom.bmu, { + von: bmuVon, bis: v.bis, neu, + links: { min: 0, einheit: "mV" }, + rechts: { einheit: "°C" }, + reihen: [ + { name: "Spreizung", achse: "links", werte: v.bmu.map(r => [r[0], r[1]]), flaeche: true, farbe: "#8fb8de", format: w => komma(w, 0) + " mV" }, + { name: "wärmster Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[3]]), farbe: "#e8a33a", format: w => komma(w, 0) + " °C" }, + { name: "kältester Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[2]]), farbe: "#4f8fb8", format: w => komma(w, 0) + " °C" }, + ], + }); + } + } + + /* ------------------------------------------------------------------------ + Linien-/Flächendiagramm mit zwei Achsen und Anzeige beim Überfahren. + Klein gehalten: genau das, was die zwei Diagramme hier brauchen. + ------------------------------------------------------------------------ */ + function diagramm(ziel, o) { + const B = Math.max(280, ziel.clientWidth), H = 190; + const L = 40, R = 46, O = 22, U = 24; + const alle = seite => o.reihen.filter(r => r.achse === seite).flatMap(r => r.werte.map(w => w[1])); + const achse = (seite, cfg) => { + const w = alle(seite); + let min = cfg.min !== undefined ? cfg.min : Math.min(...w); + let max = cfg.max !== undefined ? cfg.max : Math.max(...w); + if (cfg.symmetrisch) { const m = Math.max(0.5, Math.abs(min), Math.abs(max)); min = -m; max = m; } + if (!isFinite(min) || !isFinite(max)) { min = 0; max = 1; } + if (max - min < 1e-6) { max = min + 1; } + const pad = cfg.max !== undefined ? 0 : (max - min) * 0.1; + return { min: cfg.min !== undefined ? min : min - pad, max: max + pad, einheit: cfg.einheit }; + }; + const ax = { links: achse("links", o.links), rechts: achse("rechts", o.rechts) }; + const X = t => L + (t - o.von) / (o.bis - o.von) * (B - L - R); + const Y = (seite, v) => O + (ax[seite].max - v) / (ax[seite].max - ax[seite].min) * (H - O - U); + + // Zeitachse + const spanne = o.bis - o.von; + const schritt = spanne <= 86400 * 1.5 ? 3 * 3600 : spanne <= 86400 * 8 ? 86400 : 5 * 86400; + const ticks = []; + const start = new Date(o.von * 1000); + start.setMinutes(0, 0, 0); + if (schritt >= 86400) start.setHours(0); + for (let t = start.getTime() / 1000; t <= o.bis; t += schritt) { + if (t < o.von) continue; + const d = new Date(t * 1000); + if (schritt < 86400 && d.getHours() % 3) continue; + ticks.push([t, schritt < 86400 ? String(d.getHours()).padStart(2, "0") + ":00" + : ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"][d.getDay()] + " " + d.getDate() + "."]); + } + const gitterWerte = seite => [0, 0.25, 0.5, 0.75, 1].map(f => ax[seite].min + (ax[seite].max - ax[seite].min) * f); + const zahl = v => Math.abs(v) >= 10 ? Math.round(v) : komma(v, 1); + + // Pfade; Lücken über dem Dreifachen des üblichen Abstands werden nicht verbunden. + const pfad = (r, flaeche) => { + if (!r.werte.length) return ""; + const abst = r.werte.length > 1 ? (r.werte[r.werte.length - 1][0] - r.werte[0][0]) / (r.werte.length - 1) : 300; + let d = "", zug = []; + const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0))); + const zu = () => { + if (!zug.length) return; + d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L"); + if (flaeche) d += `L${zug[zug.length - 1][0].toFixed(1)} ${nullY.toFixed(1)}L${zug[0][0].toFixed(1)} ${nullY.toFixed(1)}Z`; + zug = []; + }; + r.werte.forEach((w, i) => { + if (i && w[0] - r.werte[i - 1][0] > abst * 3 + 60) zu(); + zug.push([X(w[0]), Y(r.achse, w[1])]); + }); + zu(); + return d; + }; + const id = "spkd" + Math.random().toString(36).slice(2, 8); + let svg = ``; + gitterWerte("links").forEach(v => { + const y = Y("links", v).toFixed(1); + svg += `${zahl(v)}`; + }); + gitterWerte("rechts").forEach(v => { + svg += `${zahl(v)}`; + }); + svg += `${ax.links.einheit}`; + svg += `${ax.rechts.einheit}`; + ticks.forEach(([t, s]) => { svg += `${s}`; }); + + o.reihen.forEach((r, i) => { + if (r.flaeche) { + const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0))); + svg += ` + `; + const d = pfad(r, true); + svg += ``; + if (r.farbeNegativ) svg += ``; + } + svg += ``; + }); + svg += ``; + svg += ``; + svg += ``; + svg += `
${o.reihen.map(r => `${r.name}`).join("")}
`; + ziel.innerHTML = svg; + + // Überfahren: nächster Zeitpunkt, alle Reihen ablesen. + const el = ziel.querySelector("svg"), zeiger = el.querySelector(".spk-zeiger"), tipp = ziel.querySelector(".spk-tipp"); + const zeigen = ev => { + const r = el.getBoundingClientRect(); + const x = (ev.clientX - r.left) / r.width * B; + const t = o.von + (x - L) / (B - L - R) * (o.bis - o.von); + const zeilen = o.reihen.map(reihe => { + let best = null; + for (const w of reihe.werte) if (!best || Math.abs(w[0] - t) < Math.abs(best[0] - t)) best = w; + return best && Math.abs(best[0] - t) < 3 * 3600 ? { reihe, w: best } : null; + }).filter(Boolean); + if (!zeilen.length) { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); return; } + const tx = X(zeilen[0].w[0]); + zeiger.setAttribute("x1", tx); zeiger.setAttribute("x2", tx); + const d = new Date(zeilen[0].w[0] * 1000); + tipp.innerHTML = `${d.getDate()}.${d.getMonth() + 1}. ${uhrzeit(d)}` + zeilen.map(z => + `${z.reihe.name}${z.reihe.format(z.w[1])}`).join(""); + tipp.hidden = false; + const links = tx / B * r.width; + tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4, Math.max(4, links + 12)) + "px"; + }; + el.addEventListener("pointermove", zeigen); + el.addEventListener("pointerleave", () => { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); }); + } + + window.Speicher = Speicher; +})(); diff --git a/restricted/solar.php b/restricted/solar.php index 26e120b..c2a3ca2 100644 --- a/restricted/solar.php +++ b/restricted/solar.php @@ -55,10 +55,12 @@
-

Realtime

+

Realtime

+
+