/* ========================================================================== Energiefluss-Übersicht: zeichnet den Katalog (energieflussKatalog.js) als animiertes SVG und hält es mit den MQTT-Werten aktuell. const bild = new Energiefluss(behaelter, { einstellungen, // Abweichungen aus homeMesh.energiefluss bearbeiten: false, // true = Einstellungsvorschau, Kreise ziehbar onKlick(knoten), // Kreis mit klick-Ziel angetippt onVerschoben(id, x, y), // nur beim Bearbeiten onGezeichnet(), // nach jedem (Neu-)Zeichnen hochkantUnter: 720, // optional: px Breite, darunter hochkant (0 = nie) }); bild.aktualisieren(mqttData); Der Renderer kennt keinen Kreis beim Namen. Alles, was einen Kreis ausmacht, steht im Katalog; hier steht nur, wie Kreise, Flüsse, Symbole und Plaketten grundsätzlich aussehen. Zwei Ansichten: quer (Lage aus dem Katalog) und hochkant für schmale Schirme. Hochkant sind x und y vertauscht - aus "Quellen links, Verbraucher rechts" wird "Quellen oben, Verbraucher unten". So braucht es keine zweite Lage je Kreis, und eine eigene Lage aus den Einstellungen gilt in beiden. Zur Leistung: die Flüsse laufen über stroke-dashoffset in groben Stufen (steps), und alle Animationen stehen still, solange die Karte nicht zu sehen ist. Werte werden nur geschrieben, wenn sie sich geändert haben. ========================================================================== */ (function () { "use strict"; const NS = "http://www.w3.org/2000/svg"; const RADIUS = 78; const RADIUS_GROSS = 92; const RAND = 60; // Luft um die äußersten Kreise const HOCHKANT_UNTER = 720; // px Breite, ab der hochkant gezeichnet wird /* ------------------------------------------------------------------------ Symbole: 24er Raster, Mitte 0/0, Linien in currentColor. Teile mit Klasse enf-s-* werden über CSS animiert oder über den Zustand gesetzt. ------------------------------------------------------------------------ */ const SYMBOLE = { sonne: () => ` ${[0, 45, 90, 135, 180, 225, 270, 315].map(w => ``).join("")} `, netz: () => ` `, akku: () => ` `, wechselrichter: () => ` `, verbrauch: () => ` `, etageOben: () => etage(-4.5, 1.5), etageMitte: () => etage(1.5, 7.5), etageUnten: () => etage(7.5, 11.5), wallbox: () => ` `, speicher: () => ` `, tropfen: () => ` `, }; /* Kleine Symbole für Zeilen in einem Kreis (Bewässerungszonen): 24er Raster, Ecke oben links, Linien in currentColor. */ const ZONENBILDER = { // Pflanzkübel mit Pflanze - ein Trog auf der Terrasse kuebel: ` `, // Baum im Gras - der normale Garten garten: ` `, // Hochbeet: Holzkasten auf Füßen, oben drei Pflanzen hochbeet: ` `, }; function etage(von, bis) { return ` `; } /* Plaketten am Kreis: Zeichen aus Bootstrap Icons (liegt ohnehin im Dashboard). */ const ZEICHEN = { // Codepunkte als Escape: die Zeichen selbst liegen im privaten Bereich // (U+F90C sogar auf einem CJK-Kompatibilitaetszeichen) und sind im Editor // unsichtbar oder werden beim Kopieren leicht verfaelscht. eco: { glyph: "\uF90C", farbe: "#6cbe58" }, // bi-leaf uhr: { glyph: "\uF291", farbe: "#e8a33a" }, // bi-clock-fill manuell: { glyph: "\uF56B", farbe: "#c9a227" }, // bi-sliders stecker: { glyph: "\uF4F6", farbe: "#8fb8de" }, // bi-plug-fill schloss: { glyph: "\uF47A", farbe: "#b9c0c8" }, // bi-lock-fill heizung: { glyph: "\uF7F6", farbe: "#e5624f" }, // bi-fire tank: { glyph: "\uF83D", farbe: "#b9c0c8" }, // bi-fuel-pump-fill warnung: { glyph: "\uF33A", farbe: "#e5624f" }, // bi-exclamation-triangle-fill }; /* ------------------------------------------------------------------------ Hilfen ------------------------------------------------------------------------ */ 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; } function leistungText(w) { const a = Math.abs(w); if (a < 1000) return Math.round(a) + " W"; return (a / 1000).toFixed(a < 10000 ? 2 : 1).replace(".", ",") + " kW"; } /** Liest mqttData: Werte kommen als Buffer, Zahl und Text wie gehabt. */ function leser(daten) { const cache = {}; return { text(topic) { const v = daten[topic]; return v === undefined ? undefined : String(v); }, zahl(topic) { const v = Number(String(daten[topic])); return isFinite(v) ? v : 0; }, json(topic) { if (topic in cache) return cache[topic]; try { cache[topic] = JSON.parse(String(daten[topic])); } catch (e) { cache[topic] = null; } return cache[topic]; }, }; } /** Farbe zwischen kalt und heiß, wie früher assignColor() fürs Pufferbild. */ function tempFarbe(t) { const f = Math.max(0, Math.min(1, (t - 20) / 60)); const kalt = [35, 137, 186], heiss = [186, 59, 35]; return "rgb(" + kalt.map((k, i) => Math.round(k + (heiss[i] - k) * f)).join(",") + ")"; } let laufendeNummer = 0; /* ------------------------------------------------------------------------ Die Übersicht ------------------------------------------------------------------------ */ class Energiefluss { constructor(behaelter, opt = {}) { this.behaelter = behaelter; this.opt = opt; this.bearbeiten = !!opt.bearbeiten; this.nummer = ++laufendeNummer; this.letzte = null; this.setzeEinstellungen(opt.einstellungen || {}); // Hochkant oder quer - neu zeichnen nur, wenn die Ansicht wechselt. this.beobachter = new ResizeObserver(() => { const hoch = this.hochkantGewuenscht(); if (hoch !== this.hochkant) this.zeichnen(); }); this.beobachter.observe(behaelter); // Animationen anhalten, solange niemand hinsieht. this.sichtbar = true; this.sicht = new IntersectionObserver(eintraege => { this.sichtbar = eintraege[0].isIntersecting; this.pausieren(); }); this.sicht.observe(behaelter); document.addEventListener("visibilitychange", () => this.pausieren()); } /** Katalog und Abweichungen zu einer Liste zusammenführen. */ setzeEinstellungen(einstellungen) { this.einstellungen = einstellungen; this.knoten = ENERGIEFLUSS_KATALOG.knoten.map(k => { const e = einstellungen[k.id] || {}; return Object.assign({}, k, { aktiv: k.pflicht || e.aktiv !== false, anzeigeName: e.name || k.name, lage: [e.x ?? k.x, e.y ?? k.y], optionenWirksam: Object.assign({}, k.optionen || {}, e.optionen || {}), }); }); this.nachId = Object.fromEntries(this.knoten.map(k => [k.id, k])); // Wirksamer Elternkreis: der nächste eingeschaltete in der Kette. for (const k of this.knoten) { let e = k.eltern ? this.nachId[k.eltern] : null; while (e && !e.aktiv) e = e.eltern ? this.nachId[e.eltern] : null; k.elternWirksam = e; let tiefe = 0; for (let p = e; p; p = p.elternWirksam) tiefe++; k.tiefe = tiefe; } } /** Antippbar: hat ein Ziel und wird nicht gerade bearbeitet. */ klickbar(k) { return !!k.klick && !this.bearbeiten; } hochkantGewuenscht() { const grenze = this.opt.hochkantUnter ?? HOCHKANT_UNTER; return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < grenze; } /** Lage eines Kreises in der aktuellen Ansicht. */ punkt(k) { return this.hochkant ? [k.lage[1], k.lage[0]] : [k.lage[0], k.lage[1]]; } radius(k) { return k.gross ? RADIUS_GROSS : RADIUS; } sichtbareKnoten() { return this.knoten.filter(k => k.aktiv || this.bearbeiten); } /* ---------------------------------------------------------- Zeichnen */ zeichnen() { this.hochkant = this.hochkantGewuenscht(); this.behaelter.innerHTML = ""; const svg = this.svg = el("svg", { class: "enf" + (this.bearbeiten ? " enf-bearbeiten" : "") + (this.hochkant ? " enf-hoch" : ""), role: "img", "aria-label": "Energiefluss", }, this.behaelter); this.ebeneFluss = el("g", { class: "enf-fluesse" }, svg); this.ebeneKnoten = el("g", { class: "enf-knotenebene" }, svg); this.elemente = {}; for (const k of this.sichtbareKnoten()) this.knotenZeichnen(k); for (const k of this.sichtbareKnoten()) if (k.aktiv && k.elternWirksam) this.flussZeichnen(k); this.rahmenSetzen(); this.pausieren(); if (this.letzte) { const d = this.letzte; this.letzte = null; this.aktualisieren(d); } if (this.opt.onGezeichnet) this.opt.onGezeichnet(); } rahmenSetzen() { const ks = this.sichtbareKnoten(); if (!ks.length) return; let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const k of ks) { const [x, y] = this.punkt(k), r = this.radius(k); x0 = Math.min(x0, x - r - 20); x1 = Math.max(x1, x + r + 20); y0 = Math.min(y0, y - r - 20); y1 = Math.max(y1, y + r + 56); // Name unter dem Kreis } this.svg.setAttribute("viewBox", [x0 - RAND / 2, y0 - RAND / 2, x1 - x0 + RAND, y1 - y0 + RAND].map(Math.round).join(" ")); } knotenZeichnen(k) { const [x, y] = this.punkt(k); const r = this.radius(k); const id = "enf" + this.nummer + "-" + k.id; const g = el("g", { class: "enf-knoten" + (k.aktiv ? "" : " enf-aus") + (this.klickbar(k) ? " enf-klickbar" : ""), transform: `translate(${x} ${y})`, style: `--farbe:${k.farbe};--verz:${k.tiefe * 110}ms`, "data-knoten": k.id, }, this.ebeneKnoten); const koerper = el("g", { class: "enf-koerper" }, g); el("circle", { r: r + 17, class: "enf-hof" }, koerper); el("circle", { r, class: "enf-spur" }, koerper); const ring = el("g", { class: "enf-ring", transform: "rotate(-90)" }, koerper); el("circle", { r: r - 11, class: "enf-scheibe" }, koerper); // Füllstand (Batterie, Auto): Welle, die mit dem Stand steigt. let fuellung = null; if (k.symbol === "akku" || k.symbol === "wallbox") { el("clipPath", { id: id + "-innen" }, el("defs", null, koerper)) .appendChild(el("circle", { r: r - 11 })); const clip = el("g", { "clip-path": `url(#${id}-innen)` }, koerper); fuellung = el("g", { class: "enf-fuellung", style: `--stand:0;--r:${r - 11}` }, clip); const b = r * 4; el("path", { class: "enf-welle", d: `M${-b} 0 q${r / 2} -9 ${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 V${r * 2} H${-b} Z`, }, fuellung); } const symbol = el("g", { class: "enf-symbol enf-sym-" + k.symbol, transform: `translate(0 ${-r * 0.43}) scale(${k.gross ? 1.95 : 1.75})`, }, koerper); symbol.innerHTML = SYMBOLE[k.symbol]().replace(/\{id\}/g, id); // Die kleine Zeile sitzt dicht unter dem Wert: weiter unten wird die // Scheibe schmal, und "89 % entlädt" stößt an den Rand. const wert = el("text", { class: "enf-wert", y: r * 0.2 + 6 }, koerper); const neben = el("text", { class: "enf-neben", y: r * 0.2 + 26 }, koerper); const name = el("text", { class: "enf-name", y: r + 46 }, g); name.textContent = k.anzeigeName; const zeichen = el("g", { class: "enf-zeichen" }, koerper); if (this.klickbar(k)) { const t = el("g", { class: "enf-tipp", transform: `translate(${r * 0.72} ${-r * 0.72})` }, koerper); el("circle", { r: 15 }, t); el("path", { d: "M-3.5-6l6 6-6 6" }, t); g.setAttribute("tabindex", "0"); g.setAttribute("role", "button"); g.setAttribute("aria-label", k.anzeigeName + " – Details"); const los = () => this.opt.onKlick && this.opt.onKlick(k); g.addEventListener("click", los); g.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); los(); } }); } // Bewässerung: Zonen als Zeilen im Kreis. Die Ids gehören solarMQTT.js // und werden nur außerhalb der Einstellungsvorschau vergeben. if (k.zonen) { wert.remove(); neben.remove(); symbol.setAttribute("transform", `translate(0 ${-r * 0.5}) scale(1.4)`); k.zonen.forEach((z, i) => { const zy = -r * 0.1 + i * 25; // Eigenes Symbol (ZONENBILDER) oder ein Zeichen aus Bootstrap Icons. // updateWateringIndicator() färbt es über color und schreibt den // Tooltip in das . let glyph; if (z.bild && ZONENBILDER[z.bild]) { glyph = el("g", { class: "enf-zone-bild", transform: `translate(${(-r * 0.52 - 9.6).toFixed(1)} ${(zy - 9.6).toFixed(1)}) scale(.8)` }, koerper); glyph.innerHTML = ZONENBILDER[z.bild]; } else { glyph = el("text", { class: "enf-zone-glyph", x: -r * 0.52, y: zy }, koerper); glyph.textContent = z.zeichen || ""; } el("title", null, glyph).textContent = z.name; const nm = el("text", { class: "enf-zone-name", x: -r * 0.36, y: zy }, koerper); nm.textContent = z.name; const st = el("text", { class: "enf-zone-status", x: r * 0.6, y: zy }, koerper); st.textContent = "Aus"; if (!this.bearbeiten) { glyph.id = z.symbol; st.id = z.status; } }); } if (this.bearbeiten) this.ziehbarMachen(g, k); this.elemente[k.id] = { g, ring, wert, neben, zeichen, symbol, fuellung, fluss: null, segmente: [] }; } flussPfad(k) { const e = k.elternWirksam; const a = this.punkt(e), b = this.punkt(k); // Knick: quer vom Elternkreis senkrecht los, dann waagerecht in den Kreis // (Ecke x vom Eltern, y vom Kind); hochkant genau umgekehrt. let c; if (Math.abs(a[0] - b[0]) < 30 || Math.abs(a[1] - b[1]) < 30) { c = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; } else { c = this.hochkant ? [b[0], a[1]] : [a[0], b[1]]; } const rand = (p, q, r) => { const dx = q[0] - p[0], dy = q[1] - p[1], l = Math.hypot(dx, dy) || 1; return [p[0] + dx / l * (r + 20), p[1] + dy / l * (r + 20)]; }; const s = rand(a, c, this.radius(e)), z = rand(b, c, this.radius(k)); return `M${s[0].toFixed(1)} ${s[1].toFixed(1)} Q${c[0]} ${c[1]} ${z[0].toFixed(1)} ${z[1].toFixed(1)}`; } flussZeichnen(k) { const d = this.flussPfad(k); const g = el("g", { class: "enf-fluss enf-still", style: `--farbe:${k.farbe};--verz:${k.tiefe * 110 + 150}ms` }, this.ebeneFluss); const bett = el("path", { d, class: "enf-bett", pathLength: "1" }, g); const hof = el("path", { d, class: "enf-strom-hof" }, g); const strom = el("path", { d, class: "enf-strom" }, g); this.elemente[k.id].fluss = { g, bett, hof, strom }; } /* ------------------------------------------------------ Aktualisieren */ aktualisieren(daten) { this.letzte = daten; if (!this.svg) return; const m = leser(daten); for (const k of this.sichtbareKnoten()) { const e = this.elemente[k.id]; if (!e) continue; let z; try { z = k.lesen(m, k.optionenWirksam) || {}; } catch (fehler) { console.warn("Energiefluss: " + k.id, fehler); continue; } this.knotenSetzen(k, e, z); if (e.fluss) this.flussSetzen(e.fluss, z.fluss || 0); } } knotenSetzen(k, e, z) { // Werte const wert = z.wert !== undefined ? z.wert : leistungText(z.leistung || 0); if (e.wert.isConnected && e.wert.textContent !== wert) e.wert.textContent = wert; const neben = z.neben || ""; if (e.neben.isConnected && e.neben.textContent !== neben) e.neben.textContent = neben; e.g.classList.toggle("enf-ruhe", !!z.ruhe && k.aktiv); // Ring: Segmente in Hundertsteln des Umfangs let segmente = z.segmente || (z.leistung !== undefined ? [{ w: Math.abs(z.leistung), farbe: k.farbe }] : []); segmente = this.segmenteZusammenfassen(segmente); const skala = k.skala || 15000; while (e.segmente.length < segmente.length) { e.segmente.push(el("circle", { r: this.radius(k), class: "enf-bogen", pathLength: "100" }, e.ring)); } let start = 0; e.segmente.forEach((kreis, i) => { const s = segmente[i]; let laenge = s ? Math.max(0, Math.min(100 - start, (s.w / skala) * 100)) : 0; if (laenge < 0.4) laenge = 0; const luecke = laenge > 0 && start > 0 ? 0.6 : 0; const a = (laenge - luecke).toFixed(2), off = (-(start + luecke)).toFixed(2); const schluessel = a + "|" + off + "|" + (s ? s.farbe : ""); if (kreis._stand !== schluessel) { kreis._stand = schluessel; kreis.style.strokeDasharray = `${Math.max(0, a)} 100`; kreis.style.strokeDashoffset = off; if (s) kreis.style.stroke = s.farbe; } start += laenge; }); // Zustand des Symbols const zu = z.zustand || {}; e.g.classList.toggle("enf-an", !!zu.an); e.g.classList.toggle("enf-laden", !!zu.laden); if (zu.stand !== undefined && isFinite(zu.stand)) { const s = Math.max(0, Math.min(1, zu.stand)).toFixed(3); if (e.g.style.getPropertyValue("--stand") !== s) { e.g.style.setProperty("--stand", s); if (e.fuellung) e.fuellung.style.setProperty("--stand", s); } } if (zu.temps) { zu.temps.forEach((t, i) => { const r = e.symbol.querySelector(".enf-s-t" + i); const f = tempFarbe(t); if (r && r._f !== f) { r._f = f; r.style.fill = f; } }); } // Plaketten const zeichen = z.zeichen || []; const sig = zeichen.map(x => x.art + ":" + (x.text || "") + ":" + (x.titel || "")).join("|"); if (e.zeichen._sig !== sig) { e.zeichen._sig = sig; this.zeichenSetzen(k, e.zeichen, zeichen); } } /** Anteile ausgeschalteter Kreise fallen ins letzte Segment. */ segmenteZusammenfassen(segmente) { const aus = segmente.filter(s => s.knoten && this.nachId[s.knoten] && !this.nachId[s.knoten].aktiv); if (!aus.length) return segmente; const rest = segmente.filter(s => !aus.includes(s)).map(s => Object.assign({}, s)); if (rest.length) rest[rest.length - 1].w += aus.reduce((a, s) => a + Math.max(0, s.w), 0); return rest; } zeichenSetzen(k, gruppe, zeichen) { gruppe.innerHTML = ""; const r = this.radius(k); zeichen.forEach((z, i) => { const art = ZEICHEN[z.art]; if (!art) return; // Außen am Ring rechts unten, eine Plakette je 36 Grad - so bleiben // Wert und Zeile darunter frei. const winkel = (88 + i * 36) * Math.PI / 180; const px = Math.sin(winkel) * (r + 12), py = -Math.cos(winkel) * (r + 12); const p = el("g", { class: "enf-plakette", transform: `translate(${px.toFixed(1)} ${py.toFixed(1)})`, style: `--pfarbe:${art.farbe};--i:${i}` }, gruppe); if (z.titel) el("title", null, p).textContent = z.titel; if (z.text) { const breite = 26 + z.text.length * 10; el("rect", { x: -16, y: -16, width: breite, height: 32, rx: 16 }, p); el("text", { class: "enf-plakette-text", x: 12, y: 1 }, p).textContent = z.text; } else { el("circle", { r: 16 }, p); } el("text", { class: "enf-plakette-glyph", x: 0, y: 1 }, p).textContent = art.glyph; }); } flussSetzen(f, w) { const a = Math.abs(w); const still = a < 15; const breite = still ? 0 : 3.5 + 6.5 * (1 - Math.exp(-a / 3000)); // Schneller bei mehr Leistung: 2,6 s je Abschnitt bei wenig, 0,7 s bei viel. const dauer = still ? 0 : 0.7 + 1.9 * Math.exp(-a / 3000); const sig = still ? "still" : breite.toFixed(1) + "|" + dauer.toFixed(2) + "|" + (w < 0); if (f._sig === sig) return; f._sig = sig; f.g.classList.toggle("enf-still", still); f.g.classList.toggle("enf-rueck", w < 0); f.g.style.setProperty("--breite", breite.toFixed(1)); f.g.style.setProperty("--dauer", dauer.toFixed(2) + "s"); } pausieren() { if (!this.svg) return; this.svg.classList.toggle("enf-pause", !this.sichtbar || document.hidden); } /* -------------------------------------------------------- Bearbeiten */ ziehbarMachen(g, k) { g.classList.add("enf-ziehbar"); g.addEventListener("pointerdown", ev => { ev.preventDefault(); g.setPointerCapture(ev.pointerId); const pt = this.svg.createSVGPoint(); const svgPunkt = e2 => { pt.x = e2.clientX; pt.y = e2.clientY; return pt.matrixTransform(this.svg.getScreenCTM().inverse()); }; const start = svgPunkt(ev); const [x0, y0] = k.lage; g.classList.add("enf-zieht"); const bewegen = e2 => { const p = svgPunkt(e2); k.lage = [Math.round((x0 + p.x - start.x) / 10) * 10, Math.round((y0 + p.y - start.y) / 10) * 10]; g.setAttribute("transform", `translate(${k.lage[0]} ${k.lage[1]})`); for (const kind of this.knoten) { const f = this.elemente[kind.id] && this.elemente[kind.id].fluss; if (f && (kind === k || kind.elternWirksam === k)) { const d = this.flussPfad(kind); f.bett.setAttribute("d", d); f.hof.setAttribute("d", d); f.strom.setAttribute("d", d); } } }; const ende = () => { g.removeEventListener("pointermove", bewegen); g.classList.remove("enf-zieht"); this.rahmenSetzen(); if (this.opt.onVerschoben) this.opt.onVerschoben(k.id, k.lage[0], k.lage[1]); }; g.addEventListener("pointermove", bewegen); g.addEventListener("pointerup", ende, { once: true }); g.addEventListener("pointercancel", ende, { once: true }); }); } } window.Energiefluss = Energiefluss; })();