From de2892f7a7d349b0e254a9d35ce9606c02d76721 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 17:56:37 +0200 Subject: [PATCH] Solar: Anlage-Ansicht neu, im Stil der Energiefluss-Uebersicht Der alte Plan (pvplan.svg) ist ersetzt durch einen Katalog der Anlage (js/solar/anlageKatalog.js: Flaechen, Wechselrichter, echte Lage jeder Platte) und einen Renderer (js/solar/anlage.js): - Flaechen mit Leistung und Anteil als Balken - Lageplan: Farbe stufenlos nach Leistung im Vergleich zu den staerksten Platten der Flaeche, Warnzeichen nur bei genug Licht unter 70 % des Medians, "ohne Daten" wenn der Wechselrichter nichts meldet; Auftritt, Lichtwelle bei Sonne, pulsierende Warnung; hochkant gedreht auf schmalen Schirmen - Detail zu Platte, String oder Wechselrichter (Eingaenge als Balken, Auslastung, Temperatur), Verlauf je Platte ab Seitenaufruf - Wechselrichterliste mit Zustand ok / hochgerechnet / keine Daten Die Flaechen im Sonnenring der Uebersicht kommen jetzt aus demselben Katalog. Co-Authored-By: Claude Opus 5 --- README.md | 18 +- assets/img/pvplan.svg | 192 ----------- css/solar.css | 139 ++++++++ index.php | 8 +- js/solar/anlage.js | 558 ++++++++++++++++++++++++++++++++ js/solar/anlageKatalog.js | 81 +++++ js/solar/energieflussKatalog.js | 29 +- js/solar/solarMQTT.js | 34 +- restricted/solar.php | 5 +- 9 files changed, 834 insertions(+), 230 deletions(-) delete mode 100644 assets/img/pvplan.svg create mode 100644 js/solar/anlage.js create mode 100644 js/solar/anlageKatalog.js diff --git a/README.md b/README.md index 9c92757..80287a2 100644 --- a/README.md +++ b/README.md @@ -303,8 +303,7 @@ Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick Regler und Ist-Werte lesen aus dem Thermostat-Zweig des Raums (`mqttZweig()`). **`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht -(`energieflussStarten()`), füllt den PV-Lageplan hinter der Sonne -(`assets/img/pvplan.svg`, Ids `det_pvNP`, `invList`) und die Bewässerungszonen, +(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen, darunter hängen die Diagramme Prognose, Verbrauch und Erzeugung über `getData()`. Aktualisiert wird höchstens einmal je Sekunde (`solarSvgUpdatePending`); die Setter (`setText`, `setAttr`) schreiben nur, @@ -328,6 +327,21 @@ still, solange die Karte nicht zu sehen ist, und entfallen bei `css/solar.css`. Bis September 2026 stand hier `assets/img/realtime.svg` mit festen Kreisen. +**Anlage-Ansicht** (Umschalter „Anlage“ oder Klick auf die Sonne) — dieselbe +Arbeitsteilung: + +| Datei | enthält | +|---|---| +| `anlageKatalog.js` | die PV-Anlage dieses Hauses: Flächen mit Umriss, Wechselrichter (`invertersN` und ihre Eingänge in `P_PVn`), Strings ohne Einzelwerte (Gen24) und jede Platte mit ihrer echten Lage. Auch die Flächen im Sonnenring der Übersicht leiten sich daraus ab. | +| `anlage.js` | den Renderer (`new Anlage(behälter)`): Flächen mit Leistungsanteil, Lageplan (hochkant gedreht unter 800 px), Detail zu Platte, String oder Wechselrichter, Wechselrichterliste mit Auslastung und Zustand. | + +Die Farbe einer Platte zeigt ihre Leistung im Vergleich zu den stärksten +ihrer Fläche (90. Perzentil) — nur Anzeige, kein Urteil. Ein Warnzeichen +bekommt sie erst, wenn sie bei genug Licht (Flächenmedian ab 30 W) unter 70 % +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). + **`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/assets/img/pvplan.svg b/assets/img/pvplan.svg deleted file mode 100644 index 22e602f..0000000 --- a/assets/img/pvplan.svg +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -.-- kW - - - - - - - - - - - - -.-- kW - - - #19 - - #19 - - #18 - - #17 - - #16 - - #15 - - #14 - - #13 - - #12 - - #11 - - #10 - - #9 - - #8 - - #7 - - #6 - - #5 - - #1 - - #2 - - #3 - - #4 - - - - W - - - W - - - - W - - - W - - - - W - - - W - - - - W - - - W - - - - W - - - W - - - - W - - - - W - - - - - - diff --git a/css/solar.css b/css/solar.css index 989957a..cd322a5 100644 --- a/css/solar.css +++ b/css/solar.css @@ -3594,6 +3594,145 @@ svg.enf.enf-hoch { max-height: none; } .enf-e-flaeche { display: grid; grid-template-columns: 2.2rem minmax(0, 1fr) 4.2rem 4.2rem 1.2rem; gap: .35rem; align-items: center; margin-bottom: .3rem; } .enf-e-flaeche .form-control.form-control-color { width: 2.2rem; height: 1.9rem; padding: .15rem; } +/* ========================================================================== + Anlage-Ansicht (js/solar/anlage.js, Katalog js/solar/anlageKatalog.js). + Dieselben Töne wie die Energiefluss-Übersicht. + ========================================================================== */ +.anl { display: grid; gap: .9rem; } +.anl .anl-flaechen { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: .6rem; } +.anl .anl-flaeche { background: var(--ton-grund, #191d20); border-radius: .8rem; padding: .6rem .85rem .55rem; display: grid; gap: .35rem; } +.anl .anl-flaeche-kopf { display: flex; align-items: baseline; gap: .5rem; } +.anl .anl-flaeche-kopf i { width: .6rem; height: .6rem; border-radius: 50%; background: var(--farbe); align-self: center; box-shadow: 0 0 0 3px color-mix(in srgb, var(--farbe) 25%, transparent); } +.anl .anl-flaeche-kopf span { font-weight: 600; color: var(--ton-schrift, #e8ede9); } +.anl .anl-flaeche-kopf b { margin-left: auto; font-size: 1.2rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.anl .anl-flaeche-balken { height: 5px; border-radius: 3px; background: #2b3139; overflow: hidden; } +.anl .anl-flaeche-balken span { display: block; height: 100%; background: var(--farbe); border-radius: 3px; transition: width 1s cubic-bezier(.2, .7, .2, 1); } +.anl .anl-flaeche small { color: var(--ton-schrift-3, #6f7a81); font-size: .76rem; } +@media (max-width: 575.98px) { + /* Auf dem Handy drei schmale Kacheln nebeneinander statt drei Zeilen. */ + .anl .anl-flaechen { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .4rem; } + .anl .anl-flaeche { padding: .45rem .55rem; } + .anl .anl-flaeche-kopf { flex-wrap: wrap; gap: .1rem .4rem; } + .anl .anl-flaeche-kopf span { font-size: .8rem; } + .anl .anl-flaeche-kopf b { margin-left: 0; width: 100%; font-size: 1rem; } + .anl .anl-flaeche small { display: none; } +} + +.anl .anl-plan-rahmen { background: var(--ton-grund, #191d20); border-radius: .9rem; padding: .4rem; } +svg.anl-plan { display: block; width: 100%; height: auto; max-height: 72vh; font-family: "Poppins", system-ui, sans-serif; user-select: none; -webkit-tap-highlight-color: transparent; } +svg.anl-plan.anl-hoch { max-height: none; } + +.anl-plan .anl-dachflaeche { fill: var(--farbe); fill-opacity: .06; stroke: var(--farbe); stroke-opacity: .18; stroke-width: 22; stroke-linejoin: round; } +.anl-plan .anl-bereich { fill: var(--farbe); fill-opacity: .035; stroke: var(--farbe); stroke-opacity: .25; stroke-width: 2; stroke-dasharray: 7 7; } + +.anl-plan .anl-teil { transform-box: fill-box; transform-origin: center; animation: anl-auf .55s cubic-bezier(.2, .9, .3, 1.2) backwards; animation-delay: calc(var(--d) * 1ms); } +@keyframes anl-auf { from { opacity: 0; transform: scale(.4); } } +.anl-plan .anl-koerper { fill: #2f353c; stroke: rgba(255, 255, 255, .1); stroke-width: 1; transition: fill 1s, stroke .2s, stroke-width .2s; } +.anl-plan .anl-platte .anl-koerper, +.anl-plan .anl-string .anl-koerper { fill: var(--fuellung, #3b3a32); } +.anl-plan .anl-platte.anl-offline .anl-koerper { fill: #262b31; stroke: #5c646d; stroke-dasharray: 4 3; } +.anl-plan .anl-zellen, +.anl-plan .anl-glanz { pointer-events: none; } +.anl-plan .anl-glanz { fill: #fff; opacity: 0; } +/* Lichtwelle: jede Platte hellt kurz auf, versetzt nach ihrer Lage. */ +.anl-plan.anl-sonnig .anl-hell .anl-glanz { + animation: anl-glanz 7s ease-in-out infinite; animation-delay: calc(var(--glanz) * 7s); } +@keyframes anl-glanz { 0%, 100% { opacity: 0; } 7% { opacity: .3; } 15% { opacity: 0; } } + +.anl-plan .anl-platte, +.anl-plan .anl-string { cursor: pointer; } +.anl-plan .anl-platte:hover .anl-koerper, +.anl-plan .anl-string:hover .anl-koerper { stroke: rgba(255, 255, 255, .7); stroke-width: 2; } +.anl-plan .anl-geschwister .anl-koerper { stroke: #e3e6ea; stroke-width: 2; stroke-dasharray: 6 4; } +.anl-plan .anl-gewaehlt .anl-koerper { stroke: #fff; stroke-width: 3.5; stroke-dasharray: none; } + +.anl-plan .anl-w { font-size: 17px; font-weight: 600; fill: #c9d0d6; text-anchor: middle; dominant-baseline: central; pointer-events: none; font-variant-numeric: tabular-nums; } +.anl-plan .anl-hell .anl-w { fill: #15181c; } +.anl-plan .anl-offline .anl-w { fill: #8d96a0; } +.anl-plan.anl-klein .anl-w { display: none; } + +.anl-plan .anl-warnung { display: none; pointer-events: none; } +.anl-plan .anl-schwach .anl-warnung { display: inline; } +.anl-plan .anl-warnung circle { fill: #e5624f; stroke: #1c2024; stroke-width: 2; } +.anl-plan .anl-warnung .anl-warnung-hof { stroke: none; opacity: .5; transform-box: fill-box; transform-origin: center; animation: anl-puls 1.8s ease-out infinite; } +@keyframes anl-puls { from { transform: scale(.6); opacity: .6; } to { transform: scale(1.5); opacity: 0; } } +.anl-plan .anl-warnung text { fill: #fff; font-size: 13px; font-weight: 700; text-anchor: middle; dominant-baseline: central; } + +.anl-plan .anl-schild { pointer-events: none; } +.anl-plan .anl-schild rect { fill: rgba(21, 24, 28, .86); stroke: rgba(255, 255, 255, .08); } +.anl-plan .anl-schild-wert { font-size: 23px; font-weight: 600; fill: var(--ton-schrift, #e8ede9); text-anchor: middle; dominant-baseline: central; font-variant-numeric: tabular-nums; } +.anl-plan .anl-schild-name { font-size: 11px; font-weight: 600; fill: var(--ton-schrift-2, #9aa6ad); text-anchor: middle; dominant-baseline: central; text-transform: uppercase; letter-spacing: .08em; } +.anl-plan .anl-nord path { fill: #808090; } +.anl-plan .anl-nord text { fill: #808090; font-size: 15px; font-weight: 600; text-anchor: middle; } +.anl-plan.anl-pause, +.anl-plan.anl-pause * { animation-play-state: paused !important; } + +.anl .anl-legende { display: flex; flex-wrap: wrap; gap: .3rem 1.1rem; font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); padding: 0 .3rem; } +.anl .anl-legende i { display: inline-block; width: .75rem; height: .75rem; border-radius: 3px; margin-right: .35rem; vertical-align: -.1rem; } +.anl .anl-l-verlauf { width: 2.6rem !important; background: linear-gradient(90deg, #3b3a32, #f7c000); } +.anl .anl-l-warnung { background: #e5624f; border-radius: 50% !important; color: #fff; font-style: normal; font-size: .6rem; font-weight: 700; text-align: center; line-height: .75rem; } +.anl .anl-l-offline { background: #262b31; box-shadow: inset 0 0 0 1px #5c646d; } + +.anl .anl-unten { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .9rem; align-items: start; } +@media (max-width: 860px) { .anl .anl-unten { grid-template-columns: minmax(0, 1fr); } } +.anl .anl-fach { background: var(--ton-grund, #191d20); border-radius: .9rem; padding: .9rem 1rem; display: grid; gap: .7rem; align-content: start; } +.anl .anl-ueberschrift { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--ton-schrift-2, #9aa6ad); margin: 0; } +.anl .anl-kopf { display: flex; justify-content: space-between; align-items: center; gap: .6rem; } +.anl .anl-pille { font-size: .72rem; font-weight: 600; border-radius: 99px; padding: .1rem .55rem; white-space: nowrap; color: var(--ton-schrift-2, #9aa6ad); background: rgba(255, 255, 255, .06); } +.anl .anl-pille.anl-ok { color: #6cbe58; background: rgba(108, 190, 88, .13); } +.anl .anl-pille.anl-warn { color: #e8a33a; background: rgba(232, 163, 58, .14); } +.anl .anl-pille.anl-krit { color: #e5624f; background: rgba(229, 98, 79, .14); } +.anl .anl-gross { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .8rem; } +.anl .anl-gross b { font-size: 2rem; font-weight: 600; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--ton-schrift, #e8ede9); } +.anl .anl-gross span { color: var(--ton-schrift-2, #9aa6ad); font-size: .88rem; } +.anl .anl-funken { width: 100%; height: auto; display: block; } +.anl .anl-gitter { stroke: #2a3037; } +.anl .anl-funken-flaeche { fill: rgba(247, 192, 0, .14); } +.anl .anl-funken-linie { fill: none; stroke: #f7c000; stroke-width: 2; stroke-linejoin: round; } +.anl .anl-funken-punkt { fill: #f7c000; } +.anl .anl-achse { fill: var(--ton-schrift-3, #6f7a81); font-size: 10px; } +.anl .anl-funken-leer { color: var(--ton-schrift-3, #6f7a81); font-size: .82rem; padding: .7rem 0; } +.anl .anl-paare { display: grid; grid-template-columns: auto 1fr; gap: .25rem .9rem; font-size: .88rem; margin: 0; } +.anl .anl-paare dt { color: var(--ton-schrift-2, #9aa6ad); font-weight: 400; } +.anl .anl-paare dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; } +.anl .anl-hinweis { font-size: .84rem; color: var(--ton-schrift-2, #9aa6ad); margin: 0; } +.anl button.anl-link { all: unset; cursor: pointer; color: #8fb8de; } +.anl button.anl-link:hover { text-decoration: underline; } + +.anl .anl-wr, +.anl .anl-eingaenge, +.anl .anl-hinweise { list-style: none; margin: 0; padding: 0; display: grid; gap: .15rem; } +.anl .anl-wr button, +.anl .anl-eingaenge button, +.anl .anl-hinweise button { all: unset; box-sizing: border-box; cursor: pointer; width: 100%; border-radius: .5rem; padding: .42rem .55rem; transition: background .2s; } +.anl .anl-wr button:hover, +.anl .anl-eingaenge button:hover, +.anl .anl-hinweise button:hover { background: var(--ton-flaeche-2, #2b3237); } +.anl .anl-wr button:focus-visible, +.anl .anl-eingaenge button:focus-visible, +.anl .anl-hinweise button:focus-visible, +.anl button.anl-link:focus-visible { outline: 2px solid #8fb8de; outline-offset: 1px; } +.anl .anl-wr button { display: grid; grid-template-columns: minmax(0, 1fr) auto 6.5rem; gap: .7rem; align-items: center; font-size: .9rem; } +.anl .anl-wr button > .anl-pille { justify-self: end; } +.anl .anl-wr button[aria-pressed="true"] { background: var(--ton-flaeche-2, #2b3237); box-shadow: inset 3px 0 0 #f7c000; } +.anl .anl-wr-name small { display: block; color: var(--ton-schrift-3, #6f7a81); font-size: .74rem; } +.anl .anl-wr-wert { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; } +.anl .anl-wr-last { display: block; width: 4rem; height: 3px; margin: .25rem 0 0 auto; border-radius: 2px; background: #2b3139; overflow: hidden; } +.anl .anl-wr-last span { display: block; height: 100%; background: #f7c000; transition: width 1s; } +.anl .anl-eingaenge button { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr) 4.2rem; gap: .6rem; align-items: center; font-size: .86rem; } +.anl .anl-eingaenge b { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; } +.anl .anl-balken { height: 7px; border-radius: 4px; background: #2b3139; overflow: hidden; } +.anl .anl-balken span { display: block; height: 100%; border-radius: 4px; background: #f7c000; transition: width .8s cubic-bezier(.2, .7, .2, 1); } +.anl .anl-balken.anl-mittel span { background: #c98a22; } +.anl .anl-balken.anl-schwach span { background: #e5624f; } +.anl .anl-balken.anl-offline span, +.anl .anl-balken.anl-dunkel span { background: #5c646d; } +.anl .anl-hinweise button { display: flex; align-items: center; gap: .6rem; font-size: .88rem; } + +@media (prefers-reduced-motion: reduce) { + .anl *, .anl-plan * { animation: none !important; transition: none !important; } +} + /* --- 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 89041b4..5211273 100644 --- a/index.php +++ b/index.php @@ -100,8 +100,8 @@ if (isset($_GET["addUser"])) { */ $pages = [ "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, - "scripts" => ["js/solar/jahresstatistik.js", "js/solar/energieflussKatalog.js", - "js/solar/energiefluss.js", "js/solar/solarMQTT.js"]], + "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.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"]], "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false, @@ -114,8 +114,8 @@ if (isset($_GET["addUser"])) { "scripts" => ["js/solar/weatherMQTT.js"]], "settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/settings.js", "js/solar/grundriss.js", - "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", - "js/solar/energieflussEinstellungen.js"]], + "js/solar/anlageKatalog.js", "js/solar/energieflussKatalog.js", + "js/solar/energiefluss.js", "js/solar/energieflussEinstellungen.js"]], "logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/logs.js"]], "einfuehrung" => ["template" => "einfuehrung.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, diff --git a/js/solar/anlage.js b/js/solar/anlage.js new file mode 100644 index 0000000..b4734b8 --- /dev/null +++ b/js/solar/anlage.js @@ -0,0 +1,558 @@ +/* ========================================================================== + Anlage-Ansicht: die PV-Anlage als Lageplan mit Leistung je Platte. + + const plan = new Anlage(behaelter, { katalog: ANLAGE_KATALOG }); + plan.zeichnen(); + plan.aktualisieren(mqttData); + + Was die Anlage ausmacht (Flächen, Wechselrichter, Lage jeder Platte), + steht in js/solar/anlageKatalog.js. Hier steht nur, wie ein Plan + grundsätzlich aussieht und wie aus den Werten ein Zustand wird - im + selben Stil wie die Energiefluss-Übersicht (energiefluss.js). + + Farbe einer Platte: ihre Leistung im Vergleich zum Median ihrer Fläche. + So fällt eine verschattete oder defekte Platte auf, ohne dass Sonnenstand + oder Bewölkung eine Rolle spielen. Bei schwachem Licht (Median unter + SCHWACHLICHT) wird nicht verglichen - dann liefern alle fast nichts. + Liefert ein Wechselrichter keine Daten, sind seine Platten "ohne Daten" + und nicht "auffällig". + + Den Verlauf je Platte gibt es in der Datenbank nicht; er wird hier seit + dem Aufruf der Seite mitgeschrieben. + ========================================================================== */ + +(function () { + "use strict"; + + const NS = "http://www.w3.org/2000/svg"; + const HOCHKANT_UNTER = 720; + const SCHWACHLICHT = 30; // W Median, darunter kein Vergleich + const VERLAUF_ABSTAND = 15000; // ms zwischen zwei Punkten + const VERLAUF_DAUER = 40 * 60000; // ms, die der Verlauf zurückreicht + + let laufendeNummer = 0; + + const kw = w => (Math.abs(w) / 1000).toFixed(Math.abs(w) < 10000 ? 2 : 1).replace(".", ",") + " kW"; + const watt = w => Math.abs(w) < 1000 ? Math.round(w) + " W" : kw(w); + const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + + function el(name, attr, eltern) { + const e = document.createElementNS(NS, name); + if (attr) for (const k in attr) e.setAttribute(k, attr[k]); + if (eltern) eltern.appendChild(e); + return e; + } + + /** + * Füllfarbe einer Platte: von gedämpftem Oliv (nichts) zur Sonnenfarbe + * (so viel wie die stärksten). Als --fuellung, damit CSS sie weich + * überblendet und "ohne Daten" sie überstimmen kann. + */ + const DUNKEL = [59, 58, 50]; + function setzeFuellung(g, anteil, farbe) { + const ziel = [1, 3, 5].map(i => parseInt(farbe.substr(i, 2), 16)); + const t = Math.pow(Math.max(0, Math.min(1, anteil)), 0.8); + const rgb = "rgb(" + DUNKEL.map((d, i) => Math.round(d + (ziel[i] - d) * t)).join(",") + ")"; + if (g._fuellung !== rgb) { g._fuellung = rgb; g.style.setProperty("--fuellung", rgb); } + } + + function median(werte) { + if (!werte.length) return 0; + const s = [...werte].sort((a, b) => a - b); + return s[Math.floor(s.length / 2)]; + } + + function leser(daten) { + return { + zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; }, + text(t) { return daten[t] === undefined ? undefined : String(daten[t]); }, + json(t) { try { return JSON.parse(String(daten[t])); } catch (e) { return null; } }, + }; + } + + class Anlage { + constructor(behaelter, opt = {}) { + this.behaelter = behaelter; + this.katalog = opt.katalog || ANLAGE_KATALOG; + this.nummer = ++laufendeNummer; + this.auswahl = null; // {art: "platte"|"string"|"wr", schluessel} + this.verlauf = {}; // Eingang -> [[zeit, W], ...] + this.letzterPunkt = 0; + this.stand = null; + + const k = this.katalog; + this.flaeche = Object.fromEntries(k.flaechen.map(f => [f.id, f])); + this.wrNachEingang = {}; + k.wechselrichter.forEach(w => w.eingaenge.forEach((e, i) => { this.wrNachEingang[e] = { wr: w, index: i }; })); + + this.beobachter = new ResizeObserver(() => { + if (!this.behaelter.clientWidth) return; + if ((this.behaelter.clientWidth < HOCHKANT_UNTER) !== this.hochkant) this.zeichnen(); + else this.masstab(); + }); + this.beobachter.observe(behaelter); + this.sichtbar = true; + new IntersectionObserver(e => { this.sichtbar = e[0].isIntersecting; this.pausieren(); }).observe(behaelter); + document.addEventListener("visibilitychange", () => this.pausieren()); + } + + /* ------------------------------------------------------- Geometrie */ + /** Hochkant: um 90 Grad im Uhrzeigersinn gedreht. Norden dreht mit. */ + rechteck(x, y, w, h) { + return this.hochkant ? [-(y + h), x, h, w] : [x, y, w, h]; + } + punkt(x, y) { + return this.hochkant ? [-y, x] : [x, y]; + } + + /* --------------------------------------------------------- Zeichnen */ + zeichnen() { + const breite = this.behaelter.clientWidth; + this.hochkant = breite > 0 && breite < HOCHKANT_UNTER; + const id = "anl" + this.nummer; + const k = this.katalog; + + this.behaelter.innerHTML = ` +
+
+
+ +
+
+ Leistung im Vergleich zu den stärksten Platten der Fläche + !deutlich unter dem Mittel der Fläche + ohne Daten +
+
+
+
+

