Dritte Ansicht der Realtime-Karte (auch per Klick auf die Batterie): Kennzahlen (Ladestand mit kWh und voll/leer um, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, Durchsatz, Zustand), der Turm aus BMU und acht Modulen mit je 16 Zellbalken, das gewaehlte Modul mit Zellspannungen und Temperaturfuehlern, dazu Verlaeufe fuer Ladestand und Leistung (EnergyFlow) sowie Spreizung und Temperatur (byd) mit Zeitraum- wahl und Anzeige beim Ueberfahren. Neu: ajax/speicher.php. Nebenbei: Icon-Zeichen in energiefluss.js und im Katalog stehen als Escape (\uF...) statt als unsichtbare Zeichen im Quelltext. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
533 lines
28 KiB
JavaScript
533 lines
28 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]));
|
||
|
||
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 um " + uhrzeit(new Date(Date.now() + h * 3600000));
|
||
} else if (p < -50 && kwh) {
|
||
const h = kwh * soc / 100 / (-p / 1000);
|
||
prognose = "leer um " + uhrzeit(new Date(Date.now() + h * 3600000));
|
||
}
|
||
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;
|
||
})();
|