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