Die duenne dunkle Linie gibt es nur noch im Bearbeitungsmodus, wo sie beim Schieben zeigt, was mit was verbunden ist. In der Ansicht tragen die laufenden Punkte den Weg allein. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
675 lines
29 KiB
JavaScript
675 lines
29 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";
|
||
/**
|
||
* 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);
|
||
// Das Bett gibt es nur beim Bearbeiten: dort zeigt es, was mit was
|
||
// verbunden ist, waehrend man die Kreise schiebt. In der Ansicht
|
||
// erzaehlen die laufenden Punkte den Weg, und wo nichts fliesst, soll
|
||
// auch keine Linie stehen.
|
||
const bett = this.bearbeiten ? el("path", { d, class: "enf-bett", pathLength: "1" }, g) : null;
|
||
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);
|
||
if (f.bett) 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;
|
||
})();
|