Vor W, kW, kWh, V, mV, A, % und °C steht jetzt ueberall U+2009 wie in der Uebersicht von Hand begonnen (65 Stellen, nur in Anzeigetexten; CSS-Werte und freistehende Achseneinheiten bleiben). Dazu "Wirkungsgrad 96 %" mit Leerzeichen und die Heizstab-Temperaturen mit " / " wie Sonne und Netz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
560 lines
28 KiB
JavaScript
560 lines
28 KiB
JavaScript
/* ==========================================================================
|
||
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 = `
|
||
<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;
|
||
})();
|