Files
Smart-Dashboard/js/solar/anlage.js
T
adminandClaude Opus 5 b8b2e66fd0 Vollbildanzeige fuer den Raspberry Pi (800 x 480)
index.php?action=anzeige: Uebersicht, Anlage und Speicher ohne Kopf und
Seitenleiste, schmale Leiste links, immer quer. Nach zwei Minuten ohne
Beruehrung zurueck zur Uebersicht, MQTT verbindet still neu (Punkt in der
Leiste), alle sechs Stunden neu laden. Standard "ruhig" fuer den Pi 3B+
(nur Fluesse, grobe Stufen, kein Mauszeiger); &animation=1 und &maus=1.

Dafuer:
- Seitentabelle kennt "eigen" (Vorlage ist eine ganze Seite)
- Energiefluss: Optionen hochkantUnter und klickFilter; Anlage: hochkantUnter
- Bewaesserungszustand aus solarMQTT.js nach bewaesserungAnzeige.js, von
  Solar-Seite und Anzeige gemeinsam genutzt
- Speicher: "voll/leer" mit Tag ("morgen 21:52", "in 3 Tagen")

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:15:46 +02:00

560 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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.hochkantUnter = opt.hochkantUnter ?? HOCHKANT_UNTER; // px; 0 = nie hochkant
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 < this.hochkantUnter) !== 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 < this.hochkantUnter;
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;
})();