Solar: neue Energiefluss-Uebersicht, anpassbar je Haus
Das feste realtime.svg ist ersetzt durch einen Katalog (js/solar/energieflussKatalog.js: jeder Kreis einmal, samt Werten aus MQTT) und einen Renderer (js/solar/energiefluss.js): Ringe mit Segmenten, Fluesse als Boegen mit laufenden Punkten, Fuellstandswelle fuer Batterie und Auto, animierte Symbole, Plaketten, Hochkant-Ansicht auf schmalen Schirmen. Hierarchie und Lage wie bisher: Quellen -> Wechselrichter -> Verbrauch -> OG/EG/UG -> Wallboxen, Heizstab. Einstellungen -> Uebersicht: Kreise ein/aus (Kinder ruecken eine Ebene hoeher), Namen, Lage per Ziehen in der Vorschau, PV-Flaechen. Gespeichert werden nur Abweichungen in homeMesh.energiefluss. Der PV-Lageplan hinter der Sonne liegt unveraendert als assets/img/pvplan.svg und ist ueber den Umschalter Uebersicht/Anlage erreichbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/* ==========================================================================
|
||||
Reiter "Übersicht": die Energiefluss-Übersicht an dieses Haus anpassen.
|
||||
|
||||
Links die Übersicht selbst im Bearbeitungsmodus (energiefluss.js) mit
|
||||
Beispielwerten, rechts je Kreis Schalter und Name, beim PV-Kreis die
|
||||
Flächen. Verschoben wird in der Vorschau. Gespeichert wird der ganze Stand
|
||||
auf einmal (restricted/energiefluss.php hält davon nur die Abweichungen).
|
||||
|
||||
Nutzt textSchuetzen() aus settings.js.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Beispielwerte: ein sonniger Nachmittag, Auto lädt im EG, Heizstab läuft.
|
||||
const BEISPIEL = {
|
||||
"solarManager/P_PV": "8400", "solarManager/P_PVn": JSON.stringify([2600, 1300, 130, 130, 140, 140, 130, 130, 135, 135, 130, 130, 120, 120,
|
||||
155, 150, 152, 158, 150, 149, 150, 154, 151, 150, 153, 155, 150, 92, 150, 152, 151, 150, 149, 0]),
|
||||
"solarManager/P_Grid": "-900", "solarManager/i_l1evu": "-1.3", "solarManager/i_l2evu": "-1.4", "solarManager/i_l3evu": "-1.2",
|
||||
"solarManager/P_Akku": "-2100", "solarManager/SOC": "64", "solarManager/crgMaxPct": "100",
|
||||
"solarManager/autarky": "100", "solarManager/eff": "96",
|
||||
"solarManager/P_Load": "-5400", "solarManager/og": "-350", "solarManager/eg": "3720", "solarManager/ug": "270",
|
||||
"solarManager/pHeat": "1000", "solarManager/t_buffT": "61", "solarManager/t_buffM": "48", "solarManager/t_buffB": "35",
|
||||
"solarManager/heatMode": "eco", "solarManager/heatOG": "1", "solarManager/heatEG": "0",
|
||||
"solarManager/evPower": "3.2", "solarManager/evSOC": "78", "solarManager/evFuel": "0", "solarManager/evMode": "Eco",
|
||||
"solarManager/evPlug": "1", "solarManager/evLock": "1", "solarManager/carRemChrg": "95",
|
||||
"solarManager/evPowerOG": "0", "solarManager/evPlugOG": "0", "solarManager/evModeOG": "Eco",
|
||||
};
|
||||
|
||||
let bild = null;
|
||||
let gespeichert = {}; // Stand der Datenbank
|
||||
let stand = {}; // Stand der Maske
|
||||
let geladen = false;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const katalog = () => ENERGIEFLUSS_KATALOG.knoten;
|
||||
const kopie = o => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function hinweis(text, klasse) {
|
||||
const ziel = $("energieflussHinweis");
|
||||
if (!ziel) return;
|
||||
ziel.className = "small " + (klasse || "text-body-secondary");
|
||||
ziel.textContent = text;
|
||||
}
|
||||
|
||||
function geaendert() {
|
||||
const sauber = s => JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]));
|
||||
const anders = sauber(normalisiert(stand)) !== sauber(normalisiert(gespeichert));
|
||||
hinweis(anders ? "Nicht gespeichert." : "", anders ? "text-warning" : "");
|
||||
}
|
||||
|
||||
/** Nur echte Abweichungen, in fester Form - zum Vergleichen und Schicken. */
|
||||
function normalisiert(s) {
|
||||
const aus = {};
|
||||
for (const k of katalog()) {
|
||||
const e = s[k.id] || {};
|
||||
const eintrag = {
|
||||
aktiv: k.pflicht ? true : e.aktiv !== false,
|
||||
name: (e.name || "").trim() || null,
|
||||
x: e.x ?? null, y: e.y ?? null,
|
||||
optionen: e.optionen || null,
|
||||
};
|
||||
if (eintrag.x === k.x && eintrag.y === k.y) { eintrag.x = null; eintrag.y = null; }
|
||||
if (!eintrag.aktiv || eintrag.name || eintrag.x !== null || eintrag.optionen) aus[k.id] = eintrag;
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
function vorschauZeichnen() {
|
||||
if (!bild) {
|
||||
bild = new Energiefluss($("energieflussVorschau"), {
|
||||
bearbeiten: true,
|
||||
einstellungen: stand,
|
||||
onVerschoben(id, x, y) {
|
||||
stand[id] = Object.assign({}, stand[id], { x, y });
|
||||
listeZeichnen();
|
||||
geaendert();
|
||||
},
|
||||
});
|
||||
}
|
||||
bild.setzeEinstellungen(normalisiert(stand));
|
||||
bild.zeichnen();
|
||||
bild.aktualisieren(BEISPIEL);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Liste */
|
||||
function listeZeichnen() {
|
||||
const ziel = $("energieflussListe");
|
||||
const wirksam = bild ? bild.nachId : {};
|
||||
ziel.innerHTML = katalog().map(k => {
|
||||
const e = stand[k.id] || {};
|
||||
const aktiv = k.pflicht || e.aktiv !== false;
|
||||
const w = wirksam[k.id];
|
||||
const eltern = w && w.elternWirksam;
|
||||
const elternName = eltern ? (stand[eltern.id] && stand[eltern.id].name) || eltern.titel || eltern.name : null;
|
||||
const verschoben = e.x != null && (e.x !== k.x || e.y !== k.y);
|
||||
const flaechen = k.id === "pv" && aktiv ? flaechenZeichnen(k, e) : "";
|
||||
return `
|
||||
<div class="enf-e-zeile${aktiv ? "" : " enf-e-aus"}" data-id="${k.id}">
|
||||
<div class="form-check form-switch m-0" title="${k.pflicht ? "Gehört immer dazu" : "Ein- oder ausschalten"}">
|
||||
<input class="form-check-input" type="checkbox" id="enf-aktiv-${k.id}" data-feld="aktiv"
|
||||
${aktiv ? "checked" : ""} ${k.pflicht ? "disabled" : ""}>
|
||||
</div>
|
||||
<label class="enf-e-name" for="enf-name-${k.id}">
|
||||
<span class="enf-e-punkt" style="--farbe:${k.farbe}"></span>
|
||||
<input class="form-control form-control-sm" id="enf-name-${k.id}" data-feld="name" maxlength="40"
|
||||
placeholder="${textSchuetzen(k.name)}" title="${textSchuetzen(k.titel || k.name)}" value="${textSchuetzen(e.name || "")}" ${aktiv ? "" : "disabled"}>
|
||||
</label>
|
||||
<span class="enf-e-eltern small text-body-secondary">${elternName ? "an " + textSchuetzen(elternName) : k.eltern ? "" : "frei"}</span>
|
||||
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="lage"
|
||||
title="Lage aus dem Katalog" ${verschoben ? "" : "hidden"}><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
${flaechen}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function flaechenZeichnen(k, e) {
|
||||
const liste = (e.optionen && e.optionen.flaechen) || k.optionen.flaechen;
|
||||
const eigen = !!(e.optionen && e.optionen.flaechen);
|
||||
return `
|
||||
<div class="enf-e-flaechen">
|
||||
<div class="small text-body-secondary mb-1">
|
||||
Flächen im Sonnenring – welche Einträge von <code>P_PVn</code> dazugehören
|
||||
${eigen ? `<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-aktion="flaechen-zurueck">Katalog</button>` : ""}
|
||||
</div>
|
||||
${liste.map((f, i) => `
|
||||
<div class="enf-e-flaeche" data-index="${i}">
|
||||
<input type="color" class="form-control form-control-sm form-control-color" data-flaeche="farbe" value="${f.farbe}" title="Farbe">
|
||||
<input class="form-control form-control-sm" data-flaeche="name" value="${textSchuetzen(f.name)}" placeholder="Name" maxlength="20">
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="von" value="${f.von}" title="erster Eintrag">
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="bis" value="${f.bis}" title="letzter Eintrag">
|
||||
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="flaeche-weg" title="Fläche entfernen"><i class="bi bi-x-lg"></i></button>
|
||||
</div>`).join("")}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-aktion="flaeche-neu"><i class="bi bi-plus-lg me-1"></i>Fläche</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function flaechenLesen(zeile) {
|
||||
return [...zeile.querySelectorAll(".enf-e-flaeche")].map(r => ({
|
||||
name: r.querySelector('[data-flaeche="name"]').value.trim(),
|
||||
von: Math.max(0, parseInt(r.querySelector('[data-flaeche="von"]').value, 10) || 0),
|
||||
bis: Math.max(0, parseInt(r.querySelector('[data-flaeche="bis"]').value, 10) || 0),
|
||||
farbe: r.querySelector('[data-flaeche="farbe"]').value,
|
||||
}));
|
||||
}
|
||||
|
||||
function flaechenSetzen(id, flaechen) {
|
||||
const e = stand[id] = Object.assign({}, stand[id]);
|
||||
e.optionen = Object.assign({}, e.optionen, { flaechen });
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Ereignisse */
|
||||
function binden() {
|
||||
const liste = $("energieflussListe");
|
||||
|
||||
liste.addEventListener("change", ev => {
|
||||
const zeile = ev.target.closest(".enf-e-zeile");
|
||||
if (!zeile) return;
|
||||
const id = zeile.dataset.id;
|
||||
if (ev.target.dataset.feld === "aktiv") {
|
||||
stand[id] = Object.assign({}, stand[id], { aktiv: ev.target.checked });
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
} else if (ev.target.dataset.flaeche) {
|
||||
flaechenSetzen(id, flaechenLesen(zeile));
|
||||
vorschauZeichnen();
|
||||
}
|
||||
geaendert();
|
||||
});
|
||||
|
||||
liste.addEventListener("input", ev => {
|
||||
const zeile = ev.target.closest(".enf-e-zeile");
|
||||
if (!zeile || ev.target.dataset.feld !== "name") return;
|
||||
stand[zeile.dataset.id] = Object.assign({}, stand[zeile.dataset.id], { name: ev.target.value });
|
||||
clearTimeout(binden._uhr);
|
||||
binden._uhr = setTimeout(vorschauZeichnen, 300);
|
||||
geaendert();
|
||||
});
|
||||
|
||||
liste.addEventListener("click", ev => {
|
||||
const knopf = ev.target.closest("[data-aktion]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest(".enf-e-zeile");
|
||||
const id = zeile.dataset.id;
|
||||
switch (knopf.dataset.aktion) {
|
||||
case "lage":
|
||||
stand[id] = Object.assign({}, stand[id], { x: null, y: null });
|
||||
break;
|
||||
case "flaeche-neu": {
|
||||
const f = flaechenLesen(zeile);
|
||||
const bis = f.length ? f[f.length - 1].bis + 1 : 0;
|
||||
f.push({ name: "Fläche " + (f.length + 1), von: bis, bis, farbe: "#f7c000" });
|
||||
flaechenSetzen(id, f);
|
||||
break;
|
||||
}
|
||||
case "flaeche-weg": {
|
||||
const f = flaechenLesen(zeile);
|
||||
f.splice(Number(knopf.closest(".enf-e-flaeche").dataset.index), 1);
|
||||
flaechenSetzen(id, f);
|
||||
break;
|
||||
}
|
||||
case "flaechen-zurueck":
|
||||
if (stand[id] && stand[id].optionen) {
|
||||
const o = Object.assign({}, stand[id].optionen);
|
||||
delete o.flaechen;
|
||||
stand[id].optionen = Object.keys(o).length ? o : null;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
geaendert();
|
||||
});
|
||||
|
||||
$("energieflussSpeichern").addEventListener("click", speichern);
|
||||
$("energieflussNeuLaden").addEventListener("click", () => {
|
||||
stand = kopie(gespeichert);
|
||||
vorschauZeichnen(); listeZeichnen(); geaendert();
|
||||
});
|
||||
$("energieflussZuruecksetzen").addEventListener("click", () => {
|
||||
stand = {};
|
||||
vorschauZeichnen(); listeZeichnen(); geaendert();
|
||||
});
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
const knopf = $("energieflussSpeichern");
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
const n = normalisiert(stand);
|
||||
const knoten = Object.keys(n).map(id => Object.assign({ id }, n[id]));
|
||||
const antwort = await fetch("ajax/settings.php?action=energiefluss-save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ knoten }),
|
||||
});
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
gespeichert = d.einstellungen || {};
|
||||
stand = kopie(gespeichert);
|
||||
vorschauZeichnen(); listeZeichnen();
|
||||
hinweis("Gespeichert – die Solar-Seite zeigt es beim nächsten Laden.", "text-success");
|
||||
} catch (e) {
|
||||
hinweis(e.message, "text-danger");
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
if (geladen) return;
|
||||
geladen = true;
|
||||
try {
|
||||
const antwort = await fetch("ajax/settings.php?action=energiefluss", { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
gespeichert = d.einstellungen || {};
|
||||
stand = kopie(gespeichert);
|
||||
binden();
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
} catch (e) {
|
||||
geladen = false;
|
||||
$("energieflussListe").innerHTML = '<div class="alert alert-danger mb-0">Übersicht nicht abrufbar: '
|
||||
+ textSchuetzen(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!$("energieflussVorschau") || typeof Energiefluss === "undefined") return;
|
||||
// Erst zeichnen, wenn der Reiter offen ist - verborgen hat die Vorschau keine Größe.
|
||||
const knopf = $("einstellung-energiefluss-tab");
|
||||
if (knopf) knopf.addEventListener("shown.bs.tab", laden);
|
||||
if ($("einstellung-energiefluss").classList.contains("active")) laden();
|
||||
// Ein Anker in der Adresse öffnet den Reiter erst nach DOMContentLoaded.
|
||||
setTimeout(() => { if ($("einstellung-energiefluss").classList.contains("active")) laden(); }, 0);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user