Vor W, kW, kWh, V, mV, A, % und °C steht jetzt ueberall U+2009 wie in der Uebersicht von Hand begonnen (65 Stellen, nur in Anzeigetexten; CSS-Werte und freistehende Achseneinheiten bleiben). Dazu "Wirkungsgrad 96 %" mit Leerzeichen und die Heizstab-Temperaturen mit " / " wie Sonne und Netz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
29 KiB
JavaScript
542 lines
29 KiB
JavaScript
/* ==========================================================================
|
||
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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;
|
||
})();
|