index.php?action=anzeige: Uebersicht, Anlage und Speicher ohne Kopf und
Seitenleiste, schmale Leiste links, immer quer. Nach zwei Minuten ohne
Beruehrung zurueck zur Uebersicht, MQTT verbindet still neu (Punkt in der
Leiste), alle sechs Stunden neu laden. Standard "ruhig" fuer den Pi 3B+
(nur Fluesse, grobe Stufen, kein Mauszeiger); &animation=1 und &maus=1.
Dafuer:
- Seitentabelle kennt "eigen" (Vorlage ist eine ganze Seite)
- Energiefluss: Optionen hochkantUnter und klickFilter; Anlage: hochkantUnter
- Bewaesserungszustand aus solarMQTT.js nach bewaesserungAnzeige.js, von
Solar-Seite und Anzeige gemeinsam genutzt
- Speicher: "voll/leer" mit Tag ("morgen 21:52", "in 3 Tagen")
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
568 lines
24 KiB
JavaScript
568 lines
24 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
|
||
klickFilter(knoten), // optional: welche Kreise antippbar sind
|
||
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"/>`,
|
||
};
|
||
|
||
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, nicht im Bearbeiten, und opt.klickFilter lässt es zu. */
|
||
klickbar(k) {
|
||
return !!k.klick && !this.bearbeiten && (!this.opt.klickFilter || this.opt.klickFilter(k));
|
||
}
|
||
|
||
hochkantGewuenscht() {
|
||
const grenze = this.opt.hochkantUnter ?? HOCHKANT_UNTER;
|
||
return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < grenze;
|
||
}
|
||
|
||
/** Lage eines Kreises in der aktuellen Ansicht. */
|
||
punkt(k) {
|
||
return this.hochkant ? [k.lage[1], k.lage[0]] : [k.lage[0], k.lage[1]];
|
||
}
|
||
|
||
radius(k) {
|
||
return k.gross ? RADIUS_GROSS : RADIUS;
|
||
}
|
||
|
||
sichtbareKnoten() {
|
||
return this.knoten.filter(k => k.aktiv || this.bearbeiten);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- Zeichnen */
|
||
zeichnen() {
|
||
this.hochkant = this.hochkantGewuenscht();
|
||
this.behaelter.innerHTML = "";
|
||
const svg = this.svg = el("svg", {
|
||
class: "enf" + (this.bearbeiten ? " enf-bearbeiten" : "") + (this.hochkant ? " enf-hoch" : ""),
|
||
role: "img", "aria-label": "Energiefluss",
|
||
}, this.behaelter);
|
||
this.ebeneFluss = el("g", { class: "enf-fluesse" }, svg);
|
||
this.ebeneKnoten = el("g", { class: "enf-knotenebene" }, svg);
|
||
|
||
this.elemente = {};
|
||
for (const k of this.sichtbareKnoten()) this.knotenZeichnen(k);
|
||
for (const k of this.sichtbareKnoten()) if (k.aktiv && k.elternWirksam) this.flussZeichnen(k);
|
||
this.rahmenSetzen();
|
||
this.pausieren();
|
||
|
||
if (this.letzte) {
|
||
const d = this.letzte;
|
||
this.letzte = null;
|
||
this.aktualisieren(d);
|
||
}
|
||
if (this.opt.onGezeichnet) this.opt.onGezeichnet();
|
||
}
|
||
|
||
rahmenSetzen() {
|
||
const ks = this.sichtbareKnoten();
|
||
if (!ks.length) return;
|
||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||
for (const k of ks) {
|
||
const [x, y] = this.punkt(k), r = this.radius(k);
|
||
x0 = Math.min(x0, x - r - 20); x1 = Math.max(x1, x + r + 20);
|
||
y0 = Math.min(y0, y - r - 20); y1 = Math.max(y1, y + r + 56); // Name unter dem Kreis
|
||
}
|
||
this.svg.setAttribute("viewBox",
|
||
[x0 - RAND / 2, y0 - RAND / 2, x1 - x0 + RAND, y1 - y0 + RAND].map(Math.round).join(" "));
|
||
}
|
||
|
||
knotenZeichnen(k) {
|
||
const [x, y] = this.punkt(k);
|
||
const r = this.radius(k);
|
||
const id = "enf" + this.nummer + "-" + k.id;
|
||
const g = el("g", {
|
||
class: "enf-knoten" + (k.aktiv ? "" : " enf-aus") + (this.klickbar(k) ? " enf-klickbar" : ""),
|
||
transform: `translate(${x} ${y})`,
|
||
style: `--farbe:${k.farbe};--verz:${k.tiefe * 110}ms`,
|
||
"data-knoten": k.id,
|
||
}, this.ebeneKnoten);
|
||
|
||
const koerper = el("g", { class: "enf-koerper" }, g);
|
||
el("circle", { r: r + 17, class: "enf-hof" }, koerper);
|
||
el("circle", { r, class: "enf-spur" }, koerper);
|
||
const ring = el("g", { class: "enf-ring", transform: "rotate(-90)" }, koerper);
|
||
el("circle", { r: r - 11, class: "enf-scheibe" }, koerper);
|
||
|
||
// Füllstand (Batterie, Auto): Welle, die mit dem Stand steigt.
|
||
let fuellung = null;
|
||
if (k.symbol === "akku" || k.symbol === "wallbox") {
|
||
el("clipPath", { id: id + "-innen" }, el("defs", null, koerper))
|
||
.appendChild(el("circle", { r: r - 11 }));
|
||
const clip = el("g", { "clip-path": `url(#${id}-innen)` }, koerper);
|
||
fuellung = el("g", { class: "enf-fuellung", style: `--stand:0;--r:${r - 11}` }, clip);
|
||
const b = r * 4;
|
||
el("path", {
|
||
class: "enf-welle",
|
||
d: `M${-b} 0 q${r / 2} -9 ${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 t${r} 0 V${r * 2} H${-b} Z`,
|
||
}, fuellung);
|
||
}
|
||
|
||
const symbol = el("g", {
|
||
class: "enf-symbol enf-sym-" + k.symbol,
|
||
transform: `translate(0 ${-r * 0.43}) scale(${k.gross ? 1.95 : 1.75})`,
|
||
}, koerper);
|
||
symbol.innerHTML = SYMBOLE[k.symbol]().replace(/\{id\}/g, id);
|
||
|
||
const wert = el("text", { class: "enf-wert", y: r * 0.2 + 6 }, koerper);
|
||
const neben = el("text", { class: "enf-neben", y: r * 0.2 + 33 }, koerper);
|
||
const name = el("text", { class: "enf-name", y: r + 46 }, g);
|
||
name.textContent = k.anzeigeName;
|
||
|
||
const zeichen = el("g", { class: "enf-zeichen" }, koerper);
|
||
|
||
if (this.klickbar(k)) {
|
||
const t = el("g", { class: "enf-tipp", transform: `translate(${r * 0.72} ${-r * 0.72})` }, koerper);
|
||
el("circle", { r: 15 }, t);
|
||
el("path", { d: "M-3.5-6l6 6-6 6" }, t);
|
||
g.setAttribute("tabindex", "0");
|
||
g.setAttribute("role", "button");
|
||
g.setAttribute("aria-label", k.anzeigeName + " – Details");
|
||
const los = () => this.opt.onKlick && this.opt.onKlick(k);
|
||
g.addEventListener("click", los);
|
||
g.addEventListener("keydown", e => {
|
||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); los(); }
|
||
});
|
||
}
|
||
|
||
// Bewässerung: Zonen als Zeilen im Kreis. Die Ids gehören solarMQTT.js
|
||
// und werden nur außerhalb der Einstellungsvorschau vergeben.
|
||
if (k.zonen) {
|
||
wert.remove(); neben.remove();
|
||
symbol.setAttribute("transform", `translate(0 ${-r * 0.5}) scale(1.4)`);
|
||
k.zonen.forEach((z, i) => {
|
||
const zy = -r * 0.1 + i * 25;
|
||
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;
|
||
})();
|