Wechselrichter

+
    +
    +
    +
    `; + + this.dom = { + flaechen: this.behaelter.querySelector(".anl-flaechen"), + svg: this.behaelter.querySelector(".anl-plan"), + auswahl: this.behaelter.querySelector(".anl-auswahl"), + wr: this.behaelter.querySelector(".anl-wr"), + }; + const svg = this.dom.svg; + + el("pattern", { id: id + "-zellen", width: 10, height: 10, patternUnits: "userSpaceOnUse" }, el("defs", null, svg)) + .appendChild(el("path", { d: "M10 0V10M0 10H10", fill: "none", stroke: "rgba(0,0,0,.2)", "stroke-width": 1 })); + + // Flächen + const umrisse = el("g", { class: "anl-umrisse" }, svg); + for (const f of k.flaechen) { + const g = el("g", { style: `--farbe:${f.farbe}`, "data-flaeche": f.id }, umrisse); + if (Array.isArray(f.umriss)) { + f.umriss.forEach(p => el("polygon", { + class: "anl-dachflaeche", + points: p.split(" ").map(xy => this.punkt(...xy.split(",").map(Number)).join(",")).join(" "), + }, g)); + } else if (f.umriss) { + const [x, y, w, h] = this.rechteck(f.umriss.x, f.umriss.y, f.umriss.w, f.umriss.h); + el("rect", { class: "anl-bereich", x, y, width: w, height: h, rx: 12 }, g); + } + } + + // Strings (ohne Einzelwerte) + this.stringKnoten = {}; + const strings = el("g", { class: "anl-platten" }, svg); + for (const s of k.strings) { + const g = el("g", { class: "anl-string anl-dunkel", "data-string": s.id, style: `--farbe:${s.farbe}` }, strings); + s.platten.forEach(r => this.platteZeichnen(g, id, r[0], r[1], r[2], r[3])); + this.stringKnoten[s.id] = g; + } + + // Platten + this.platteKnoten = {}; + for (const p of k.platten) { + const g = el("g", { class: "anl-platte anl-dunkel", "data-eingang": p.eingang }, strings); + const r = this.platteZeichnen(g, id, p.x, p.y, p.w || 70, p.h || 40); + const t = el("text", { class: "anl-w", x: r[0] + r[2] / 2, y: r[1] + r[3] / 2 }, g); + t.textContent = "–"; + const warn = el("g", { class: "anl-warnung", transform: `translate(${r[0] + r[2] - 3} ${r[1] + 3})` }, g); + el("circle", { r: 13, class: "anl-warnung-hof" }, warn); + el("circle", { r: 8.5 }, warn); + el("text", { y: 0.5 }, warn).textContent = "!"; + this.platteKnoten[p.eingang] = { g, t }; + } + + // Stringwerte als Schild + this.schilder = {}; + for (const s of k.strings) { + const [x, y] = this.punkt(...s.schild); + const g = el("g", { class: "anl-schild", transform: `translate(${x} ${y})` }, svg); + el("rect", { x: -64, y: -26, width: 128, height: 52, rx: 12 }, g); + const w = el("text", { class: "anl-schild-wert", y: -5 }, g); + const n = el("text", { class: "anl-schild-name", y: 15 }, g); + n.textContent = s.name; + this.schilder[s.id] = w; + } + + // Nordpfeil + const nord = el("g", { class: "anl-nord" }, svg); + const [nx, ny] = this.punkt(k.nord.x, k.nord.y); + nord.setAttribute("transform", `translate(${nx} ${ny}) rotate(${k.nord.winkel + (this.hochkant ? 90 : 0)})`); + el("path", { d: "M0-26L12 14 0 7-12 14Z" }, nord); + const nt = el("text", { y: 34 }, nord); + nt.textContent = "N"; + if (this.hochkant) nt.setAttribute("transform", "rotate(-100 0 34)"); + + this.rahmen(); + this.masstab(); + this.binden(); + this.pausieren(); + if (this.stand) this.anzeigen(); + } + + platteZeichnen(g, id, x, y, w, h) { + const r = this.rechteck(x, y, w, h); + // --d: Verzögerung des Auftritts, --glanz: Takt der Lichtwelle - beide + // laufen über die Anlage von links oben nach rechts unten. + const teil = el("g", { class: "anl-teil", style: `--d:${Math.round((x + y) / 2.2)};--glanz:${((x + y * 0.4) / 1500).toFixed(3)}` }, g); + el("rect", { class: "anl-koerper", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4 }, teil); + el("rect", { class: "anl-zellen", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4, fill: `url(#${id}-zellen)` }, teil); + el("rect", { class: "anl-glanz", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4 }, teil); + return r; + } + + /** Zeichenfläche aus dem Katalog - getBBox() taugt nicht, die Ansicht ist beim Zeichnen oft verborgen. */ + rahmen() { + const k = this.katalog; + const punkte = []; + const rechteck = (x, y, w, h) => { const r = this.rechteck(x, y, w, h); punkte.push([r[0], r[1]], [r[0] + r[2], r[1] + r[3]]); }; + k.platten.forEach(p => rechteck(p.x, p.y, p.w || 70, p.h || 40)); + k.strings.forEach(s => s.platten.forEach(r => rechteck(...r))); + k.flaechen.forEach(f => { + if (Array.isArray(f.umriss)) f.umriss.forEach(p => p.split(" ").forEach(xy => punkte.push(this.punkt(...xy.split(",").map(Number))))); + else if (f.umriss) rechteck(f.umriss.x, f.umriss.y, f.umriss.w, f.umriss.h); + }); + const [nx, ny] = this.punkt(k.nord.x, k.nord.y); + punkte.push([nx - 30, ny - 40], [nx + 30, ny + 45]); + const xs = punkte.map(p => p[0]), ys = punkte.map(p => p[1]); + const rand = 24; + const x0 = Math.min(...xs) - rand, y0 = Math.min(...ys) - rand; + this.dom.svg.setAttribute("viewBox", + [x0, y0, Math.max(...xs) + rand - x0, Math.max(...ys) + rand - y0].map(Math.round).join(" ")); + } + + /** Beschriftung ausblenden, wenn sie zu klein würde. */ + masstab() { + const vb = this.dom && this.dom.svg.viewBox.baseVal; + if (!vb || !vb.width) return; + const pxJeEinheit = this.dom.svg.clientWidth / vb.width; + this.dom.svg.classList.toggle("anl-klein", pxJeEinheit < 0.55); + } + + binden() { + this.dom.svg.addEventListener("click", ev => { + const p = ev.target.closest("[data-eingang]"); + const s = ev.target.closest("[data-string]"); + if (p) this.waehle("platte", Number(p.dataset.eingang)); + else if (s) this.waehle("string", s.dataset.string); + else this.waehle(null); + }); + const zuWahl = ev => { + const b = ev.target.closest("[data-wahl]"); + if (!b) return; + const [art, schluessel] = b.dataset.wahl.split(":"); + this.waehle(art || null, art === "string" ? schluessel : Number(schluessel)); + }; + this.dom.wr.addEventListener("click", zuWahl); + this.dom.auswahl.addEventListener("click", zuWahl); + } + + waehle(art, schluessel) { + const gleich = this.auswahl && this.auswahl.art === art && this.auswahl.schluessel === schluessel; + this.auswahl = art && !gleich ? { art, schluessel } : null; + this.detailSig = null; + if (this.stand) this.anzeigen(); + } + + pausieren() { + if (this.dom) this.dom.svg.classList.toggle("anl-pause", !this.sichtbar || document.hidden); + } + + /* ---------------------------------------------------- Aktualisieren */ + aktualisieren(daten) { + const m = leser(daten); + const pvn = m.json("solarManager/P_PVn") || []; + const k = this.katalog; + + const wr = {}; + for (const w of k.wechselrichter) { + const b = "solarManager/inverters" + w.nr + "/"; + const geschaetzt = m.text(b + "estimated"); + wr[w.nr] = { + def: w, + ac: m.zahl(b + "p_AC"), + temp: m.zahl(b + "temp"), + gestoert: m.zahl(b + "error") > 0, + geschaetzt: geschaetzt === "True" || Number(geschaetzt) > 0, + limit: m.zahl(b + "limit"), + gemeldet: m.text(b + "name") !== undefined, + }; + } + + const platten = k.platten.map(p => { + const zu = this.wrNachEingang[p.eingang]; + const w = Math.max(0, Number(pvn[p.eingang]) || 0); + return { def: p, w, wr: zu ? wr[zu.wr.nr] : null, index: zu ? zu.index : 0, offline: zu ? wr[zu.wr.nr].gestoert : false }; + }); + // Zwei getrennte Fragen: + // hell - wie viel liefert die Platte im Vergleich zu den stärksten + // ihrer Fläche (90. Perzentil)? Nur Farbe, kein Urteil. + // klasse - fällt sie auf? Nur bei genug Licht, gemessen am Median. + // Früher bestimmte der Median beides; auf der Veranda mit Platten in + // verschiedenen Richtungen waren dann nachmittags alle "dunkel", auch + // die mit 200 W. + const mittel = {}, bezug = {}; + for (const f of k.flaechen) { + const werte = platten.filter(p => p.def.flaeche === f.id && !p.offline).map(p => p.w).sort((a, b) => a - b); + mittel[f.id] = median(werte); + bezug[f.id] = werte.length ? werte[Math.floor(0.9 * (werte.length - 1))] : 0; + } + for (const p of platten) { + const md = mittel[p.def.flaeche]; + p.anteil = md > 0 ? p.w / md : 0; + p.hell = bezug[p.def.flaeche] > 0 ? Math.min(1, p.w / bezug[p.def.flaeche]) : 0; + p.klasse = p.offline ? "offline" : md < SCHWACHLICHT ? "dunkel" + : p.anteil >= 0.9 ? "gut" : p.anteil >= 0.7 ? "mittel" : "schwach"; + } + const strings = k.strings.map(s => { + const w = Math.max(0, Number(pvn[s.eingang]) || 0); + return { def: s, w, jePlatte: w / s.platten.length }; + }); + for (const s of strings) { + const max = Math.max(...strings.filter(x => x.def.flaeche === s.def.flaeche).map(x => x.jePlatte)); + s.hell = max > 0 ? s.jePlatte / max : 0; + } + + // Verlauf mitschreiben + const jetzt = Date.now(); + if (jetzt - this.letzterPunkt >= VERLAUF_ABSTAND) { + this.letzterPunkt = jetzt; + pvn.forEach((w, i) => { + const v = this.verlauf[i] || (this.verlauf[i] = []); + v.push([jetzt, Number(w) || 0]); + while (v.length && jetzt - v[0][0] > VERLAUF_DAUER) v.shift(); + }); + } + + this.stand = { platten, strings, wr, mittel, pvn, gesamt: m.zahl("solarManager/P_PV") }; + if (this.dom) this.anzeigen(); + } + + anzeigen() { + const { platten, strings, wr, gesamt } = this.stand; + const k = this.katalog; + const wahl = this.auswahl; + const wahlWr = wahl && wahl.art === "wr" ? wahl.schluessel + : wahl && wahl.art === "platte" ? (this.wrNachEingang[wahl.schluessel] || {}).wr?.nr + : wahl && wahl.art === "string" ? 0 : null; + + // Platten + for (const p of platten) { + const n = this.platteKnoten[p.def.eingang]; + if (!n) continue; + const klasse = "anl-platte anl-" + p.klasse + (p.hell > 0.45 ? " anl-hell" : "") + + (wahl && wahl.art === "platte" && wahl.schluessel === p.def.eingang ? " anl-gewaehlt" + : wahlWr !== null && wahlWr !== undefined && p.wr && p.wr.def.nr === wahlWr ? " anl-geschwister" : ""); + if (n.g.getAttribute("class") !== klasse) n.g.setAttribute("class", klasse); + setzeFuellung(n.g, p.hell, "#f7c000"); + const text = p.offline ? "–" : String(Math.round(p.w)); + if (n.t.textContent !== text) n.t.textContent = text; + } + for (const s of strings) { + const g = this.stringKnoten[s.def.id]; + const klasse = "anl-string" + (s.hell > 0.45 ? " anl-hell" : "") + + (wahl && wahl.art === "string" && wahl.schluessel === s.def.id ? " anl-gewaehlt" + : wahlWr === 0 ? " anl-geschwister" : ""); + if (g.getAttribute("class") !== klasse) g.setAttribute("class", klasse); + setzeFuellung(g, s.w < 5 ? 0 : s.hell, s.def.farbe); + const text = kw(s.w); + if (this.schilder[s.def.id].textContent !== text) this.schilder[s.def.id].textContent = text; + } + this.dom.svg.classList.toggle("anl-sonnig", gesamt > 300); + + // Flächen oben + const summen = k.flaechen.map(f => { + const w = platten.filter(p => p.def.flaeche === f.id).reduce((a, p) => a + p.w, 0) + + strings.filter(s => s.def.flaeche === f.id).reduce((a, s) => a + s.w, 0); + const n = platten.filter(p => p.def.flaeche === f.id).length + + k.strings.filter(s => s.flaeche === f.id).reduce((a, s) => a + s.platten.length, 0); + return { f, w, n }; + }); + const summe = summen.reduce((a, s) => a + s.w, 0) || 1; + const flaechenHtml = summen.map(s => ` +
    +
    ${schutz(s.f.name)}${kw(s.w)}
    +
    + ${s.n} Platten · ${Math.round(s.w / summe * 100)} % der Erzeugung +
    `).join(""); + if (this.dom.flaechen._html !== flaechenHtml) { + // Beim ersten Mal ganz schreiben, danach nur Werte - sonst springen die Balken. + if (!this.dom.flaechen.children.length) { + this.dom.flaechen.innerHTML = flaechenHtml; + } else { + [...this.dom.flaechen.children].forEach((c, i) => { + const s = summen[i]; + c.querySelector("b").textContent = kw(s.w); + c.querySelector(".anl-flaeche-balken span").style.width = (s.w / summe * 100).toFixed(1) + "%"; + c.querySelector("small").textContent = s.n + " Platten · " + Math.round(s.w / summe * 100) + " % der Erzeugung"; + }); + } + this.dom.flaechen._html = flaechenHtml; + } + + // Wechselrichter + const wrHtml = k.wechselrichter.map(d => { + const w = wr[d.nr]; + const pille = !w.gemeldet || w.gestoert ? 'keine Daten' + : w.geschaetzt ? 'hochgerechnet' + : 'ok'; + const ausl = w.limit > 0 ? Math.min(100, w.ac / w.limit * 100) : 0; + const aktiv = wahlWr === d.nr ? ' aria-pressed="true"' : ' aria-pressed="false"'; + return `
  • `; + }).join(""); + if (this.dom.wr._html !== wrHtml) { this.dom.wr.innerHTML = wrHtml; this.dom.wr._html = wrHtml; } + + // Auswahl + const detail = this.detailHtml(); + if (this.dom.auswahl._html !== detail) { this.dom.auswahl.innerHTML = detail; this.dom.auswahl._html = detail; } + } + + /* ------------------------------------------------------------ Detail */ + plattenName(p) { + const f = this.flaeche[p.def.flaeche]; + return p.def.nr ? `${f.name} · Platte #${p.def.nr}` : `${p.wr ? p.wr.def.name : f.name} · Eingang ${p.index + 1}`; + } + + detailHtml() { + const { platten, strings, wr, mittel } = this.stand; + const wahl = this.auswahl; + + if (wahl && wahl.art === "platte") { + const p = platten.find(x => x.def.eingang === wahl.schluessel); + if (p) { + const f = this.flaeche[p.def.flaeche]; + const pille = p.offline ? 'keine Daten' + : p.klasse === "schwach" ? 'auffällig' + : p.klasse === "mittel" ? 'etwas schwächer' + : p.klasse === "dunkel" ? 'wenig Licht' + : 'normal'; + const vergleich = p.offline ? "Wechselrichter meldet nichts" + : mittel[p.def.flaeche] < SCHWACHLICHT ? "zu wenig Licht zum Vergleichen" + : Math.round(p.anteil * 100) + " % des Mittels " + f.name; + return ` +

    ${schutz(this.plattenName(p))}

    ${pille}
    +
    ${p.offline ? "–" : watt(p.w)}${vergleich}
    + ${this.funkenlinie(p.def.eingang)} +
    +
    Wechselrichter
    +
    Eingang
    ${p.index + 1} von ${p.wr.def.eingaenge.length}
    +
    Mittel ${schutz(f.name)}
    ${watt(mittel[p.def.flaeche])}
    +
    Temperatur Wechselrichter
    ${p.wr.temp > 0 && !p.offline ? Math.round(p.wr.temp) + " °C" : "–"}
    +
    + ${p.klasse === "schwach" ? '

    Liefert deutlich weniger als die anderen Platten dieser Fläche. Mögliche Gründe: Verschattung, Schmutz, ein loser Stecker – oder die Platte liegt anders ausgerichtet.

    ' : ""}`; + } + } + + if (wahl && wahl.art === "string") { + const s = strings.find(x => x.def.id === wahl.schluessel); + if (s) { + return ` +

    ${schutz(this.flaeche[s.def.flaeche].name)} · ${schutz(s.def.name)}

    Wert je String
    +
    ${kw(s.w)}${s.def.platten.length} Platten in Reihe
    + ${this.funkenlinie(s.def.eingang)} +
    +
    Wechselrichter
    +
    je Platte rechnerisch
    ${watt(s.w / s.def.platten.length)}
    +
    +

    Die Platten hängen in Reihe am Wechselrichter. Er misst nur den ganzen String – eine einzelne schwache Platte lässt sich hier nicht erkennen.

    `; + } + } + + if (wahl && wahl.art === "wr") { + const w = wr[wahl.schluessel]; + if (w) { + const werte = w.def.eingaenge.map(e => Math.max(0, Number(this.stand.pvn[e]) || 0)); + const max = Math.max(1, ...werte); + const eingaenge = w.def.eingaenge.map((e, i) => { + const p = platten.find(x => x.def.eingang === e); + const s = strings.find(x => x.def.eingang === e); + const name = p ? (p.def.nr ? "Platte #" + p.def.nr : "Eingang " + (i + 1)) : s ? s.def.name : "Eingang " + (i + 1); + const wahlZiel = p ? "platte:" + e : s ? "string:" + s.def.id : ""; + return `
  • `; + }).join(""); + const pille = w.gestoert ? 'keine Daten' + : w.geschaetzt ? 'hochgerechnet' : 'ok'; + return ` +

    ${schutz(w.def.name)}

    ${pille}
    +
    ${w.gestoert ? "–" : watt(w.ac)}${schutz(w.def.modell)}
    + +
    + ${w.limit > 0 ? `
    Leistungsgrenze
    ${watt(w.limit)} · ${Math.round(w.ac / w.limit * 100)} % genutzt
    ` : ""} +
    Temperatur
    ${w.temp > 0 && !w.gestoert ? Math.round(w.temp) + " °C" : "–"}
    +
    + ${w.gestoert ? '

    Der Wechselrichter antwortet der DTU gerade nicht. Seine Platten stehen deshalb auf „ohne Daten“ – über ihren Zustand sagt das nichts.

    ' : ""} + ${w.geschaetzt ? '

    Aktuelle Werte fehlen; die Leistung ist aus den anderen Wechselrichtern hochgerechnet.

    ' : ""}`; + } + } + + // Ohne Auswahl: Zusammenfassung mit allem, was Aufmerksamkeit braucht. + const auffaellig = platten.filter(p => p.klasse === "schwach"); + const ohne = Object.values(wr).filter(w => w.gestoert || !w.gemeldet); + const geschaetzt = Object.values(wr).filter(w => w.geschaetzt && !w.gestoert); + const n = platten.length + this.katalog.strings.reduce((a, s) => a + s.platten.length, 0); + const punkte = [ + ...ohne.map(w => `
  • `), + ...geschaetzt.map(w => `
  • `), + ...auffaellig.map(p => `
  • `), + ]; + return ` +

    Anlage

    ${punkte.length ? punkte.length + " Hinweis" + (punkte.length > 1 ? "e" : "") : "alles in Ordnung"}
    +
    ${kw(this.stand.gesamt)}${n} Platten an ${this.katalog.wechselrichter.length} Wechselrichtern
    + ${punkte.length ? `` : ""} +

    Eine Platte oder einen Wechselrichter antippen für Einzelheiten.

    `; + } + + funkenlinie(eingang) { + const v = this.verlauf[eingang] || []; + if (v.length < 3) { + return '
    Der Verlauf wird ab jetzt mitgeschrieben …
    '; + } + const t0 = v[0][0], t1 = v[v.length - 1][0]; + const max = Math.max(10, ...v.map(p => p[1])); + const X = t => 4 + (t - t0) / Math.max(1, t1 - t0) * 272; + const Y = w => 58 - w / max * 50; + const pfad = v.map((p, i) => (i ? "L" : "M") + X(p[0]).toFixed(1) + " " + Y(p[1]).toFixed(1)).join(""); + const letzt = v[v.length - 1]; + const minuten = Math.max(1, Math.round((t1 - t0) / 60000)); + return ` + + + + + + vor ${minuten} min + jetzt + ${watt(max)} + `; + } + } + + window.Anlage = Anlage; +})(); diff --git a/js/solar/anlageKatalog.js b/js/solar/anlageKatalog.js new file mode 100644 index 0000000..de208f6 --- /dev/null +++ b/js/solar/anlageKatalog.js @@ -0,0 +1,81 @@ +/* ========================================================================== + Katalog der Anlage-Ansicht (Solar-Seite, Karte "Realtime" -> "Anlage"). + + Beschreibt die PV-Anlage dieses Hauses: Flächen, Wechselrichter und jede + Platte mit ihrer tatsächlichen Lage. Gezeichnet wird er von + js/solar/anlage.js, ohne dass der Renderer eine Fläche beim Namen kennt. + Die Lagen stammen aus dem früheren Plan in realtime.svg (Einheiten wie + dort, Platte 70 x 40, Zeichenfläche etwa 1250 x 740). + + Die Leistung kommt aus solarManager/P_PVn - ein Eintrag je Eingang, in der + Reihenfolge von solarManager.py -, der Zustand der Geräte aus + solarManager/invertersN/... . + + flaechen [{id, name, farbe, umriss: "x,y x,y ..." | {x,y,w,h}}] + wechselrichter[{nr, name, modell, eingaenge: [P_PVn-Index, ...]}] + nr = N in solarManager/invertersN + strings Platten ohne eigenen Messwert (in Reihe am Gen24): + [{id, name, flaeche, eingang, farbe, platten: [[x,y,w,h], ...]}] + platten [{eingang, flaeche, nr?, x, y, w, h}] - w/h ohne Angabe 70/40; + nr ist die Beschriftung (#14), sonst "Eingang n" + nord {x, y, winkel} für den Nordpfeil + ========================================================================== */ + +const ANLAGE_KATALOG = (function () { + "use strict"; + + // Dach, String A: vier Spalten stehend, darunter liegend, rechts stehend. + const dachA = []; + [600, 640, 680].forEach(x => [80, 150, 220, 290].forEach(y => dachA.push([x - 40, y, 40, 70]))); + [80, 150, 220].forEach(y => dachA.push([680, y, 40, 70])); + [[790, 290], [720, 330], [790, 330], [870, 290], [870, 330], [940, 330]].forEach(([x, y]) => dachA.push([x, y, 70, 40])); + [1160, 1120, 1080].forEach(x => dachA.push([x - 40, 300, 40, 70])); + + // Dach, String B: drei Reihen stehend, etwas breitere Platten. + const dachB = [[1160, 200], [1115, 200], [1070, 200], [1160, 130], [1115, 130], [1070, 130], [1025, 130], + [1160, 60], [1115, 60], [1070, 60]].map(([x, y]) => [x - 45, y, 45, 70]); + + const stehend = (eingang, x, y) => ({ eingang, flaeche: "veranda", x, y, w: 40, h: 70 }); + const carport = (eingang, nr, x, y) => ({ eingang, flaeche: "carport", nr, x, y }); + + return { + flaechen: [ + { id: "dach", name: "Dach", farbe: "#f7c000", + umriss: ["560,80 720,80 720,300 1160,300 1160,370 560,370", "980,60 1160,60 1160,265 980,265"] }, + { id: "veranda", name: "Veranda", farbe: "#f78d00", umriss: { x: 385, y: 35, w: 300, h: 520 } }, + { id: "carport", name: "Carport", farbe: "#f75a1e", umriss: { x: 85, y: 495, w: 380, h: 240 } }, + ], + + wechselrichter: [ + { nr: 0, name: "Gen24", modell: "Fronius Symo Gen24", eingaenge: [0, 1] }, + { nr: 1, name: "Veranda UG2", modell: "Hoymiles (DTU-BI)", eingaenge: [2, 3] }, + { nr: 2, name: "Veranda UG/OG1", modell: "Hoymiles, 4 Eingänge", eingaenge: [4, 5, 6, 7] }, + { nr: 3, name: "Veranda OG2–4", modell: "Hoymiles, 6 Eingänge", eingaenge: [8, 9, 10, 11, 12, 13] }, + { nr: 4, name: "Carport 1–6", modell: "Hoymiles HMS-2250-6T", eingaenge: [14, 15, 16, 17, 18, 19] }, + { nr: 5, name: "Carport 7–12", modell: "Hoymiles HMS-2250-6T", eingaenge: [20, 21, 22, 23, 24, 25] }, + { nr: 6, name: "Carport 13–16", modell: "Hoymiles HMS-1800-4T", eingaenge: [26, 27, 28, 29] }, + { nr: 7, name: "Carport 17–20", modell: "Hoymiles HMS-1600-4T", eingaenge: [30, 31, 32, 33] }, + ], + + strings: [ + { id: "A", name: "String A", flaeche: "dach", eingang: 0, farbe: "#f7c000", platten: dachA, schild: [640, 222] }, + { id: "B", name: "String B", flaeche: "dach", eingang: 1, farbe: "#f7a000", platten: dachB, schild: [1070, 168] }, + ], + + platten: [ + stehend(7, 400, 50), stehend(6, 400, 120), stehend(3, 400, 200), stehend(2, 400, 270), + stehend(5, 460, 50), stehend(4, 460, 120), stehend(9, 460, 200), stehend(8, 460, 270), + stehend(11, 460, 350), stehend(10, 460, 420), + { eingang: 12, flaeche: "veranda", x: 500, y: 500 }, { eingang: 13, flaeche: "veranda", x: 600, y: 500 }, + + carport(14, 1, 100, 510), carport(15, 2, 100, 550), carport(16, 3, 100, 590), carport(17, 4, 100, 630), + carport(21, 7, 170, 550), carport(20, 8, 170, 510), carport(19, 6, 170, 590), carport(18, 5, 170, 630), + carport(25, 12, 240, 510), carport(24, 11, 240, 550), carport(23, 10, 240, 590), carport(22, 9, 240, 630), + carport(29, 16, 310, 510), carport(28, 15, 310, 550), carport(27, 14, 310, 590), carport(26, 13, 310, 630), + carport(32, 19, 380, 550), carport(31, 18, 380, 590), carport(30, 17, 380, 630), + carport(33, 20, 200, 680), + ], + + nord: { x: 1235, y: 45, winkel: 10 }, + }; +})(); diff --git a/js/solar/energieflussKatalog.js b/js/solar/energieflussKatalog.js index 586cd9b..eff084f 100644 --- a/js/solar/energieflussKatalog.js +++ b/js/solar/energieflussKatalog.js @@ -48,6 +48,24 @@ const ENERGIEFLUSS_KATALOG = (function () { const kw = w => (Math.abs(w) / 1000).toFixed(1).replace(".", ","); + /** Flächen des Sonnenrings aus anlageKatalog.js - fehlt der, feste Vorgabe. */ + function flaechenAusAnlage() { + if (typeof ANLAGE_KATALOG === "undefined") { + return [ + { name: "Dach", von: 0, bis: 1, farbe: "#f7c000" }, + { name: "Veranda", von: 2, bis: 13, farbe: "#f78d00" }, + { name: "Carport", von: 14, bis: 33, farbe: "#f75a1e" }, + ]; + } + return ANLAGE_KATALOG.flaechen.map(f => { + const eingaenge = [ + ...ANLAGE_KATALOG.platten.filter(p => p.flaeche === f.id).map(p => p.eingang), + ...ANLAGE_KATALOG.strings.filter(s => s.flaeche === f.id).map(s => s.eingang), + ]; + return { name: f.name, von: Math.min(...eingaenge), bis: Math.max(...eingaenge), farbe: f.farbe }; + }).filter(f => isFinite(f.von)); + } + const wallboxZeichen = (modus, stecker, gesperrt) => { const z = []; if (modus === "Eco") z.push({ art: "eco", titel: "Eco-Modus" }); @@ -65,14 +83,9 @@ const ENERGIEFLUSS_KATALOG = (function () { eltern: "wr", x: 180, y: 130, skala: 15000, klick: { ansicht: "anlage" }, // Welche Einträge von solarManager/P_PVn zu welcher Fläche gehören. - // Reihenfolge wie in solarManager.py. - optionen: { - flaechen: [ - { name: "Dach", von: 0, bis: 1, farbe: "#f7c000" }, - { name: "Veranda", von: 2, bis: 13, farbe: "#f78d00" }, - { name: "Carport", von: 14, bis: 33, farbe: "#f75a1e" }, - ], - }, + // Vorgabe aus dem Anlagenkatalog, damit die Zuordnung nur einmal + // gepflegt wird; in den Einstellungen überschreibbar. + optionen: { flaechen: flaechenAusAnlage() }, lesen(m, opt) { const pvn = m.json("solarManager/P_PVn") || []; const pv = m.zahl("solarManager/P_PV"); diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index a710710..2a1f14d 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -142,11 +142,12 @@ function setFlowWidth(id, width) { flow.style.willChange = sichtbar ? "transform" : "auto"; } -// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und der PV-Lageplan. -// Bis September 2026 stand hier updateValuesMQTT fuer das feste realtime.svg - -// jeder Kreis mit eigenen Zeilen. Was ein Kreis zeigt, steht jetzt im Katalog -// (energieflussKatalog.js); hier bleibt nur der Lageplan hinter der Sonne. +// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und die Anlage-Ansicht +// (js/solar/anlage.js). Bis September 2026 stand hier updateValuesMQTT fuer das +// feste realtime.svg - jeder Kreis mit eigenen Zeilen. Was gezeigt wird, steht +// jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js). let energiefluss = null; +let anlage = null; const solarSVG = { updateCnt: 99, @@ -163,23 +164,8 @@ const solarSVG = { }, updateValuesMQTT: function (mqttData) { if (energiefluss) energiefluss.aktualisieren(mqttData); - - // Lageplan (assets/img/pvplan.svg): Leistung je Eingang und die Liste - // der Wechselrichter. - let pvn = []; - try { pvn = JSON.parse(mqttData["solarManager/P_PVn"]); } catch (e) { pvn = []; } - for (let i = 0; i < pvn.length; i++) { - if (svgElement("det_pv" + i + "P")) setText("det_pv" + i + "P", powerToString(pvn[i])); - } - let liste = ""; - for (let i = 0; mqttData["solarManager/inverters" + i + "/name"] !== undefined; i++) { - const basis = "solarManager/inverters" + i; - const gestoert = mqttData[basis + "/error"] > 0; - liste += '' + mqttData[basis + "/name"] + ":" - + '' + (gestoert ? "--" : Math.round(mqttData[basis + "/temp"])) + " °C" - + '' + (gestoert ? "-- W" : powerToString(mqttData[basis + "/p_AC"])) + ""; - } - if (svgElement("invList")) setHTML("invList", liste); + // Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit. + if (anlage) anlage.aktualisieren(mqttData); } }; @@ -220,6 +206,12 @@ function energieflussStarten() { }, }); energiefluss.zeichnen(); + + const anlageBehaelter = document.getElementById("anlage"); + if (anlageBehaelter && typeof Anlage !== "undefined") { + anlage = new Anlage(anlageBehaelter); + anlage.zeichnen(); + } } var chartSettings = { diff --git a/restricted/solar.php b/restricted/solar.php index 1585509..26e120b 100644 --- a/restricted/solar.php +++ b/restricted/solar.php @@ -68,9 +68,8 @@
    - + +