/* ========================================================================== 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 = `

Module und Zellen

unter dem Mittel im Mittel darüber

Ladestand und Leistung

Zellspreizung und Temperatur

`; 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 = '

Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.

'; 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 => `
${k.zeichen}${k.titel} ${schutz(k.wert)} ${schutz(k.neben)} ${k.balken !== undefined ? `` : ""}
`).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 = `
BMU${schutz(s.seriennummer || "")}
`; const reihen = daten.map((d, i) => ` `).join(""); this.dom.turm.innerHTML = kopf + reihen + '
'; 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 `Zelle ${nrGesamt}: ${tausend(v)} mV`; }).join(""); const temps = d.temps.map((t, j) => `${t}°`).join(""); const html = `

Modul ${nr + 1}

${spr > 20 ? "ungleich" : "ausgeglichen"}
${spr} mVSpreizung der ${d.zellen.length} Zellen
${stufen.map(v => `${v}`).join("")} ${balken} 1 ${d.zellen.length}
Zellspannung in mV · gestrichelt: Mittel aller Zellen · weiß umrandet: tiefste und höchste Zelle der Batterie
${temps}
Modulspannung
${komma(d.zellen.reduce((a, v) => a + v, 0) / 1000)} V
Zellen
${komma(d.min / 1000, 3)} – ${komma(d.max / 1000, 3)} V
Temperatur
${d.tMin !== null ? d.tMin + " – " + d.tMax + " °C" : "–"}
Ganze Batterie
Zelle ${s.zelleMinNr} am tiefsten, ${s.zelleMaxNr} am höchsten
`; 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 = '

Verlauf nicht abrufbar: ' + schutz(e.message) + "

"; } } } 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 = '

Noch keine Werte der BMU in diesem Zeitraum – sie werden seit 15.09.2026 alle 5 Minuten gespeichert.

'; 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 = ``; gitterWerte("links").forEach(v => { const y = Y("links", v).toFixed(1); svg += `${zahl(v)}`; }); gitterWerte("rechts").forEach(v => { svg += `${zahl(v)}`; }); svg += `${ax.links.einheit}`; svg += `${ax.rechts.einheit}`; ticks.forEach(([t, s]) => { svg += `${s}`; }); 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 += ` `; const d = pfad(r, true); svg += ``; if (r.farbeNegativ) svg += ``; } svg += ``; }); svg += ``; svg += ``; svg += ``; svg += `
${o.reihen.map(r => `${r.name}`).join("")}
`; 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 = `${d.getDate()}.${d.getMonth() + 1}. ${uhrzeit(d)}` + zeilen.map(z => `${z.reihe.name}${z.reihe.format(z.w[1])}`).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; })();