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
+
+
+
`;
+
+ 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 `
+ ${schutz(d.name)}${schutz(d.modell)}${w.temp > 0 && !w.gestoert ? " · " + Math.round(w.temp) + " °C" : ""}
+ ${w.gestoert ? "–" : watt(w.ac)}${w.limit > 0 ? ` ` : ""}
+ ${pille}
+ `;
+ }).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 ${schutz(p.wr.def.name)}
+ 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 ${schutz(wr[0].def.name)}
+ 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 `
+ ${schutz(name)}
+
+ ${w.gestoert ? "–" : watt(werte[i])}
+ `;
+ }).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 => `keine Daten ${schutz(w.def.name)} `),
+ ...geschaetzt.map(w => `hochgerechnet ${schutz(w.def.name)} `),
+ ...auffaellig.map(p => `${Math.round(p.anteil * 100)} % ${schutz(this.plattenName(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 @@