/* ========================================================================== 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äche ohne 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 => `
    ${schutz(s.f.name)}${kw(s.w)}
    ${s.n} Platten · ${Math.round(s.w / summe * 100)} % der Erzeugung
    `).join(""); if (this.dom.flaechen._html !== flaechenHtml) { // Beim ersten Mal ganz schreiben, danach nur Werte - sonst springen die Balken. if (!this.dom.flaechen.children.length) { this.dom.flaechen.innerHTML = flaechenHtml; } else { [...this.dom.flaechen.children].forEach((c, i) => { const s = summen[i]; c.querySelector("b").textContent = kw(s.w); c.querySelector(".anl-flaeche-balken span").style.width = (s.w / summe * 100).toFixed(1) + "%"; c.querySelector("small").textContent = s.n + " Platten · " + Math.round(s.w / summe * 100) + " % der Erzeugung"; }); } this.dom.flaechen._html = flaechenHtml; } // Wechselrichter const wrHtml = k.wechselrichter.map(d => { const w = wr[d.nr]; const pille = !w.gemeldet || w.gestoert ? 'keine Daten' : w.geschaetzt ? 'hochgerechnet' : 'ok'; const ausl = w.limit > 0 ? Math.min(100, w.ac / w.limit * 100) : 0; const aktiv = wahlWr === d.nr ? ' aria-pressed="true"' : ' aria-pressed="false"'; return `
  • `; }).join(""); if (this.dom.wr._html !== wrHtml) { this.dom.wr.innerHTML = wrHtml; this.dom.wr._html = wrHtml; } // Auswahl const detail = this.detailHtml(); if (this.dom.auswahl._html !== detail) { this.dom.auswahl.innerHTML = detail; this.dom.auswahl._html = detail; } } /* ------------------------------------------------------------ Detail */ plattenName(p) { const f = this.flaeche[p.def.flaeche]; return p.def.nr ? `${f.name} · Platte #${p.def.nr}` : `${p.wr ? p.wr.def.name : f.name} · Eingang ${p.index + 1}`; } detailHtml() { const { platten, strings, wr, mittel } = this.stand; const wahl = this.auswahl; if (wahl && wahl.art === "platte") { const p = platten.find(x => x.def.eingang === wahl.schluessel); if (p) { const f = this.flaeche[p.def.flaeche]; const pille = p.offline ? 'keine Daten' : p.klasse === "schwach" ? 'auffällig' : p.klasse === "mittel" ? 'etwas schwächer' : p.klasse === "dunkel" ? 'wenig Licht' : 'normal'; const vergleich = p.offline ? "Wechselrichter meldet nichts" : mittel[p.def.flaeche] < SCHWACHLICHT ? "zu wenig Licht zum Vergleichen" : Math.round(p.anteil * 100) + " % des Mittels " + f.name; return `

    ${schutz(this.plattenName(p))}

    ${pille}
    ${p.offline ? "–" : watt(p.w)}${vergleich}
    ${this.funkenlinie(p.def.eingang)}
    Wechselrichter
    Eingang
    ${p.index + 1} von ${p.wr.def.eingaenge.length}
    Mittel ${schutz(f.name)}
    ${watt(mittel[p.def.flaeche])}
    Temperatur Wechselrichter
    ${p.wr.temp > 0 && !p.offline ? Math.round(p.wr.temp) + " °C" : "–"}
    ${p.klasse === "schwach" ? '

    Liefert deutlich weniger als die anderen Platten dieser Fläche. Mögliche Gründe: Verschattung, Schmutz, ein loser Stecker – oder die Platte liegt anders ausgerichtet.

    ' : ""}`; } } if (wahl && wahl.art === "string") { const s = strings.find(x => x.def.id === wahl.schluessel); if (s) { return `

    ${schutz(this.flaeche[s.def.flaeche].name)} · ${schutz(s.def.name)}

    Wert je String
    ${kw(s.w)}${s.def.platten.length} Platten in Reihe
    ${this.funkenlinie(s.def.eingang)}
    Wechselrichter
    je Platte rechnerisch
    ${watt(s.w / s.def.platten.length)}

    Die Platten hängen in Reihe am Wechselrichter. Er misst nur den ganzen String – eine einzelne schwache Platte lässt sich hier nicht erkennen.

    `; } } if (wahl && wahl.art === "wr") { const w = wr[wahl.schluessel]; if (w) { const werte = w.def.eingaenge.map(e => Math.max(0, Number(this.stand.pvn[e]) || 0)); const max = Math.max(1, ...werte); const eingaenge = w.def.eingaenge.map((e, i) => { const p = platten.find(x => x.def.eingang === e); const s = strings.find(x => x.def.eingang === e); const name = p ? (p.def.nr ? "Platte #" + p.def.nr : "Eingang " + (i + 1)) : s ? s.def.name : "Eingang " + (i + 1); const wahlZiel = p ? "platte:" + e : s ? "string:" + s.def.id : ""; return `
  • `; }).join(""); const pille = w.gestoert ? 'keine Daten' : w.geschaetzt ? 'hochgerechnet' : 'ok'; return `

    ${schutz(w.def.name)}

    ${pille}
    ${w.gestoert ? "–" : watt(w.ac)}${schutz(w.def.modell)}
    ${w.limit > 0 ? `
    Leistungsgrenze
    ${watt(w.limit)} · ${Math.round(w.ac / w.limit * 100)} % genutzt
    ` : ""}
    Temperatur
    ${w.temp > 0 && !w.gestoert ? Math.round(w.temp) + " °C" : "–"}
    ${w.gestoert ? '

    Der Wechselrichter antwortet der DTU gerade nicht. Seine Platten stehen deshalb auf „ohne Daten“ – über ihren Zustand sagt das nichts.

    ' : ""} ${w.geschaetzt ? '

    Aktuelle Werte fehlen; die Leistung ist aus den anderen Wechselrichtern hochgerechnet.

    ' : ""}`; } } // Ohne Auswahl: Zusammenfassung mit allem, was Aufmerksamkeit braucht. const auffaellig = platten.filter(p => p.klasse === "schwach"); const ohne = Object.values(wr).filter(w => w.gestoert || !w.gemeldet); const geschaetzt = Object.values(wr).filter(w => w.geschaetzt && !w.gestoert); const n = platten.length + this.katalog.strings.reduce((a, s) => a + s.platten.length, 0); const punkte = [ ...ohne.map(w => `
  • `), ...geschaetzt.map(w => `
  • `), ...auffaellig.map(p => `
  • `), ]; return `

    Anlage

    ${punkte.length ? punkte.length + " Hinweis" + (punkte.length > 1 ? "e" : "") : "alles in Ordnung"}
    ${kw(this.stand.gesamt)}${n} Platten an ${this.katalog.wechselrichter.length} Wechselrichtern
    ${punkte.length ? `` : ""}

    Eine Platte oder einen Wechselrichter antippen für Einzelheiten.

    `; } funkenlinie(eingang) { const v = this.verlauf[eingang] || []; if (v.length < 3) { return '
    Der Verlauf wird ab jetzt mitgeschrieben …
    '; } const t0 = v[0][0], t1 = v[v.length - 1][0]; const max = Math.max(10, ...v.map(p => p[1])); const X = t => 4 + (t - t0) / Math.max(1, t1 - t0) * 272; const Y = w => 58 - w / max * 50; const pfad = v.map((p, i) => (i ? "L" : "M") + X(p[0]).toFixed(1) + " " + Y(p[1]).toFixed(1)).join(""); const letzt = v[v.length - 1]; const minuten = Math.max(1, Math.round((t1 - t0) / 60000)); return ` vor ${minuten} min jetzt ${watt(max)} `; } } window.Anlage = Anlage; })();