From 0cb0d4c3ae7d731aa6f39e5da9ce7d2805cea465 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 8 Sep 2026 20:08:51 +0200 Subject: [PATCH] Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt. Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe, damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit, Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr (?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im Zaehlerstand aber nicht). Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die Blende am Bug ist schmaler und heller - von oben sieht man von einer senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand traegt jetzt die Farbe statt einer zweiten Form. Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist nicht das Mittel der Monatsmittel. Gezeichnet wird das von einem Baustein, den sich Historien- und Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden. Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn Zeilen aufmacht statt mit siebzehn. Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot und Gelb bleiben den Zustaenden vorbehalten. Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger gibt es kein Ueberfahren, und ein title erscheint dort nie. Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen Teil des Jahres. Co-Authored-By: Claude Opus 5 --- ajax/getStats.php | 506 ++++++++++------ ajax/skoda.php | 47 ++ css/solar.css | 1101 ++++++++++++++++++++++++++++++++--- index.php | 4 +- js/solar/common.js | 13 - js/solar/historyMQTT.js | 5 +- js/solar/jahresstatistik.js | 465 +++++++++++++++ js/solar/skodaMQTT.js | 462 ++++++++++++--- js/solar/solarMQTT.js | 5 +- restricted/costs.php | 29 +- restricted/history.php | 53 +- restricted/skoda.php | 555 ++++++++++++------ restricted/solar.php | 33 +- 13 files changed, 2691 insertions(+), 587 deletions(-) create mode 100644 js/solar/jahresstatistik.js diff --git a/ajax/getStats.php b/ajax/getStats.php index 8dc5104..bb51c2c 100644 --- a/ajax/getStats.php +++ b/ajax/getStats.php @@ -1,73 +1,177 @@ date("Y")){ - exit; -} -if(isset($_GET["type"])){ - if(strtolower($_GET["type"]) =="lastyear"){ - $_GET["year"] = date("Y")-1; - }elseif(strtolower($_GET["type"]) =="prelastyear"){ - $_GET["year"] = date("Y")-2; - } -} - -/** - * Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily. +/* + * Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON. * - * Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber - * die Tageswerte. Zwei Gruende: + * Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln, + * Einheit und Richtung in zwei Arrays, die ueber den Spaltenindex mit der + * Abfrage synchron gehalten werden mussten. Wer eine Kennzahl einfuegte, + * musste an drei Stellen zaehlen. + * + * Jetzt kommt alles auf einmal und beschreibt sich selbst: + * + * kennzahlen Metadaten je Kennzahl - Name, Einheit, Nachkommastellen, + * Richtung, Gruppe, Einrueckung. Wer eine hinzufuegt, schreibt + * sie EINMAL in KENNZAHLEN und liefert ihren Wert in + * kennzahlenAus(); Anzeige und Reihenfolge ergeben sich daraus. + * werte drei Saetze: ganze Jahre, gleicher Zeitraum (1. Januar bis + * zum heutigen Stichtag) und die Summe ueber alle Jahre. + * monate Monatswerte je Jahr fuer die Verlaufsbalken. + * + * Warum drei Saetze und nicht einer: ein laufendes Jahr gegen ein volles zu + * stellen vergleicht Aepfel mit Birnen. Der Satz "gleich" schneidet jedes Jahr + * am selben Kalendertag ab und macht das laufende Jahr damit beurteilbar. + * + * Gerechnet wird auf stats_daily, den Tageswerten: * * 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins - * Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf - * EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an. + * Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow + * rechnet, zeigt fuer alte Jahre zu wenig an. * 2. Ein Jahr sind 365 Zeilen statt rund 105.000. * - * Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt - * LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie - * zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts. + * Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD() + * beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei + * Tarife haben - die Verdichtung auf Tage verliert hier also nichts. * * Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je * Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst: * - * eigenverbrauch_kwh pv - Einspeisung - Batterieladung - * - Heizstab + Batterieentladung. Dazu addiert - * wird pv_fehlbetrag_kwh: bei Ausfall einzelner + * eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab + * + Batterieentladung. Dazu addiert wird + * pv_fehlbetrag_kwh: bei Ausfall einzelner * Wechselrichter fehlt deren Beitrag in pv, alle * Senken sind dagegen gemessen - die Differenz * faellt sonst zu klein oder negativ aus. * heizstab_solar_kwh Heizstab, anteilig nach autonomy - * auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom - * Geraet in feiner Aufloesung gerechneten - * Nicht-Netz-Anteil der Zufuhr + * auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet + * in feiner Aufloesung gerechneten Nicht-Netz- + * Anteil der Zufuhr * - * Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog - * liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten - * Abfrage stand das als /12 gegen /12000 nebeneinander. + * Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten, + * effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene + * neu. Das ist keine Formsache: Durchschnitte darf man nicht addieren, ein + * Jahresmittel ist nicht das Mittel der Monatsmittel, und die Autarkie ueber + * acht Jahre schon gar nicht die Summe von acht Prozentzahlen. * - * Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam - * vorher 0/0 heraus. + * Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in + * keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu. * - * "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie - * aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum - * Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu - * freundlich - der Speicher ist nicht umsonst zu fuellen. + * Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und + * Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der + * Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH - + * der Strom, der aus der Batterie kommt oder ins Auto geht, ist ja Strom, der + * im Haus geblieben ist. Sie sind nach Herkunft bzw. Verwendung + * herausgegriffen und duerfen nicht dazuaddiert werden. Die Heizung ist davon + * unberuehrt: heizstab_solar_kwh ist aus eigenverbrauch_kwh herausgerechnet + * und gegen den Gaspreis bewertet, nicht gegen den Strompreis. * - * Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch - * zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind - * Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie - * duerfen nicht addiert werden. - * - * Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des - * Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu. + * Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet + * ist er netto, also abzueglich der Verguetung, die fuer den zum Laden + * verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der + * Speicher ist nicht umsonst zu fuellen. */ -function statistikAbfrage($jahr) + +require_once("../helper.php"); +require_once("../restricted/costs.php"); + +header('Content-Type: application/json; charset=utf-8'); + +if (!checkLogin()) { + http_response_code(403); + echo json_encode(["error" => "Nicht angemeldet."]); + exit; +} + +/* ------------------------------------------------------------- Kennzahlen -- + * + * Die Anzeige-Beschreibung. Reihenfolge ist Anzeigereihenfolge. + * + * einheit wie sie an der Zeile steht + * dez Nachkommastellen. Jahressummen brauchen keine Cent. + * invers weniger ist besser (Verbrauch, Kosten) + * punkte Prozentwert; Veraenderungen sind Prozentpunkte, nicht Prozent + * summe Zeile, unter der sich Teilbetraege aufreihen + * stufe/teil eingerueckt: "davon" teilt die Summe darueber auf + * teilmenge "darin": steckt bereits in der genannten Zeile und darf nicht + * dazuaddiert werden. Der Wert ist deren id, damit die Anzeige + * sagen kann, WORIN es steckt - die blosse Reihenfolge liesse + * offen, ob die vorangehende oder die uebergeordnete Zeile + * gemeint ist. + * klapp Zeile mit Teilzeilen, zunaechst zusammengeklappt + * unter gehoert in die Klappgruppe dieser Zeile. Das ist etwas + * anderes als teilmenge: unter sagt, WOMIT eine Zeile + * auf- und zugeht, teilmenge, WORIN ihr Wert steckt. + * braucht Spalte in stats_daily, ohne die es die Kennzahl nicht gibt + */ +const KENNZAHLEN = [ + ["gruppe" => "Strom vom Netz"], + ["id" => "bezug", "name" => "Netzbezug", "einheit" => "kWh", "dez" => 0, "invers" => true], + ["id" => "kosten", "name" => "Netzkosten (mit Grundpreis)", "einheit" => "€", "dez" => 0, "invers" => true], + ["id" => "preis", "name" => "Effektiver Strompreis", "einheit" => "ct/kWh", "dez" => 1, "invers" => true], + + ["gruppe" => "Was die Anlage einbringt"], + ["id" => "ertrag", "name" => "Ertrag der Anlage", "einheit" => "€", "dez" => 0, "summe" => true, "klapp" => true], + ["id" => "eigen", "name" => "Eigenverbrauch", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"], + ["id" => "batterie", "name" => "Beitrag der Batterie", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"], + ["id" => "solarauto", "name" => "Solarladung des Autos", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"], + ["id" => "einsp", "name" => "Einspeisung", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"], + ["id" => "heizung", "name" => "Heizung über den Heizstab", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"], + ["id" => "autarkie", "name" => "Autarkie", "einheit" => "%", "dez" => 1, "punkte" => true], + ["id" => "quote", "name" => "Eigenverbrauchsquote", "einheit" => "%", "dez" => 1, "punkte" => true, + "braucht" => "pv_kwh"], + + ["gruppe" => "Auto"], + ["id" => "geladen", "name" => "Geladen", "einheit" => "kWh", "dez" => 0, "summe" => true, "klapp" => true], + ["id" => "ladenEG", "name" => "Wallbox EG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"], + ["id" => "ladenOG", "name" => "Wallbox OG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"], + ["id" => "solaranteil","name" => "Solaranteil der Ladung", "einheit" => "%", "dez" => 1, "punkte" => true], + ["id" => "benzin", "name" => "Benzin gespart", "einheit" => "L", "dez" => 0], + ["id" => "benzingeld", "name" => "Ersparnis gegenüber Benzin", "einheit" => "€", "dez" => 0], +]; + +/* Die drei Kennzahlen, die oben gross stehen. */ +const LEITZAHLEN = ["ertrag", "kosten", "autarkie"]; + +/* ------------------------------------------------------------------ Summen -- + * + * Alle Grundgroessen in einem Ausdruck. Was hier steht, ist addierbar - genau + * das ist die Bedingung dafuer, dass Monat, Jahr, Zeitraum und Gesamtsumme + * aus derselben Abfrage entstehen koennen. + */ +function summenSQL($hatPv) +{ + return " + SUM(d.netz_bezug_kwh) AS bezug_kwh, + SUM(d.netz_bezug_kwh * preis.cost) AS bezug_eur, + SUM(d.netz_einsp_kwh) AS einsp_kwh, + SUM(d.netz_einsp_kwh * preis.gain) AS einsp_eur, + SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS eigen_eur, + " . ($hatPv ? "SUM(d.pv_kwh + IFNULL(d.pv_fehlbetrag_kwh,0))" : "NULL") . " AS pv_kwh, + SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS heiz_eur, + SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS batt_eur, + SUM(d.autarkie_avg * d.stunden_erfasst) AS autarkie_gew, + SUM(d.stunden_erfasst) AS stunden, + SUM(d.auto_eg_kwh) AS eg_kwh, + SUM(d.auto_og_kwh) AS og_kwh, + SUM(d.auto_eg_solar_kwh) AS eg_solar_kwh, + SUM(d.auto_og_solar_kwh) AS og_solar_kwh, + SUM(d.auto_eg_solar_kwh * preis.cost) AS eg_solar_eur, + SUM(d.auto_og_solar_kwh * preis.cost) AS og_solar_eur, + SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS benzin_l, + SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) + * sprit.cost) AS benzin_eur"; +} + +/** Der gemeinsame Rumpf: Tageswerte, an jedem Tag der damals gueltige Tarif. */ +function vonTarifen() +{ + return " +FROM stats_daily d + JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt) + LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt) + LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)"; +} + +function mitTarifen() { return "WITH preis AS (SELECT active_date, cost, gain, @@ -76,136 +180,190 @@ function statistikAbfrage($jahr) LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts), sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km, LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts) -SELECT - SUM(d.netz_bezug_kwh) AS 'Stromverbrauch', - SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten', - SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis', - SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung', - SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung', - SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie', - SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie', - SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG', - SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG', - SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG', - SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG', - SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG', - SUM(d.auto_og_kwh) AS 'Autoladung ges. OG', - SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG', - SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG', - SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG', - SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG' -FROM stats_daily d - JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt) - LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt) - LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt) -WHERE YEAR(d.tag) = " . intval($jahr) . ";"; +SELECT "; } -$Query = statistikAbfrage($_GET["year"]); -$PrevQuery = statistikAbfrage($_GET["year"] - 1); +/* --------------------------------------------------------------- Rechnung -- + * + * Aus Summen werden Kennzahlen. Diese Funktion ist die einzige Stelle, an der + * eine Kennzahl definiert ist - sie laeuft fuer jeden Monat, jedes Jahr, jeden + * Zeitraum und die Gesamtsumme mit denselben Regeln. + * + * null heisst "nicht bestimmbar" und nicht "null": ohne Bezug gibt es keinen + * effektiven Preis, ohne Ladung keinen Solaranteil. Die Anzeige zeigt darauf + * einen Strich statt einer 0. + */ +function kennzahlenAus($s, $grundpreis) +{ + $teile = function ($zaehler, $nenner, $faktor = 1) { + return ($nenner === null || $nenner <= 0 || $zaehler === null) + ? null : $zaehler / $nenner * $faktor; + }; -// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4. -$units = Array("kWh","€","€","€","€","€","€","%", - "kWh","%","€","L","€", - "kWh","%","€","L","€"); -// Nur bei Verbrauch und Kosten ist weniger besser. -$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false)); + $bezug = floatval($s["bezug_kwh"]); + $kosten = floatval($s["bezug_eur"]) + $grundpreis; + $geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]); + $solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]); -function array_insert($array,$values,$offset) { - return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true); + return [ + "bezug" => $bezug, + "kosten" => $kosten, + "preis" => $teile($kosten, $bezug, 100), + "ertrag" => floatval($s["eigen_eur"]) + floatval($s["einsp_eur"]) + floatval($s["heiz_eur"]), + "eigen" => floatval($s["eigen_eur"]), + "einsp" => floatval($s["einsp_eur"]), + "heizung" => floatval($s["heiz_eur"]), + "batterie" => floatval($s["batt_eur"]), + "solarauto" => floatval($s["eg_solar_eur"]) + floatval($s["og_solar_eur"]), + "autarkie" => $teile($s["autarkie_gew"], $s["stunden"]), + // Eigenverbrauchsquote: der Anteil der Erzeugung, der im Haus blieb. + "quote" => $s["pv_kwh"] === null ? null + : $teile(floatval($s["pv_kwh"]) - floatval($s["einsp_kwh"]), + floatval($s["pv_kwh"]), 100), + "geladen" => $geladen, + "ladenEG" => floatval($s["eg_kwh"]), + "ladenOG" => floatval($s["og_kwh"]), + "solaranteil" => $teile($solar, $geladen, 100), + "benzin" => floatval($s["benzin_l"]), + "benzingeld" => floatval($s["benzin_eur"]), + ]; } - -if (checkLogin()) { - - $html = "
"; - $mysql = solarDb(); - $Res = mysqli_query($mysql,$Query); - $ResPrev = mysqli_query($mysql,$PrevQuery); - if(!$Res || !$ResPrev){ - echo "Error:
".mysqli_error($mysql)."
"; - } - if ($Res->num_rows > 0) { - $i = 0; - $row = $Res->fetch_assoc(); - $rowPrev = $ResPrev->fetch_assoc(); - // Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich - // deshalb nicht aus den Messwerten summieren. - $row["Stromkosten"] += grundpreisImJahr($_GET["year"]); - $rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1); - //$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]; - $row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4); - $rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4); - $i = 0; - foreach ($row as $name => $value) { - if(str_starts_with($name,"Autoladung ges.")){ - $html .= "

