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 .= "
";
- $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 ? ''
+ + ''
+ + 'Gleicher Zeitraum '
+ + 'Ganze Jahre '
+ + ''
+ + ''
+ + ' '
+ + '– '
+ + ''
+ + ' '
+ + ' '
+ + "Monatsverlauf zeigen
" : "")
+ + '"
+ + (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 = '› '
+ // Der Name als eigener Baustein: bei knapper Breite rutscht dann
+ // die Anzahl in die naechste Zeile, statt den Namen zu zerreissen.
+ + "" + text + " "
+ + (offen[kz.id] || !posten ? ""
+ : '' + posten + " Posten ") + " ";
+ } 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): ?>
-