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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:56:37 +02:00
co-authored by Claude Opus 5
parent a59277e33e
commit de2892f7a7
9 changed files with 834 additions and 230 deletions
+16 -2
View File
@@ -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
-192
View File
@@ -1,192 +0,0 @@
<!-- Lageplan der PV-Platten, bis September 2026 Teil von realtime.svg (Gruppe
pvDetail, geoeffnet ueber die Sonne). Unveraendert herausgeloest, damit die
neue Energiefluss-Uebersicht ihn als eigene Ansicht zeigen kann. Die Werte
schreibt solarMQTT.js ueber die Ids det_pvNP und invList. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="40 0 1260 740" width="100%" class="enf-pvplan">
<style>
.enf-pvplan .infoTextClass { font-size: 18px; font-family: sans-serif; text-anchor: middle; fill: rgb(200, 210, 210); }
.enf-pvplan .tooltipTextClass { font-size: 18px; font-family: sans-serif; text-anchor: start; fill: rgb(200, 210, 210); }
</style>
<defs>
<filter id="shadowMiddle" x="-0.4" y="-0.4" width="180%" height="180%">
<feMorphology in="SourceAlpha" result="offOut" operator="dilate" radius="3" />
<feGaussianBlur result="blurOut" in="offOut" stdDeviation="1" />
<feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
</filter>
<filter id="shadowBig" x="-0.45" y="-0.45" width="190%" height="190%">
<feMorphology in="SourceAlpha" result="offOut" operator="dilate" radius="3" />
<feGaussianBlur result="offOut" in="offOut" stdDeviation="2" />
<feBlend in="SourceGraphic" in2="offOut" mode="normal" />
</filter>
<g id="pvPlate" fill="none" stroke-width="4" stroke="#f7c000">
<rect width="70" height="40" x="0" y="0" rx="5" ry="5" />
</g>
<g id="northIcon" fill="#808090">
<path
d="M42.066 0v20h3.752V6.957L53.881 20h4.053V0h-3.752v13.355L45.996 0zm5.57 26.688l-25.77 69.949c-.823 2.238 1.658 4.248 3.677 2.978L50 84.195l24.455 15.42c2.02 1.273 4.504-.738 3.68-2.978l-25.79-70c-.472-1.096-1.283-1.632-2.384-1.635c-1.1-.003-2.017.856-2.324 1.686zm-.136 14.83V79.86L29.1 91.463z"></path>
</g>
</defs>
<g id="pvDetail">
<use href="#northIcon" transform="translate(1200,10),rotate(10)" />
<!--<polygon points="370 0, 1300 0, 1300 700, 0 700, 0 250, 370 250" fill="rgb(24, 27, 31)" />-->
<polygon points="560,80 720,80 720,300 1160,300 1160,370 560,370" stroke-linejoin="round"
fill="#f7c002" stroke-width="30" stroke="#f7c002" opacity="0.1" />
<use href="#pvPlate" transform="translate(600,80), rotate(90)" />
<use href="#pvPlate" transform="translate(600,150), rotate(90)" />
<use href="#pvPlate" transform="translate(600,220), rotate(90)" />
<use href="#pvPlate" transform="translate(600,290), rotate(90)" />
<use href="#pvPlate" transform="translate(640,80), rotate(90)" />
<use href="#pvPlate" transform="translate(640,150), rotate(90)" />
<use href="#pvPlate" transform="translate(640,220), rotate(90)" />
<use href="#pvPlate" transform="translate(640,290), rotate(90)" />
<use href="#pvPlate" transform="translate(680,80), rotate(90)" />
<use href="#pvPlate" transform="translate(680,150), rotate(90)" />
<use href="#pvPlate" transform="translate(680,220), rotate(90)" />
<use href="#pvPlate" transform="translate(680,290), rotate(90)" />
<use href="#pvPlate" transform="translate(720,80), rotate(90)" />
<use href="#pvPlate" transform="translate(720,150), rotate(90)" />
<use href="#pvPlate" transform="translate(720,220), rotate(90)" />
<use href="#pvPlate" transform="translate(790,290)" />
<use href="#pvPlate" transform="translate(720,330)" />
<use href="#pvPlate" transform="translate(790,330)" />
<use href="#pvPlate" transform="translate(870,290)" />
<use href="#pvPlate" transform="translate(870,330)" />
<use href="#pvPlate" transform="translate(940,330)" />
<use href="#pvPlate" transform="translate(1160,300), rotate(90)" />
<use href="#pvPlate" transform="translate(1120,300), rotate(90)" />
<use href="#pvPlate" transform="translate(1080,300), rotate(90)" />
<text id="det_pv0P" filter="url(#shadowBig)" class="infoTextClass" dx="55" dy="30"
transform="translate(560, 150), scale(1.5,1.5)">-.-- kW</text>
<polygon points="980,60 1160,60 1160,265 980,265" stroke-linejoin="round" fill="#F76D00"
stroke-width="30" stroke="#F76D00" opacity="0.1" />
<use href="#pvPlate" transform="translate(1160,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,200), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1160,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1025,130), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1160,60), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1115,60), rotate(90), scale(1,1.125)" />
<use href="#pvPlate" transform="translate(1070,60), rotate(90), scale(1,1.125)" />
<text id="det_pv1P" filter="url(#shadowBig)" class="infoTextClass" dx="55" dy="30"
transform="translate(990, 140), scale(1.5,1.5)">-.-- kW</text>
<use href="#pvPlate" x="200" y="680" />
<text id="det_pv33P" filter="url(#shadowMiddle)" class="infoTextClass" x="200" y="680" dx="36"
dy="26">#19</text>
<use href="#pvPlate" x="380" y="550" />
<text id="det_pv32P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="550" dx="36"
dy="26">#19</text>
<use href="#pvPlate" x="380" y="590" />
<text id="det_pv31P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="590" dx="36"
dy="26">#18</text>
<use href="#pvPlate" x="380" y="630" />
<text id="det_pv30P" filter="url(#shadowMiddle)" class="infoTextClass" x="380" y="630" dx="36"
dy="26">#17</text>
<use href="#pvPlate" x="310" y="510" />
<text id="det_pv29P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="510" dx="36"
dy="26">#16</text>
<use href="#pvPlate" x="310" y="550" />
<text id="det_pv28P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="550" dx="36"
dy="26">#15</text>
<use href="#pvPlate" x="310" y="590" />
<text id="det_pv27P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="590" dx="36"
dy="26">#14</text>
<use href="#pvPlate" x="310" y="630" />
<text id="det_pv26P" filter="url(#shadowMiddle)" class="infoTextClass" x="310" y="630" dx="36"
dy="26">#13</text>
<use href="#pvPlate" x="240" y="510" />
<text id="det_pv25P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="510" dx="36"
dy="26">#12</text>
<use href="#pvPlate" x="240" y="550" />
<text id="det_pv24P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="550" dx="36"
dy="26">#11</text>
<use href="#pvPlate" x="240" y="590" />
<text id="det_pv23P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="590" dx="36"
dy="26">#10</text>
<use href="#pvPlate" x="240" y="630" />
<text id="det_pv22P" filter="url(#shadowMiddle)" class="infoTextClass" x="240" y="630" dx="36"
dy="26">#9</text>
<use href="#pvPlate" x="170" y="510" />
<text id="det_pv20P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="510" dx="36"
dy="26">#8</text>
<use href="#pvPlate" x="170" y="550" />
<text id="det_pv21P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="550" dx="36"
dy="26">#7</text>
<use href="#pvPlate" x="170" y="590" />
<text id="det_pv19P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="590" dx="36"
dy="26">#6</text>
<use href="#pvPlate" x="170" y="630" />
<text id="det_pv18P" filter="url(#shadowMiddle)" class="infoTextClass" x="170" y="630" dx="36"
dy="26">#5</text>
<use href="#pvPlate" x="100" y="510" />
<text id="det_pv14P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="510" dx="36"
dy="26">#1</text>
<use href="#pvPlate" x="100" y="550" />
<text id="det_pv15P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="550" dx="36"
dy="26">#2</text>
<use href="#pvPlate" x="100" y="590" />
<text id="det_pv16P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="590" dx="36"
dy="26">#3</text>
<use href="#pvPlate" x="100" y="630" />
<text id="det_pv17P" filter="url(#shadowMiddle)" class="infoTextClass" x="100" y="630" dx="36"
dy="26">#4</text>
<use href="#pvPlate" transform="translate(600,500)" />
<text id="det_pv13P" filter="url(#shadowMiddle)" class="infoTextClass" x="600" y="500" dx="36"
dy="26">- W</text>
<use href="#pvPlate" transform="translate(500,500)" />
<text id="det_pv12P" filter="url(#shadowMiddle)" class="infoTextClass" x="500" y="500" dx="36"
dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,120), rotate(-90)" />
<text id="det_pv5P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,120), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,190), rotate(-90)" />
<text id="det_pv4P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,190), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,270), rotate(-90)" />
<text id="det_pv9P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,270), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,340), rotate(-90)" />
<text id="det_pv8P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,340), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,420), rotate(-90)" />
<text id="det_pv11P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,420), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(460,490), rotate(-90)" />
<text id="det_pv10P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(460,490), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,120), rotate(-90)" />
<text id="det_pv7P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,120), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,190), rotate(-90)" />
<text id="det_pv6P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,190), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,270), rotate(-90)" />
<text id="det_pv3P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,270), rotate(-90)" dx="36" dy="26">- W</text>
<use href="#pvPlate" transform="translate(400,340), rotate(-90)" />
<text id="det_pv2P" filter="url(#shadowMiddle)" class="infoTextClass"
transform="translate(400,340), rotate(-90)" dx="36" dy="26">- W</text>
<text id="invList" filter="url(#shadowMiddle)" class="tooltipTextClass"
transform="translate(80,255)"></text>
<rect width="305" height="210" x="70" y="250" rx="5" ry="5" fill="none" stroke-width="4px"
stroke="#f7c000" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

