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

+
+