Files
Smart-Dashboard/js/solar/speicher.js
T
adminandClaude Opus 5 b8b2e66fd0 Vollbildanzeige fuer den Raspberry Pi (800 x 480)
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>
2026-09-15 19:15:46 +02:00

542 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
Speicher-Ansicht: die Batterie bis zur einzelnen Zelle.
const s = new Speicher(behaelter);
s.zeichnen();
s.aktualisieren(mqttData); // jede Sekunde, auch wenn verborgen
Live kommt alles über MQTT: solarManager/SOC und P_Akku vom Wechselrichter,
solarManager/byd/# direkt aus der BMU (gatherBYDData.py im SolarManager -
Zellspannungen, Temperaturfühler, SOH, Zähler). Der Verlauf kommt aus
ajax/speicher.php.
Ohne BMU-Daten (andere Batterie, Sammler aus) bleibt die Ansicht nützlich:
Ladestand, Leistung und ihr Verlauf stehen trotzdem da, Turm und
Zellwerte machen einem Hinweis Platz.
Die Zellen sind durchgezählt wie bei der BMU: Modul m hat die Zellen
(m-1)*16+1 bis m*16 und die Fühler (m-1)*8+1 bis m*8.
========================================================================== */
(function () {
"use strict";
// Nennenergie eines Moduls in kWh. HVM: 2,76; HVS: 2,56. Die BMU meldet die
// Zahl der Module, aber nicht diese Größe.
const KWH_JE_MODUL = 2.76;
const ZELLEN_JE_MODUL = 16;
const FUEHLER_JE_MODUL = 8;
const VERALTET_S = 600;
const NS = "http://www.w3.org/2000/svg";
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
const tausend = z => Math.round(z).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/** Zeitpunkt in h Stunden: "um 17:40", "morgen 06:10" oder "in 3 Tagen". */
function wann(h) {
const ziel = new Date(Date.now() + h * 3600000);
const tage = Math.round((new Date(ziel).setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / 86400000);
if (tage <= 0) return "um " + uhrzeit(ziel);
if (tage === 1) return "morgen " + uhrzeit(ziel);
return "in " + tage + " Tagen";
}
function leser(daten) {
return {
da(t) { return daten[t] !== undefined; },
zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; },
text(t) { return daten[t] === undefined ? "" : String(daten[t]); },
json(t) { try { return JSON.parse(String(daten[t])); } catch (e) { return null; } },
};
}
/** Abweichung -1..1 als Farbe: unter dem Mittel blau, im Mittel grün, darüber hellgrün. */
function zellFarbe(t) {
const mix = (a, b, f) => a.map((x, i) => Math.round(x + (b[i] - x) * f));
const blau = [79, 143, 184], gruen = [108, 190, 88], hell = [176, 222, 128];
const c = t < 0 ? mix(gruen, blau, Math.min(1, -t)) : mix(gruen, hell, Math.min(1, t));
return "rgb(" + c.join(",") + ")";
}
function tempFarbe(t) {
const f = Math.max(0, Math.min(1, (t - 10) / 30));
const kalt = [79, 143, 184], mild = [108, 190, 88], warm = [232, 163, 58];
const c = f < 0.5 ? kalt.map((x, i) => Math.round(x + (mild[i] - x) * f * 2))
: mild.map((x, i) => Math.round(x + (warm[i] - x) * (f - 0.5) * 2));
return "rgb(" + c.join(",") + ")";
}
class Speicher {
constructor(behaelter, opt = {}) {
this.behaelter = behaelter;
this.kwhJeModul = opt.kwhJeModul || KWH_JE_MODUL;
this.modul = null; // gewähltes Modul (0-basiert), null = selbst wählen
this.zeitraum = 24; // Stunden
this.verlauf = null;
this.zuletzt = {};
new ResizeObserver(() => this.diagrammeZeichnen()).observe(behaelter);
this.sichtbar = false;
new IntersectionObserver(e => {
const vorher = this.sichtbar;
this.sichtbar = e[0].isIntersecting;
this.behaelter.classList.toggle("spk-pause", !this.sichtbar);
// Beim ersten Hinsehen den Verlauf holen - vorher kostet er nur.
if (this.sichtbar && !vorher && !this.verlauf) this.verlaufHolen();
}).observe(behaelter);
setInterval(() => { if (this.sichtbar && !document.hidden) this.verlaufHolen(); }, 5 * 60000);
}
/* --------------------------------------------------------- Zeichnen */
zeichnen() {
this.behaelter.innerHTML = `
<div class="spk">
<div class="spk-meldung" hidden></div>
<div class="spk-kennzahlen"></div>
<div class="spk-mitte">
<div class="spk-fach spk-turm-fach">
<div class="spk-kopf"><h3 class="spk-ueberschrift">Module und Zellen</h3><span class="spk-stand"></span></div>
<div class="spk-turm"></div>
<div class="spk-legende">
<span><i style="background:${zellFarbe(-1)}"></i>unter dem Mittel</span>
<span><i style="background:${zellFarbe(0)}"></i>im Mittel</span>
<span><i style="background:${zellFarbe(1)}"></i>darüber</span>
</div>
</div>
<div class="spk-fach spk-detail" aria-live="polite"></div>
</div>
<div class="spk-diagramme">
<div class="spk-fach">
<div class="spk-kopf">
<h3 class="spk-ueberschrift">Ladestand und Leistung</h3>
<div class="spk-zeitraum" role="group" aria-label="Zeitraum">
<button type="button" data-stunden="24" class="aktiv">24 h</button>
<button type="button" data-stunden="168">7 Tage</button>
<button type="button" data-stunden="720">30 Tage</button>
</div>
</div>
<div class="spk-diagramm" data-diagramm="soc"></div>
</div>
<div class="spk-fach">
<div class="spk-kopf"><h3 class="spk-ueberschrift">Zellspreizung und Temperatur</h3></div>
<div class="spk-diagramm" data-diagramm="bmu"></div>
</div>
</div>
</div>`;
const $ = s => this.behaelter.querySelector(s);
this.dom = {
meldung: $(".spk-meldung"), kennzahlen: $(".spk-kennzahlen"), turm: $(".spk-turm"),
stand: $(".spk-stand"), detail: $(".spk-detail"),
soc: $('[data-diagramm="soc"]'), bmu: $('[data-diagramm="bmu"]'),
};
this.behaelter.querySelector(".spk-zeitraum").addEventListener("click", ev => {
const b = ev.target.closest("[data-stunden]");
if (!b) return;
this.zeitraum = Number(b.dataset.stunden);
b.parentElement.querySelectorAll("button").forEach(x => x.classList.toggle("aktiv", x === b));
this.verlaufHolen();
});
this.dom.turm.addEventListener("click", ev => {
const b = ev.target.closest("[data-modul]");
if (!b) return;
this.modul = Number(b.dataset.modul);
this.zuletzt.detail = null;
this.zuletzt.turm = null;
if (this.stand) this.anzeigen();
});
if (this.stand) this.anzeigen();
}
/* ---------------------------------------------------- Aktualisieren */
aktualisieren(daten) {
const m = leser(daten);
const b = "solarManager/byd/";
const bmu = m.da(b + "stand");
const s = {
bmu,
socWr: m.zahl("solarManager/SOC"),
pWr: -m.zahl("solarManager/P_Akku"), // positiv = laden
grenze: m.zahl("solarManager/crgMaxPct"),
};
if (bmu) {
Object.assign(s, {
ok: m.zahl(b + "ok") > 0,
stand: m.zahl(b + "stand"),
soc: m.zahl(b + "soc"), soh: m.zahl(b + "soh"),
spannung: m.zahl(b + "spannung"), strom: m.zahl(b + "strom"),
tMin: m.zahl(b + "tMin"), tMax: m.zahl(b + "tMax"),
zelleMin: m.zahl(b + "zelleMin"), zelleMax: m.zahl(b + "zelleMax"),
zelleMinNr: m.zahl(b + "zelleMinNr"), zelleMaxNr: m.zahl(b + "zelleMaxNr"),
spreizung: m.zahl(b + "spreizung"), ausgleich: m.zahl(b + "ausgleich"),
fehler: m.zahl(b + "fehler"), fehlertext: m.text(b + "fehlertext"),
geladen: m.zahl(b + "geladen"), entladen: m.zahl(b + "entladen"),
module: m.zahl(b + "module"), firmware: m.text(b + "firmware"), seriennummer: m.text(b + "seriennummer"),
zellenRoh: m.text(b + "zellen"), tempRoh: m.text(b + "temperaturen"),
});
if (s.zellenRoh !== (this.stand && this.stand.zellenRoh)) {
s.zellen = m.json(b + "zellen") || [];
s.temps = m.json(b + "temperaturen") || [];
} else {
s.zellen = this.stand.zellen;
s.temps = this.stand.temps;
}
}
this.stand = s;
if (this.dom) this.anzeigen();
}
anzeigen() {
const s = this.stand;
const alt = s.bmu && Date.now() / 1000 - s.stand > VERALTET_S;
// Meldung oben
let meldung = "";
if (!s.bmu) meldung = "Von der Batteriesteuerung (BMU) kommen keine Daten gezeigt wird, was der Wechselrichter weiß.";
else if (alt) meldung = "Die Werte der BMU sind veraltet: letzter Stand " + uhrzeit(new Date(s.stand * 1000)) + " Uhr.";
else if (s.fehler) meldung = "Die BMU meldet: " + s.fehlertext;
if (this.dom.meldung.textContent !== meldung) {
this.dom.meldung.textContent = meldung;
this.dom.meldung.hidden = !meldung;
this.dom.meldung.classList.toggle("spk-fehler", !!(s.bmu && s.fehler && !alt));
}
this.kennzahlenZeigen(s);
// Laden/Entladen als Bewegung im Turm
this.dom.turm.classList.toggle("spk-laden", s.pWr > 50);
this.dom.turm.classList.toggle("spk-entladen", s.pWr < -50);
if (s.bmu && s.zellen && s.zellen.length) {
const sig = s.zellenRoh + "|" + s.tempRoh + "|" + this.modul;
if (this.zuletzt.turm !== sig) { this.zuletzt.turm = sig; this.turmZeigen(s); }
const stand = "Messung " + uhrzeit(new Date(s.stand * 1000));
if (this.dom.stand.textContent !== stand) this.dom.stand.textContent = stand;
} else if (this.zuletzt.turm !== "leer") {
this.zuletzt.turm = "leer";
this.dom.turm.innerHTML = '<p class="spk-leer">Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.</p>';
this.dom.detail.innerHTML = "";
}
}
/* ------------------------------------------------------ Kennzahlen */
kennzahlenZeigen(s) {
const soc = s.bmu ? s.soc : s.socWr;
const module = s.bmu && s.module ? s.module : 0;
const kwh = module * this.kwhJeModul;
const p = s.pWr;
let prognose = "in Ruhe";
if (p > 50 && kwh) {
const h = kwh * (100 - soc) / 100 / (p / 1000);
prognose = soc >= 99.5 ? "voll" : "voll " + wann(h);
} else if (p < -50 && kwh) {
const h = kwh * soc / 100 / (-p / 1000);
prognose = "leer " + wann(h);
}
if (p > 50 && s.grenze > 0 && s.grenze < 100) prognose += " · begrenzt auf " + Math.round(s.grenze) + " %";
const kacheln = [
{ titel: "Ladestand", zeichen: "\uF187", wert: Math.round(soc) + " %", neben: kwh ? "≈ " + komma(kwh * soc / 100) + " von " + komma(kwh) + " kWh" : "", balken: soc },
{ titel: "Leistung", zeichen: p > 50 ? "\uF185" : "\uF46E", wert: (p > 50 ? "+" : p < -50 ? "" : "") + komma(Math.abs(p) < 50 ? 0 : Math.abs(p) / 1000, 2) + " kW",
neben: (p > 50 ? "lädt · " : p < -50 ? "entlädt · " : "") + prognose, klasse: p > 50 ? "spk-gut" : p < -50 ? "spk-warm" : "" },
];
if (s.bmu) {
const zyklen = kwh ? s.entladen / kwh : 0;
kacheln.push(
{ titel: "Gesundheit", zeichen: "\uF76E", wert: komma(s.soh, 0) + " %", neben: "SOH laut BMU" },
{ titel: "Zellspreizung", zeichen: "\uF304", wert: tausend(s.spreizung) + " mV",
neben: komma(s.zelleMin / 1000, 3) + "" + komma(s.zelleMax / 1000, 3) + " V",
klasse: s.spreizung > 30 ? "spk-warm" : "" },
{ titel: "Temperatur", zeichen: "\uF5CD", wert: Math.round(s.tMin) + "" + Math.round(s.tMax) + " °C", neben: (s.temps || []).length + " Fühler" },
{ titel: "Spannung", zeichen: "\uF66B", wert: komma(s.spannung) + " V", neben: komma(s.strom) + " A" },
{ titel: "Durchsatz", zeichen: "\uF130", wert: tausend(s.entladen) + " kWh",
neben: "≈ " + tausend(zyklen) + " Zyklen · " + (s.geladen ? Math.round(s.entladen / s.geladen * 100) : 0) + " % zurück",
titelLang: "entladen seit Inbetriebnahme; Zyklen gerechnet mit " + komma(kwh) + " kWh; zurück = entladen je geladen" },
{ titel: "Zustand", zeichen: s.fehler ? "\uF33A" : "\uF26A", wert: s.fehler ? "Fehler" : "in Ordnung",
neben: (s.firmware || "") + (s.ausgleich ? " · gleicht " + s.ausgleich + " Zellen aus" : ""), klasse: s.fehler ? "spk-krit" : "spk-gut" },
);
}
const html = kacheln.map(k => `
<div class="spk-kachel ${k.klasse || ""}"${k.titelLang ? ` title="${schutz(k.titelLang)}"` : ""}>
<span class="spk-kachel-titel"><i>${k.zeichen}</i>${k.titel}</span>
<b>${schutz(k.wert)}</b>
<small>${schutz(k.neben)}</small>
${k.balken !== undefined ? `<span class="spk-kachel-balken"><span style="width:${Math.max(0, Math.min(100, k.balken))}%"></span></span>` : ""}
</div>`).join("");
if (this.zuletzt.kennzahlen === html) return;
// Beim ersten Mal ganz, danach Kachel für Kachel - der Balken soll gleiten, nicht springen.
const alte = this.dom.kennzahlen.children;
if (alte.length !== kacheln.length) {
this.dom.kennzahlen.innerHTML = html;
} else {
const neu = document.createElement("div");
neu.innerHTML = html;
[...neu.children].forEach((k, i) => {
const a = alte[i];
if (a.className !== k.className) a.className = k.className;
["b", "small", ".spk-kachel-titel"].forEach(sel => {
if (a.querySelector(sel).innerHTML !== k.querySelector(sel).innerHTML) a.querySelector(sel).innerHTML = k.querySelector(sel).innerHTML;
});
const bal = k.querySelector(".spk-kachel-balken span");
if (bal) a.querySelector(".spk-kachel-balken span").style.width = bal.style.width;
});
}
this.zuletzt.kennzahlen = html;
}
/* ------------------------------------------------------------ Turm */
turmZeigen(s) {
const z = s.zellen, t = s.temps || [];
const module = Math.max(1, Math.round(z.length / ZELLEN_JE_MODUL));
const gueltig = z.filter(v => v > 2000 && v < 4500);
const mittel = gueltig.reduce((a, v) => a + v, 0) / (gueltig.length || 1);
const skala = Math.max(3, ...gueltig.map(v => Math.abs(v - mittel)));
this.mittel = mittel; this.skala = skala;
const daten = [];
for (let i = 0; i < module; i++) {
const zellen = z.slice(i * ZELLEN_JE_MODUL, (i + 1) * ZELLEN_JE_MODUL);
const temps = t.slice(i * FUEHLER_JE_MODUL, (i + 1) * FUEHLER_JE_MODUL);
daten.push({
zellen, temps,
min: Math.min(...zellen), max: Math.max(...zellen),
tMin: temps.length ? Math.min(...temps) : null, tMax: temps.length ? Math.max(...temps) : null,
});
}
this.moduldaten = daten;
if (this.modul === null || this.modul >= module) {
// Ohne Wahl das Modul mit der größten Spreizung - dort gibt es am meisten zu sehen.
let best = 0;
daten.forEach((d, i) => { if (d.max - d.min > daten[best].max - daten[best].min) best = i; });
this.modulAuto = best;
}
const gewaehlt = this.modul !== null && this.modul < module ? this.modul : this.modulAuto;
// Oben das Modul 1 wie im Turm selbst: BMU oben, Module darunter.
const kopf = `
<div class="spk-bmu">
<span class="spk-led${s.ok ? " an" : ""}" title="${s.ok ? "BMU liefert Daten" : "keine aktuellen Daten"}"></span>
<b>BMU</b><span>${schutz(s.seriennummer || "")}</span>
</div>`;
const reihen = daten.map((d, i) => `
<button type="button" class="spk-modul${i === gewaehlt ? " gewaehlt" : ""}" data-modul="${i}" style="--i:${i}"
aria-pressed="${i === gewaehlt}" aria-label="Modul ${i + 1}: ${komma(d.min / 1000, 3)} bis ${komma(d.max / 1000, 3)} Volt">
<span class="spk-nr">M${i + 1}</span>
<span class="spk-zellen">${d.zellen.map((v, j) => {
const a = (v - mittel) / skala;
return `<i style="--h:${(52 + a * 40).toFixed(0)}%;--f:${zellFarbe(a)};--j:${j}" title="Zelle ${i * ZELLEN_JE_MODUL + j + 1}: ${tausend(v)} mV"></i>`;
}).join("")}</span>
<span class="spk-werte"><b>${komma(d.min / 1000, 3)}${komma(d.max / 1000, 3)} V</b><small>${d.tMin !== null ? d.tMin + "" + d.tMax + " °C" : ""}</small></span>
</button>`).join("");
this.dom.turm.innerHTML = kopf + reihen + '<div class="spk-sockel"></div>';
this.detailZeigen(s, gewaehlt);
}
detailZeigen(s, nr) {
const d = this.moduldaten[nr];
const spr = d.max - d.min;
const mittel = this.mittel;
const lo = Math.min(d.min, mittel) - 3, hi = Math.max(d.max, mittel) + 3;
const B = 320, H = 150, L = 38, U = 20;
const X = j => L + j * ((B - L - 4) / d.zellen.length);
const bw = (B - L - 4) / d.zellen.length - 3;
const Y = v => 8 + (hi - v) / (hi - lo) * (H - U - 8);
const stufen = [];
const schritt = Math.max(1, Math.ceil((hi - lo) / 4));
for (let v = Math.ceil(lo / schritt) * schritt; v <= hi; v += schritt) stufen.push(v);
const balken = d.zellen.map((v, j) => {
const a = (v - mittel) / this.skala;
const nrGesamt = nr * ZELLEN_JE_MODUL + j + 1;
const markiert = nrGesamt === s.zelleMinNr || nrGesamt === s.zelleMaxNr;
return `<rect class="spk-zelle${markiert ? " markiert" : ""}" x="${X(j).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}"
height="${(H - U - Y(v)).toFixed(1)}" rx="2" style="fill:${zellFarbe(a)};--j:${j}"><title>Zelle ${nrGesamt}: ${tausend(v)} mV</title></rect>`;
}).join("");
const temps = d.temps.map((t, j) => `<span style="--f:${tempFarbe(t)}" title="Fühler ${nr * FUEHLER_JE_MODUL + j + 1}">${t}°</span>`).join("");
const html = `
<div class="spk-kopf"><h3 class="spk-ueberschrift">Modul ${nr + 1}</h3>
<span class="spk-pille ${spr > 20 ? "warn" : "ok"}">${spr > 20 ? "ungleich" : "ausgeglichen"}</span></div>
<div class="spk-gross"><b>${spr} mV</b><span>Spreizung der ${d.zellen.length} Zellen</span></div>
<svg class="spk-zellchart" viewBox="0 0 ${B} ${H}" role="img" aria-label="Zellspannungen Modul ${nr + 1}">
${stufen.map(v => `<line x1="${L}" x2="${B}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" class="spk-gitter"/><text x="${L - 5}" y="${(Y(v) + 3).toFixed(1)}" class="spk-achse" text-anchor="end">${v}</text>`).join("")}
<line x1="${L}" x2="${B}" y1="${Y(mittel).toFixed(1)}" y2="${Y(mittel).toFixed(1)}" class="spk-mittel"/>
${balken}
<text x="${X(0) + bw / 2}" y="${H - 5}" class="spk-achse" text-anchor="middle">1</text>
<text x="${X(d.zellen.length - 1) + bw / 2}" y="${H - 5}" class="spk-achse" text-anchor="middle">${d.zellen.length}</text>
</svg>
<div class="spk-notiz">Zellspannung in mV · gestrichelt: Mittel aller Zellen · weiß umrandet: tiefste und höchste Zelle der Batterie</div>
<div class="spk-fuehler" aria-label="Temperaturfühler">${temps}</div>
<dl class="spk-paare">
<dt>Modulspannung</dt><dd>${komma(d.zellen.reduce((a, v) => a + v, 0) / 1000)} V</dd>
<dt>Zellen</dt><dd>${komma(d.min / 1000, 3)} ${komma(d.max / 1000, 3)} V</dd>
<dt>Temperatur</dt><dd>${d.tMin !== null ? d.tMin + " " + d.tMax + " °C" : ""}</dd>
<dt>Ganze Batterie</dt><dd>Zelle ${s.zelleMinNr} am tiefsten, ${s.zelleMaxNr} am höchsten</dd>
</dl>`;
this.dom.detail.innerHTML = html;
}
/* -------------------------------------------------------- Verlauf */
async verlaufHolen() {
try {
const antwort = await fetch("ajax/speicher.php?was=verlauf&stunden=" + this.zeitraum, { cache: "no-store" });
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
this.verlauf = d;
this.diagrammeZeichnen(true);
} catch (e) {
if (this.dom) {
this.dom.soc.innerHTML = this.dom.bmu.innerHTML = '<p class="spk-leer">Verlauf nicht abrufbar: ' + schutz(e.message) + "</p>";
}
}
}
diagrammeZeichnen(neu) {
if (!this.verlauf || !this.dom || !this.dom.soc.clientWidth) return;
const v = this.verlauf;
diagramm(this.dom.soc, {
von: v.von, bis: v.bis, neu,
links: { min: 0, max: 100, einheit: "%" },
rechts: { einheit: "kW", symmetrisch: true },
reihen: [
{ name: "Leistung", achse: "rechts", werte: v.soc.map(r => [r[0], r[2] / 1000]), flaeche: true, farbe: "#6cbe58", farbeNegativ: "#e8a33a", format: w => (w > 0 ? "+" : "") + komma(w, 2) + " kW" },
{ name: "Ladestand", achse: "links", werte: v.soc.map(r => [r[0], r[1]]), farbe: "#e3e8ec", format: w => komma(w, 0) + " %" },
],
});
if (!v.bmu.length) {
this.dom.bmu.innerHTML = '<p class="spk-leer">Noch keine Werte der BMU in diesem Zeitraum sie werden seit 15.09.2026 alle 5 Minuten gespeichert.</p>';
return;
}
// Die BMU wird erst seit 15.09.2026 mitgeschrieben: reichen die Werte
// nicht über den halben Zeitraum, beginnt die Achse kurz vor dem ersten.
const erster = v.bmu[0][0];
const bmuVon = erster - v.von > (v.bis - v.von) / 2 ? Math.max(v.von, erster - 1800) : v.von;
diagramm(this.dom.bmu, {
von: bmuVon, bis: v.bis, neu,
links: { min: 0, einheit: "mV" },
rechts: { einheit: "°C" },
reihen: [
{ name: "Spreizung", achse: "links", werte: v.bmu.map(r => [r[0], r[1]]), flaeche: true, farbe: "#8fb8de", format: w => komma(w, 0) + " mV" },
{ name: "wärmster Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[3]]), farbe: "#e8a33a", format: w => komma(w, 0) + " °C" },
{ name: "kältester Fühler", achse: "rechts", werte: v.bmu.map(r => [r[0], r[2]]), farbe: "#4f8fb8", format: w => komma(w, 0) + " °C" },
],
});
}
}
/* ------------------------------------------------------------------------
Linien-/Flächendiagramm mit zwei Achsen und Anzeige beim Überfahren.
Klein gehalten: genau das, was die zwei Diagramme hier brauchen.
------------------------------------------------------------------------ */
function diagramm(ziel, o) {
const B = Math.max(280, ziel.clientWidth), H = 190;
const L = 40, R = 46, O = 22, U = 24;
const alle = seite => o.reihen.filter(r => r.achse === seite).flatMap(r => r.werte.map(w => w[1]));
const achse = (seite, cfg) => {
const w = alle(seite);
let min = cfg.min !== undefined ? cfg.min : Math.min(...w);
let max = cfg.max !== undefined ? cfg.max : Math.max(...w);
if (cfg.symmetrisch) { const m = Math.max(0.5, Math.abs(min), Math.abs(max)); min = -m; max = m; }
if (!isFinite(min) || !isFinite(max)) { min = 0; max = 1; }
if (max - min < 1e-6) { max = min + 1; }
const pad = cfg.max !== undefined ? 0 : (max - min) * 0.1;
return { min: cfg.min !== undefined ? min : min - pad, max: max + pad, einheit: cfg.einheit };
};
const ax = { links: achse("links", o.links), rechts: achse("rechts", o.rechts) };
const X = t => L + (t - o.von) / (o.bis - o.von) * (B - L - R);
const Y = (seite, v) => O + (ax[seite].max - v) / (ax[seite].max - ax[seite].min) * (H - O - U);
// Zeitachse
const spanne = o.bis - o.von;
const schritt = spanne <= 86400 * 1.5 ? 3 * 3600 : spanne <= 86400 * 8 ? 86400 : 5 * 86400;
const ticks = [];
const start = new Date(o.von * 1000);
start.setMinutes(0, 0, 0);
if (schritt >= 86400) start.setHours(0);
for (let t = start.getTime() / 1000; t <= o.bis; t += schritt) {
if (t < o.von) continue;
const d = new Date(t * 1000);
if (schritt < 86400 && d.getHours() % 3) continue;
ticks.push([t, schritt < 86400 ? String(d.getHours()).padStart(2, "0") + ":00"
: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"][d.getDay()] + " " + d.getDate() + "."]);
}
const gitterWerte = seite => [0, 0.25, 0.5, 0.75, 1].map(f => ax[seite].min + (ax[seite].max - ax[seite].min) * f);
const zahl = v => Math.abs(v) >= 10 ? Math.round(v) : komma(v, 1);
// Pfade; Lücken über dem Dreifachen des üblichen Abstands werden nicht verbunden.
const pfad = (r, flaeche) => {
if (!r.werte.length) return "";
const abst = r.werte.length > 1 ? (r.werte[r.werte.length - 1][0] - r.werte[0][0]) / (r.werte.length - 1) : 300;
let d = "", zug = [];
const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0)));
const zu = () => {
if (!zug.length) return;
d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L");
if (flaeche) d += `L${zug[zug.length - 1][0].toFixed(1)} ${nullY.toFixed(1)}L${zug[0][0].toFixed(1)} ${nullY.toFixed(1)}Z`;
zug = [];
};
r.werte.forEach((w, i) => {
if (i && w[0] - r.werte[i - 1][0] > abst * 3 + 60) zu();
zug.push([X(w[0]), Y(r.achse, w[1])]);
});
zu();
return d;
};
const id = "spkd" + Math.random().toString(36).slice(2, 8);
let svg = `<svg class="spk-verlauf${o.neu ? " neu" : ""}" viewBox="0 0 ${B} ${H}" width="${B}" height="${H}">`;
gitterWerte("links").forEach(v => {
const y = Y("links", v).toFixed(1);
svg += `<line x1="${L}" x2="${B - R}" y1="${y}" y2="${y}" class="spk-gitter"/><text x="${L - 6}" y="${+y + 3}" class="spk-achse" text-anchor="end">${zahl(v)}</text>`;
});
gitterWerte("rechts").forEach(v => {
svg += `<text x="${B - R + 6}" y="${Y("rechts", v) + 3}" class="spk-achse">${zahl(v)}</text>`;
});
svg += `<text x="${L - 6}" y="9" class="spk-achse spk-einheit" text-anchor="end">${ax.links.einheit}</text>`;
svg += `<text x="${B - R + 6}" y="9" class="spk-achse spk-einheit">${ax.rechts.einheit}</text>`;
ticks.forEach(([t, s]) => { svg += `<text x="${X(t).toFixed(1)}" y="${H - 6}" class="spk-achse" text-anchor="middle">${s}</text>`; });
o.reihen.forEach((r, i) => {
if (r.flaeche) {
const nullY = Y(r.achse, Math.max(ax[r.achse].min, Math.min(ax[r.achse].max, 0)));
svg += `<defs><clipPath id="${id}-o${i}"><rect x="0" y="0" width="${B}" height="${nullY}"/></clipPath>
<clipPath id="${id}-u${i}"><rect x="0" y="${nullY}" width="${B}" height="${H}"/></clipPath></defs>`;
const d = pfad(r, true);
svg += `<path d="${d}" clip-path="url(#${id}-o${i})" class="spk-flaeche" style="fill:${r.farbe}"/>`;
if (r.farbeNegativ) svg += `<path d="${d}" clip-path="url(#${id}-u${i})" class="spk-flaeche" style="fill:${r.farbeNegativ}"/>`;
}
svg += `<path d="${pfad(r, false)}" class="spk-linie" pathLength="1" style="stroke:${r.farbe};--i:${i}"/>`;
});
svg += `<line class="spk-zeiger" y1="${O}" y2="${H - U}" x1="-10" x2="-10"/>`;
svg += `<rect x="${L}" y="0" width="${B - L - R}" height="${H}" fill="transparent" class="spk-fang"/></svg>`;
svg += `<div class="spk-tipp" hidden></div>`;
svg += `<div class="spk-reihen">${o.reihen.map(r => `<span><i style="background:${r.farbe}"></i>${r.name}</span>`).join("")}</div>`;
ziel.innerHTML = svg;
// Überfahren: nächster Zeitpunkt, alle Reihen ablesen.
const el = ziel.querySelector("svg"), zeiger = el.querySelector(".spk-zeiger"), tipp = ziel.querySelector(".spk-tipp");
const zeigen = ev => {
const r = el.getBoundingClientRect();
const x = (ev.clientX - r.left) / r.width * B;
const t = o.von + (x - L) / (B - L - R) * (o.bis - o.von);
const zeilen = o.reihen.map(reihe => {
let best = null;
for (const w of reihe.werte) if (!best || Math.abs(w[0] - t) < Math.abs(best[0] - t)) best = w;
return best && Math.abs(best[0] - t) < 3 * 3600 ? { reihe, w: best } : null;
}).filter(Boolean);
if (!zeilen.length) { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); return; }
const tx = X(zeilen[0].w[0]);
zeiger.setAttribute("x1", tx); zeiger.setAttribute("x2", tx);
const d = new Date(zeilen[0].w[0] * 1000);
tipp.innerHTML = `<b>${d.getDate()}.${d.getMonth() + 1}. ${uhrzeit(d)}</b>` + zeilen.map(z =>
`<span><i style="background:${z.reihe.farbe}"></i>${z.reihe.name}<em>${z.reihe.format(z.w[1])}</em></span>`).join("");
tipp.hidden = false;
const links = tx / B * r.width;
tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4, Math.max(4, links + 12)) + "px";
};
el.addEventListener("pointermove", zeigen);
el.addEventListener("pointerleave", () => { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); });
}
window.Speicher = Speicher;
})();