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

+
+ +
+
+ +
+
+ +