Symbol etwas kleiner und hoeher, Wert und Nebenzeile nach oben - dort ist die Scheibe breiter, und Zeilen wie "89 % entlaedt" stossen nicht mehr an den Rand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
607 lines
26 KiB
JavaScript
607 lines
26 KiB
JavaScript
/* ==========================================================================
|
||
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: () => `
|
||
<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],
|
||
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.47}) scale(${k.gross ? 1.8 : 1.6})`,
|
||
}, koerper);
|
||
symbol.innerHTML = SYMBOLE[k.symbol]().replace(/\{id\}/g, id);
|
||
|
||
// Wert und Zeile darunter so weit oben, wie das Symbol es zulässt: weiter
|
||
// unten wird die Scheibe schmal, und "89 % · entlädt" passt nicht mehr.
|
||
const wert = el("text", { class: "enf-wert", y: r * 0.14 + 4 }, koerper);
|
||
const neben = el("text", { class: "enf-neben", y: r * 0.14 + 27 }, 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 <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));
|
||
}
|
||
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;
|
||
})();
|