Files
Smart-Dashboard/js/solar/energiefluss.js
T
adminandClaude Opus 5 357c73cf8a Uebersicht: Ladestaende deutlicher, dunkle Bahn unter den Punkten weg
Die Fuellung war mit 14 Prozent Deckung kaum zu sehen; jetzt 22 Prozent,
beim Laden 32, dazu die Wasserlinie als eigene Kante. Ohne gemeldeten Stand
bleibt die Fuellung ganz weg - eine Wallbox ohne Auto zeigte sonst eine
Linie am unteren Rand. Das Bett ist nur noch eine duenne Linie und
verschwindet, sobald Punkte laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:01:17 +02:00

670 lines
29 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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";
/**
* Wie voll der Ring bei dieser Leistung ist (0..1).
*
* Nicht linear: bei 15 kW Vollausschlag waeren 300 W ein Strich von zwei
* Grad - man saehe nicht, ob das Erdgeschoss 50 W oder 500 W zieht, und
* genau in diesem Bereich steht die Anlage die meiste Zeit. Die gestauchte
* Skala gibt dem unteren Zehntel knapp ein Drittel des Rings und laesst den
* vollen Ausschlag trotzdem genau bei "skala".
*/
function ringAnteil(w, skala) {
if (!(skala > 0)) return 0;
const teil = Math.max(0, Math.min(1, Math.abs(w) / skala));
return Math.log10(1 + 9 * teil);
}
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"/>`,
};
/* 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: `
<path d="M12 12V7.2"/>
<path d="M12 9.6c-2.6.1-4.3-1.5-4.4-4.2 2.6-.1 4.3 1.5 4.4 4.2z"/>
<path d="M12 8.4c.1-2.7 1.8-4.3 4.4-4.2-.1 2.7-1.8 4.3-4.4 4.2z"/>
<rect x="4.6" y="12" width="14.8" height="2.8" rx=".8"/>
<path d="M6.2 14.8 7.6 21h8.8l1.4-6.2"/>`,
// Baum im Gras - der normale Garten
garten: `
<path d="M12 3.6a4.3 4.3 0 0 0-4.2 5.3 3.4 3.4 0 0 0 1.3 6.5h5.8a3.4 3.4 0 0 0 1.3-6.5A4.3 4.3 0 0 0 12 3.6z"/>
<path d="M12 15.4V20.6M12 18l2-1.8"/>
<path d="M3.5 20.6h17M6 20.6l-.7-1.9M7.6 20.6l.3-2.1M17.2 20.6l.6-2M18.6 20.6l.9-1.6"/>`,
// Hochbeet: Holzkasten auf Füßen, oben drei Pflanzen
hochbeet: `
<path d="M8 11V8.6"/>
<path d="M8 9.4c-1.5 0-2.4-.9-2.4-2.4 1.5 0 2.4.9 2.4 2.4z"/>
<path d="M12 11V6.4"/>
<path d="M12 8.6c-1.6 0-2.6-1-2.6-2.6 1.6 0 2.6 1 2.6 2.6z"/>
<path d="M12 7.6c0-1.6 1-2.6 2.6-2.6 0 1.6-1 2.6-2.6 2.6z"/>
<path d="M16 11V8.6"/>
<path d="M16 9.4c0-1.5.9-2.4 2.4-2.4 0 1.5-.9 2.4-2.4 2.4z"/>
<rect x="3.5" y="11" width="17" height="8.4" rx=".8"/>
<path d="M3.5 13.8h17M3.5 16.6h17" stroke-width="1.1"/>
<path d="M5.4 19.4v1.9M18.6 19.4v1.9"/>`,
};
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 = {
// 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],
// Hochkant wird sonst nur x und y getauscht. Wo das die Kreise
// ungluecklich stellt, nennt der Katalog eine eigene Lage.
hochLage: k.hochX === undefined ? null : [k.hochX, k.hochY],
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.
*
* Hochkant werden x und y getauscht - aus einem breiten Bild wird ein
* hohes. Ein Kreis mit hochX/hochY im Katalog steht dort stattdessen,
* wo der Katalog ihn hinstellt; verschieben im Bearbeitungsmodus (den es
* nur quer gibt) laesst diese Lage unberuehrt.
*/
punkt(k) {
if (this.hochkant) return k.hochLage || [k.lage[1], k.lage[0]];
return [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);
// Die Welle ist in Bögen der Breite ri gezeichnet, eine Periode sind
// zwei davon. Die CSS-Animation schiebt um genau 2 * --r - stimmen
// beide nicht überein, springt die Welle am Ende jeder Runde.
const ri = r - 11;
fuellung = el("g", { class: "enf-fuellung", style: `--stand:0;--r:${ri}` }, clip);
const b = ri * 4;
const bogen = `M${-b} 0 q${ri / 2} -9 ${ri} 0` + ` t${ri} 0`.repeat(7);
el("path", { class: "enf-welle", d: bogen + ` V${ri * 2} H${-b} Z` }, fuellung);
// Dieselbe Linie noch einmal als Kante: die Flaeche allein ist so
// blass, dass man den Ladestand suchen muss. Die Kante zeichnet die
// Wasserlinie scharf nach.
el("path", { class: "enf-welle-kante", d: bogen }, 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.06 + i * 20;
// Eigenes Symbol (ZONENBILDER) oder ein Zeichen aus Bootstrap Icons.
// updateWateringIndicator() färbt es über color und schreibt den
// Tooltip in das <title>.
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));
}
// Gerechnet wird ueber die Summe, nicht je Segment: die gestauchte
// Skala ist nicht additiv, sonst reichten drei Viertel-Segmente weiter
// als ein volles.
let start = 0, summe = 0;
e.segmente.forEach((kreis, i) => {
const s = segmente[i];
let laenge = 0;
if (s) {
const vorher = ringAnteil(summe, skala) * 100;
summe += Math.max(0, s.w);
laenge = Math.max(0, Math.min(100 - start, ringAnteil(summe, skala) * 100 - vorher));
}
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);
// Ohne gemeldeten Stand bleibt die Fuellung ganz weg: sonst stuende
// bei einer Wallbox ohne Auto eine Wasserlinie am unteren Rand.
e.g.classList.toggle("enf-stand", zu.stand !== undefined && isFinite(zu.stand));
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;
// Die Punktgroesse soll die Leistung erzaehlen: von 2,4 (ein paar
// hundert Watt) bis 13 (Volllast). Vorher lag alles ab 3 kW dicht
// beieinander.
const breite = still ? 0 : 2.4 + 11 * (1 - Math.exp(-a / 5000));
// Schneller bei mehr Leistung: 2,6 s je Abschnitt bei wenig, 0,7 s bei viel.
// Gesetzt wird nicht die Dauer der CSS-Animation - eine neue Dauer
// rechnet die Position neu, und die Punkte springen -, sondern ihre
// Abspielgeschwindigkeit; die behält die Position.
const tempo = still ? 1 : 1 / (0.7 + 1.9 * Math.exp(-a / 3000));
const sig = still ? "still" : breite.toFixed(1) + "|" + (w < 0);
if (f._sig !== sig) {
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));
}
if (still) return;
// Nach dem Wechsel aus "still" entsteht die Animation erst beim nächsten
// Stilabgleich - deshalb im nächsten Bild.
requestAnimationFrame(() => {
for (const pfad of [f.strom, f.hof]) {
for (const anim of pfad.getAnimations()) {
if (Math.abs(anim.playbackRate - tempo) < 0.02) continue;
if (anim.updatePlaybackRate) anim.updatePlaybackRate(tempo);
else anim.playbackRate = tempo;
}
}
});
}
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;
// Auch die Bedienfenster (bedienfenster.js) zeigen die Zonen mit diesen Symbolen.
window.ENERGIEFLUSS_ZONENBILDER = ZONENBILDER;
})();