Solar: neue Energiefluss-Uebersicht, anpassbar je Haus

Das feste realtime.svg ist ersetzt durch einen Katalog
(js/solar/energieflussKatalog.js: jeder Kreis einmal, samt Werten aus MQTT)
und einen Renderer (js/solar/energiefluss.js): Ringe mit Segmenten,
Fluesse als Boegen mit laufenden Punkten, Fuellstandswelle fuer Batterie
und Auto, animierte Symbole, Plaketten, Hochkant-Ansicht auf schmalen
Schirmen. Hierarchie und Lage wie bisher: Quellen -> Wechselrichter ->
Verbrauch -> OG/EG/UG -> Wallboxen, Heizstab.

Einstellungen -> Uebersicht: Kreise ein/aus (Kinder ruecken eine Ebene
hoeher), Namen, Lage per Ziehen in der Vorschau, PV-Flaechen. Gespeichert
werden nur Abweichungen in homeMesh.energiefluss.

Der PV-Lageplan hinter der Sonne liegt unveraendert als assets/img/pvplan.svg
und ist ueber den Umschalter Uebersicht/Anlage erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:42:52 +02:00
co-authored by Claude Opus 5
parent 5ed3a0796e
commit a59277e33e
14 changed files with 1812 additions and 1148 deletions
+556
View File
@@ -0,0 +1,556 @@
/* ==========================================================================
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
});
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: () => `
<g class="enf-s-strahlen">
${[0, 45, 90, 135, 180, 225, 270, 315].map(w =>
`<path d="M0-8.2V-11" transform="rotate(${w})"/>`).join("")}
</g>
<circle r="4.6" class="enf-s-kern"/>`,
netz: () => `
<path d="M-5 11.5L-1.6-10.5h3.2L5 11.5"/>
<path d="M-9.5-5.5h19M-7-1h14"/>
<path d="M-8.5-5.5v2.4M8.5-5.5v2.4M-6-1v2.4M6-1v2.4"/>
<path d="M-3.3 3.5l6.8 8M3.3 3.5l-6.8 8" class="enf-s-fein"/>`,
akku: () => `
<rect x="-6.5" y="-8.5" width="13" height="20" rx="2.4"/>
<path d="M-2.6-11h5.2"/>
<rect x="-4" y="-6" width="8" height="15" rx="1" class="enf-s-stand"/>
<path d="M1.2-4.2l-4 5.6h3l-1.4 4.6 4.2-6h-3z" class="enf-s-blitz"/>`,
wechselrichter: () => `
<rect x="-10.5" y="-10.5" width="21" height="21" rx="3.5"/>
<path d="M-10.5 10.5l21-21" class="enf-s-fein"/>
<path d="M-7.5-5.2h5M-7.5-2.4h5"/>
<path d="M1.8 4.2c1-1.7 2.1-1.7 3.1 0s2.1 1.7 3.1 0" class="enf-s-welle"/>`,
verbrauch: () => `
<path d="M-11-1.5L0-10.5l11 9"/>
<path d="M-8-4v14.5h16V-4"/>
<path d="M1.3-3.8l-4.4 6.2h3.3l-1.6 5 4.6-6.6H-.1z" class="enf-s-blitz enf-s-an"/>`,
etageOben: () => etage(-4.5, 1.5),
etageMitte: () => etage(1.5, 7.5),
etageUnten: () => etage(7.5, 11.5),
wallbox: () => `
<rect x="-10.5" y="-10.5" width="11" height="21" rx="2.2"/>
<rect x="-8" y="-7.5" width="6" height="4" rx=".8" class="enf-s-fein"/>
<path d="M-6.4 1.2l-2 3.5h2.2l-1 3.2 2.9-4.2h-2.2z" class="enf-s-blitz"/>
<path d="M.5 2h2.8a2.3 2.3 0 0 1 2.3 2.3v3.9a1.9 1.9 0 0 0 3.8 0V-.5" class="enf-s-kabel"/>
<path d="M7.2-4.6h3.6v2.3a1.8 1.8 0 0 1-3.6 0z"/>
<path d="M8.2-4.6v-2M9.8-4.6v-2"/>`,
speicher: () => `
<defs><clipPath id="{id}-tank"><rect x="-6.8" y="-10.8" width="13.6" height="19.6" rx="5.2"/></clipPath></defs>
<g clip-path="url(#{id}-tank)" class="enf-s-temps">
<rect x="-8" y="-12" width="16" height="7.6" class="enf-s-t0"/>
<rect x="-8" y="-4.4" width="16" height="6.6" class="enf-s-t1"/>
<rect x="-8" y="2.2" width="16" height="7.6" class="enf-s-t2"/>
</g>
<rect x="-7" y="-11" width="14" height="20" rx="5.4"/>
<path d="M-3.4 5.6l1.1-1.8 1.2 1.8 1.1-1.8 1.2 1.8 1.1-1.8" class="enf-s-spirale"/>
<path d="M-4 9v2.5M4 9v2.5"/>`,
tropfen: () => `
<path d="M0-11c4.6 5.5 7 9 7 12.6a7 7 0 0 1-14 0C-7-2-4.6-5.5 0-11z"/>
<path d="M-3.6 3.4c1.2 1 2.4 1 3.6 0s2.4-1 3.6 0" class="enf-s-welle"/>`,
};
function etage(von, bis) {
return `
<rect x="-8" y="${von}" width="16" height="${bis - von}" class="enf-s-ebene"/>
<path d="M-11.5-3L0-11l11.5 8"/>
<path d="M-8-5v12.5h16V-5"/>
<path d="M-8 1.5h16" class="enf-s-fein"/>
<path d="M-12 7.5h24"/>
<path d="M-8 7.5v4h16v-4" class="enf-s-fein enf-s-keller"/>`;
}
/* Plaketten am Kreis: Zeichen aus Bootstrap Icons (liegt ohnehin im Dashboard). */
const ZEICHEN = {
eco: { glyph: "奈", farbe: "#6cbe58" }, // bi-leaf
uhr: { glyph: "", farbe: "#e8a33a" }, // bi-clock-fill
manuell: { glyph: "", farbe: "#c9a227" }, // bi-sliders
stecker: { glyph: "", farbe: "#8fb8de" }, // bi-plug-fill
schloss: { glyph: "", farbe: "#b9c0c8" }, // bi-lock-fill
heizung: { glyph: "", farbe: "#e5624f" }, // bi-fire
tank: { glyph: "", farbe: "#b9c0c8" }, // bi-fuel-pump-fill
warnung: { glyph: "", 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;
}
}
hochkantGewuenscht() {
return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < HOCHKANT_UNTER;
}
/** 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") + ((k.klick && !this.bearbeiten) ? " 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 (k.klick && !this.bearbeiten) {
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;
const 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;
})();
+279
View File
@@ -0,0 +1,279 @@
/* ==========================================================================
Reiter "Übersicht": die Energiefluss-Übersicht an dieses Haus anpassen.
Links die Übersicht selbst im Bearbeitungsmodus (energiefluss.js) mit
Beispielwerten, rechts je Kreis Schalter und Name, beim PV-Kreis die
Flächen. Verschoben wird in der Vorschau. Gespeichert wird der ganze Stand
auf einmal (restricted/energiefluss.php hält davon nur die Abweichungen).
Nutzt textSchuetzen() aus settings.js.
========================================================================== */
(function () {
"use strict";
// Beispielwerte: ein sonniger Nachmittag, Auto lädt im EG, Heizstab läuft.
const BEISPIEL = {
"solarManager/P_PV": "8400", "solarManager/P_PVn": JSON.stringify([2600, 1300, 130, 130, 140, 140, 130, 130, 135, 135, 130, 130, 120, 120,
155, 150, 152, 158, 150, 149, 150, 154, 151, 150, 153, 155, 150, 92, 150, 152, 151, 150, 149, 0]),
"solarManager/P_Grid": "-900", "solarManager/i_l1evu": "-1.3", "solarManager/i_l2evu": "-1.4", "solarManager/i_l3evu": "-1.2",
"solarManager/P_Akku": "-2100", "solarManager/SOC": "64", "solarManager/crgMaxPct": "100",
"solarManager/autarky": "100", "solarManager/eff": "96",
"solarManager/P_Load": "-5400", "solarManager/og": "-350", "solarManager/eg": "3720", "solarManager/ug": "270",
"solarManager/pHeat": "1000", "solarManager/t_buffT": "61", "solarManager/t_buffM": "48", "solarManager/t_buffB": "35",
"solarManager/heatMode": "eco", "solarManager/heatOG": "1", "solarManager/heatEG": "0",
"solarManager/evPower": "3.2", "solarManager/evSOC": "78", "solarManager/evFuel": "0", "solarManager/evMode": "Eco",
"solarManager/evPlug": "1", "solarManager/evLock": "1", "solarManager/carRemChrg": "95",
"solarManager/evPowerOG": "0", "solarManager/evPlugOG": "0", "solarManager/evModeOG": "Eco",
};
let bild = null;
let gespeichert = {}; // Stand der Datenbank
let stand = {}; // Stand der Maske
let geladen = false;
const $ = id => document.getElementById(id);
const katalog = () => ENERGIEFLUSS_KATALOG.knoten;
const kopie = o => JSON.parse(JSON.stringify(o));
function hinweis(text, klasse) {
const ziel = $("energieflussHinweis");
if (!ziel) return;
ziel.className = "small " + (klasse || "text-body-secondary");
ziel.textContent = text;
}
function geaendert() {
const sauber = s => JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]));
const anders = sauber(normalisiert(stand)) !== sauber(normalisiert(gespeichert));
hinweis(anders ? "Nicht gespeichert." : "", anders ? "text-warning" : "");
}
/** Nur echte Abweichungen, in fester Form - zum Vergleichen und Schicken. */
function normalisiert(s) {
const aus = {};
for (const k of katalog()) {
const e = s[k.id] || {};
const eintrag = {
aktiv: k.pflicht ? true : e.aktiv !== false,
name: (e.name || "").trim() || null,
x: e.x ?? null, y: e.y ?? null,
optionen: e.optionen || null,
};
if (eintrag.x === k.x && eintrag.y === k.y) { eintrag.x = null; eintrag.y = null; }
if (!eintrag.aktiv || eintrag.name || eintrag.x !== null || eintrag.optionen) aus[k.id] = eintrag;
}
return aus;
}
function vorschauZeichnen() {
if (!bild) {
bild = new Energiefluss($("energieflussVorschau"), {
bearbeiten: true,
einstellungen: stand,
onVerschoben(id, x, y) {
stand[id] = Object.assign({}, stand[id], { x, y });
listeZeichnen();
geaendert();
},
});
}
bild.setzeEinstellungen(normalisiert(stand));
bild.zeichnen();
bild.aktualisieren(BEISPIEL);
}
/* ------------------------------------------------------------ Liste */
function listeZeichnen() {
const ziel = $("energieflussListe");
const wirksam = bild ? bild.nachId : {};
ziel.innerHTML = katalog().map(k => {
const e = stand[k.id] || {};
const aktiv = k.pflicht || e.aktiv !== false;
const w = wirksam[k.id];
const eltern = w && w.elternWirksam;
const elternName = eltern ? (stand[eltern.id] && stand[eltern.id].name) || eltern.titel || eltern.name : null;
const verschoben = e.x != null && (e.x !== k.x || e.y !== k.y);
const flaechen = k.id === "pv" && aktiv ? flaechenZeichnen(k, e) : "";
return `
<div class="enf-e-zeile${aktiv ? "" : " enf-e-aus"}" data-id="${k.id}">
<div class="form-check form-switch m-0" title="${k.pflicht ? "Gehört immer dazu" : "Ein- oder ausschalten"}">
<input class="form-check-input" type="checkbox" id="enf-aktiv-${k.id}" data-feld="aktiv"
${aktiv ? "checked" : ""} ${k.pflicht ? "disabled" : ""}>
</div>
<label class="enf-e-name" for="enf-name-${k.id}">
<span class="enf-e-punkt" style="--farbe:${k.farbe}"></span>
<input class="form-control form-control-sm" id="enf-name-${k.id}" data-feld="name" maxlength="40"
placeholder="${textSchuetzen(k.name)}" title="${textSchuetzen(k.titel || k.name)}" value="${textSchuetzen(e.name || "")}" ${aktiv ? "" : "disabled"}>
</label>
<span class="enf-e-eltern small text-body-secondary">${elternName ? "an " + textSchuetzen(elternName) : k.eltern ? "" : "frei"}</span>
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="lage"
title="Lage aus dem Katalog" ${verschoben ? "" : "hidden"}><i class="bi bi-arrow-counterclockwise"></i></button>
${flaechen}
</div>`;
}).join("");
}
function flaechenZeichnen(k, e) {
const liste = (e.optionen && e.optionen.flaechen) || k.optionen.flaechen;
const eigen = !!(e.optionen && e.optionen.flaechen);
return `
<div class="enf-e-flaechen">
<div class="small text-body-secondary mb-1">
Flächen im Sonnenring welche Einträge von <code>P_PVn</code> dazugehören
${eigen ? `<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-aktion="flaechen-zurueck">Katalog</button>` : ""}
</div>
${liste.map((f, i) => `
<div class="enf-e-flaeche" data-index="${i}">
<input type="color" class="form-control form-control-sm form-control-color" data-flaeche="farbe" value="${f.farbe}" title="Farbe">
<input class="form-control form-control-sm" data-flaeche="name" value="${textSchuetzen(f.name)}" placeholder="Name" maxlength="20">
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="von" value="${f.von}" title="erster Eintrag">
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="bis" value="${f.bis}" title="letzter Eintrag">
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="flaeche-weg" title="Fläche entfernen"><i class="bi bi-x-lg"></i></button>
</div>`).join("")}
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-aktion="flaeche-neu"><i class="bi bi-plus-lg me-1"></i>Fläche</button>
</div>`;
}
function flaechenLesen(zeile) {
return [...zeile.querySelectorAll(".enf-e-flaeche")].map(r => ({
name: r.querySelector('[data-flaeche="name"]').value.trim(),
von: Math.max(0, parseInt(r.querySelector('[data-flaeche="von"]').value, 10) || 0),
bis: Math.max(0, parseInt(r.querySelector('[data-flaeche="bis"]').value, 10) || 0),
farbe: r.querySelector('[data-flaeche="farbe"]').value,
}));
}
function flaechenSetzen(id, flaechen) {
const e = stand[id] = Object.assign({}, stand[id]);
e.optionen = Object.assign({}, e.optionen, { flaechen });
}
/* --------------------------------------------------------- Ereignisse */
function binden() {
const liste = $("energieflussListe");
liste.addEventListener("change", ev => {
const zeile = ev.target.closest(".enf-e-zeile");
if (!zeile) return;
const id = zeile.dataset.id;
if (ev.target.dataset.feld === "aktiv") {
stand[id] = Object.assign({}, stand[id], { aktiv: ev.target.checked });
vorschauZeichnen();
listeZeichnen();
} else if (ev.target.dataset.flaeche) {
flaechenSetzen(id, flaechenLesen(zeile));
vorschauZeichnen();
}
geaendert();
});
liste.addEventListener("input", ev => {
const zeile = ev.target.closest(".enf-e-zeile");
if (!zeile || ev.target.dataset.feld !== "name") return;
stand[zeile.dataset.id] = Object.assign({}, stand[zeile.dataset.id], { name: ev.target.value });
clearTimeout(binden._uhr);
binden._uhr = setTimeout(vorschauZeichnen, 300);
geaendert();
});
liste.addEventListener("click", ev => {
const knopf = ev.target.closest("[data-aktion]");
if (!knopf) return;
const zeile = knopf.closest(".enf-e-zeile");
const id = zeile.dataset.id;
switch (knopf.dataset.aktion) {
case "lage":
stand[id] = Object.assign({}, stand[id], { x: null, y: null });
break;
case "flaeche-neu": {
const f = flaechenLesen(zeile);
const bis = f.length ? f[f.length - 1].bis + 1 : 0;
f.push({ name: "Fläche " + (f.length + 1), von: bis, bis, farbe: "#f7c000" });
flaechenSetzen(id, f);
break;
}
case "flaeche-weg": {
const f = flaechenLesen(zeile);
f.splice(Number(knopf.closest(".enf-e-flaeche").dataset.index), 1);
flaechenSetzen(id, f);
break;
}
case "flaechen-zurueck":
if (stand[id] && stand[id].optionen) {
const o = Object.assign({}, stand[id].optionen);
delete o.flaechen;
stand[id].optionen = Object.keys(o).length ? o : null;
}
break;
default:
return;
}
vorschauZeichnen();
listeZeichnen();
geaendert();
});
$("energieflussSpeichern").addEventListener("click", speichern);
$("energieflussNeuLaden").addEventListener("click", () => {
stand = kopie(gespeichert);
vorschauZeichnen(); listeZeichnen(); geaendert();
});
$("energieflussZuruecksetzen").addEventListener("click", () => {
stand = {};
vorschauZeichnen(); listeZeichnen(); geaendert();
});
}
async function speichern() {
const knopf = $("energieflussSpeichern");
knopf.disabled = true;
try {
const n = normalisiert(stand);
const knoten = Object.keys(n).map(id => Object.assign({ id }, n[id]));
const antwort = await fetch("ajax/settings.php?action=energiefluss-save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ knoten }),
});
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
gespeichert = d.einstellungen || {};
stand = kopie(gespeichert);
vorschauZeichnen(); listeZeichnen();
hinweis("Gespeichert die Solar-Seite zeigt es beim nächsten Laden.", "text-success");
} catch (e) {
hinweis(e.message, "text-danger");
} finally {
knopf.disabled = false;
}
}
async function laden() {
if (geladen) return;
geladen = true;
try {
const antwort = await fetch("ajax/settings.php?action=energiefluss", { cache: "no-store" });
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
gespeichert = d.einstellungen || {};
stand = kopie(gespeichert);
binden();
vorschauZeichnen();
listeZeichnen();
} catch (e) {
geladen = false;
$("energieflussListe").innerHTML = '<div class="alert alert-danger mb-0">Übersicht nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
document.addEventListener("DOMContentLoaded", () => {
if (!$("energieflussVorschau") || typeof Energiefluss === "undefined") return;
// Erst zeichnen, wenn der Reiter offen ist - verborgen hat die Vorschau keine Größe.
const knopf = $("einstellung-energiefluss-tab");
if (knopf) knopf.addEventListener("shown.bs.tab", laden);
if ($("einstellung-energiefluss").classList.contains("active")) laden();
// Ein Anker in der Adresse öffnet den Reiter erst nach DOMContentLoaded.
setTimeout(() => { if ($("einstellung-energiefluss").classList.contains("active")) laden(); }, 0);
});
})();
+290
View File
@@ -0,0 +1,290 @@
/* ==========================================================================
Katalog der Energiefluss-Übersicht (Solar-Seite, Karte "Realtime").
Jeder Kreis steht hier genau einmal: wie er heißt, wie er aussieht, woran
er hängt, wo er steht und woher seine Werte kommen. Gezeichnet wird er von
js/solar/energiefluss.js, ohne dass der Renderer einen Kreis beim Namen
kennt. Ein neuer Kreis ist also ein neuer Eintrag hier - sonst nichts.
Was ein Haus davon abweichend einstellt (Kreis aus, eigener Name, eigene
Lage, Optionen), steht in homeMesh.energiefluss und wird unter
Einstellungen -> Übersicht gepflegt.
Felder eines Eintrags:
id feste Kennung, steht in der Datenbank
name Beschriftung unter dem Kreis
titel Bezeichnung in den Einstellungen, falls name dort missverständlich ist
symbol Schlüssel in ENERGIEFLUSS_SYMBOLE (energiefluss.js)
farbe Hauptfarbe: Ring, Symbol, Fluss
eltern Kennung des Kreises, mit dem er über einen Fluss verbunden
ist; null = frei stehend. Ist der Elternkreis aus, hängt der
Kreis am nächsten eingeschalteten darüber.
pflicht lässt sich nicht ausschalten (die Mitte des Bildes)
gross größerer Kreis
x, y Mitte in der Queransicht (Zeichenfläche etwa 1400 x 850)
skala Leistung in W, bei der der Ring voll ist
klick {ansicht: "anlage"} oder {modal: "heater"} - Ziel beim Antippen
optionen Vorgabe für die Optionen, die in den Einstellungen änderbar sind
zonen nur Bewässerung: die Anzeigen, die solarMQTT.js beschreibt
lesen(m, opt) liefert den Zustand aus den MQTT-Werten:
leistung W, bestimmt den Hauptwert und den Ring
wert Text statt der Leistung (optional)
neben kleine Zeile unter dem Wert
segmente [{w, farbe, knoten?}] statt eines einzigen Rings; knoten =
der Anteil gehört zu diesem Kreis und fällt, ist der aus, in
das letzte Segment
fluss W vom Elternkreis zu diesem hin; negativ = in Gegenrichtung
ruhe Kreis blass zeichnen (nichts los)
zeichen kleine Plaketten: [{art, text?, titel?}] - art siehe
ENERGIEFLUSS_ZEICHEN in energiefluss.js
zustand was das Symbol braucht: {an, stand, laden, temps}
Vorzeichen wie im Manager: P_Grid > 0 Bezug, P_Akku > 0 Entladen,
P_Load und og negativ.
========================================================================== */
const ENERGIEFLUSS_KATALOG = (function () {
"use strict";
const kw = w => (Math.abs(w) / 1000).toFixed(1).replace(".", ",");
const wallboxZeichen = (modus, stecker, gesperrt) => {
const z = [];
if (modus === "Eco") z.push({ art: "eco", titel: "Eco-Modus" });
else if (modus === "Next Trip") z.push({ art: "uhr", titel: "Nächste Fahrt" });
else if (modus !== undefined) z.push({ art: "manuell", titel: "Standard-Modus" });
if (stecker) z.push({ art: "stecker", titel: "Fahrzeug angesteckt" });
if (gesperrt) z.push({ art: "schloss", titel: "Fahrzeug verriegelt" });
return z;
};
const knoten = [
// ---------------------------------------------------------------- Quellen
{
id: "pv", name: "Sonne", symbol: "sonne", farbe: "#f7c000",
eltern: "wr", x: 180, y: 130, skala: 15000,
klick: { ansicht: "anlage" },
// Welche Einträge von solarManager/P_PVn zu welcher Fläche gehören.
// Reihenfolge wie in solarManager.py.
optionen: {
flaechen: [
{ name: "Dach", von: 0, bis: 1, farbe: "#f7c000" },
{ name: "Veranda", von: 2, bis: 13, farbe: "#f78d00" },
{ name: "Carport", von: 14, bis: 33, farbe: "#f75a1e" },
],
},
lesen(m, opt) {
const pvn = m.json("solarManager/P_PVn") || [];
const pv = m.zahl("solarManager/P_PV");
const flaechen = (opt.flaechen || []).map(f => {
let w = 0;
for (let i = f.von; i <= f.bis && i < pvn.length; i++) w += Number(pvn[i]) || 0;
return { w, farbe: f.farbe, name: f.name };
});
return {
leistung: pv,
segmente: flaechen.length ? flaechen : null,
neben: flaechen.map(f => kw(f.w)).join(" · "),
fluss: -pv,
ruhe: pv < 20,
zustand: { an: pv >= 20, stand: Math.min(1, pv / 10000) },
};
},
},
{
id: "netz", name: "Netz", symbol: "netz", farbe: "#9a9aab",
eltern: "wr", x: 100, y: 380, skala: 15000,
lesen(m) {
const netz = m.zahl("solarManager/P_Grid");
const a = n => (Math.round(Math.abs(m.zahl("solarManager/i_" + n + "evu")) * 10) / 10).toString().replace(".", ",");
return {
leistung: Math.abs(netz),
neben: a("l1") + "·" + a("l2") + "·" + a("l3") + " A",
fluss: -netz,
ruhe: Math.abs(netz) < 30,
};
},
},
{
id: "akku", name: "Batterie", symbol: "akku", farbe: "#6cbe58",
eltern: "wr", x: 180, y: 630, skala: 15000,
lesen(m) {
const p = m.zahl("solarManager/P_Akku");
const soc = m.zahl("solarManager/SOC");
const grenze = m.zahl("solarManager/crgMaxPct");
const laden = p < -20;
const zeichen = [];
if (laden && grenze < 100) {
zeichen.push({ art: "uhr", text: Math.round(grenze) + " %", titel: "Ladeleistung begrenzt" });
}
return {
leistung: Math.abs(p),
neben: Math.round(soc) + " %" + (laden ? " · lädt" : p > 20 ? " · entlädt" : ""),
fluss: -p,
ruhe: Math.abs(p) < 20,
zeichen,
zustand: { stand: soc / 100, laden, an: Math.abs(p) >= 20 },
};
},
},
// ------------------------------------------------------------------ Mitte
{
id: "wr", name: "Autarkie", titel: "Wechselrichter (Autarkie)", symbol: "wechselrichter", farbe: "#b9c0c8",
eltern: null, pflicht: true, gross: true, x: 430, y: 380, skala: 100,
lesen(m) {
const aut = m.zahl("solarManager/autarky");
return {
leistung: aut,
wert: Math.round(aut) + " %",
neben: "Wirkungsgrad " + Math.round(m.zahl("solarManager/eff")) + " %",
segmente: [{ w: aut, farbe: "#6cbe58" }],
zustand: { an: m.zahl("solarManager/P_PV") >= 20 },
};
},
},
{
id: "verbrauch", name: "Verbrauch", symbol: "verbrauch", farbe: "#71afcd",
eltern: "wr", pflicht: true, gross: true, x: 770, y: 380, skala: 15000,
lesen(m) {
const last = -m.zahl("solarManager/P_Load");
const og = -m.zahl("solarManager/og");
const eg = m.zahl("solarManager/eg");
const ug = m.zahl("solarManager/ug");
const heiz = m.zahl("solarManager/pHeat");
const gemein = Math.max(0, last - ug - eg - og);
return {
leistung: last,
neben: "Gemein " + kw(gemein) + " kW",
segmente: [
{ w: og, farbe: "#70ccbb", knoten: "og" },
{ w: eg, farbe: "#70cc9c", knoten: "eg" },
{ w: ug, farbe: "#83a55b", knoten: "ug" },
{ w: heiz, farbe: "#c2614e", knoten: "heizstab" },
{ w: gemein, farbe: "#71afcd" },
],
fluss: last,
};
},
},
// --------------------------------------------------------------- Etagen
{
id: "og", name: "Obergeschoss", symbol: "etageOben", farbe: "#70ccbb",
eltern: "verbrauch", x: 920, y: 70, skala: 15000,
lesen(m) {
const w = -m.zahl("solarManager/og");
return {
leistung: w, fluss: w, ruhe: w < 20,
zeichen: m.zahl("solarManager/heatOG") ? [{ art: "heizung", titel: "Heizung an" }] : [],
};
},
},
{
id: "eg", name: "Erdgeschoss", symbol: "etageMitte", farbe: "#70cc9c",
eltern: "verbrauch", x: 920, y: 670, skala: 15000,
lesen(m) {
const w = m.zahl("solarManager/eg");
const wb = m.zahl("solarManager/evPower") * 1000;
return {
leistung: w, fluss: w, ruhe: w < 20,
segmente: [
{ w: Math.max(0, w - wb), farbe: "#70cc9c" },
{ w: Math.min(w, wb), farbe: "#60afbf", knoten: "wallboxEG" },
],
zeichen: m.zahl("solarManager/heatEG") ? [{ art: "heizung", titel: "Heizung an" }] : [],
};
},
},
{
id: "ug", name: "Untergeschoss", symbol: "etageUnten", farbe: "#83a55b",
eltern: "verbrauch", x: 600, y: 670, skala: 15000,
lesen(m) {
const w = m.zahl("solarManager/ug");
return { leistung: w, fluss: w, ruhe: w < 20 };
},
},
// ----------------------------------------------------------- Verbraucher
{
id: "heizstab", name: "Heizstab", symbol: "speicher", farbe: "#c2614e",
eltern: "verbrauch", x: 1120, y: 380, skala: 6000,
klick: { modal: "heater" },
lesen(m) {
const w = m.zahl("solarManager/pHeat");
const oben = m.zahl("solarManager/t_buffT");
const mitte = m.zahl("solarManager/t_buffM");
const unten = m.zahl("solarManager/t_buffB");
const eco = m.text("solarManager/heatMode") === "eco";
const grad = t => (Math.round(t * 10) / 10).toString().replace(".", ",");
return {
leistung: w, fluss: w, ruhe: w < 20,
neben: grad(oben) + " · " + grad(mitte) + " °C",
zeichen: [eco ? { art: "eco", titel: "Automatik (Überschuss)" } : { art: "manuell", titel: "Feste Leistung" }],
zustand: { an: w >= 20, temps: [oben, mitte, unten] },
};
},
},
{
id: "wallboxOG", name: "Wallbox OG", symbol: "wallbox", farbe: "#4b71a6",
eltern: "og", x: 1220, y: 70, skala: 11000,
klick: { modal: "CarOG" },
lesen(m) {
const w = m.zahl("solarManager/evPowerOG") * 1000;
const stecker = m.zahl("solarManager/evPlugOG") > 0;
return {
leistung: w, fluss: w, ruhe: w < 50 && !stecker,
neben: w >= 50 ? "lädt" : stecker ? "angesteckt" : "kein Auto",
zeichen: wallboxZeichen(m.text("solarManager/evModeOG"), stecker, false),
zustand: { an: w >= 50 },
};
},
},
{
id: "wallboxEG", name: "Wallbox EG", symbol: "wallbox", farbe: "#60afbf",
eltern: "eg", x: 1220, y: 670, skala: 11000,
klick: { modal: "CarEG" },
lesen(m) {
const w = m.zahl("solarManager/evPower") * 1000;
const soc = m.zahl("solarManager/evSOC");
const tank = m.zahl("solarManager/evFuel");
// solarManager/evPlug kommt als 1/0; aeltere Manager schickten den
// Text der Wallbox ("no car", "charging", ...).
const roh = m.text("solarManager/evPlug");
const stecker = isNaN(Number(roh)) ? (roh !== undefined && roh !== "no car") : Number(roh) > 0;
let neben = Math.round(soc) + " %";
const rest = m.zahl("solarManager/carRemChrg");
if (w >= 50 && rest > -1 && m.text("solarManager/carRemChrg") !== undefined) {
const fertig = new Date(Date.now() + rest * 60000);
neben = "fertig " + String(fertig.getHours()).padStart(2, "0") + ":" + String(fertig.getMinutes()).padStart(2, "0");
}
const zeichen = wallboxZeichen(m.text("solarManager/evMode"), stecker, m.zahl("solarManager/evLock") > 0);
if (tank > 0) zeichen.push({ art: "tank", text: Math.round(tank) + " %", titel: "Tank" });
return {
leistung: w, fluss: w, ruhe: w < 50 && !stecker,
neben, zeichen,
zustand: { an: w >= 50, stand: soc / 100 },
};
},
},
// ------------------------------------------------------------- frei
{
id: "bewaesserung", name: "Bewässerung", symbol: "tropfen", farbe: "#4b8fd6",
eltern: null, x: 550, y: 70,
klick: { modal: "watering" },
// Ids wie in wateringZones (solarMQTT.js) - dort werden Farbe, Text und
// Tooltip gesetzt, hier nur die Stellen dafür angelegt.
zonen: [
{ name: "Tröge", zeichen: "", status: "wateringTroughStatus", symbol: "wateringTroughIcon" },
{ name: "Vorn", zeichen: "", status: "wateringFrontStatus", symbol: "wateringFrontIcon" },
{ name: "Beete", zeichen: "", status: "wateringBedsStatus", symbol: "wateringBedsIcon" },
],
lesen() {
return { wert: "" };
},
},
];
return { knoten };
})();
+64 -235
View File
@@ -142,255 +142,84 @@ function setFlowWidth(id, width) {
flow.style.willChange = sichtbar ? "transform" : "auto";
}
// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und der PV-Lageplan.
// Bis September 2026 stand hier updateValuesMQTT fuer das feste realtime.svg -
// jeder Kreis mit eigenen Zeilen. Was ein Kreis zeigt, steht jetzt im Katalog
// (energieflussKatalog.js); hier bleibt nur der Lageplan hinter der Sonne.
let energiefluss = null;
const solarSVG = {
updateCnt: 99,
/**
* Beide Zwischenspeicher verwerfen - noetig, falls das SVG neu gezeichnet
* wird. Nur die Knoten zu vergessen reicht nicht: letzterWert wuerde dann
* jeden Schreibzugriff als "unveraendert" ueberspringen und das frische
* SVG behielte seine Platzhalter.
* Beide Zwischenspeicher verwerfen - noetig, wenn die Uebersicht neu
* gezeichnet wird (Wechsel quer/hochkant). Nur die Knoten zu vergessen
* reicht nicht: letzterWert wuerde dann jeden Schreibzugriff als
* "unveraendert" ueberspringen und die frischen Elemente behielten ihre
* Platzhalter.
*/
fillElementArray: function () {
for (const id in svgElements) delete svgElements[id];
letzterWert.clear();
},
updateValuesMQTT: function (mqttData) {
if (energiefluss) energiefluss.aktualisieren(mqttData);
const angleFactor = 15000 / 288;
const sixkWangleFactor = 6000 / 288;
var cons = Number(mqttData["solarManager/P_Load"]);
var pvn = JSON.parse(mqttData["solarManager/P_PVn"]);
var pv = Number(mqttData["solarManager/P_PV"]);
var pv2 = Number(pvn[1]);
var pv1 = Number(pvn[0]);
var pv3 = pv - (pv1 + pv2);
var pbatt = Number(mqttData["solarManager/P_Akku"]);
var limbatt = Number(mqttData["solarManager/crgMaxPct"]);
var grid = Number(mqttData["solarManager/P_Grid"]);
var soc = Number(mqttData["solarManager/SOC"]);
var og = Number(mqttData["solarManager/og"]);
var eg = Number(mqttData["solarManager/eg"]);
var ug = Number(mqttData["solarManager/ug"]);
var evsoc = Number(mqttData["solarManager/evSOC"]);
var evlock = Number(mqttData["solarManager/evLock"]);
var evMode = mqttData["solarManager/evMode"];
var evPower = Number(mqttData["solarManager/evPower"]);
var plugev = Number(mqttData["solarManager/evPlug"]);
var fuelev = Number(mqttData["solarManager/evFuel"]);
var heatOG = Number(mqttData["solarManager/heatOG"]);
var heatEG = Number(mqttData["solarManager/heatEG"]);
var puffO = Number(mqttData["solarManager/t_buffT"]);
var puffM = Number(mqttData["solarManager/t_buffM"]);
var puffU = Number(mqttData["solarManager/t_buffB"]);
var heatMode = mqttData["solarManager/heatMode"];
var carRemChrg = Number(mqttData["solarManager/carRemChrg"]);
var aut = Number(mqttData["solarManager/autarky"]);
var Pheat = Number(mqttData["solarManager/pHeat"]);
var waterHeight = Number(mqttData["solarManager/waterHeight"]);
var waterTemp = Number(mqttData["solarManager/waterTemp"]);
var p_wr = Number(mqttData["solarManager/P_WR"]);
var eff = Number(mqttData["solarManager/eff"]);
var i_l1evu = Number(mqttData["solarManager/i_l1evu"]);
var i_l2evu = Number(mqttData["solarManager/i_l2evu"]);
var i_l3evu = Number(mqttData["solarManager/i_l3evu"]);
var evPowerOG = Number(mqttData["solarManager/evPowerOG"]);
var evPlugOG = Number(mqttData["solarManager/evPlugOG"]);
var evModeOG = mqttData["solarManager/evModeOG"];
var common = -cons - ug - eg + og ;
if(common < 0){
common = 0;
// Lageplan (assets/img/pvplan.svg): Leistung je Eingang und die Liste
// der Wechselrichter.
let pvn = [];
try { pvn = JSON.parse(mqttData["solarManager/P_PVn"]); } catch (e) { pvn = []; }
for (let i = 0; i < pvn.length; i++) {
if (svgElement("det_pv" + i + "P")) setText("det_pv" + i + "P", powerToString(pvn[i]));
}
let liste = "";
for (let i = 0; mqttData["solarManager/inverters" + i + "/name"] !== undefined; i++) {
const basis = "solarManager/inverters" + i;
const gestoert = mqttData[basis + "/error"] > 0;
liste += '<tspan x="0" dy="1.2em">' + mqttData[basis + "/name"] + ":</tspan>"
+ '<tspan x="160">' + (gestoert ? "--" : Math.round(mqttData[basis + "/temp"])) + " °C</tspan>"
+ '<tspan x="215">' + (gestoert ? "-- W" : powerToString(mqttData[basis + "/p_AC"])) + "</tspan>";
}
if (svgElement("invList")) setHTML("invList", liste);
}
};
setAttr('consumerArc', "d", describeArc(100, 100, 95, 0, Math.round(-cons / angleFactor)));
/** Energiefluss aufbauen und den Umschalter Uebersicht/Anlage verdrahten. */
function energieflussStarten() {
const behaelter = document.getElementById("energiefluss");
if (!behaelter || typeof Energiefluss === "undefined") return;
setAttr("ogArc", "d", describeArc(100, 100, 95, 0, Math.round(-og / angleFactor)));
setText("consumerText", powerToString(-cons));
setText("consumerTextAllg", "Gemein: "+powerToString(common));
if (evPower > 0) {
setDisplay("evCharge", "");
if (carRemChrg > -1) {
var today = new Date();
today.setMinutes(today.getMinutes() + carRemChrg);
var h = today.getHours();
var m = today.getMinutes();
if (h < 10)
h = "0" + h;
if (m < 10)
m = "0" + m;
setText("evRemText", h + ":" + m);
const zeigeAnsicht = function (name) {
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
const an = knopf.dataset.enfAnsicht === name;
knopf.classList.toggle("active", an);
knopf.setAttribute("aria-selected", an ? "true" : "false");
});
document.querySelectorAll("[data-enf-ansicht-flaeche]").forEach(function (flaeche) {
const an = flaeche.dataset.enfAnsichtFlaeche === name;
flaeche.hidden = !an;
if (an) {
flaeche.classList.remove("enf-rein");
void flaeche.offsetWidth;
flaeche.classList.add("enf-rein");
}
} else {
setText("evRemText", "");
}
setText("ogText", powerToString(-og));
setText("egText", powerToString(eg));
setText("ugText", powerToString(ug));
setText("pvText", powerToString(pv));
setText("pv1txt", powerToString(pv1));
setText("pv2txt", powerToString(pv2));
setText("pv3txt", powerToString(pv3));
setText("il1txt", Math.round(i_l1evu * 10) / 10 + "A");
setText("il2txt", Math.round(i_l2evu * 10) / 10 + "A");
setText("il3txt", Math.round(i_l3evu * 10) / 10 + "A");
setText("batText", powerToString(pbatt));
setText("heatText", powerToString(Pheat));
setText("evText", powerToString(evPower * 1000));
setText("evTextOG", powerToString(evPowerOG * 1000));
setText("genText", "Autarkie: " + Math.round(aut) + "%");
setText("genInfoText", "Wirkungsgrad: " + Math.round(eff) + "%");// powerToString(p_wr);
setText("gridText", powerToString(grid));
for (i = 0; i < pvn.length; i++) {
setText("det_pv" + i + "P", powerToString(pvn[i]));
}
tttext = ""
var i = 0;
while (mqttData["solarManager/inverters" + i]) {
if (mqttData["solarManager/inverters" + i+"/error"] > 0) {
tttext = tttext + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
tttext = tttext + '<tspan x="160">' + "--" + " °C</tspan>"
tttext = tttext + '<tspan x="215">' + "--W" + "</tspan>"
} else {
tttext = tttext + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
tttext = tttext + '<tspan x="160">' + Math.round(mqttData["solarManager/inverters" + i+"/temp"]) + " °C</tspan>"
tttext = tttext + '<tspan x="215">' + powerToString(mqttData["solarManager/inverters" + i+"/p_AC"]) + "</tspan>"
}
i++;
}
setHTML("invList", tttext);
setAttr("egArc", "d", describeArc(100, 100, 95, 0, Math.round(eg / angleFactor)));
setAttr("ugArc", "d", describeArc(100, 100, 95, 0, Math.round(ug / angleFactor)));
setAttr("pvArc", "d", describeArc(100, 100, 95, 0, Math.round(pv1 / angleFactor)));
setAttr("pv2Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor)));
setAttr("pv3Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor) + Math.round(pv3 / angleFactor)));
if (pbatt < 0) {
if (limbatt < 100) {
setDisplay("charge", "none");
setDisplay("batLim", "");
setText("batLimText", Math.round(limbatt) + "%");
} else {
setDisplay("charge", "");
setDisplay("batLim", "none");
}
setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(-pbatt / angleFactor)));
setAttr("battani", "class", "stream-rev");
setFlowWidth("battani", 55 * (1 - Math.exp(pbatt / 2000)));
} else {
setDisplay("charge", "none");
setDisplay("batLim", "none");
setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(pbatt / angleFactor)));
setAttr("battani", "class", "stream");
setFlowWidth("battani", 55 * (1 - Math.exp(-pbatt / 2000)));
}
setAttr("batChargeState", "y", (100 - soc) + "%");
//setAttr("heatChargeState", "y", 100 - ((((puffO + puffM) / 2) - 30) * 1.66) + "%");
setAttr('tmpH', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffO));
setAttr('tmpM', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffM));
setAttr('tmpL', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffU));
setText("batSOC", soc + "%");
setAttr("evArc", "d", describeArc(100, 100, 95, 0, Math.round(evPower * 2000 / angleFactor)));
setAttr("evChargeState", "y", (100 - evsoc) + "%");
setText("evSOC", evsoc + "%");
setText("evFuel", fuelev + "%");
setAttr("heatArc", "d", describeArc(100, 100, 95, 0, Math.round(Pheat / sixkWangleFactor)));
setText("heatUp", Math.round(puffO * 10) / 10 + " °C");
setText("heatDown", Math.round(puffM * 10) / 10 + " °C");
if (evlock == false) {
setDisplay("evLock", "none");
} else {
setDisplay("evLock", "");
}
if (heatMode == "eco") {
setDisplay("heatEco", "");
setDisplay("heatDefault", "none");
} else {
setDisplay("heatEco", "none");
setDisplay("heatDefault", "");
}
if (evMode == "Eco") {
setDisplay("evEco", "");
setDisplay("evNextTrip", "none");
setDisplay("evDefault", "none");
} else if (evMode == "Next Trip") {
setDisplay("evEco", "none");
setDisplay("evNextTrip", "");
setDisplay("evDefault", "none");
} else {
setDisplay("evEco", "none");
setDisplay("evNextTrip", "none");
setDisplay("evDefault", "");
}
if (heatOG) {
setDisplay("heatOG", "");
} else {
setDisplay("heatOG", "none");
}
if (heatEG) {
setDisplay("heatEG", "");
} else {
setDisplay("heatEG", "none");
}
// solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam
// hier der Text der Wallbox an ("no car", "charging", ...); Number()
// machte daraus NaN, und NaN != "no car" ist immer wahr - das
// Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen
// kostet eine Zeile und haelt die Anzeige richtig, egal ob der
// Manager schon neu gestartet ist oder nicht.
const plugText = mqttData["solarManager/evPlug"];
if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) {
setDisplay("evPlug", "");
} else {
setDisplay("evPlug", "none");
}
if (evModeOG == "Eco") {
setDisplay("evEcoOG", "");
setDisplay("evNextTripOG", "none");
setDisplay("evDefaultOG", "none");
} else if (evModeOG == "Next Trip") {
setDisplay("evEcoOG", "none");
setDisplay("evNextTripOG", "");
setDisplay("evDefaultOG", "none");
} else {
setDisplay("evEcoOG", "none");
setDisplay("evNextTripOG", "none");
setDisplay("evDefaultOG", "");
}
if (evPlugOG > 0) {
setDisplay("evPlugOG", "");
} else {
setDisplay("evPlugOG", "none");
}
if (grid < 0) {
setAttr("gridArc", "d", describeArc(100, 100, 95, 0, -grid / angleFactor));
setAttr("consGridani", "class", "stream-rev");
setFlowWidth("consGridani", 55 * (1 - Math.exp(grid / 2000)));
} else {
setAttr("gridArc", "d", describeArc(100, 100, 95, 0, grid / angleFactor));
setAttr("consGridani", "class", "stream");
setFlowWidth("consGridani", 55 * (1 - Math.exp(-grid / 2000)));
}
setFlowWidth("consEVani", 55 * (1 - Math.exp(-evPower)));
setFlowWidth("consEVOGani", 55 * (1 - Math.exp(-evPowerOG)));
setFlowWidth("consOGani", 55 * (1 - Math.exp(og / 2000)));
setFlowWidth("consUGani", 55 * (1 - Math.exp( -ug / 2000)));
setFlowWidth("consHeatAni", 55 * (1 - Math.exp(-Pheat / 2000)));
setFlowWidth("consAni", 55 * (1 - Math.exp(cons / 2000)));
setFlowWidth("consEGani", 55 * (1 - Math.exp(-eg / 2000)));
setFlowWidth("pvani", 55 * (1 - Math.exp(-pv / 2000)));
}
});
};
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
knopf.addEventListener("click", function () { zeigeAnsicht(knopf.dataset.enfAnsicht); });
});
energiefluss = new Energiefluss(behaelter, {
einstellungen: typeof energieflussEinstellungen !== "undefined" ? energieflussEinstellungen : {},
onKlick: function (knoten) {
if (knoten.klick.ansicht) zeigeAnsicht(knoten.klick.ansicht);
else if (knoten.klick.modal) openModal(knoten.klick.modal);
},
onGezeichnet: function () {
solarSVG.fillElementArray();
if (typeof updateWateringStatus === "function") updateWateringStatus();
},
});
energiefluss.zeichnen();
}
var chartSettings = {
@@ -604,7 +433,7 @@ var chartSettings = {
// hier ist sie Beiwerk, dort der Zweck der Seite.
baueJahresstatistik(document.getElementById("Stats-Year"),
{ spalten: 1, werkzeuge: false, fussnoten: false });
solarSVG.fillElementArray();
energieflussStarten();
mountMeteogram();
}
});