/* * Jahresvergleich der Anlage - ein Baustein, zwei Seiten. * * Auf der Historienseite steht er mit allem: drei Jahresspalten, umschaltbarem * Zeitraum, verschiebbarem Jahrfenster. Auf der Solarseite genügt das laufende * Jahr. Beide bauen dieselbe Tabelle aus derselben Antwort - was sich * unterscheidet, sind Einstellungen, nicht zwei Fassungen desselben Codes. * * baueJahresstatistik(wurzel, { spalten: 3, werkzeuge: true, fussnoten: true }) * * Alles Inhaltliche kommt von ajax/getStats.php, und zwar auch die Beschreibung * der Zeilen: welche Kennzahlen es gibt, wie sie heißen, welche Einheit sie * tragen, ob weniger besser ist und wie sie zusammenhängen. Hier steht nur, wie * das aussieht. Eine neue Kennzahl braucht deshalb keine Zeile in dieser Datei. * * Der Abruf liefert alle Jahre auf einmal. Das Verschieben des Fensters und das * Umschalten des Zeitraums brauchen darum keine weitere Anfrage - und die * Antwort wird geteilt, falls eine Seite den Baustein zweimal zeigt. */ const MONATSNAMEN = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; /* Ein Abruf für alle Bausteine der Seite. */ let statistikAbruf = null; function holeJahresstatistik() { if (!statistikAbruf) { statistikAbruf = fetch("ajax/getStats.php", { cache: "no-store" }) .then(function (antwort) { if (!antwort.ok) throw new Error("HTTP " + antwort.status); return antwort.json(); }) .then(function (daten) { if (daten.error) throw new Error(daten.error); return daten; }); } return statistikAbruf; } /* ------------------------------------------------------------------ Format */ const statZahl = (wert, dez) => (wert === null || wert === undefined || !isFinite(wert)) ? "–" : Number(wert).toLocaleString("de-DE", { minimumFractionDigits: dez, maximumFractionDigits: dez }); /** * Veränderung zweier Werte. * * Prozentwerte vergleichen sich in Prozentpunkten: "die Autarkie stieg um 5 %" * ließe offen, ob 70 auf 75 oder auf 73,5 gemeint ist. Bei allem anderen ist * die relative Veränderung die Aussage. Was unter einem Prozent bzw. drei * Zehntel Prozentpunkten liegt, gilt als unverändert - sonst zeigt die Tabelle * Bewegung, wo keine ist. */ function statDelta(jetzt, vorher, kz) { if (jetzt === null || jetzt === undefined || !vorher) return null; const roh = kz.punkte ? jetzt - vorher : (jetzt - vorher) / Math.abs(vorher) * 100; const klein = Math.abs(roh) < (kz.punkte ? 0.3 : 1); const besser = kz.invers ? roh < 0 : roh > 0; return { text: (roh >= 0 ? "+" : "−") + statZahl(Math.abs(roh), 1) + (kz.punkte ? " Pp" : " %"), art: klein ? "gleich" : (besser ? "gut" : "schlecht"), zeichen: klein ? "→" : (roh > 0 ? "↑" : "↓") }; } /* --------------------------------------------------------------- Baustein */ function baueJahresstatistik(wurzel, einstellungen) { if (!wurzel) return null; const opt = Object.assign({ spalten: 3, // Jahresspalten nebeneinander werkzeuge: true, // Zeitraum, Jahrfenster, Verlauf fussnoten: true, zeitraum: "gleich", // "gleich" (bis zum Stichtag) oder "voll" verlauf: true }, einstellungen || {}); let daten = null; let zeitraum = opt.zeitraum; let verlauf = opt.verlauf; let fenster = 0; // 0 = die jüngsten Jahre const offen = {}; // aufgeklappte Summenzeilen /* --- Gerüst ------------------------------------------------------------ */ wurzel.classList.add("jahresstatistik"); wurzel.innerHTML = '
' + (opt.werkzeuge ? '
' + '' + '' + '' + '' + '' + '' + '' + '
" : "") + '
' + "" + '' + "
Statistik wird geladen …
" + (opt.fussnoten ? '

' + "

Davon teilt den Ertrag auf: Eigenverbrauch, Einspeisung und Heizung " + "ergeben ihn zusammen. Darin greift aus einer dieser Zeilen etwas heraus – " + "der Beitrag der Batterie und die Solarladung des Autos stecken beide im " + "Eigenverbrauch, denn Strom aus der Batterie und Strom ins Auto ist Strom, der im " + "Haus geblieben ist. Sie dürfen deshalb nicht dazugezählt werden. Der Beitrag der " + "Batterie ist dabei netto gerechnet: abzüglich der Vergütung, die für den zum " + "Laden verwendeten Strom entgangen ist.

" + "

Verhältniszahlen – Autarkie, Quoten, effektiver Strompreis – sind für jeden " + "Zeitraum neu aus seinen Summen gerechnet und nicht über Jahre gemittelt. Der " + "effektive Strompreis ist Netzkosten samt Grundpreis geteilt durch den " + "Netzbezug.

" : ""); const teil = (auswahl) => wurzel.querySelector(auswahl); const kopf = teil("thead tr"); const koerper = teil("tbody"); /* --- Auswahl ----------------------------------------------------------- */ /** Die Jahre ab dem Fensteranfang, jüngstes zuerst. */ function jahreAbFenster() { const alle = daten.jahre.slice().sort((a, b) => b - a); const start = Math.max(0, Math.min(fenster, Math.max(0, alle.length - opt.spalten))); return alle.slice(start); } function spaltenJahre() { return jahreAbFenster().slice(0, opt.spalten); } function werteVon(jahr) { const satz = daten[zeitraum] || {}; return satz[jahr] || null; } /** * Welche beiden Jahre die Δ-Spalte vergleicht. Bei ganzen Jahren bleibt das * laufende außen vor: es ist noch nicht fertig, ein Vergleich mit einem * vollen Jahr wäre keiner. Das Paar hängt am Fenster, nicht an den * sichtbaren Spalten - so vergleicht auch eine einspaltige Ansicht. */ function vergleichsPaar() { const laufend = new Date().getFullYear(); const jahre = jahreAbFenster(); const brauchbar = zeitraum === "gleich" ? jahre : jahre.filter(j => j !== laufend); return brauchbar.length >= 2 ? [brauchbar[0], brauchbar[1]] : null; } /** Das Jahr, dessen Monatsverlauf die letzte Spalte zeigt. */ function verlaufsJahr() { const paar = vergleichsPaar(); return paar ? paar[0] : (spaltenJahre()[0] || null); } /** Der Stichtag als "7. September" - so, wie man ihn ausspricht. */ function stichtagText() { const d = new Date(daten.stichtag + "T00:00:00"); return d.toLocaleDateString("de-DE", { day: "numeric", month: "long" }); } /* --- Monatsbalken ------------------------------------------------------ */ /* * Zwölf Balken, eine Grundlinie, sonst nichts: der Verlauf sagt "wann", die * Zahlen daneben sagen "wieviel". Der letzte Monat mit Wert ist hervorgehoben, * damit erkennbar bleibt, wo das Jahr steht. */ function sparkline(kz, jahr) { const reihe = (daten.monate[jahr] || {})[kz.id]; if (!reihe) return ""; const zahlen = reihe.map(w => (w === null || !isFinite(w)) ? null : Number(w)); const gueltig = zahlen.filter(w => w !== null); if (!gueltig.length) return ""; const hoch = Math.max.apply(null, gueltig.map(Math.abs)) || 1; const letzter = zahlen.reduce((idx, w, i) => (w === null ? idx : i), -1); const b = 7, luecke = 2, H = 24; const breite = zahlen.length * b + (zahlen.length - 1) * luecke; let s = '' + ''; zahlen.forEach(function (w, i) { if (w === null) return; const x = i * (b + luecke); const h = Math.max(1.5, Math.abs(w) / hoch * (H - 4)); s += ''; }); return s + ""; } /* --- Zeichnen ---------------------------------------------------------- */ function zeichneKopf() { const gleich = zeitraum === "gleich"; const laufend = new Date().getFullYear(); const paar = vergleichsPaar(); let h = 'Kennzahl'; spaltenJahre().forEach(function (jahr, i) { const unten = gleich ? "bis " + stichtagText() : (jahr === laufend ? "läuft noch" : "ganzes Jahr"); h += '" + jahr + '' + unten + ""; }); h += 'Δ' + (paar ? paar[0] + " zu " + paar[1] : "–") + "" + 'Gesamtseit ' + daten.jahre[0] + ""; if (verlauf) { h += 'Monatsverlauf' + verlaufsJahr() + ""; } kopf.innerHTML = h; const text = teil('[data-rolle="zeitraumtext"]'); if (text) { text.innerHTML = gleich ? "Gleicher Zeitraum: jedes Jahr vom 1. Januar bis zum " + stichtagText() + " – so vergleicht sich das laufende Jahr mit dem, was zum selben Stichtag im " + "Vorjahr zusammengekommen war." : "Ganze Jahre: abgeschlossene Jahre vollständig; das laufende Jahr steht " + "so weit, wie es ist, und trägt deshalb keine Tendenz."; } } function zeichneZeilen() { const jahre = spaltenJahre(); const paar = vergleichsPaar(); const jahrDesVerlaufs = verlaufsJahr(); const spalten = jahre.length + 3 + (verlauf ? 1 : 0); let h = ""; daten.kennzahlen.forEach(function (kz) { if (kz.gruppe) { h += '' + kz.gruppe + ""; return; } const versteckt = kz.unter && !offen[kz.unter]; const klassen = [kz.summe ? "summe" : "", kz.teilmenge ? "teilmenge" : "", versteckt ? "versteckt" : ""].filter(Boolean).join(" "); /* * Beschriftung. Die Einheit steht einmal an der Zeile statt in jeder * Spalte - sie ändert sich ja nicht von Jahr zu Jahr. Sie sitzt INNERHALB * der Einrückung, damit sie beim Umbruch dem Namen folgt und nicht * allein am linken Zellenrand landet. */ const text = kz.name + '' + kz.einheit + ""; let name; if (kz.klapp) { /* * Zusammengefasste Zeilen stehen zunaechst zu - die Tabelle soll mit * zehn Zeilen aufmachen und nicht mit siebzehn. Damit das Zugeklappte * nicht verschwindet, sagt die Zeile, wieviel darin steckt. */ const posten = daten.kennzahlen.filter(k => k.unter === kz.id).length; name = '"; } else if (kz.teilmenge) { // "darin" allein liesse offen, worin - deshalb steht der Name der // uebergeordneten Zeile daneben. Wer die Tabelle ueberfliegt, soll // nicht raten muessen, ob die Zeile direkt darueber gemeint ist. const traeger = daten.kennzahlen.find(k => k.id === kz.teilmenge); name = '' + (traeger ? "im " + traeger.name : "darin") + "" + text + ""; } else if (kz.teil) { name = '' + text + ""; } else { name = text; } h += '' + name + ""; jahre.forEach(function (jahr, i) { const w = werteVon(jahr); h += '' + statZahl(w ? w[kz.id] : null, kz.dez) + ""; }); const d = paar ? statDelta((werteVon(paar[0]) || {})[kz.id], (werteVon(paar[1]) || {})[kz.id], kz) : null; h += "" + (d ? '' + d.zeichen + " " + d.text + "" : '') + ""; // Die Summe über alles. Verhältniszahlen sind darin über den ganzen // Zeitraum gerechnet, nicht über die Jahre gemittelt. h += '' + statZahl(daten.gesamt ? daten.gesamt[kz.id] : null, kz.dez) + ""; if (verlauf) h += '' + sparkline(kz, jahrDesVerlaufs) + ""; h += ""; }); koerper.innerHTML = h; } function zeichneLeitzahlen() { const paar = vergleichsPaar(); const jahr = paar ? paar[0] : spaltenJahre()[0]; const gegen = paar ? paar[1] : null; const werte = werteVon(jahr) || {}; const vorher = gegen ? (werteVon(gegen) || {}) : {}; teil(".leitzahlen").innerHTML = daten.leitzahlen.map(function (id, i) { const kz = daten.kennzahlen.find(k => k.id === id); if (!kz) return ""; const d = statDelta(werte[id], vorher[id], kz); return '
' + '
' + kz.name + "
" + '
' + statZahl(werte[id], kz.dez) + '' + kz.einheit + "
" + '
' + (d ? '' + d.zeichen + " " + d.text + "" : "") + "" + jahr + (zeitraum === "gleich" ? " bis " + stichtagText() : "") + (gegen ? " gegen " + gegen : "") + "
"; }).join(""); } function zeichneJahrwahl() { const wahl = teil(".jahrwahl"); if (!wahl) return; const jahre = spaltenJahre(); wahl.querySelector(".fenster").textContent = jahre.length > 1 ? jahre[jahre.length - 1] + "–" + jahre[0] : String(jahre[0] || "–"); // Links liegt die Vergangenheit: dort ist Schluss beim ersten // aufgezeichneten Jahr, rechts beim laufenden. wahl.querySelector('[data-schritt="-1"]').disabled = fenster >= daten.jahre.length - opt.spalten; wahl.querySelector('[data-schritt="1"]').disabled = fenster <= 0; } function zeichne() { if (!daten || !daten.jahre.length) return; zeichneLeitzahlen(); zeichneKopf(); zeichneZeilen(); zeichneJahrwahl(); } /* --- Bedienung --------------------------------------------------------- */ wurzel.querySelectorAll("[data-zeitraum]").forEach(function (b) { b.setAttribute("aria-pressed", String(b.dataset.zeitraum === zeitraum)); b.addEventListener("click", function () { zeitraum = b.dataset.zeitraum; wurzel.querySelectorAll("[data-zeitraum]").forEach(function (x) { x.setAttribute("aria-pressed", String(x === b)); }); zeichne(); }); }); wurzel.querySelectorAll("[data-schritt]").forEach(function (b) { b.addEventListener("click", function () { const grenze = Math.max(0, daten.jahre.length - opt.spalten); fenster = Math.max(0, Math.min(grenze, fenster - Number(b.dataset.schritt))); zeichne(); }); }); const schalter = teil('[data-rolle="verlauf"]'); if (schalter) { schalter.checked = verlauf; schalter.addEventListener("change", function (e) { verlauf = e.target.checked; zeichneKopf(); zeichneZeilen(); }); } koerper.addEventListener("click", function (e) { const knopf = e.target.closest("[data-klapp]"); if (!knopf) return; offen[knopf.dataset.klapp] = !offen[knopf.dataset.klapp]; zeichneZeilen(); }); /* * Sprechblase am Monatsverlauf. * * Nicht je Balken, sondern über die ganze Reihe: ein Balken ist sieben Pixel * breit, eine Fingerkuppe deckt rund vierzig - einzeln zu treffen wären sie * nicht, und ein hoher Balken deckte seine eigene Trefferfläche zu. Deshalb * entscheidet die Stelle, an der Finger oder Zeiger die Reihe berühren, * welcher Monat gemeint ist; Wischen fährt sie ab. Damit ist jeder Monat * erreichbar, unabhängig von seiner Höhe. */ const tipp = document.createElement("div"); tipp.className = "spark-tipp"; tipp.setAttribute("role", "status"); document.body.appendChild(tipp); /** Der Balken, über dem die Stelle x liegt - auch wenn sie daneben trifft. */ function balkenBei(svg, x) { const r = svg.getBoundingClientRect(); if (!r.width) return null; const platz = Math.max(0, Math.min(MONATSNAMEN.length - 1, Math.floor((x - r.left) / r.width * MONATSNAMEN.length))); // Monate ohne Wert haben keinen Balken - dort gibt es nichts zu zeigen. return svg.querySelector('rect.balken[data-platz="' + platz + '"]'); } function zeigeTipp(balken) { koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv")); balken.classList.add("aktiv"); tipp.textContent = balken.dataset.monat + " · " + balken.dataset.wert; const r = balken.getBoundingClientRect(); tipp.style.left = Math.max(8, Math.min(window.innerWidth - 180, r.left - 60)) + "px"; tipp.style.top = Math.max(8, r.top - 34) + "px"; tipp.classList.add("an"); } function verbergeTipp() { koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv")); tipp.classList.remove("an"); } /* * Gemeint ist die ganze Zelle, nicht die Zeichnung darin: die Balkenreihe * ist 24 Pixel hoch, die Zelle gut dreissig - wer knapp darueber oder * darunter tippt, meint trotzdem diesen Monat. */ function zeigeAnStelle(e) { const zelle = e.target.closest("td.verlauf"); const svg = zelle && zelle.querySelector("svg.spark"); if (!svg) { verbergeTipp(); return; } const balken = balkenBei(svg, e.clientX); if (balken) zeigeTipp(balken); else verbergeTipp(); } koerper.addEventListener("pointerdown", zeigeAnStelle); koerper.addEventListener("pointermove", zeigeAnStelle); koerper.addEventListener("pointerleave", verbergeTipp); /* --- Laden ------------------------------------------------------------- */ holeJahresstatistik().then(function (antwort) { daten = antwort; if (!daten.jahre.length) { koerper.innerHTML = 'Noch keine ausgewerteten Tage.'; return; } zeichne(); }).catch(function (e) { koerper.innerHTML = 'Statistik nicht abrufbar.'; console.log("Jahresstatistik: " + e.message); }); return { zeichne: zeichne }; }