/* ==========================================================================
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);
const wert = el("text", { class: "enf-wert", y: r * 0.2 + 6 }, koerper);
const neben = el("text", { class: "enf-neben", y: r * 0.2 + 33 }, 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;
})();