"; - $html .= "
"; - } - /*$html .= "
-
-
- ".$name." - ". - number_format(floatval($value),2,",",".") - ."".$units[$i++]." - -
- -
-
";*/ - $html .= "
-
-
- ".$name." -
- - -
-
"; - $i++; +/** Zwei Summensaetze addieren - fuer Jahre aus Monaten und die Gesamtsumme. */ +function summenPlus($a, $b) +{ + if ($a === null) return $b; + foreach ($b as $k => $v) { + if ($v === null) continue; + $a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v; } - } - $html .= "
"; + return $a; } +/* ------------------------------------------------------------------ Abruf --*/ -//header('Content-Type: application/json'); -echo $html; -//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}'; +$db = solarDb(); + +// Gibt es die Erzeugung als Tageswert? Ohne sie entfaellt die +// Eigenverbrauchsquote - lieber eine Kennzahl weniger als eine Abfrage, die +// an einer fehlenden Spalte scheitert. +$hatPv = false; +if ($res = $db->query("SHOW COLUMNS FROM stats_daily LIKE 'pv_kwh'")) { + $hatPv = $res->num_rows > 0; +} + +$heute = new DateTimeImmutable("today"); + +/* + * Monatswerte. Daraus entstehen die Verlaufsbalken, durch Addition die ganzen + * Jahre und durch nochmalige Addition die Gesamtsumme - eine Abfrage fuer + * drei Ebenen. + */ +$monate = []; +$sql = mitTarifen() . "YEAR(d.tag) AS jahr, MONTH(d.tag) AS monat, " . summenSQL($hatPv) + . vonTarifen() . " GROUP BY 1, 2 ORDER BY 1, 2"; +$res = $db->query($sql); +if (!$res) { + http_response_code(500); + echo json_encode(["error" => "Statistik nicht abrufbar: " . $db->error]); + exit; +} +while ($z = $res->fetch_assoc()) { + $jahr = intval($z["jahr"]); + $monat = intval($z["monat"]); + unset($z["jahr"], $z["monat"]); // ab hier sind nur noch Summen drin + $monate[$jahr][$monat] = $z; +} + +/* + * Gleicher Zeitraum: jedes Jahr vom 1. Januar bis zum heutigen Kalendertag. + * Verglichen wird nach Monat und Tag statt nach DAYOFYEAR - der 7. September + * ist in einem Schaltjahr der 251., sonst der 250. Tag, und ein Vergleich + * ueber die Tagesnummer waere in Schaltjahren um einen Tag verschoben. + */ +$gleichSummen = []; +$sql = mitTarifen() . "YEAR(d.tag) AS jahr, " . summenSQL($hatPv) . vonTarifen() + . " WHERE (MONTH(d.tag) < MONTH(CURDATE()) + OR (MONTH(d.tag) = MONTH(CURDATE()) AND DAY(d.tag) <= DAY(CURDATE()))) + GROUP BY 1 ORDER BY 1"; +$res = $db->query($sql); +while ($res && $z = $res->fetch_assoc()) { + $jahr = intval($z["jahr"]); + unset($z["jahr"]); + $gleichSummen[$jahr] = $z; +} + +$jahre = array_keys($monate); +sort($jahre); + +/* ------------------------------------------------------- Antwort aufbauen --*/ + +$antwort = [ + "stichtag" => $heute->format("Y-m-d"), + "jahre" => array_values($jahre), + "kennzahlen" => array_values(array_filter(KENNZAHLEN, function ($k) use ($hatPv) { + return !isset($k["braucht"]) || ($k["braucht"] === "pv_kwh" && $hatPv); + })), + "leitzahlen" => LEITZAHLEN, + "voll" => [], + "gleich" => [], + "monate" => [], +]; + +$gesamtSummen = null; +$gesamtGrund = 0.0; + +foreach ($jahre as $jahr) { + $jahresSummen = null; + $grundJahr = grundpreisImJahr($jahr); + + // Monatswerte. Der Grundpreis des Jahres verteilt sich nach Tagen auf die + // Monate - fuer den Verlauf genau genug, und eine Abfrage je Monat waere + // hundert Abfragen fuer eine Balkenhoehe. + $tageImJahr = intval((new DateTimeImmutable($jahr . "-12-31"))->format("z")) + 1; + for ($m = 1; $m <= 12; $m++) { + if (!isset($monate[$jahr][$m])) continue; + $tageImMonat = intval((new DateTimeImmutable(sprintf("%d-%02d-01", $jahr, $m)))->format("t")); + $werte = kennzahlenAus($monate[$jahr][$m], $grundJahr * $tageImMonat / $tageImJahr); + foreach ($werte as $id => $wert) { + $antwort["monate"][$jahr][$id][$m - 1] = $wert; + } + $jahresSummen = summenPlus($jahresSummen, $monate[$jahr][$m]); + } + // Monate ohne Daten bleiben als Luecke stehen, damit der Verlauf zwoelf + // Plaetze hat und der Januar links liegt. + foreach (array_keys($antwort["monate"][$jahr] ?? []) as $id) { + for ($m = 0; $m < 12; $m++) { + if (!array_key_exists($m, $antwort["monate"][$jahr][$id])) { + $antwort["monate"][$jahr][$id][$m] = null; + } + } + ksort($antwort["monate"][$jahr][$id]); + $antwort["monate"][$jahr][$id] = array_values($antwort["monate"][$jahr][$id]); + } + + if ($jahresSummen !== null) { + $antwort["voll"][$jahr] = kennzahlenAus($jahresSummen, $grundJahr); + $gesamtSummen = summenPlus($gesamtSummen, $jahresSummen); + $gesamtGrund += $grundJahr; + } + + if (isset($gleichSummen[$jahr])) { + /* + * Der Stichtag im jeweiligen Jahr. Der Tag wird auf die Laenge des + * Monats begrenzt: am 29. Februar gibt es in den anderen Jahren + * keinen 29. Februar, und ein ungueltiges Datum rutschte sonst + * stillschweigend in den Maerz. + */ + $tag = min(intval($heute->format("d")), + intval((new DateTimeImmutable($jahr . "-" . $heute->format("m") . "-01"))->format("t"))); + $bis = sprintf("%d-%s-%02d", $jahr, $heute->format("m"), $tag); + $antwort["gleich"][$jahr] = kennzahlenAus( + $gleichSummen[$jahr], grundpreisImZeitraum($jahr . "-01-01", $bis)); + } +} + +// Die Summe ueber alles - dieselbe Rechnung, nur ueber alle Jahre. +$antwort["gesamt"] = $gesamtSummen === null ? null : kennzahlenAus($gesamtSummen, $gesamtGrund); + +echo json_encode($antwort, JSON_UNESCAPED_UNICODE); diff --git a/ajax/skoda.php b/ajax/skoda.php index 5685c9e..7173326 100644 --- a/ajax/skoda.php +++ b/ajax/skoda.php @@ -7,6 +7,7 @@ * ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit) * ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend * ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten + * ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr * * Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von * gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn @@ -358,6 +359,52 @@ case "fahrten": $antwort["kapazitaet"] = kapazitaetSchaetzen($mysql); break; +/* --------------------------------------------------------------- strecke -- */ +case "strecke": + /* + * Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem + * Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz + * zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die + * gefahrene Strecke. Das ist belastbarer als eine Summe über die + * erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die + * Fahrt in der Liste, im Zählerstand aber nicht. + * + * CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine + * Differenz unter null bräche die Abfrage ab. + */ + $zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365]; + + $spalten = []; + foreach ($zeitraeume as $name => $spanne) { + $spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY + THEN CAST(odo_km AS SIGNED) END) AS von_$name"; + } + $sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, " + . implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL"; + $res = $mysql->query($sql); + $z = $res ? $res->fetch_assoc() : null; + + $jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null; + $antwort["odo"] = $jetzt; + $antwort["seit"] = $z ? $z["seit"] : null; + $antwort["strecke"] = []; + + foreach ($zeitraeume as $name => $spanne) { + $von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null; + $antwort["strecke"][$name] = [ + "tage" => $spanne, + "km" => ($von === null || $jetzt === null) ? null : $jetzt - $von, + /* + * Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert + * aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss + * das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben. + */ + "vollstaendig" => ($z && $z["seit"] !== null) + && strtotime($z["seit"]) <= time() - $spanne * 86400 + ]; + } + break; + default: http_response_code(400); $antwort = ["error" => "Unbekannter Abschnitt."]; diff --git a/css/solar.css b/css/solar.css index 38ec19b..b6fd285 100644 --- a/css/solar.css +++ b/css/solar.css @@ -1,3 +1,585 @@ +/* ========================================================================== + Grundton der Oberflaeche + + Die Seite laeuft auf AdminLTE, also auf Bootstrap. Beide beziehen ihre + Farben aus Variablen - deshalb wird hier nicht Regel fuer Regel + ueberschrieben, sondern die Palette ausgetauscht: ein paar Variablen am + Wurzelelement, und Karten, Tabellen, Abzeichen und Knoepfe folgen von + selbst. Was danach noch kommt, sind wenige gezielte Regeln fuer die Dinge, + die Bootstrap fest verdrahtet hat (Kartenrahmen, Kopfzeilen, Trennlinien). + + Die Palette selbst ist die des Jahresvergleichs: ein leicht blaustichiges + Anthrazit als Grund, eine Spur hellere Flaeche fuer Karten, Haarlinien statt + Rahmen - und Bernstein als einzige Akzentfarbe, weil das auf dieser Seite + die Farbe der eigenen Anlage ist. Gruen, Rot und Gelb bleiben den + Zustaenden vorbehalten und sind kein Schmuck. + ========================================================================== */ + +:root, +[data-bs-theme="dark"] { + --ton-grund: #191d20; /* Seitenhintergrund */ + --ton-flaeche: #23282c; /* Karten */ + --ton-flaeche-2: #2b3237; /* Kopfzeilen, Tabellenkopf */ + --ton-linie: rgba(138, 148, 158, .20); + --ton-linie-satt: rgba(138, 148, 158, .38); + --ton-schrift: #e8ede9; + --ton-schrift-2: #9aa6ad; + --ton-schrift-3: #6f7a81; + --ton-sonne: #f0b429; /* die eigene Anlage */ + --ton-sonne-rgb: 240, 180, 41; + --ton-gut: #4caf7d; + --ton-schlecht: #e06060; + --ton-radius: .7rem; + + /* ... und dieselben Werte dort, wo Bootstrap sie sucht. */ + --bs-body-bg: var(--ton-grund); + --bs-body-bg-rgb: 25, 29, 32; + --bs-body-color: var(--ton-schrift); + --bs-body-color-rgb: 232, 237, 233; + --bs-emphasis-color: #fff; + --bs-secondary-color: var(--ton-schrift-2); + --bs-secondary-bg: var(--ton-flaeche-2); + --bs-tertiary-color: var(--ton-schrift-3); + --bs-tertiary-bg: var(--ton-flaeche-2); + --bs-border-color: var(--ton-linie-satt); + --bs-border-radius: var(--ton-radius); + --bs-link-color: var(--ton-sonne); + --bs-link-hover-color: #ffc94d; + --bs-heading-color: var(--ton-schrift); +} + +/* Der Rumpf traegt zwei Grundfarben: die Seite und die Leisten drumherum. + AdminLTE setzt sie ueber eigene Klassen, die hier auf denselben Ton + gebracht werden - sonst steht eine Karte auf drei verschiedenen Grautoenen. */ +body.bg-body-tertiary, +.app-content, +.app-content-header { background: var(--ton-grund); } + +.app-header.bg-body, +.app-sidebar.bg-body-secondary { + background: var(--ton-flaeche) !important; + border-color: var(--ton-linie) !important; +} +.app-header { box-shadow: 0 1px 0 var(--ton-linie); } + +/* --- Karten -------------------------------------------------------------- + Eine Karte ist eine Flaeche, kein Kasten: ein Ton heller als der Grund, eine + Haarlinie als Rand, kein Schlagschatten. Der Schatten trennte frueher, was + der Farbunterschied jetzt allein leistet. */ +.card { + background: var(--ton-flaeche); + border: 1px solid var(--ton-linie); + border-radius: var(--ton-radius); + box-shadow: none; +} +.card-header { + background: transparent; + border-bottom: 1px solid var(--ton-linie); + padding-inline: 1rem; +} +.card-title { + font-size: 1rem; + font-weight: 500; + letter-spacing: -.01em; +} +.card-body { padding: 1rem; } +.card-footer { + background: transparent; + border-top: 1px solid var(--ton-linie); +} + +/* Werkzeugknoepfe in der Kopfzeile: ruhig, bis man sie braucht. */ +.btn-tool { + color: var(--ton-schrift-3); + transition: color .18s, background .18s; + border-radius: .35rem; +} +.btn-tool:hover, +.btn-tool:focus-visible { color: var(--ton-schrift); background: rgba(138, 148, 158, .12); } + +/* --- Menue --------------------------------------------------------------- + Der aktive Punkt traegt die Akzentfarbe, nicht das Blau des Themes - + sonst haette die Seite zwei Hauptfarben. */ +.sidebar-menu .nav-link { + border-radius: .45rem; + color: var(--ton-schrift-2); +} +.sidebar-menu .nav-link:hover { background: rgba(138, 148, 158, .1); color: var(--ton-schrift); } +.sidebar-menu .nav-link.active, +.sidebar-menu .nav-link.active:hover { + background: rgba(var(--ton-sonne-rgb), .16); + color: var(--ton-sonne); +} +.sidebar-brand { border-bottom: 1px solid var(--ton-linie); } +.brand-text { font-weight: 400; letter-spacing: .01em; } + +/* --- Schrift ------------------------------------------------------------- + Zahlen stehen ueberall in Spalten untereinander - mit Ziffern gleicher + Breite bleiben sie ausgerichtet, auch wenn sich der Wert aendert. */ +.card-body, +.table, +.info-box-number, +.progress-bar { font-variant-numeric: tabular-nums; } + +.app-content-header h3 { + font-weight: 500; + letter-spacing: -.01em; +} + +/* --- Tabellen ------------------------------------------------------------ */ +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--ton-schrift); + --bs-table-border-color: var(--ton-linie); + --bs-table-hover-bg: rgba(138, 148, 158, .06); + --bs-table-striped-bg: rgba(138, 148, 158, .04); +} +.table > thead { + color: var(--ton-schrift-2); + font-size: .82rem; + font-weight: 500; +} + +/* --- Abzeichen ----------------------------------------------------------- + Gedaempft statt grell: ein Abzeichen soll den Zustand sagen, nicht die + Kachel dominieren. */ +.badge { font-weight: 500; letter-spacing: .01em; } +.badge.text-bg-secondary { + background: rgba(138, 148, 158, .18) !important; + color: var(--ton-schrift-2) !important; +} +.badge.text-bg-success { + background: rgba(76, 175, 125, .18) !important; + color: var(--ton-gut) !important; +} +.badge.text-bg-warning { + background: rgba(240, 180, 41, .18) !important; + color: var(--ton-sonne) !important; +} +.badge.text-bg-danger { + background: rgba(224, 96, 96, .18) !important; + color: var(--ton-schlecht) !important; +} +.badge.text-bg-info { + background: rgba(122, 167, 199, .18) !important; + color: #9cc4de !important; +} + +/* --- Formulare ----------------------------------------------------------- */ +.form-control, +.form-select { + background: rgba(0, 0, 0, .18); + border-color: var(--ton-linie-satt); + color: var(--ton-schrift); +} +.form-control:focus, +.form-select:focus { + background: rgba(0, 0, 0, .18); + border-color: var(--ton-sonne); + box-shadow: 0 0 0 .2rem rgba(var(--ton-sonne-rgb), .2); + color: var(--ton-schrift); +} +.form-check-input:checked { background-color: var(--ton-sonne); border-color: var(--ton-sonne); } +.form-check-input:focus { box-shadow: 0 0 0 .2rem rgba(var(--ton-sonne-rgb), .2); } + +/* Sichtbarer Tastaturfokus, einheitlich - AdminLTE setzt ihn stellenweise ab. */ +a:focus-visible, +button:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--ton-sonne); + outline-offset: 2px; +} + +/* --- Trennlinien und Modals ---------------------------------------------- */ +hr { border-color: var(--ton-linie); opacity: 1; } +.modal-content { + background: var(--ton-flaeche); + border: 1px solid var(--ton-linie-satt); + border-radius: var(--ton-radius); +} +.modal-header, +.modal-footer { border-color: var(--ton-linie); } + + +/* ========================================================================== + Jahresstatistik (js/solar/jahresstatistik.js) + + Die Tabelle traegt die Seite "Vergangenheit" und steht in kleiner Form auch + auf der Solarseite. Sie ist zum Lesen einer Zeile gebaut: Kennzahlen links, + Jahre nebeneinander, die Veraenderung als Zahl - nicht als blosser Pfeil. + ========================================================================== */ + +/* Der Baustein setzt seine Bloecke selbst auf Abstand - und zwar auf denselben + wie den zwischen den Leitzahlen und den Innenabstand der Karte. Alle Nahtstellen + sind dadurch gleich weit, gleich ob die Werkzeugleiste dabei ist oder nicht. */ +.jahresstatistik { + display: flex; + flex-direction: column; + gap: var(--stat-abstand, 1.25rem); +} + +/* Der Innenabstand der Karte gehoert zum selben Mass - sonst sitzt die erste + Kachel enger unter der Ueberschrift als die zweite unter der ersten. + + Der Vorsatz .app-content ist noetig, nicht schmueckend: weiter unten in + dieser Datei setzt .app-content .card-body auf dem Handy einen kleineren + Innenabstand, und bei gleicher Spezifitaet gewaenne die spaetere Regel. */ +.app-content .card-body.statistik { padding: var(--stat-abstand, 1.25rem); } + +/* Auf dem Handy ruecken alle Nahtstellen zusammen - aber gemeinsam, sonst + waere die Gleichheit wieder dahin. */ +@media (max-width: 767.98px) { + .jahresstatistik, + .app-content .card-body.statistik { --stat-abstand: 1rem; } +} + +/* --- Leitzahlen ---------------------------------------------------------- */ +.jahresstatistik .leitzahlen { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--stat-abstand, 1.25rem); +} +.jahresstatistik .leitzahl { + background: rgba(138, 148, 158, .07); + border: 1px solid var(--ton-linie); + border-radius: var(--ton-radius); + padding: .9rem 1.05rem 1rem; +} +.jahresstatistik .leitzahl .name { font-size: .78rem; color: var(--ton-schrift-2); } +.jahresstatistik .leitzahl .zahl { + display: flex; + align-items: baseline; + gap: .35rem; + margin-top: .3rem; + font-size: 1.9rem; + font-weight: 500; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.jahresstatistik .leitzahl .einheit { + font-size: .95rem; + font-weight: 400; + color: var(--ton-schrift-2); +} +.jahresstatistik .leitzahl .fuss { + margin-top: .45rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + font-size: .76rem; + color: var(--ton-schrift-3); +} +.jahresstatistik .leitzahl.betont { border-color: rgba(var(--ton-sonne-rgb), .45); } +.jahresstatistik .leitzahl.betont .zahl { color: var(--ton-sonne); } + +/* Die Veraenderung: Richtung und Groesse, nicht nur Richtung. */ +.jahresstatistik .delta { + display: inline-flex; + align-items: center; + gap: .25rem; + font-size: .78rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.jahresstatistik .delta.gut { color: var(--ton-gut); } +.jahresstatistik .delta.schlecht { color: var(--ton-schlecht); } +.jahresstatistik .delta.gleich { color: var(--ton-schrift-3); } + +/* --- Werkzeugleiste ------------------------------------------------------ */ +.jahresstatistik .werkzeuge { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .75rem 1.25rem; +} +.jahresstatistik .umschalter { + display: inline-flex; + border: 1px solid var(--ton-linie-satt); + border-radius: .45rem; + overflow: hidden; +} +.jahresstatistik .umschalter button { + appearance: none; + border: 0; + background: none; + color: var(--ton-schrift-2); + font: inherit; + font-size: .82rem; + padding: .4rem .9rem; + cursor: pointer; + transition: background .18s, color .18s; +} +.jahresstatistik .umschalter button + button { border-left: 1px solid var(--ton-linie-satt); } +.jahresstatistik .umschalter button:hover { background: rgba(138, 148, 158, .1); } +.jahresstatistik .umschalter button[aria-pressed="true"] { + background: rgba(var(--ton-sonne-rgb), .16); + color: var(--ton-sonne); +} + +/* Das Jahrfenster: zwei Pfeile und dazwischen, was gerade zu sehen ist. */ +.jahresstatistik .jahrwahl { + display: inline-flex; + align-items: center; + gap: .35rem; + font-size: .82rem; + color: var(--ton-schrift-2); +} +.jahresstatistik .jahrwahl button { + appearance: none; + border: 1px solid var(--ton-linie-satt); + background: none; + color: var(--ton-schrift-2); + border-radius: .35rem; + width: 1.9rem; + height: 1.9rem; + line-height: 1; + cursor: pointer; + transition: background .18s, color .18s, opacity .18s; +} +.jahresstatistik .jahrwahl button:hover:not(:disabled) { + background: rgba(138, 148, 158, .12); + color: var(--ton-schrift); +} +.jahresstatistik .jahrwahl button:disabled { opacity: .3; cursor: default; } +.jahresstatistik .jahrwahl .fenster { font-variant-numeric: tabular-nums; min-width: 5.5rem; text-align: center; } + +.jahresstatistik .kasten { + display: inline-flex; + align-items: center; + gap: .45rem; + font-size: .82rem; + color: var(--ton-schrift-2); + cursor: pointer; + user-select: none; +} +.jahresstatistik .kasten input { accent-color: var(--ton-sonne); width: 1rem; height: 1rem; } + +/* --- Die Tabelle --------------------------------------------------------- */ +.jahresstatistik .tafel-scroll { + overflow-x: auto; + border: 1px solid var(--ton-linie); + border-radius: var(--ton-radius); +} +.jahresstatistik .tafel { + width: 100%; + border-collapse: collapse; + font-size: .88rem; + margin: 0; +} +.jahresstatistik .tafel thead th { + background: var(--ton-flaeche-2); + text-align: right; + font-weight: 500; + font-size: .78rem; + padding: .55rem .9rem .5rem; + border-bottom: 1px solid var(--ton-linie-satt); + white-space: nowrap; +} +.jahresstatistik .tafel thead th:first-child { text-align: left; } +.jahresstatistik .tafel thead th.jetzt { color: var(--ton-sonne); } +.jahresstatistik .tafel thead .zeitraum { + display: block; + font-weight: 400; + font-size: .68rem; + color: var(--ton-schrift-3); + letter-spacing: .03em; +} + +.jahresstatistik .tafel tbody td { + padding: .4rem .9rem; + border-bottom: 1px solid var(--ton-linie); + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.jahresstatistik .tafel tbody td.name { + text-align: left; + width: 40%; + min-width: 14rem; + white-space: normal; +} +.jahresstatistik .tafel tbody tr:last-child td { border-bottom: 0; } +@media (hover: hover) { + .jahresstatistik .tafel tbody tr:hover td { background: rgba(138, 148, 158, .06); } + .jahresstatistik .tafel tbody tr.gruppe:hover td { background: none; } +} + +/* Gruppenkopf: kein Kasten, nur eine Zaesur. */ +.jahresstatistik .tafel tr.gruppe td { + padding: 1rem .9rem .25rem; + border-bottom: 1px solid var(--ton-linie-satt); + font-size: .7rem; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--ton-schrift-3); + text-align: left; +} + +/* Die Summenzeile traegt das Gewicht, die Teile darunter nicht. */ +.jahresstatistik .tafel tr.summe td { font-size: .95rem; } +.jahresstatistik .tafel tr.summe td.name { font-weight: 500; } +.jahresstatistik .tafel tr.summe .wert-jetzt { color: var(--ton-sonne); } + +/* Einrueckung: "davon" teilt die Summe auf, "darin" greift etwas heraus, das + schon in einer Zeile darueber steckt. */ +.jahresstatistik .tafel td.name .stufe-1 { + padding-left: 1.4rem; + position: relative; + display: inline-block; +} +.jahresstatistik .tafel td.name .stufe-1::before { + content: ""; + position: absolute; + left: .45rem; + top: .75em; + width: .55rem; + border-top: 1px solid var(--ton-linie-satt); +} +.jahresstatistik .tafel tr.teilmenge td { color: var(--ton-schrift-2); font-size: .82rem; } + +/* Zweite Stufe. Der Strich sitzt tiefer eingerueckt als der der ersten und + zeigt damit, an welcher Zeile die Teilmenge haengt. */ +.jahresstatistik .tafel td.name .stufe-2 { + padding-left: 2.8rem; + position: relative; + display: inline-block; +} +.jahresstatistik .tafel td.name .stufe-2::before { + content: ""; + position: absolute; + left: 1.85rem; + top: .75em; + width: .55rem; + border-top: 1px dashed var(--ton-linie-satt); +} +.jahresstatistik .tafel .darin { + font-size: .68rem; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--ton-schrift-3); + margin-right: .4rem; +} +.jahresstatistik .tafel .einheit-klein { + font-size: .7rem; + color: var(--ton-schrift-3); + margin-left: .4rem; +} + +.jahresstatistik .tafel .wert-alt { color: var(--ton-schrift-2); } +.jahresstatistik .tafel .leer { color: var(--ton-schrift-3); text-align: left; } + +/* Die Gesamtspalte ist eine andere Art Zahl als ein Jahreswert - sie steht + deshalb abgesetzt und ruhiger. */ +.jahresstatistik .tafel .gesamt { + border-left: 1px solid var(--ton-linie-satt); + color: var(--ton-schrift-2); +} +.jahresstatistik .tafel thead th.gesamt { color: var(--ton-schrift-2); } + +/* Aufklappbare Teilzeilen */ +.jahresstatistik .tafel button.klapp { + appearance: none; + background: none; + border: 0; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: .2rem .4rem; + text-align: left; +} +.jahresstatistik .tafel button.klapp .pfeil { + display: inline-block; + width: .55rem; + color: var(--ton-schrift-3); + transition: transform .18s; +} +.jahresstatistik .tafel button.klapp[aria-expanded="true"] .pfeil { transform: rotate(90deg); } + +/* Was hinter einer zugeklappten Zeile steckt - sonst waere das Zugeklappte + nicht nur zusammengefasst, sondern verschwunden. */ +.jahresstatistik .tafel .posten { + margin-left: .55rem; + padding: .05rem .45rem; + border: 1px solid var(--ton-linie-satt); + border-radius: 1rem; + font-size: .68rem; + font-weight: 400; + letter-spacing: .03em; + color: var(--ton-schrift-3); + white-space: nowrap; +} +@media (hover: hover) { + .jahresstatistik .tafel button.klapp:hover .posten { + border-color: var(--ton-sonne); + color: var(--ton-sonne); + } +} +.jahresstatistik .tafel tr.versteckt { display: none; } + +/* --- Monatsverlauf ------------------------------------------------------- */ +.jahresstatistik .tafel th.verlauf, +.jahresstatistik .tafel td.verlauf { width: 7.5rem; } +.jahresstatistik .spark { + display: block; + /* Waagerecht faehrt der Finger die Monate ab, senkrecht scrollt die Seite + weiter - ohne diese Angabe verschluckt der Browser das Wischen. */ + touch-action: pan-y; +} +.jahresstatistik .spark rect.balken { fill: var(--ton-linie-satt); transition: fill .15s; } +.jahresstatistik .spark rect.balken.jetzt { fill: var(--ton-sonne); } +.jahresstatistik .spark rect.balken.aktiv { fill: var(--ton-sonne); } +.jahresstatistik .spark line.basis { stroke: var(--ton-linie); stroke-width: 1; } + +/* Die ganze Zelle nimmt die Beruehrung an, nicht nur die Balken darin - + ein SVG-Wurzelelement laesst sich nicht mit Innenabstand vergroessern. */ +.jahresstatistik .tafel td.verlauf { touch-action: pan-y; cursor: crosshair; } + +.spark-tipp { + position: fixed; + z-index: 1080; + pointer-events: none; + background: #12161a; + border: 1px solid var(--ton-linie-satt); + border-radius: .35rem; + padding: .3rem .55rem; + font-size: .75rem; + font-variant-numeric: tabular-nums; + box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .5); + opacity: 0; + transition: opacity .12s; +} +.spark-tipp.an { opacity: 1; } + +/* --- Fussnoten ----------------------------------------------------------- */ +.jahresstatistik .fussnoten { + display: grid; + gap: .5rem; + font-size: .78rem; + color: var(--ton-schrift-2); + max-width: 68ch; +} +.jahresstatistik .fussnoten p { margin: 0; } +.jahresstatistik .fussnoten b { color: var(--ton-schrift); font-weight: 500; } + +@media (max-width: 575.98px) { + .jahresstatistik .tafel tbody td.name { min-width: 11rem; } + .jahresstatistik .werkzeuge { gap: .6rem .9rem; } +} + +@media (prefers-reduced-motion: reduce) { + .jahresstatistik .umschalter button, + .jahresstatistik .jahrwahl button, + .jahresstatistik .spark rect.balken, + .jahresstatistik .tafel button.klapp .pfeil, + .spark-tipp { transition: none; } +} + + .winddir { position:absolute; left:60%; @@ -36,20 +618,6 @@ input[type='range']::-webkit-slider-runnable-track { } } -/* Statistik-Kacheln (ajax/getStats.php). Sie lagen in nackten .col ohne - Breitenangabe - Bootstrap laesst sie dann nach Textlaenge umbrechen, mal - zwei, mal drei je Zeile und unterschiedlich breit. Die Breite kommt jetzt - aus row-cols-* in getStats.php; hier sorgt die volle Kartenhoehe dafuer, - dass die Werte trotz ein- oder zweizeiliger Titel auf einer Linie - stehen. */ -.card.stats { - height: 100%; -} - -.card.stats .card-body { - flex: 1 1 auto; -} - /* Auf dem Handy gehen von 375 px rund 75 an Innenabstaende verloren, und zwischen den Karten stehen 24 px. Beides wird hier enger gefasst - nur im Inhaltsbereich, damit Seitenleiste, Kopfzeile und Modals unberuehrt @@ -73,16 +641,17 @@ input[type='range']::-webkit-slider-runnable-track { } /* Nur der Standardwert wird kleiner; eigene Angaben im Markup (p-0 fuer - das Realtime-SVG, p-2 fuer die Statistik) gelten weiter. */ + das Realtime-SVG) gelten weiter. */ .app-content .card-body { padding: .5rem; } - /* Eine Kachel ist auf dem Handy rund 150 px breit. In voller Schrift - passt "Verbrauchsersparnis" dort nicht in eine Zeile und wird mitten - im Wort umbrochen. */ - .card.stats .card-body { - font-size: .8rem; + /* Die Jahrestabelle scrollt auf dem Handy seitwaerts; damit moeglichst + viel davon ohne Schieben zu sehen ist, ruecken die Spalten enger + zusammen. */ + .jahresstatistik .tafel tbody td, + .jahresstatistik .tafel thead th { + padding-inline: .55rem; } } @@ -429,18 +998,72 @@ input[type='range']::-webkit-slider-runnable-track { Skoda-Seite (restricted/skoda.php) Farben kommen aus den Bootstrap-Variablen des Themes, damit die Seite dem - Hell-/Dunkelmodus folgt, ohne eine zweite Palette zu pflegen. Nur drei + Hell-/Dunkelmodus folgt, ohne eine zweite Palette zu pflegen. Nur die Bedeutungsfarben stehen fuer sich: bernstein fuer "offen", gelb fuer - eingeschaltetes Licht, gruen fuer Laden. + eingeschaltetes Licht, gruen fuer Laden, rot fuer einen leeren Akku. + + Sie haengen alle an .skoda-seite (dem
der Seite) und werden von dort + vererbt - Fahrzeugbild, Kennzahlen, Balken und Karte holen sie sich also + aus einer einzigen Quelle. Wo eine Farbe mit Deckkraft gebraucht wird, + steht sie zusaetzlich als RGB-Tripel bereit, denn rgba() kann eine + Hex-Variable nicht aufloesen. ========================================================================== */ -.skoda-auto, -.skoda-teile, -.skoda-karte { - --skoda-offen: #e0904f; - --skoda-licht: #f2d16b; - --skoda-laden: #4caf7d; - --skoda-blech: 128, 138, 148; +.skoda-seite { + --skoda-offen: #e0904f; /* offene Tuer, Klappe, Fenster */ + --skoda-licht: #f2d16b; /* eingeschaltetes Licht */ + --skoda-laden: #4caf7d; /* Laden, gesunder Ladestand */ + --skoda-laden-rgb: 76, 175, 125; + --skoda-niedrig: #e06060; /* Ladestand unter 15 Prozent */ + --skoda-ruhe: #7aa7c7; /* Ladestand ohne Ladung */ + --skoda-ruhe-rgb: 122, 167, 199; + --skoda-pv: #f0b429; /* Strom aus der eigenen Anlage */ + --skoda-netz: #7a8791; /* Strom aus dem Netz */ + --skoda-blech: 128, 138, 148; /* alles Unbeteiligte am Fahrzeug */ +} + +/* --- Aufbau der Fahrzeugkachel ------------------------------------------- + Links das Bild, rechts die Zahlen dazu. Die Kachel bricht um, sobald die + Zahlenspalte unter 24rem faellt; das Bild behaelt dann seine Breite und + rueckt in die Mitte. Die drei Bloecke rechts verteilen sich ueber die Hoehe + des Bildes, damit die Kachel unten nicht ausfranst. */ +.skoda-fahrzeug { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: clamp(1rem, 3vw, 2.5rem); + align-items: stretch; +} +.skoda-bild { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} +/* Die Zahlenspalte laeuft nicht ueber die ganze Breite mit: ein Balken von + einem Meter Laenge teilt nicht besser als einer von dreissig Zentimetern. + Auf sehr breiten Schirmen steht die Kachel deshalb mittig statt gedehnt. */ +/* Mittig statt oben buendig: ohne Ladung faellt der Ladestrombalken weg, und + die uebrigen Bloecke sollen sich dann gleichmaessig neben dem Fahrzeug + verteilen statt eine Luecke in der Mitte zu lassen. */ +.skoda-daten { + flex: 1 1 24rem; + max-width: 48rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.5rem; +} + +/* Kleine Beschriftung ueber einem Block oder einer Zahl. Einmal beschrieben, + von Kennzahlen, Ladestrom und Zustandszeile gemeinsam benutzt. */ +.skoda-marke, +.kennzahl .k, +.skoda-fahrleistung .posten .k { + font-size: .7rem; + letter-spacing: .09em; + text-transform: uppercase; + color: var(--bs-secondary-color, #9aa0a6); } /* --- Fahrzeug in Draufsicht ---------------------------------------------- @@ -448,7 +1071,7 @@ input[type='range']::-webkit-slider-runnable-track { Radstand von 2765 mm die Lage der Raeder. */ .skoda-auto { width: 100%; - max-width: 215px; + max-width: 258px; /* die viewBox traegt 16 Einheiten Rand je Seite */ height: auto; } @@ -460,8 +1083,15 @@ input[type='range']::-webkit-slider-runnable-track { .skoda-auto .rad rect { fill: rgba(18, 20, 24, 0.9); } .skoda-auto .spiegel rect { fill: rgba(var(--skoda-blech), 0.55); } -.skoda-auto .reling rect { fill: rgba(var(--skoda-blech), 0.62); } -.skoda-auto .spoiler { fill: rgba(var(--skoda-blech), 0.5); } +.skoda-auto .spoiler { fill: rgba(var(--skoda-blech), 0.55); } + +/* Dachreling als Linie mit runden Enden - eine Strichstaerke beschreibt sie + vollstaendig, und die Enden runden sich von selbst. */ +.skoda-auto .reling line { + stroke: rgba(var(--skoda-blech), 0.62); + stroke-width: 6; + stroke-linecap: round; +} .skoda-auto .sicke { fill: none; stroke: rgba(var(--skoda-blech), 0.35); @@ -470,9 +1100,13 @@ input[type='range']::-webkit-slider-runnable-track { /* Das Tech-Deck Face: beim Enyaq eine glaenzend schwarze Blende ueber die volle Bugbreite. Von oben sieht man ihre Oberkante. */ +/* Die Blende ist glaenzendes Schwarz, aber nicht das dunkelste im Bild: mit + voller Deckung sprang sie nach vorn und las sich wie ein Loch statt wie eine + Flaeche. Etwas durchscheinend und mit ruhiger Kante bleibt sie das, was sie + ist - eine Blende auf dem Blech. */ .skoda-auto .techdeck { - fill: rgba(12, 14, 18, 0.92); - stroke: rgba(var(--skoda-blech), 0.4); + fill: rgba(20, 24, 30, 0.66); + stroke: rgba(var(--skoda-blech), 0.3); stroke-width: 1; } @@ -494,6 +1128,130 @@ input[type='range']::-webkit-slider-runnable-track { .skoda-auto .teil-fenster > rect { fill: rgba(var(--skoda-blech), 0.5); } +/* --- Antriebsbatterie ---------------------------------------------------- + Die Platte im Fahrzeugboden ist die Ladestandsanzeige der Seite. Welche + Farbe sie traegt, steht in einer einzigen Variablen: die Zustandsklasse am + setzt --akku-farbe, und Huelle, Fuellung, Pegelkante, Stroemung und + Verlauf lesen sie von dort. Ein weiterer Zustand ist damit eine Zeile und + keine Suche durch die Datei. */ +.skoda-auto { --akku-farbe: var(--skoda-ruhe); } +.skoda-auto.akku-laedt { --akku-farbe: var(--skoda-laden); } +.skoda-auto.akku-niedrig { --akku-farbe: var(--skoda-niedrig); } +.skoda-auto.akku-unbekannt { --akku-farbe: rgb(var(--skoda-blech)); } + +/* Der Farbverlauf steht in und damit ausserhalb der Akkugruppe - + deshalb haengt --akku-farbe am und nicht an .akku. */ +.skoda-auto #akkuVerlauf stop { stop-color: var(--akku-farbe); } + +.skoda-auto .akku .huelle { + fill: rgba(8, 10, 12, .35); + stroke: var(--akku-farbe); + stroke-opacity: .55; + stroke-width: 1.5; +} +.skoda-auto .akku .fuellung { + fill: url(#akkuVerlauf); + stroke: none; + transition: y .8s ease, height .8s ease; +} + +/* Die helle Kante auf der Fuellung. Sie macht den Stand auch dort ablesbar, + wo die getoente Flaeche darunter kaum auffaellt. */ +.skoda-auto .akku .kante { + fill: var(--akku-farbe); + transition: y .8s ease; +} +.skoda-auto.akku-laedt .akku .kante { + filter: drop-shadow(0 0 5px var(--akku-farbe)); +} + +/* Modulfugen. Sie liegen auf 20, 40, 60 und 80 Prozent und sind damit + zugleich die Skala, an der sich der Stand ablesen laesst. */ +.skoda-auto .akku .fugen line { + stroke: rgba(var(--skoda-blech), 0.4); + stroke-width: 1; + stroke-dasharray: 3 4; +} + +/* Ladestrom: Streifen, die im gefuellten Teil nach vorn wandern. Der Schnitt + auf den Pegel kommt aus #akkuPegel, das zeichneAkku() mitfuehrt. */ +.skoda-auto .akku .stroemung { opacity: 0; transition: opacity .4s; } +.skoda-auto .akku .stroemung rect { fill: var(--akku-farbe); } +.skoda-auto.akku-laedt .akku .stroemung { opacity: .4; } +.skoda-auto.akku-laedt .akku .stroemung .bahn { + animation: akkuStroemung 2.4s linear infinite; +} +@keyframes akkuStroemung { + from { transform: translateY(0); } + to { transform: translateY(-54px); } /* genau ein Streifenabstand */ +} + +/* Zielmarke. Sie wird als Ganzes verschoben, damit Linie und Beschriftung + zusammenbleiben und der Weg dorthin weich laeuft. */ +.skoda-auto .akku .zielmarke { transition: transform .8s ease; } +.skoda-auto .akku .zielmarke line { + stroke: var(--bs-body-color, #e8ede9); + stroke-width: 1.5; + stroke-dasharray: 5 3; + opacity: .6; +} +.skoda-auto .akku .zielmarke text { + fill: var(--bs-body-color, #e8ede9); + font-size: 10px; + font-weight: 500; + text-anchor: end; + opacity: .8; +} + +/* Zahlen in der Platte. Der dunkle Umriss liegt hinter der Fuellung der + Zeichen (paint-order), damit sie ueber geladenem wie leerem Teil der + Platte gleich gut lesbar bleiben. */ +.skoda-auto .akku text { + paint-order: stroke; + stroke: rgba(8, 10, 12, .7); + stroke-linejoin: round; + stroke-width: 2.5; +} +.skoda-auto .akku .wert { + fill: var(--bs-body-color, #e8ede9); + font-size: 40px; + font-weight: 600; + text-anchor: middle; + font-variant-numeric: tabular-nums; + stroke-width: 4; +} +.skoda-auto .akku .wert .einheit { + font-size: 17px; + font-weight: 500; + opacity: .65; +} +.skoda-auto .akku .neben { + fill: var(--bs-body-color, #e8ede9); + fill-opacity: .8; + font-size: 14px; + text-anchor: middle; +} + +/* Der Weg des Ladestroms vom Anschluss in die Platte. Die Striche laufen in + Fahrtrichtung des Stroms, also zum Akku hin. */ +.skoda-auto .ladefluss { + fill: none; + stroke: var(--skoda-laden); + stroke-width: 2.5; + stroke-linecap: round; + stroke-dasharray: 3 7; + opacity: 0; + transition: opacity .3s; +} +.skoda-auto.akku-laedt .ladefluss { + opacity: .95; + animation: akkuFluss 1s linear infinite; +} +@keyframes akkuFluss { + from { stroke-dashoffset: 20; } + to { stroke-dashoffset: 0; } +} + /* Offen: bernstein und leicht leuchtend. Die Farbe allein traegt die Aussage nicht - die Zeile unter dem Bild benennt jedes offene Teil zusaetzlich. */ .skoda-auto .teil.offen > *, @@ -515,13 +1273,17 @@ input[type='range']::-webkit-slider-runnable-track { filter: drop-shadow(0 0 8px rgba(224, 96, 96, 0.7)); } -.skoda-auto .teil-stecker > path { fill: none; stroke-width: 2; } -.skoda-auto .teil-stecker.laedt > circle { - fill: var(--skoda-laden); - stroke: var(--skoda-laden); +/* Ladeanschluss. Er ist gebaut wie der Klimaknopf; den Ladezustand traegt + nicht die Form, sondern die Farbe: blau wie jeder Knopf, gruen solange + geladen wird, dazu das Pulsen als Schein um die Scheibe. Die Scheibe selbst + bleibt deckend - gruen ausgefuellt verschluckte sie den Blitz. */ +.skoda-auto .teil-stecker .glyphe { stroke-width: 2.2; } +.skoda-auto .teil-stecker.laedt .ring, +.skoda-auto .teil-stecker.laedt .flaeche { stroke: var(--skoda-laden); } +.skoda-auto .teil-stecker.laedt .glyphe { stroke: var(--skoda-laden); } +.skoda-auto .teil-stecker.laedt .flaeche { animation: skodaPuls 1.8s ease-in-out infinite; } -.skoda-auto .teil-stecker.laedt > path { stroke: #0d1210; } @keyframes skodaPuls { 0%, 100% { filter: drop-shadow(0 0 3px rgba(76, 175, 125, 0.5)); } @@ -553,7 +1315,14 @@ input[type='range']::-webkit-slider-runnable-track { @media (prefers-reduced-motion: reduce) { .skoda-auto .teil > *, .skoda-auto .teil > g > * { transition: none; } - .skoda-auto .teil-stecker.laedt > circle { animation: none; } + .skoda-auto .teil-stecker.laedt .flaeche { animation: none; } + /* Der Ladestand bleibt sichtbar, nur nicht mehr in Bewegung. */ + .skoda-auto .akku .fuellung, + .skoda-auto .akku .kante, + .skoda-auto .akku .zielmarke { transition: none; } + .skoda-auto.akku-laedt .akku .stroemung .bahn, + .skoda-auto.akku-laedt .ladefluss, + .skoda-auto.klima-laeuft .steuer .luefter .blaetter { animation: none; } } /* --- Zustandszeile unter dem Fahrzeug ------------------------------------ */ @@ -561,8 +1330,8 @@ input[type='range']::-webkit-slider-runnable-track { display: flex; flex-wrap: wrap; gap: .35rem; - justify-content: center; - margin-top: .9rem; + justify-content: flex-start; + margin-top: .45rem; } .skoda-teile .chip { display: inline-flex; @@ -576,7 +1345,19 @@ input[type='range']::-webkit-slider-runnable-track { cursor: default; white-space: nowrap; } +/* Ein Punkt vor dem Wort traegt dieselbe Aussage wie die Farbe im Bild und + macht die Zeile auf einen Blick ueberfliegbar. */ +.skoda-teile .chip::before { + content: ""; + width: .45rem; + height: .45rem; + border-radius: 50%; + background: rgba(var(--skoda-blech), .75); + flex: 0 0 auto; +} .skoda-teile .chip.offen { border-color: var(--skoda-offen); color: var(--skoda-offen); } +.skoda-teile .chip.offen::before { background: var(--skoda-offen); } +.skoda-teile .chip.an::before { background: var(--skoda-licht); } .skoda-teile .chip.hervor { border-color: var(--bs-primary, #0d6efd); color: var(--bs-body-color, #e8ede9); @@ -597,72 +1378,180 @@ input[type='range']::-webkit-slider-runnable-track { } .skoda-teile .chip.an { border-color: var(--skoda-licht); color: var(--skoda-licht); } -/* --- Ladestandsring ------------------------------------------------------ */ -.skoda-ring { width: 100%; max-width: 190px; height: auto; } -.skoda-ring .ring-bahn { - fill: none; - stroke: rgba(128, 138, 148, 0.22); - stroke-width: 16; +/* --- Kennzahlen neben dem Fahrzeug --------------------------------------- + Was sich nicht ins Bild zeichnen laesst, steht in gleich gebauten Kacheln: + Zeichen, Bezeichnung, Wert und bei Bedarf eine Nebenzeile. Eine Kachel + bekommt die Klasse .aktiv, solange ihre Zahl gerade etwas aussagt - die + Ladeleistung waehrend des Ladens, das Klima, solange es laeuft. Welche + Farbe die Hervorhebung traegt, steht in --kennzahl-rgb. */ +.skoda-kennzahlen { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr)); + gap: .75rem; } -.skoda-ring .ring-wert { - fill: none; - stroke: #4caf7d; - stroke-width: 16; - stroke-linecap: round; - transform: rotate(-90deg); - transform-origin: 100px 100px; - transition: stroke-dashoffset .6s ease, stroke .3s; +.kennzahl { + --kennzahl-rgb: var(--skoda-laden-rgb); + display: flex; + align-items: center; + gap: .75rem; + padding: .7rem .85rem; + border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .25)); + border-radius: .7rem; + background: rgba(var(--skoda-blech), .07); + transition: border-color .3s, background .3s; } -.skoda-ring .ring-ziel { - stroke: var(--bs-body-color, #e8ede9); - stroke-width: 3; - opacity: .75; - transform-origin: 100px 100px; +.kennzahl .zeichen { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 2.1rem; + height: 2.1rem; + border-radius: 50%; + background: rgba(var(--skoda-blech), .16); + color: var(--bs-secondary-color, #9aa0a6); + transition: background .3s, color .3s; } -.skoda-ring .ring-zahl { - fill: var(--bs-body-color, #e8ede9); - font-size: 46px; - font-weight: 600; - text-anchor: middle; +.kennzahl .text { display: flex; flex-direction: column; min-width: 0; } +.kennzahl .v { + font-size: 1.2rem; + line-height: 1.3; font-variant-numeric: tabular-nums; } -.skoda-ring .ring-einheit { - fill: var(--bs-secondary-color, #9aa0a6); - font-size: 14px; - letter-spacing: .12em; - text-anchor: middle; - text-transform: uppercase; -} - -/* --- Wertetabelle -------------------------------------------------------- */ -.skoda-werte { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); - gap: .75rem 1.25rem; -} -.skoda-wert { display: flex; flex-direction: column; } -.skoda-wert .k { - font-size: .7rem; - letter-spacing: .09em; - text-transform: uppercase; +.kennzahl .neben { + font-size: .72rem; color: var(--bs-secondary-color, #9aa0a6); } -.skoda-wert .v { - font-size: 1.15rem; - font-variant-numeric: tabular-nums; +.kennzahl .neben:empty { display: none; } + +.kennzahl.aktiv { + border-color: rgba(var(--kennzahl-rgb), .5); + background: rgba(var(--kennzahl-rgb), .1); +} +.kennzahl.aktiv .zeichen { + background: rgba(var(--kennzahl-rgb), .2); + color: rgb(var(--kennzahl-rgb)); +} +#skodaKachelKlima { --kennzahl-rgb: var(--skoda-ruhe-rgb); } + +/* --- Fahrleistung -------------------------------------------------------- + Drei Zahlen nebeneinander, durch einen Strich getrennt. Kein Kasten und + keine Kachel: es ist eine Nebenauskunft, kein Messwert des Augenblicks. + Ein Wert, dessen Zeitraum weiter zurueckreicht als die Aufzeichnung, wird + gestrichelt unterstrichen und nennt im Titel, seit wann gemessen wird. */ +.skoda-fahrleistung .reihe { + display: flex; + flex-wrap: wrap; + gap: .35rem 1.4rem; + margin-top: .5rem; +} +.skoda-fahrleistung .posten { + display: flex; + flex-direction: column; + min-width: 5.5rem; +} +.skoda-fahrleistung .posten + .posten { + border-left: 1px solid var(--bs-border-color, rgba(128, 128, 128, .25)); + padding-left: 1.4rem; +} +/* Auf dem Handy zaehlt jeder Millimeter: die Trennstriche weichen, damit die + drei Zahlen in einer Zeile bleiben statt in zwei umzubrechen. */ +@media (max-width: 30rem) { + .skoda-fahrleistung .reihe { gap: .3rem .9rem; } + .skoda-fahrleistung .posten { min-width: 0; } + .skoda-fahrleistung .posten + .posten { border-left: none; padding-left: 0; } } -/* --- Herkunft des Ladestroms --------------------------------------------- */ +.skoda-fahrleistung .posten .v { + font-size: 1.15rem; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} +/* --- Erklärungen --------------------------------------------------------- + Ein Element mit Erklärung traegt ein Info-Zeichen. Das ist der einzige + Hinweis darauf, dass es dort etwas zu lesen gibt - am Zeiger genuegte das + Ueberfahren, am Finger gibt es das nicht. Angetippt oder ueberfahren oeffnet + setzeHinweis() ein Popover; das Zeichen sagt vorher, dass sich das lohnt. */ +/* Kein Textmarkieren beim Antippen: der Finger trifft Text, gemeint ist der + Auslöser - eine blau markierte Zahl waere die falsche Antwort darauf. */ +.hat-hinweis { + cursor: help; + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} +.skoda-fahrleistung .posten.hat-hinweis .v::after, +#skodaVerbrauchBasis.hat-hinweis::after { + content: "\f431"; /* bi-info-circle */ + font-family: bootstrap-icons; + font-size: .78em; + margin-left: .3em; + opacity: .65; + vertical-align: .05em; +} +.hat-hinweis:focus { outline: none; } +.hat-hinweis:focus-visible { + outline: 2px solid var(--bs-primary, #4b8fd6); + outline-offset: 3px; + border-radius: .2rem; +} + +/* Das Fenster selbst: dunkel wie die Kacheln, schmal genug zum Lesen. Auf dem + Handy darf es die halbe Breite einnehmen, mehr nicht. */ +.skoda-erklaerung { + --bs-popover-max-width: 22rem; + --bs-popover-bg: #23282c; + --bs-popover-border-color: rgba(128, 138, 148, .35); + --bs-popover-body-color: var(--bs-body-color, #e8ede9); + --bs-popover-body-padding-x: .8rem; + --bs-popover-body-padding-y: .6rem; + --bs-popover-font-size: .82rem; + box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45); +} + +/* --- Herkunft des Ladestroms --------------------------------------------- + Der Balken teilt die Ladeleistung in den Anteil aus der eigenen Anlage und + den Rest aus dem Netz. Die Legende nennt beide Anteile in Zahlen - ein + Farbstueck allein sagt nicht, wie viel es ist. */ +.skoda-quelle .kopf { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: .75rem; + margin-bottom: .4rem; +} +.skoda-quelle .summe { + font-size: .85rem; + font-variant-numeric: tabular-nums; + color: var(--bs-secondary-color, #9aa0a6); +} .skoda-balken { display: flex; - height: .7rem; - border-radius: .35rem; + height: .8rem; + border-radius: .4rem; overflow: hidden; - background: rgba(128, 138, 148, 0.2); + background: rgba(var(--skoda-blech), 0.2); } .skoda-balken > div { transition: width .5s ease; } -.skoda-balken .teil-pv { background: #f0b429; } -.skoda-balken .teil-netz { background: #7a8791; } +.skoda-balken .teil-pv { background: var(--skoda-pv); } +.skoda-balken .teil-netz { background: var(--skoda-netz); } + +.skoda-quelle .legende { + display: flex; + gap: 1.1rem; + margin-top: .45rem; + font-size: .78rem; + color: var(--bs-secondary-color, #9aa0a6); +} +.skoda-quelle .legende span { display: inline-flex; align-items: center; gap: .35rem; } +.skoda-quelle .legende b { + color: var(--bs-body-color, #e8ede9); + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.skoda-quelle .legende .pkt { width: .55rem; height: .55rem; border-radius: 50%; } +.skoda-quelle .legende .pkt.pv { background: var(--skoda-pv); } +.skoda-quelle .legende .pkt.netz { background: var(--skoda-netz); } /* --- Listen (Ladungen, Fahrten) ------------------------------------------ */ /* Der Innenabstand oben trennt die erste Zeile von der Ueberschrift. Ohne @@ -881,16 +1770,36 @@ input[type='range']::-webkit-slider-runnable-track { opacity: .55; transition: opacity .2s, stroke-width .2s; } +/* Der Knopf sitzt mitten auf der Akkuplatte und muss sich gegen jeden + Fuellstand behaupten - gegen die dunkle leere Zelle ebenso wie gegen die + gefuellte. Eine durchscheinende Flaeche kann das nicht: sie nimmt die Farbe + an, die zufaellig darunter liegt. Deshalb eine eigene, deckende Scheibe. */ .skoda-auto .steuer .flaeche { - fill: rgba(75, 143, 214, .22); + fill: rgba(10, 13, 17, .92); stroke: var(--bs-primary, #4b8fd6); stroke-width: 1.5; + filter: drop-shadow(0 0 3px rgba(0, 0, 0, .55)); } .skoda-auto .steuer .glyphe { fill: none; - stroke: var(--bs-primary, #4b8fd6); + stroke: #7db3f0; /* dieselbe Farbe wie die Luefterfluegel */ stroke-width: 1.8; stroke-linecap: round; + stroke-linejoin: round; +} + +/* Luefterrad des Klimaknopfs. Es dreht sich, solange die Klimatisierung + arbeitet - dasselbe Mittel wie beim Ladeanschluss: der Zustand steht als + Klasse am , die Form bleibt, wo sie ist. */ +.skoda-auto .steuer .luefter use { fill: #7db3f0; } +.skoda-auto .steuer .luefter .nabe { fill: rgba(10, 13, 17, .92); } +.skoda-auto .steuer .luefter .blaetter { transform-origin: 0 0; } +.skoda-auto.klima-laeuft .steuer .luefter .blaetter { + animation: klimaDreht 3.2s linear infinite; +} +@keyframes klimaDreht { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } @media (hover: hover) { .skoda-auto .steuer:hover .ring { opacity: 1; stroke-width: 2.5; } diff --git a/index.php b/index.php index b7dd3ed..121b7c5 100644 --- a/index.php +++ b/index.php @@ -100,13 +100,13 @@ if (isset($_GET["addUser"])) { */ $pages = [ "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, - "scripts" => ["js/solar/solarMQTT.js"]], + "scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]], "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/heatMQTT.js"]], "history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false, - "scripts" => ["js/solar/historyMQTT.js"]], + "scripts" => ["js/solar/jahresstatistik.js", "js/solar/historyMQTT.js"]], "skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true, "scripts" => ["js/solar/skodaMQTT.js"]], "weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false, diff --git a/js/solar/common.js b/js/solar/common.js index 63fbdcc..63c24df 100644 --- a/js/solar/common.js +++ b/js/solar/common.js @@ -73,19 +73,6 @@ async function getData(chart, url, options = {}) { } } -/** Vorgerendertes HTML-Schnipsel in ein Element laden. */ -async function getStats(elem_id, url) { - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Response status: ${response.status}`); - } - document.getElementById(elem_id).innerHTML = await response.text(); - } catch (error) { - console.log("getStats " + url + ": " + error.message); - } -} - /** * Formular des Modals per POST abschicken. * Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst. diff --git a/js/solar/historyMQTT.js b/js/solar/historyMQTT.js index 7c32ec8..0f7ba62 100644 --- a/js/solar/historyMQTT.js +++ b/js/solar/historyMQTT.js @@ -145,8 +145,7 @@ Chart.defaults.plugins.legend.labels.generateLabels = legendLabels; getData(consChartYear, 'ajax/energyHistory.php?series=cons&range=year'); getData(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade'); getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade'); - getStats("Stats-Year","ajax/getStats.php?type=ThisYear"); - getStats("Stats-Lastyear","ajax/getStats.php?type=LastYear"); - getStats("Stats-Prelastyear","ajax/getStats.php?type=PreLastYear"); + baueJahresstatistik(document.getElementById("jahresvergleich"), + { spalten: 3, werkzeuge: true, fussnoten: true }); } }); diff --git a/js/solar/jahresstatistik.js b/js/solar/jahresstatistik.js new file mode 100644 index 0000000..acf413e --- /dev/null +++ b/js/solar/jahresstatistik.js @@ -0,0 +1,465 @@ +/* + * 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 }; +} diff --git a/js/solar/skodaMQTT.js b/js/solar/skodaMQTT.js index bf88daa..30d0c80 100644 --- a/js/solar/skodaMQTT.js +++ b/js/solar/skodaMQTT.js @@ -15,6 +15,11 @@ * Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb * immer auch das Alter des Standes, statt einen alten Wert als aktuell * auszugeben. + * + * Der Ladestand hat keine eigene Anzeige mehr: er steckt in der Akkuplatte der + * Fahrzeuggrafik - Füllhöhe, Zielmarke, Prozent und Reichweite setzt + * zeichneAkku(). Was sich nicht zeichnen lässt, steht in den Kennzahlen + * daneben (zeichneKennzahlen). */ var mqttData = {}; @@ -86,6 +91,70 @@ function dauer(minuten) { return Math.floor(m / 60) + " h " + (m % 60) + " min"; } +/* --------------------------------------------------------------- Hinweise */ + +/* + * Erklärungen zu einer Zahl - woher sie stammt, was sie nicht abdeckt. + * + * Im title stehen sie am Zeiger gut, auf einem Berührungsgerät aber nirgends: + * Überfahren gibt es dort nicht, und der title erscheint nie. Das übliche + * Mittel dagegen ist, aus der Erklärung etwas Anfassbares zu machen - einen + * Auslöser, der Fokus annehmen kann, und ein Fenster, das beim nächsten + * Tippen daneben oder mit Esc wieder verschwindet. Genau das leistet ein + * Popover mit den Auslösern "focus hover": tippen öffnet, danebentippen + * schliesst, für Maus und Tastatur bleibt es beim Überfahren und Tabben. + * + * Damit das gefunden wird, trägt der Auslöser sichtbar ein Zeichen - die + * Klasse hat-hinweis setzt es. Eine Erklärung, die niemand vermutet, ist so + * gut wie keine. + */ + +/** Erklärung an ein Element hängen; ohne Text wird sie wieder entfernt. */ +function setzeHinweis(el, text) { + if (!el) return; + + // Ohne Bootstrap bleibt der title. Weniger gut, aber nicht nichts. + if (typeof bootstrap === "undefined" || !bootstrap.Popover) { + if (text) el.title = text; else el.removeAttribute("title"); + return; + } + + const alt = bootstrap.Popover.getInstance(el); + if (alt) alt.dispose(); + el.removeAttribute("title"); // sonst zeigt der Browser beides + + if (!text) { + el.classList.remove("hat-hinweis"); + ["tabindex", "role", "aria-label"].forEach(a => el.removeAttribute(a)); + return; + } + + el.classList.add("hat-hinweis"); + el.setAttribute("tabindex", "0"); // ohne Fokus kein Antippen auf iOS + el.setAttribute("role", "button"); + el.setAttribute("aria-label", "Erklärung anzeigen"); + new bootstrap.Popover(el, { + content: text, + trigger: "focus hover", + placement: "top", + container: "body", // sonst schneidet die Kachel ab + customClass: "skoda-erklaerung" + }); +} + +/** + * Erklärungen unterhalb eines Knotens auflösen, bevor er neu gebaut wird. + * Ein offenes Fenster gehört zu seinem Auslöser - verschwindet der aus dem + * Dokument, bliebe es sonst am Bildschirm stehen. + */ +function loeseHinweise(wurzel) { + if (!wurzel || typeof bootstrap === "undefined" || !bootstrap.Popover) return; + wurzel.querySelectorAll(".hat-hinweis").forEach(function (el) { + const p = bootstrap.Popover.getInstance(el); + if (p) p.dispose(); + }); +} + async function hole(was, extra = "") { const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" }); if (!antwort.ok) throw new Error("HTTP " + antwort.status); @@ -115,7 +184,6 @@ const ZUSTANDSWORT = { const TEILE = [ { id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" }, { id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" }, - { id: "teil-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" }, { id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" }, { id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" }, { id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" } @@ -201,6 +269,17 @@ function zeichneTeile(stand) { /* -------------------------------------------------------------- Live-Block */ +/* + * Der Live-Block ist die Fahrzeugkachel: die Draufsicht mit dem Ladestand in + * der Akkuplatte und die Kennzahlen daneben. + * + * Er arbeitet in drei Schritten. liveWerte() sammelt aus MQTT und Tabelle + * einen Satz fertiger Werte, jede zeichne*-Funktion zeigt genau einen Bereich + * davon, und zeichneLive() ruft sie der Reihe nach auf. Keine der + * Zeichenfunktionen holt sich selbst etwas - sie bekommen alles übergeben und + * bleiben dadurch einzeln prüfbar und für sich verwendbar. + */ + let livePlanung = null; function planeLiveAnzeige() { if (livePlanung) return; // höchstens fünfmal je Sekunde @@ -209,108 +288,289 @@ function planeLiveAnzeige() { let letzterStand = null; // letzte Zeile aus der Tabelle -function zeichneLive() { +/* + * Maße der Akkuplatte in Einheiten der viewBox. Sie stehen genau einmal hier + * und einmal im Markup (restricted/skoda.php); wer die Platte verschiebt, + * ändert beides. + */ +const AKKU = { oben: 108, hoehe: 270 }; + +/* Zustand der Platte. Die Klasse sitzt am , nicht an der Gruppe: der + Farbverlauf steht in und muss dieselbe Farbe erben. */ +const AKKU_KLASSEN = ["akku-laedt", "akku-niedrig", "akku-unbekannt"]; + +/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht + bei null, sondern dort, wo eine Fahrt zu planen ist. */ +const AKKU_NIEDRIG = 15; + +const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" }; + +const KLIMA = { + OFF: "aus", COOLING: "kühlt", HEATING: "heizt", + HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet", + COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" +}; +/* Zustände, in denen die Klimatisierung tatsächlich arbeitet - nur dann hebt + sich ihre Kachel hervor. */ +const KLIMA_LAEUFT = ["COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"]; + +/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu + bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften. + Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */ +const KLIMA_ZEICHEN = { + COOLING: "bi-snow", + HEATING: "bi-thermometer-sun", + HEATING_AUXILIARY: "bi-fire", + VENTILATION: "bi-wind", + COMPLETED: "bi-check2-circle" +}; +const KLIMA_ZEICHEN_SONST = "bi-fan"; + +const LADEZUSTAND = { + CHARGING: ["lädt", "text-bg-success"], + CONSERVING: ["hält Ladung", "text-bg-secondary"], + READY_FOR_CHARGING: ["bereit", "text-bg-info"], + CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"], + CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"], + DISCHARGING: ["entlädt", "text-bg-warning"] +}; + +/* ------------------------------------------------------- kleine Werkzeuge */ + +/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */ +function feldZahl(wert) { + if (wert === null || wert === undefined || wert === "") return null; + const z = Number(wert); + return Number.isFinite(z) ? z : null; +} + +/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */ +function setzeText(id, text) { + const el = $(id); + if (el) el.textContent = text; +} + +/** Eine Klasse an einem Element setzen oder entfernen. */ +function schalteKlasse(id, klasse, an) { + const el = $(id); + if (el) el.classList.toggle(klasse, !!an); +} + +/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */ +function setzeAusGruppe(el, gruppe, aktiv) { + if (!el) return; + gruppe.forEach(k => el.classList.toggle(k, k === aktiv)); +} + +/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */ +function fertigUm(minuten) { + const m = Number(minuten); + if (!Number.isFinite(m) || m <= 0) return ""; + const ende = new Date(Date.now() + m * 60000); + return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr"; +} + +/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */ +function akkuKante(prozent) { + const p = Math.max(0, Math.min(100, Number(prozent) || 0)); + return AKKU.oben + AKKU.hoehe * (1 - p / 100); +} + +/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */ +function setzeFuellstand(el, prozent) { + if (!el) return; + const y = akkuKante(prozent); + el.setAttribute("y", y.toFixed(1)); + el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1)); +} + +/* ------------------------------------------------------------- Sammelstelle */ + +/** + * Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen. + * MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine + * Stunde alt sein, MQTT ist von eben. + */ +function liveWerte() { const stand = letzterStand; - // Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall. - const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null); - const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null; const zustand = mqText("evChgState") || (stand ? stand.chg_state : null); const laedt = zustand === "CHARGING"; - // Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand. - const ring = $("skodaRing"); - if (ring) { - const umfang = 2 * Math.PI * 82; - ring.style.strokeDasharray = umfang; - ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100); - ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7"); - } - const socText = $("skodaSocText"); - if (socText) socText.textContent = soc === null ? "–" : nf(soc); - - // Zielmarke auf dem Ring - const zielLinie = $("skodaZiel"); - if (zielLinie) { - zielLinie.style.display = ziel ? "" : "none"; - if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)"); - } - $("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "–"; - // Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter // gemeint sein, ein Akku trägt keine 1500 Kilometer. let km = mqZahl("evRange"); - if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) { - const roh = Number(stand.range_m); - km = roh > 1500 ? roh / 1000 : roh; + if (km === null && stand) { + const roh = feldZahl(stand.range_m); + if (roh !== null) km = roh > 1500 ? roh / 1000 : roh; } - $("skodaReichweite").textContent = km === null ? "–" : nf(km) + " km"; // Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am // Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert. const wbKw = mqZahl("evPower"); - const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null; - const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null); - $("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–"; + const carKw = stand ? feldZahl(stand.chg_kw) : null; - const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null); - $("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–"; + const pvW = mqZahl("P_PV"); - $("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–"; - - const klima = stand ? stand.ac_state : null; - const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt", - HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet", - COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" }; - $("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–"; - - // Ladezustand als Kennzeichen - const ZUSTAND = { - CHARGING: ["lädt", "text-bg-success"], - CONSERVING: ["hält Ladung", "text-bg-secondary"], - READY_FOR_CHARGING: ["bereit", "text-bg-info"], - CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"], - CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"], - DISCHARGING: ["entlädt", "text-bg-warning"] + return { + zustand: zustand, + laedt: laedt, + soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null), + ziel: stand ? feldZahl(stand.target_soc) : null, + km: km, + kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null), + pvKw: pvW === null ? null : Math.max(0, pvW / 1000), + restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null), + ladeart: stand ? stand.chg_type : null, + odo: stand ? feldZahl(stand.odo_km) : null, + klima: stand ? stand.ac_state : null, + // null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht. + verriegelt: (stand && stand.locked !== null && stand.locked !== undefined) + ? Number(stand.locked) === 1 : null }; - const kz = $("skodaLadeZustand"); - const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"]; - kz.textContent = eintrag[0]; - kz.className = "badge " + eintrag[1]; +} + +/* --------------------------------------------------------- Zeichenschritte */ + +/** + * Die Akkuplatte im Fahrzeugboden - die Ladestandsanzeige der Seite. + * + * Gefüllt wird von hinten nach vorn, also im Bild von unten nach oben. Die + * Farbe kommt aus einer einzigen CSS-Variablen, die an der Zustandsklasse des + * hängt; hier wird nur die Klasse gewechselt. + */ +function zeichneAkku(w) { + const bild = $("skodaAuto"); + if (!bild) return; + + const bekannt = w.soc !== null && Number.isFinite(w.soc); + setzeAusGruppe(bild, AKKU_KLASSEN, !bekannt ? "akku-unbekannt" + : (w.laedt ? "akku-laedt" : (w.soc < AKKU_NIEDRIG ? "akku-niedrig" : null))); + + const stand = bekannt ? w.soc : 0; + setzeFuellstand($("akkuFuellung"), stand); + setzeFuellstand($("akkuPegelFeld"), stand); // Schnittkante für die Strömung + + // Die helle Kante sitzt auf der Füllung. Bei leerer Platte hat sie nichts + // zu begrenzen und bliebe sonst als Strich am Boden liegen. + const kante = $("akkuKante"); + if (kante) { + kante.setAttribute("y", (akkuKante(stand) - 1.25).toFixed(1)); + kante.style.display = (bekannt && stand > 0) ? "" : "none"; + } + + // Zielmarke. Gezeichnet ist sie auf 100 Prozent; verschoben wird die ganze + // Gruppe, damit Linie und Beschriftung beieinanderbleiben. + const marke = $("akkuZielMarke"); + if (marke) { + marke.style.display = w.ziel ? "" : "none"; + if (w.ziel) { + marke.style.transform = "translateY(" + (akkuKante(w.ziel) - AKKU.oben).toFixed(1) + "px)"; + setzeText("akkuZielText", "Ziel " + nf(w.ziel) + " %"); + } + } + + setzeText("akkuText", bekannt ? nf(w.soc) : "–"); + setzeText("akkuNeben", w.km === null ? "–" : nf(w.km) + " km"); + + // Der Titel der Gruppe ist Sprechblase und Vorlesetext zugleich - er nennt + // in Worten, was die Platte zeigt. + setzeText("akkuTitel", "Antriebsbatterie: " + + (bekannt ? nf(w.soc) + " Prozent" : "Ladestand unbekannt") + + (w.ziel ? ", Ladeziel " + nf(w.ziel) + " Prozent" : "") + + (w.km === null ? "" : ", Reichweite " + nf(w.km) + " Kilometer") + + (w.laedt ? ", lädt" : "")); + + // Der Ladeanschluss pulst, der Weg zur Platte fließt - beides hängt an + // derselben Zustandsklasse wie die Füllung. + schalteKlasse("teil-plug", "laedt", w.laedt); +} + +/** + * Der Klimaknopf im Fahrzeugbild. Sein Lüfterrad dreht sich, solange die + * Klimatisierung arbeitet - so zeigt der Knopf nicht nur, was er kann, + * sondern auch, ob es gerade geschieht. + */ +function zeichneKlimaknopf(w) { + schalteKlasse("skodaAuto", "klima-laeuft", KLIMA_LAEUFT.indexOf(w.klima) >= 0); +} + +/** Die Kacheln neben dem Fahrzeug: alles, was sich nicht zeichnen lässt. */ +function zeichneKennzahlen(w) { + setzeText("skodaLeistung", w.kw ? nf(w.kw, 1) + " kW" : "–"); + setzeText("skodaLadeart", (w.laedt && w.ladeart && LADEART[w.ladeart]) || ""); + schalteKlasse("skodaKachelLeistung", "aktiv", w.laedt && w.kw); + + setzeText("skodaRestzeit", w.laedt ? dauer(w.restMin) : "–"); + setzeText("skodaFertig", w.laedt ? fertigUm(w.restMin) : ""); + schalteKlasse("skodaKachelRestzeit", "aktiv", w.laedt && w.restMin > 0); + + setzeText("skodaKm", w.odo ? nf(w.odo) + " km" : "–"); + + setzeText("skodaKlima", w.klima ? (KLIMA[w.klima] || w.klima) : "–"); + schalteKlasse("skodaKachelKlima", "aktiv", KLIMA_LAEUFT.indexOf(w.klima) >= 0); + const klimaZeichen = $("skodaKlimaZeichen"); + if (klimaZeichen) { + klimaZeichen.className = "bi " + (KLIMA_ZEICHEN[w.klima] || KLIMA_ZEICHEN_SONST); + } +} + +/** Die beiden Kennzeichen in der Kopfzeile: Schloss und Ladezustand. */ +function zeichneAbzeichen(w) { + const zustand = $("skodaLadeZustand"); + if (zustand) { + const eintrag = LADEZUSTAND[w.zustand] || [w.zustand || "–", "text-bg-secondary"]; + zustand.textContent = eintrag[0]; + zustand.className = "badge " + eintrag[1]; + } - // Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN. const schloss = $("skodaSchloss"); - const zu = stand ? stand.locked : null; - if (zu === null || zu === undefined) { + if (!schloss) return; + if (w.verriegelt === null) { schloss.textContent = "Schloss unbekannt"; schloss.className = "badge text-bg-secondary"; - } else if (Number(zu) === 1) { + } else if (w.verriegelt) { schloss.textContent = "verriegelt"; schloss.className = "badge text-bg-success"; } else { schloss.textContent = "offen"; schloss.className = "badge text-bg-warning"; } +} - // Ladeanschluss pulst beim Laden - const stecker = $("teil-plug"); - if (stecker) stecker.classList.toggle("laedt", laedt); - - // Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das, - // was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht - // beitragen, auch wenn gerade mehr Sonne da ist. +/** + * Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das, + * was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und + * bleibt weg. + */ +function zeichneLadequelle(w) { const box = $("skodaQuelleBox"); - const pvW = mqZahl("P_PV"); - if (laedt && kw && pvW !== null) { - const pvKw = Math.max(0, Math.min(kw, pvW / 1000)); - const anteil = Math.round(100 * pvKw / kw); - $("skodaAnteilPV").style.width = anteil + "%"; - $("skodaAnteilNetz").style.width = (100 - anteil) + "%"; - $("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz"; - box.style.display = ""; - } else { - box.style.display = "none"; - } + if (!box) return; + + const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null; + box.hidden = !zeigen; + if (!zeigen) return; + + // Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn + // gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz. + const ausPv = Math.max(0, Math.min(w.kw, w.pvKw)); + const anteil = Math.round(100 * ausPv / w.kw); + + $("skodaAnteilPV").style.width = anteil + "%"; + $("skodaAnteilNetz").style.width = (100 - anteil) + "%"; + setzeText("skodaQuelleText", nf(w.kw, 1) + " kW gesamt"); + setzeText("skodaQuellePV", nf(ausPv, 1) + " kW"); + setzeText("skodaQuelleNetz", nf(w.kw - ausPv, 1) + " kW"); +} + +/** Die Fahrzeugkachel neu zeichnen. */ +function zeichneLive() { + const w = liveWerte(); + zeichneAkku(w); + zeichneKlimaknopf(w); + zeichneKennzahlen(w); + zeichneAbzeichen(w); + zeichneLadequelle(w); } /* ------------------------------------------------------------- Diagramme */ @@ -691,7 +951,7 @@ function zeigeVerbrauchsbasis() { if (!letzteKapazitaet) { // Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent. feld.textContent = ""; - feld.removeAttribute("title"); + setzeHinweis(feld, null); if (schalter) schalter.style.display = "none"; setzeEinheit("prozent", false); return; @@ -703,7 +963,7 @@ function zeigeVerbrauchsbasis() { // Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem // Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten. - feld.title = "Aus " + k.n + " Ladungen des letzten Jahres: " + setzeHinweis(feld, "Aus " + k.n + " Ladungen des letzten Jahres: " + nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, " + nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad " + nf(k.wirkungsgrad * 100, 1) + " %, " @@ -711,7 +971,7 @@ function zeigeVerbrauchsbasis() { ? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem " + "Ladegerät, das Fahrzeug meldet dahinter." : "angenommen, weil noch zu wenige Ladungen die Ladeleistung des " - + "Fahrzeugs mitgeliefert haben."); + + "Fahrzeugs mitgeliefert haben.")); } /** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */ @@ -757,6 +1017,47 @@ async function ladeLive() { } } +/* + * Fahrleistung. Die Namen der Zeiträume stehen hier, ihre Länge in Tagen + * kommt vom Server mit - so muss nur eine Seite geändert werden, wenn ein + * Zeitraum hinzukommt. + */ +const STRECKE_ZEITRAEUME = { woche: "7 Tage", monat: "30 Tage", jahr: "12 Monate" }; + +async function ladeStrecke() { + const feld = $("skodaFahrleistung"); + if (!feld) return; + loeseHinweise(feld); + try { + const d = await hole("strecke"); + feld.innerHTML = Object.keys(STRECKE_ZEITRAEUME).map(function (name) { + const p = d.strecke ? d.strecke[name] : null; + const km = (p && p.km !== null && p.km !== undefined) ? nf(p.km) + " km" : "–"; + + /* + * Reicht die Aufzeichnung nicht über den ganzen Zeitraum, ist die Zahl + * kein Jahreswert, sondern nur das, was aufgezeichnet ist. Sie deshalb + * wegzulassen wäre falsch - sie kommentarlos als Jahr auszugeben aber + * auch. Also gestrichelt unterstrichen, mit dem Anfang im Titel. + */ + const knapp = p && p.km !== null && !p.vollstaendig; + return '
' + + '' + km + '' + + '' + STRECKE_ZEITRAEUME[name] + "
"; + }).join(""); + + // Die knappen Zeiträume tragen ihre Erklärung selbst - antippbar. + feld.querySelectorAll(".posten.knapp").forEach(function (posten) { + setzeHinweis(posten, "Aufgezeichnet wird erst seit " + zeitpunkt(d.seit, true) + + ". Der Wert deckt deshalb nicht den ganzen Zeitraum ab, sondern nur " + + "das, was seither aufgezeichnet ist."); + }); + } catch (e) { + feld.innerHTML = '
' + + 'nicht abrufbar
'; + } +} + async function ladeLadungen() { try { const d = await hole("ladungen", "&tage=180"); @@ -1137,6 +1438,7 @@ function verdrahteBefehle(bereich) { function alleLaden() { ladeLive(); + ladeStrecke(); ladeLadungen(); ladeKurve(); ladeGesundheit(); @@ -1161,7 +1463,9 @@ document.addEventListener("readystatechange", function () { // Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters: // beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt. setInterval(ladeLive, 60000); - setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000); + setInterval(function () { + ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke(); + }, 300000); setInterval(ladeGesundheit, 900000); // Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index 05af272..ce28334 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -593,7 +593,10 @@ var chartSettings = { getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" }); getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" }); getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" }); - getStats("Stats-Year","ajax/getStats.php?type=ThisYear"); + // Dieselbe Statistik wie auf der Historienseite, nur das laufende Jahr: + // hier ist sie Beiwerk, dort der Zweck der Seite. + baueJahresstatistik(document.getElementById("Stats-Year"), + { spalten: 1, werkzeuge: false, fussnoten: false }); solarSVG.fillElementArray(); mountMeteogram(); } diff --git a/restricted/costs.php b/restricted/costs.php index 099bb8b..ff676c1 100644 --- a/restricted/costs.php +++ b/restricted/costs.php @@ -325,9 +325,32 @@ function preiseSpeichern($key, $zeilen) function grundpreisImJahr($jahr) { $jahr = intval($jahr); - $von = $jahr . "-01-01"; - $bis = $jahr . "-12-31"; - $tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366 + return grundpreisImZeitraum($jahr . "-01-01", $jahr . "-12-31"); +} + +/** + * Grundpreis Strom fuer einen Zeitraum innerhalb eines Kalenderjahres. + * + * Der Grundpreis steht als Jahresbetrag in der Tarifzeile; auf einen Zeitraum + * kommt er anteilig nach Tagen. Geteilt wird deshalb durch die Tage des + * JAHRES, nicht durch die des Zeitraums - sonst zahlte ein halbes Jahr den + * vollen Betrag. + * + * Von und bis muessen im selben Jahr liegen: die Statistik fragt entweder ein + * ganzes Jahr oder dessen Anfang bis zu einem Stichtag, und ein Zeitraum ueber + * den Jahreswechsel haette zwei verschiedene Nenner. + */ +function grundpreisImZeitraum($von, $bis) +{ + $vonTag = new DateTimeImmutable($von); + $bisTag = new DateTimeImmutable($bis); + if ($vonTag->format("Y") !== $bisTag->format("Y")) { + throw new InvalidArgumentException("Zeitraum muss innerhalb eines Jahres liegen."); + } + $von = $vonTag->format("Y-m-d"); + $bis = $bisTag->format("Y-m-d"); + // Tage des Jahres, 365 oder 366 - der Nenner des Jahresbetrags. + $tage = intval((new DateTimeImmutable($vonTag->format("Y") . "-12-31"))->format("z")) + 1; $stmt = solarDb()->prepare( "WITH p AS (SELECT active_date, base, diff --git a/restricted/history.php b/restricted/history.php index 8b62ceb..7e4c8b4 100644 --- a/restricted/history.php +++ b/restricted/history.php @@ -1,16 +1,24 @@ "Letzter Monat", "year" => "Letzte 13 Monate", "decade" => "Letzte 10 Jahre", ]; -// Die Jahresstatistiken darunter folgen demselben Muster. -$statistics = [ - "Stats-Year" => "Statistik dieses Jahr", - "Stats-Lastyear" => "Statistik letztes Jahr", - "Stats-Prelastyear" => "Statistik vorletztes Jahr", -]; ?>
@@ -63,23 +71,30 @@ $statistics = [ - $title): ?> -
-
-
-

-
- + + +
+
+
+
+

Jahresvergleich

+
+ +
+
+
+
-
-
-
- +
diff --git a/restricted/skoda.php b/restricted/skoda.php index 17987a8..09b8478 100644 --- a/restricted/skoda.php +++ b/restricted/skoda.php @@ -19,10 +19,16 @@ * 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und * die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen * ihren Zustand zeigen, nicht das Auto porträtieren. + * + * Der Ladestand ist Teil dieser Grafik und nicht mehr eine Anzeige daneben: + * die Akkuplatte im Fahrzeugboden füllt sich von hinten nach vorn, trägt die + * Zielmarke und nennt Prozent und Reichweite in ihrer Mitte. Alles, was sich + * nicht zeichnen lässt - Ladeleistung, Restzeit, Kilometerstand, Klima und + * die Herkunft des Ladestroms - steht als Kennzahl daneben. */ ?> -
+
@@ -39,208 +45,397 @@
- + +
- - -
+
-

Fahrzeug

-
+

Übersicht

+
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Beleuchtung - - - - - - Motorhaube - - - - - - - - - - - Panoramadach - - - - - - - - - - - - - - - Heckklappe - - - - - Tür vorn links - Tür hinten links - Tür vorn rechts - Tür hinten rechts - - - - - - - Seitenfenster - - - - - - - - - - - - - - - - Laden steuern - - - - - - - - Klima und Lüftung steuern - - - -
-

- Ladeanschluss und Innenraum antippen, um zu steuern -

-
-
-
- - -
-
-
-

Batterie und Ladung

-
-
+
- -
- - - - - - Prozent + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Beleuchtung + + + + + + Motorhaube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ziel – + + + + + % + + + + Antriebsbatterie + + + + + + Heckklappe + + + + + Tür vorn links + Tür hinten links + Tür vorn rechts + Tür hinten rechts + + + + + + + Seitenfenster + + + + + + + + + + + + + + + + + + + Laden steuern + + + + + + + + + + + + + + + + Klima und Lüftung steuern + + +

+ Ladeanschluss und Innenraum antippen, um zu steuern +

-
-
-
Reichweite
-
Ladeleistung
-
Restzeit
-
Ladeziel
-
Kilometerstand
-
Klima
+ +
+ +
+
+ + + Ladeleistung + + + +
+
+ + + Restzeit + + + +
+
+ + + Kilometer­stand + + +
+
+ + + Klima + + +
-
-
- Ladestrom + Rest aus dem Netz. Steht nur da, solange geladen wird - + ohne Ladung hat der Balken nichts zu teilen. --> + -
+ +
+ Fahrleistung +
+
+ + +
+ Zustand +
+
+ +
diff --git a/restricted/solar.php b/restricted/solar.php index 06e1ecf..da91baa 100644 --- a/restricted/solar.php +++ b/restricted/solar.php @@ -64,23 +64,6 @@
-
-
-
-

Statistik dieses Jahr

-
- -
-
-
- -
-
- -
-
@@ -118,6 +101,22 @@
+
+
+
+

Statistik dieses Jahr

+
+ +
+
+ +
+
+ +