+139
View File
@@ -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; }
+4 -4
View File
@@ -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,
+558
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 = `
<div class="anl">
<div class="anl-flaechen"></div>
<div class="anl-plan-rahmen">
<svg class="anl-plan${this.hochkant ? " anl-hoch" : ""}" role="img" aria-label="Lageplan der PV-Anlage"></svg>
</div>
<div class="anl-legende">
<span><i class="anl-l-verlauf"></i>Leistung im Vergleich zu den stärksten Platten der Fläche</span>
<span><i class="anl-l-warnung">!</i>deutlich unter dem Mittel der Fläche</span>
<span><i class="anl-l-offline"></i>ohne Daten</span>
</div>
<div class="anl-unten">
<div class="anl-fach anl-auswahl" aria-live="polite"></div>
<div class="anl-fach">
<h3 class="anl-ueberschrift">Wechselrichter</h3>
<ul class="anl-wr"></ul>
</div>
</div>
</div>`;
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 => `
<div class="anl-flaeche" style="--farbe:${s.f.farbe}">
<div class="anl-flaeche-kopf"><i></i><span>${schutz(s.f.name)}</span><b>${kw(s.w)}</b></div>
<div class="anl-flaeche-balken"><span style="width:${(s.w / summe * 100).toFixed(1)}%"></span></div>
<small>${s.n} Platten · ${Math.round(s.w / summe * 100)} % der Erzeugung</small>
</div>`).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 ? '<span class="anl-pille anl-krit">keine Daten</span>'
: w.geschaetzt ? '<span class="anl-pille anl-warn">hochgerechnet</span>'
: '<span class="anl-pille anl-ok">ok</span>';
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 `<li><button type="button" data-wahl="wr:${d.nr}"${aktiv}>
<span class="anl-wr-name">${schutz(d.name)}<small>${schutz(d.modell)}${w.temp > 0 && !w.gestoert ? " · " + Math.round(w.temp) + " °C" : ""}</small></span>
<span class="anl-wr-wert">${w.gestoert ? "" : watt(w.ac)}${w.limit > 0 ? `<span class="anl-wr-last"><span style="width:${ausl.toFixed(0)}%"></span></span>` : ""}</span>
${pille}
</button></li>`;
}).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 ? '<span class="anl-pille anl-krit">keine Daten</span>'
: p.klasse === "schwach" ? '<span class="anl-pille anl-krit">auffällig</span>'
: p.klasse === "mittel" ? '<span class="anl-pille anl-warn">etwas schwächer</span>'
: p.klasse === "dunkel" ? '<span class="anl-pille">wenig Licht</span>'
: '<span class="anl-pille anl-ok">normal</span>';
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 `
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(this.plattenName(p))}</h3>${pille}</div>
<div class="anl-gross"><b>${p.offline ? "" : watt(p.w)}</b><span>${vergleich}</span></div>
${this.funkenlinie(p.def.eingang)}
<dl class="anl-paare">
<dt>Wechselrichter</dt><dd><button type="button" class="anl-link" data-wahl="wr:${p.wr.def.nr}">${schutz(p.wr.def.name)}</button></dd>
<dt>Eingang</dt><dd>${p.index + 1} von ${p.wr.def.eingaenge.length}</dd>
<dt>Mittel ${schutz(f.name)}</dt><dd>${watt(mittel[p.def.flaeche])}</dd>
<dt>Temperatur Wechselrichter</dt><dd>${p.wr.temp > 0 && !p.offline ? Math.round(p.wr.temp) + " °C" : ""}</dd>
</dl>
${p.klasse === "schwach" ? '<p class="anl-hinweis">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.</p>' : ""}`;
}
}
if (wahl && wahl.art === "string") {
const s = strings.find(x => x.def.id === wahl.schluessel);
if (s) {
return `
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(this.flaeche[s.def.flaeche].name)} · ${schutz(s.def.name)}</h3><span class="anl-pille">Wert je String</span></div>
<div class="anl-gross"><b>${kw(s.w)}</b><span>${s.def.platten.length} Platten in Reihe</span></div>
${this.funkenlinie(s.def.eingang)}
<dl class="anl-paare">
<dt>Wechselrichter</dt><dd><button type="button" class="anl-link" data-wahl="wr:0">${schutz(wr[0].def.name)}</button></dd>
<dt>je Platte rechnerisch</dt><dd>${watt(s.w / s.def.platten.length)}</dd>
</dl>
<p class="anl-hinweis">Die Platten hängen in Reihe am Wechselrichter. Er misst nur den ganzen String eine einzelne schwache Platte lässt sich hier nicht erkennen.</p>`;
}
}
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 `<li><button type="button" data-wahl="${wahlZiel}">
<span>${schutz(name)}</span>
<span class="anl-balken anl-${p ? p.klasse : "gut"}"><span style="width:${(werte[i] / max * 100).toFixed(1)}%"></span></span>
<b>${w.gestoert ? "" : watt(werte[i])}</b>
</button></li>`;
}).join("");
const pille = w.gestoert ? '<span class="anl-pille anl-krit">keine Daten</span>'
: w.geschaetzt ? '<span class="anl-pille anl-warn">hochgerechnet</span>' : '<span class="anl-pille anl-ok">ok</span>';
return `
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(w.def.name)}</h3>${pille}</div>
<div class="anl-gross"><b>${w.gestoert ? "" : watt(w.ac)}</b><span>${schutz(w.def.modell)}</span></div>
<ul class="anl-eingaenge">${eingaenge}</ul>
<dl class="anl-paare">
${w.limit > 0 ? `<dt>Leistungsgrenze</dt><dd>${watt(w.limit)} · ${Math.round(w.ac / w.limit * 100)} % genutzt</dd>` : ""}
<dt>Temperatur</dt><dd>${w.temp > 0 && !w.gestoert ? Math.round(w.temp) + " °C" : ""}</dd>
</dl>
${w.gestoert ? '<p class="anl-hinweis">Der Wechselrichter antwortet der DTU gerade nicht. Seine Platten stehen deshalb auf „ohne Daten“ über ihren Zustand sagt das nichts.</p>' : ""}
${w.geschaetzt ? '<p class="anl-hinweis">Aktuelle Werte fehlen; die Leistung ist aus den anderen Wechselrichtern hochgerechnet.</p>' : ""}`;
}
}
// 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 => `<li><button type="button" data-wahl="wr:${w.def.nr}"><span class="anl-pille anl-krit">keine Daten</span>${schutz(w.def.name)}</button></li>`),
...geschaetzt.map(w => `<li><button type="button" data-wahl="wr:${w.def.nr}"><span class="anl-pille anl-warn">hochgerechnet</span>${schutz(w.def.name)}</button></li>`),
...auffaellig.map(p => `<li><button type="button" data-wahl="platte:${p.def.eingang}"><span class="anl-pille anl-krit">${Math.round(p.anteil * 100)} %</span>${schutz(this.plattenName(p))}</button></li>`),
];
return `
<div class="anl-kopf"><h3 class="anl-ueberschrift">Anlage</h3><span class="anl-pille ${punkte.length ? "anl-warn" : "anl-ok"}">${punkte.length ? punkte.length + " Hinweis" + (punkte.length > 1 ? "e" : "") : "alles in Ordnung"}</span></div>
<div class="anl-gross"><b>${kw(this.stand.gesamt)}</b><span>${n} Platten an ${this.katalog.wechselrichter.length} Wechselrichtern</span></div>
${punkte.length ? `<ul class="anl-hinweise">${punkte.join("")}</ul>` : ""}
<p class="anl-hinweis">Eine Platte oder einen Wechselrichter antippen für Einzelheiten.</p>`;
}
funkenlinie(eingang) {
const v = this.verlauf[eingang] || [];
if (v.length < 3) {
return '<div class="anl-funken-leer">Der Verlauf wird ab jetzt mitgeschrieben …</div>';
}
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 `
<svg class="anl-funken" viewBox="0 0 280 76" aria-label="Verlauf der letzten ${minuten} Minuten">
<line x1="4" x2="276" y1="58" y2="58" class="anl-gitter"/>
<path d="${pfad}L${X(t1)} 58L4 58Z" class="anl-funken-flaeche"/>
<path d="${pfad}" class="anl-funken-linie"/>
<circle cx="${X(letzt[0])}" cy="${Y(letzt[1])}" r="3.5" class="anl-funken-punkt"/>
<text x="4" y="73" class="anl-achse">vor ${minuten} min</text>
<text x="276" y="73" class="anl-achse" text-anchor="end">jetzt</text>
<text x="276" y="10" class="anl-achse" text-anchor="end">${watt(max)}</text>
</svg>`;
}
}
window.Anlage = Anlage;
})();
+81
View File
@@ -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 OG24", modell: "Hoymiles, 6 Eingänge", eingaenge: [8, 9, 10, 11, 12, 13] },
{ nr: 4, name: "Carport 16", modell: "Hoymiles HMS-2250-6T", eingaenge: [14, 15, 16, 17, 18, 19] },
{ nr: 5, name: "Carport 712", modell: "Hoymiles HMS-2250-6T", eingaenge: [20, 21, 22, 23, 24, 25] },
{ nr: 6, name: "Carport 1316", modell: "Hoymiles HMS-1800-4T", eingaenge: [26, 27, 28, 29] },
{ nr: 7, name: "Carport 1720", 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 },
};
})();
+21 -8
View File
@@ -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");
+13 -21
View File
@@ -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 += '<tspan x="0" dy="1.2em">' + mqttData[basis + "/name"] + ":</tspan>"
+ '<tspan x="160">' + (gestoert ? "--" : Math.round(mqttData[basis + "/temp"])) + " °C</tspan>"
+ '<tspan x="215">' + (gestoert ? "-- W" : powerToString(mqttData[basis + "/p_AC"])) + "</tspan>";
}
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 = {
+2 -3
View File
@@ -68,9 +68,8 @@
</div>
<div class="card-body p-2 p-md-3">
<div class="enf-ansicht enf-behaelter" data-enf-ansicht-flaeche="fluss" id="energiefluss"></div>
<div class="enf-ansicht" data-enf-ansicht-flaeche="anlage" hidden>
<?php readfile(__DIR__ . "/../assets/img/pvplan.svg"); ?>
</div>
<!-- Lageplan der PV-Anlage: js/solar/anlage.js aus anlageKatalog.js -->
<div class="enf-ansicht" data-enf-ansicht-flaeche="anlage" id="anlage" hidden></div>
</div>
</div>
</div>