Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle

Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand
jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur
Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt.

Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr
Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt
sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und
Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe,
damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im
gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die
Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit,
Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl
daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr
(?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten
Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im
Zaehlerstand aber nicht).

Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und
Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein
geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die
Blende am Bug ist schmaler und heller - von oben sieht man von einer
senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie
ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand
traegt jetzt die Farbe statt einer zweiten Form.

Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern
JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und
Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei
Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten
werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die
ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche
Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen
sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist
nicht das Mittel der Monatsmittel.

Gezeichnet wird das von einem Baustein, den sich Historien- und
Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis
zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die
sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen
Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie
und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden.
Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn
Zeilen aufmacht statt mit siebzehn.

Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette
ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und
AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche
fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige
Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot
und Gelb bleiben den Zustaenden vorbehalten.

Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger
gibt es kein Ueberfahren, und ein title erscheint dort nie.

Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte
decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft
jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen
Teil des Jahres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 20:08:51 +02:00
co-authored by Claude Opus 5
parent 22ecc1b50f
commit 0cb0d4c3ae
13 changed files with 2691 additions and 587 deletions
+332 -174
View File
@@ -1,73 +1,177 @@
<?php
require_once("../helper.php");
require_once("../restricted/costs.php");
if(!isset($_GET["year"])){
$_GET["year"] = date("Y");
}else{
$_GET["year"] = intval($_GET["year"]);
}
if($_GET["year"] < 2018 || $_GET["year"] > 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 = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
$mysql = solarDb();
$Res = mysqli_query($mysql,$Query);
$ResPrev = mysqli_query($mysql,$PrevQuery);
if(!$Res || !$ResPrev){
echo "Error:<br>".mysqli_error($mysql)."<br />";
}
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 .= "</div><hr class='mt-3 mb-3 border-light' />";
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
}
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
<div class='info-box'>
<div class='info-box-content'>
<span class='info-box-text'>".$name."</span>
<span class='info-box-number'>".
number_format(floatval($value),2,",",".")
."<small>".$units[$i++]."</small>
</span>
</div>
<!-- /.info-box-content -->
</div>
</div>";*/
$html .= "<div class='col'>
<div class='card stats border-0'>
<div class='card-body bg-dark bg-gradient rounded-top pb-1 pt-1'>
".$name."
</div>
<div class='card-footer text-center'>";
if($_GET["year"] != date("Y")){
if($LessIsBetter[$i]){
if($value == 0)
$dev=100;
else
$dev = $rowPrev[$name]/$value;
//$first = round($rowPrev[$name]*10/$rowPrev[$name]); //allow for 10% deviation for displaying no tendency
//$second = round($value); //allow for 10% deviation for displaying no tendency
$arrowGood = "down";
$arrowBad = "up";
}else{
if($rowPrev[$name] == 0)
$dev=100;
else
$dev = $value/$rowPrev[$name];
//$first = round($value); //allow for 10% deviation for displaying no tendency
//$second = round($rowPrev[$name]); //allow for 10% deviation for displaying no tendency
$arrowGood = "up";
$arrowBad = "down";
}
if($dev > 1.05){
$html .= "<span class='float-right text-success-emphasis'>
<i class='bi bi-arrow-".$arrowGood."' style='font-size: 0.9em;'></i> ";
}elseif($dev < 0.95){
$html .= "<span class='float-right text-danger-emphasis'>
<i class='bi bi-arrow-".$arrowBad."' style='font-size: 0.9em;'></i> ";
}else{
$html .= "<span class='float-right'>
<i class='bi bi-arrow-right' style='font-size: 0.9em;'></i> ";
}
}
else{
$html .= "<span class='float-right'>";
}
$html .= number_format(floatval($value),2,",",".")
."<small> ".$units[$i]."</small></span></div>
<!-- /.info-box-content -->
</div>
</div>";
$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 .= "</div>";
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);
+47
View File
@@ -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."];
+1005 -96
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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,
-13
View File
@@ -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.
+2 -3
View File
@@ -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 });
}
});
+465
View File
@@ -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 =
'<div class="leitzahlen"></div>'
+ (opt.werkzeuge ? '<div class="werkzeuge">'
+ '<span class="umschalter" data-rolle="zeitraum" role="group" aria-label="Zeitraum des Vergleichs">'
+ '<button type="button" data-zeitraum="gleich">Gleicher Zeitraum</button>'
+ '<button type="button" data-zeitraum="voll">Ganze Jahre</button></span>'
+ '<span class="jahrwahl">'
+ '<button type="button" data-schritt="-1" aria-label="Frühere Jahre zeigen">'
+ '<i class="bi bi-chevron-left"></i></button>'
+ '<span class="fenster"></span>'
+ '<button type="button" data-schritt="1" aria-label="Spätere Jahre zeigen">'
+ '<i class="bi bi-chevron-right"></i></button></span>'
+ '<label class="kasten"><input type="checkbox" data-rolle="verlauf" checked>'
+ "Monatsverlauf zeigen</label></div>" : "")
+ '<div class="tafel-scroll"><table class="tafel">'
+ "<thead><tr></tr></thead>"
+ '<tbody><tr><td class="leer">Statistik wird geladen …</td></tr></tbody>'
+ "</table></div>"
+ (opt.fussnoten ? '<div class="fussnoten"><p data-rolle="zeitraumtext"></p>'
+ "<p><b>Davon</b> teilt den Ertrag auf: Eigenverbrauch, Einspeisung und Heizung "
+ "ergeben ihn zusammen. <b>Darin</b> 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.</p>"
+ "<p>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.</p></div>" : "");
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 = '<svg class="spark" width="' + breite + '" height="' + H + '" viewBox="0 0 '
+ breite + " " + H + '" role="img" aria-label="Monatsverlauf ' + jahr + '">'
+ '<line class="basis" x1="0" y1="' + (H - .5) + '" x2="' + breite
+ '" y2="' + (H - .5) + '"/>';
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 += '<rect class="balken' + (i === letzter ? " jetzt" : "") + '" x="' + x + '" y="'
+ (H - h - 1) + '" width="' + b + '" height="' + h + '" rx="1.5"'
+ ' data-platz="' + i + '"'
+ ' data-monat="' + MONATSNAMEN[i] + " " + jahr + '"'
+ ' data-wert="' + statZahl(w, kz.dez) + " " + kz.einheit + '"></rect>';
});
return s + "</svg>";
}
/* --- Zeichnen ---------------------------------------------------------- */
function zeichneKopf() {
const gleich = zeitraum === "gleich";
const laufend = new Date().getFullYear();
const paar = vergleichsPaar();
let h = '<th scope="col">Kennzahl</th>';
spaltenJahre().forEach(function (jahr, i) {
const unten = gleich ? "bis " + stichtagText()
: (jahr === laufend ? "läuft noch" : "ganzes Jahr");
h += '<th scope="col"' + (i === 0 ? ' class="jetzt"' : "") + ">" + jahr
+ '<span class="zeitraum">' + unten + "</span></th>";
});
h += '<th scope="col">Δ<span class="zeitraum">'
+ (paar ? paar[0] + " zu " + paar[1] : "") + "</span></th>"
+ '<th scope="col" class="gesamt">Gesamt<span class="zeitraum">seit '
+ daten.jahre[0] + "</span></th>";
if (verlauf) {
h += '<th scope="col" class="verlauf">Monatsverlauf<span class="zeitraum">'
+ verlaufsJahr() + "</span></th>";
}
kopf.innerHTML = h;
const text = teil('[data-rolle="zeitraumtext"]');
if (text) {
text.innerHTML = gleich
? "<b>Gleicher Zeitraum:</b> jedes Jahr vom 1. Januar bis zum " + stichtagText()
+ " so vergleicht sich das laufende Jahr mit dem, was zum selben Stichtag im "
+ "Vorjahr zusammengekommen war."
: "<b>Ganze Jahre:</b> 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 += '<tr class="gruppe"><td colspan="' + spalten + '">' + kz.gruppe + "</td></tr>";
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 + '<span class="einheit-klein">' + kz.einheit + "</span>";
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 = '<button type="button" class="klapp" data-klapp="' + kz.id + '" aria-expanded="'
+ (offen[kz.id] ? "true" : "false") + '"><span class="pfeil"></span>'
// Der Name als eigener Baustein: bei knapper Breite rutscht dann
// die Anzahl in die naechste Zeile, statt den Namen zu zerreissen.
+ "<span>" + text + "</span>"
+ (offen[kz.id] || !posten ? ""
: '<span class="posten">' + posten + " Posten</span>") + "</button>";
} 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 = '<span class="stufe-2"><span class="darin">'
+ (traeger ? "im " + traeger.name : "darin") + "</span>" + text + "</span>";
} else if (kz.teil) {
name = '<span class="stufe-1">' + text + "</span>";
} else {
name = text;
}
h += '<tr class="' + klassen + '"><td class="name">' + name + "</td>";
jahre.forEach(function (jahr, i) {
const w = werteVon(jahr);
h += '<td class="' + (i === 0 ? "wert-jetzt" : "wert-alt") + '">'
+ statZahl(w ? w[kz.id] : null, kz.dez) + "</td>";
});
const d = paar ? statDelta((werteVon(paar[0]) || {})[kz.id],
(werteVon(paar[1]) || {})[kz.id], kz) : null;
h += "<td>" + (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text
+ "</span>" : '<span class="leer"></span>') + "</td>";
// Die Summe über alles. Verhältniszahlen sind darin über den ganzen
// Zeitraum gerechnet, nicht über die Jahre gemittelt.
h += '<td class="gesamt">'
+ statZahl(daten.gesamt ? daten.gesamt[kz.id] : null, kz.dez) + "</td>";
if (verlauf) h += '<td class="verlauf">' + sparkline(kz, jahrDesVerlaufs) + "</td>";
h += "</tr>";
});
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 '<div class="leitzahl' + (i === 0 ? " betont" : "") + '">'
+ '<div class="name">' + kz.name + "</div>"
+ '<div class="zahl">' + statZahl(werte[id], kz.dez)
+ '<span class="einheit">' + kz.einheit + "</span></div>"
+ '<div class="fuss">'
+ (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text + "</span>" : "")
+ "<span>" + jahr + (zeitraum === "gleich" ? " bis " + stichtagText() : "")
+ (gegen ? " gegen " + gegen : "") + "</span></div></div>";
}).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 = '<tr><td class="leer">Noch keine ausgewerteten Tage.</td></tr>';
return;
}
zeichne();
}).catch(function (e) {
koerper.innerHTML = '<tr><td class="leer">Statistik nicht abrufbar.</td></tr>';
console.log("Jahresstatistik: " + e.message);
});
return { zeichne: zeichne };
}
+383 -79
View File
@@ -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 <svg>, nicht an der Gruppe: der
Farbverlauf steht in <defs> 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
* <svg> 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 '<div class="posten' + (knapp ? " knapp" : "") + '">'
+ '<span class="v">' + km + '</span>'
+ '<span class="k">' + STRECKE_ZEITRAEUME[name] + "</span></div>";
}).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 = '<div class="posten"><span class="v"></span>'
+ '<span class="k">nicht abrufbar</span></div>';
}
}
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
+4 -1
View File
@@ -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();
}
+26 -3
View File
@@ -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,
+34 -19
View File
@@ -1,16 +1,24 @@
<?php
/*
* Seite "Vergangenheit": sechs Diagramme und darunter der Jahresvergleich.
*
* Der Vergleich war einmal drei Karten mit je achtzehn Kacheln - dieselben
* Beschriftungen dreimal untereinander, und wer zwei Jahre vergleichen wollte,
* musste scrollen. Jetzt ist es eine Tabelle: Kennzahlen in Zeilen, Jahre in
* Spalten, der Vergleich also eine Zeile weit.
*
* Gebaut wird der Vergleich von baueJahresstatistik() in
* js/solar/jahresstatistik.js, aus einer einzigen Antwort von
* ajax/getStats.php - auch die Zeilen selbst, denn welche Kennzahlen es gibt,
* weiss der Server. Denselben Baustein zeigt die Solarseite einspaltig.
*/
// Die sechs Diagramme unterscheiden sich nur in Zeitraum und Canvas-ID.
$periods = [
"month" => "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",
];
?>
<!--begin::App Main-->
<main class="app-main">
@@ -63,23 +71,30 @@ $statistics = [
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php foreach ($statistics as $id => $title): ?>
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
<div class="card p-0 mb-4" style="width:100%">
<div class="card-header pb-0 pt-1">
<h3 class="card-title"><?= $title ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i>
</button>
<!-- ============================================ Jahresvergleich ==== -->
<!-- Der Inhalt kommt aus baueJahresstatistik() in
js/solar/jahresstatistik.js: Leitzahlen, Werkzeugleiste, Tabelle und
Fussnoten baut der Baustein selbst, damit die Solarseite dieselbe
Statistik in schmaler Form zeigen kann, ohne dass es sie zweimal
gaebe. -->
<div class="row">
<div class="col-12">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Jahresvergleich</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="card-body statistik">
<div id="jahresvergleich"></div>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
</div>
</div>
<!-- /.card -->
</div>
<?php endforeach; ?>
<!-- /.row (main row) -->
</div>
<!--end::Container-->
+375 -180
View File
@@ -19,10 +19,16 @@
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
* ihren Zustand zeigen, nicht das Auto porträtieren.
*
* Der Ladestand ist Teil dieser Grafik und nicht mehr eine Anzeige daneben:
* die Akkuplatte im Fahrzeugboden füllt sich von hinten nach vorn, trägt die
* Zielmarke und nennt Prozent und Reichweite in ihrer Mitte. Alles, was sich
* nicht zeichnen lässt - Ladeleistung, Restzeit, Kilometerstand, Klima und
* die Herkunft des Ladestroms - steht als Kennzahl daneben.
*/
?>
<!--begin::App Main-->
<main class="app-main">
<main class="app-main skoda-seite">
<div class="app-content-header">
<div class="container-fluid">
<div class="row align-items-center">
@@ -39,208 +45,397 @@
<div class="app-content">
<div class="container-fluid">
<!-- ================================================ Zustand ===== -->
<!-- ================================================ Übersicht ==== -->
<!-- Eine Kachel, zwei Hälften: links das Fahrzeug, rechts die Zahlen
dazu. Der Ladestand steht nicht mehr als eigener Ring daneben,
sondern dort, wo er im Auto auch sitzt - in der Akkuplatte im
Fahrzeugboden. Ladestand, Ladeziel und Reichweite gehören zusammen
und stehen deshalb beieinander im Bild; alles, was sich nicht
zeichnen lässt, steht als Kennzahl daneben. -->
<div class="row">
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
hervor - Farbe allein soll die Aussage nicht tragen. -->
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
<div class="col-12">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrzeug</h3>
<div class="card-tools">
<h3 class="card-title">Übersicht</h3>
<div class="card-tools d-flex align-items-center gap-2">
<span class="badge text-bg-secondary" id="skodaSchloss"></span>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center pt-2">
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
<g class="rad">
<rect x="5" y="68" width="21" height="58" rx="9"/>
<rect x="198" y="68" width="21" height="58" rx="9"/>
<rect x="5" y="362" width="21" height="58" rx="9"/>
<rect x="198" y="362" width="21" height="58" rx="9"/>
</g>
<!-- Karosserie -->
<path class="karosserie" d="M112 6
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
volle Bugbreite, die den Enyaq seit der Überarbeitung
kennzeichnet. Von oben sieht man ihre Oberkante. -->
<path class="techdeck" d="M112 7
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
dazu die Rückleuchten als durchgehendes Band. -->
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
<rect x="36" y="30" width="9" height="13" rx="4"/>
<rect x="48" y="27" width="9" height="13" rx="4"/>
<rect x="60" y="25" width="9" height="13" rx="4"/>
<rect x="72" y="24" width="9" height="13" rx="4"/>
<rect x="143" y="24" width="9" height="13" rx="4"/>
<rect x="155" y="25" width="9" height="13" rx="4"/>
<rect x="167" y="27" width="9" height="13" rx="4"/>
<rect x="179" y="30" width="9" height="13" rx="4"/>
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 60 L112 150"/>
<!-- Windschutzscheibe, Dach, Heckscheibe -->
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
<rect x="62" y="206" width="100" height="120" rx="8"/>
<title>Panoramadach</title>
</g>
<!-- Dachreling -->
<g class="reling">
<rect x="48" y="204" width="9" height="134" rx="4"/>
<rect x="167" y="204" width="9" height="134" rx="4"/>
</g>
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
<!-- Heckklappe -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
<title>Heckklappe</title>
</g>
<!-- Türen, jeweils mit Seitenfenster darin -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="210" width="13" height="58" rx="4"/>
<rect x="17" y="286" width="13" height="50" rx="4"/>
<rect x="194" y="210" width="13" height="58" rx="4"/>
<rect x="194" y="286" width="13" height="50" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="1" y="194" width="16" height="10" rx="5"/>
<rect x="207" y="194" width="16" height="10" rx="5"/>
</g>
<!-- ============================================================
Steuerung: die beiden anklickbaren Stellen.
Bedient wird das, worum es geht - der Ladeanschluss für
das Laden, der Innenraum für Klima und Lüftung. Beide
tragen einen Ring als Hinweis darauf, dass sie mehr sind
als Anzeige, und öffnen ein Modal mit den Befehlen.
Sie stehen bewusst NICHT in der Zustandszeile darunter:
die zeigt, was ist. Diese beiden ändern etwas.
============================================================ -->
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
rechten Kotflügel; sie pulst, solange geladen wird. -->
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
role="button" data-steuer="laden" aria-label="Laden steuern">
<circle class="ring" cx="203" cy="408" r="17"/>
<circle cx="203" cy="408" r="11"/>
<path d="M198 402 l6 5 -4 0 4 6"/>
<title>Laden steuern</title>
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
<g id="steuer-klima" class="steuer" tabindex="0"
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
<circle class="ring" cx="112" cy="178" r="19"/>
<circle class="flaeche" cx="112" cy="178" r="13"/>
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
<title>Klima und Lüftung steuern</title>
</g>
</svg>
<div class="skoda-teile" id="skodaTeile"></div>
<p class="skoda-hinweis">
Ladeanschluss und Innenraum antippen, um zu steuern
</p>
</div>
</div>
</div>
<!-- Batterie, Reichweite, Ladung -->
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Batterie und Ladung</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaLadeZustand"></span>
</div>
</div>
<div class="card-body">
<div class="row align-items-center g-3">
<div class="skoda-fahrzeug">
<!-- Ladestandsring. Der Strich auf dem Ring ist das
eingestellte Ladeziel - eine Ladung, die dort endet, ist
kein Fehler, sondern Absicht. -->
<div class="col-md-5 text-center">
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
<text id="skodaSocText" class="ring-zahl" x="100" y="98"></text>
<text class="ring-einheit" x="100" y="126">Prozent</text>
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen
Zustand selbst: offene Türen und Klappen leuchten
bernstein, Licht gelb, der Ladeanschluss pulst beim Laden.
Die Zustandszeile rechts benennt dasselbe in Worten und
hebt beim Überfahren das zugehörige Teil hervor - Farbe
allein soll die Aussage nicht tragen. -->
<div class="skoda-bild">
<svg id="skodaAuto" class="skoda-auto" viewBox="-16 -8 256 516"
role="img" aria-label="Draufsicht des Fahrzeugs mit Ladestand der Batterie und dem Zustand von Türen, Fenstern, Klappen und Licht">
<defs>
<!-- Der Verlauf der Füllung ist am Fahrzeug verankert,
nicht am Füllrechteck: sonst wanderte er mit dem
Ladestand mit und die Farbe unten änderte sich bei
jedem Prozentpunkt. Die Farbe selbst kommt aus
--akku-farbe, gesetzt von der Zustandsklasse am
SVG-Element; das Attribut hier ist der Rückfall. -->
<linearGradient id="akkuVerlauf" gradientUnits="userSpaceOnUse"
x1="0" y1="378" x2="0" y2="108">
<stop offset="0" stop-color="#7aa7c7" stop-opacity=".75"/>
<stop offset="1" stop-color="#7aa7c7" stop-opacity=".3"/>
</linearGradient>
<!-- Innenkontur der Platte: schneidet Füllung, Strömung
und Pegelkante auf die abgerundete Form zu. -->
<clipPath id="akkuInnen">
<rect x="50" y="108" width="124" height="270" rx="7"/>
</clipPath>
<!-- Auf Höhe des Ladestands beschnitten, damit die
Strömung nur im gefüllten Teil läuft. Das Rechteck
darin setzt zeichneAkku() mit. -->
<clipPath id="akkuPegel">
<rect id="akkuPegelFeld" x="50" y="378" width="124" height="0"/>
</clipPath>
<!-- Ein Flügel des Lüfterrads, um die eigene Nabe im
Ursprung gezeichnet. Das Rad setzt ihn dreimal um je
120 Grad versetzt ein. -->
<path id="klimaFluegel"
d="M0 0 C 1.5 -4, 5 -7, 7.5 -5 C 9.6 -3.2, 5.5 -0.8, 0 0 Z"/>
</defs>
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
<g class="rad">
<rect x="5" y="68" width="21" height="58" rx="9"/>
<rect x="198" y="68" width="21" height="58" rx="9"/>
<rect x="5" y="362" width="21" height="58" rx="9"/>
<rect x="198" y="362" width="21" height="58" rx="9"/>
</g>
<!-- Karosserie -->
<path class="karosserie" d="M112 6
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
volle Bugbreite, die den Enyaq seit der Überarbeitung
kennzeichnet. Von oben sieht man ihre Oberkante, also
ein schmales Band, das zu den Kotflügeln hin ausläuft.
Schmal und nur halb deckend: die Blende steht am Auto
fast senkrecht, von oben ist sie deshalb ein Streifen
und keine Fläche. Als breites, fast schwarzes Band war
sie das dunkelste im Bild und las sich wie ein Loch.
Die Enden laufen spitz zusammen statt gerade abzu-
schneiden - ein gerader Schnitt ergab dort zwei Stufen,
die es am Auto nicht gibt. -->
<path class="techdeck" d="M22 46
C 30 17, 74 7, 112 7 C 150 7, 194 17, 202 46
C 196 24, 156 17, 112 17 C 68 17, 28 24, 22 46 Z"/>
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
dazu die Rückleuchten als durchgehendes Band. -->
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
<rect x="36" y="30" width="9" height="13" rx="4"/>
<rect x="48" y="27" width="9" height="13" rx="4"/>
<rect x="60" y="25" width="9" height="13" rx="4"/>
<rect x="72" y="24" width="9" height="13" rx="4"/>
<rect x="143" y="24" width="9" height="13" rx="4"/>
<rect x="155" y="25" width="9" height="13" rx="4"/>
<rect x="167" y="27" width="9" height="13" rx="4"/>
<rect x="179" y="30" width="9" height="13" rx="4"/>
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 60 L112 150"/>
<!-- Windschutzscheibe, Dach, Heckscheibe -->
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
<!-- Kein Panoramadach: dieses Fahrzeug hat ein geschlossenes
Blechdach. Die Zustandszeile kennt es deshalb auch nicht
(TEILE in js/solar/skodaMQTT.js). -->
<!-- Dachreling. Sie liegt AUF dem Dach: nach innen gerückt,
nicht auf dessen Seitenkanten, und sie bleibt zwischen
den Scheiben - vorn hinter der Windschutzscheibe, hinten
am Spoiler. Läge sie bündig auf den Kanten, ergäbe sie
mit Platte und Spoiler ein geschlossenes Rechteck, und
das sieht aus wie ein Fensterrahmen. Die runden Enden
kommen von der Strichstärke, nicht aus der Geometrie. -->
<g class="reling">
<line x1="54" y1="206" x2="54" y2="330"/>
<line x1="170" y1="206" x2="170" y2="330"/>
</g>
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
<!-- Heckspoiler: ein Blatt über der Dachkante, in der Mitte
breit, zu den Enden auslaufend - so sitzt er auf der
Kante statt sie nachzuzeichnen. -->
<path class="spoiler" d="M44 336 C 74 327, 150 327, 180 336
C 150 344, 74 344, 44 336 Z"/>
<!-- ========================================================
Antriebsbatterie - der Ladestand selbst.
Beim MEB liegt der Akku als flache Platte zwischen den
Achsen; genau dort sitzt sie hier auch, von 108 bis 378
über den Radstand. Gefüllt wird von hinten nach vorn,
also im Bild von unten nach oben - dieselbe Richtung,
in der jeder einen Füllstand liest.
Die Reihenfolge ist Absicht und gilt ohne Ausnahme:
die Platte liegt ÜBER allem, was nur Blech und Glas ist
- Karosserie, Haube, beide Scheiben, Dach, Reling,
Spoiler -, und UNTER allem, was Zustand zeigt oder
bedient wird: Türen, Fenster, Klappen, Licht und die
beiden Knöpfe. Eine offene Klappe muss sichtbar
bleiben, auch wenn die Batterie darunter liegt - der
Zustand hat Vorrang vor der Anzeige.
Deshalb steht dieser Block hier und nicht weiter oben:
stand er vor der Heckscheibe, lag die Platte über der
Windschutzscheibe, aber unter der Heckscheibe, und die
Relingstäbe schwammen auf ihr.
Alles Veränderliche - Füllhöhe, Zielmarke, Zahlen und
Farbe - setzt zeichneAkku().
======================================================== -->
<g id="akku" class="akku">
<rect class="huelle" x="46" y="104" width="132" height="278" rx="10"/>
<g clip-path="url(#akkuInnen)">
<rect class="fuellung" id="akkuFuellung" x="50" y="378" width="124" height="0"/>
<!-- Ladestrom: Streifen, die im gefüllten Teil nach
vorn wandern. Nur beim Laden sichtbar. -->
<g class="stroemung" clip-path="url(#akkuPegel)">
<g class="bahn">
<rect x="50" y="430" width="124" height="3" rx="1.5"/>
<rect x="50" y="376" width="124" height="3" rx="1.5"/>
<rect x="50" y="322" width="124" height="3" rx="1.5"/>
<rect x="50" y="268" width="124" height="3" rx="1.5"/>
<rect x="50" y="214" width="124" height="3" rx="1.5"/>
<rect x="50" y="160" width="124" height="3" rx="1.5"/>
<rect x="50" y="106" width="124" height="3" rx="1.5"/>
<rect x="50" y="52" width="124" height="3" rx="1.5"/>
</g>
</g>
<!-- Pegelkante: die helle Linie oben auf der Füllung.
Sie macht den Stand auch dann ablesbar, wenn die
Fläche darunter nur schwach getönt ist. -->
<rect class="kante" id="akkuKante" x="50" y="377" width="124" height="2.5"/>
</g>
<!-- Modulfugen. Sie liegen auf 20, 40, 60 und 80 Prozent
und dienen damit zugleich als Skala. -->
<g class="fugen">
<line x1="50" y1="324" x2="174" y2="324"/>
<line x1="50" y1="270" x2="174" y2="270"/>
<line x1="50" y1="216" x2="174" y2="216"/>
<line x1="50" y1="162" x2="174" y2="162"/>
</g>
<!-- Ladeziel. Eine Ladung, die dort endet, ist kein
Fehler, sondern Absicht - deshalb steht die Marke im
Bild und nicht nur als Zahl daneben. -->
<g class="zielmarke" id="akkuZielMarke">
<line id="akkuZiel" x1="44" y1="108" x2="180" y2="108"/>
<text id="akkuZielText" x="174" y="102">Ziel </text>
</g>
<!-- Ladestand und Reichweite in der Platte. Der dunkle
Umriss der Zahlen (paint-order) hält sie lesbar,
gleich ob Füllung oder leere Zelle dahinter liegt.
Die Höhe ist gerechnet, nicht geschätzt: die Platte
reicht von 108 bis 378, ist also 270 hoch; der
Goldene Schnitt von oben liegt bei 108 + 270 × 0,618
= 274,9. Dort sitzt die optische Mitte der beiden
Zeilen (Oberkante der Ziffern 246, Grundlinie der
Reichweite 303). Weiter oben klebten sie am
Klimaknopf, dessen Ring bei 205 endet - jetzt liegen
41 Einheiten Luft dazwischen. -->
<text class="wert" x="112" y="275">
<tspan id="akkuText"></tspan><tspan class="einheit" dx="3">%</tspan>
</text>
<text class="neben" id="akkuNeben" x="112" y="303"></text>
<title id="akkuTitel">Antriebsbatterie</title>
</g>
<!-- Heckklappe -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
<title>Heckklappe</title>
</g>
<!-- Türen, jeweils mit Seitenfenster darin -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="210" width="13" height="58" rx="4"/>
<rect x="17" y="286" width="13" height="50" rx="4"/>
<rect x="194" y="210" width="13" height="58" rx="4"/>
<rect x="194" y="286" width="13" height="50" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="1" y="194" width="16" height="10" rx="5"/>
<rect x="207" y="194" width="16" height="10" rx="5"/>
</g>
<!-- ========================================================
Steuerung: die beiden anklickbaren Stellen.
Bedient wird das, worum es geht - der Ladeanschluss für
das Laden, der Innenraum für Klima und Lüftung. Beide
tragen einen Ring als Hinweis darauf, dass sie mehr
sind als Anzeige, und öffnen ein Modal mit den
Befehlen.
Sie stehen bewusst NICHT in der Zustandszeile: die
zeigt, was ist. Diese beiden ändern etwas.
======================================================== -->
<!-- Der Weg des Ladestroms vom Anschluss in die Platte.
Läuft nur, solange geladen wird. -->
<path class="ladefluss" d="M199 399 C 192 393, 183 386, 176 376"/>
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
rechten Kotflügel.
Gleiche Bauweise wie der Klimaknopf: gestrichelter Ring,
deckende Scheibe, Zeichen darauf. Beides sind Knöpfe, also
sehen beide gleich aus - was sie unterscheidet, ist das
Zeichen und beim Laden die Farbe. Die deckende Scheibe ist
auch hier nötig, weil darunter die Akkuplatte durchläuft. -->
<g id="teil-plug" class="teil-stecker steuer" tabindex="0"
role="button" data-steuer="laden" aria-label="Laden steuern">
<circle class="ring" cx="203" cy="408" r="19"/>
<circle class="flaeche" cx="203" cy="408" r="13"/>
<path class="glyphe" d="M199 401 L207 407 L202 407 L207 415"/>
<title>Laden steuern</title>
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
<g id="steuer-klima" class="steuer" tabindex="0"
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
<circle class="ring" cx="112" cy="186" r="19"/>
<circle class="flaeche" cx="112" cy="186" r="13"/>
<!-- Lüfterrad. Es dreht sich, solange die Klimatisierung
läuft; die Drehung sitzt in der inneren Gruppe, damit
sie die Verschiebung nach aussen nicht überschreibt. -->
<g class="luefter" transform="translate(112 186)">
<g class="blaetter">
<use href="#klimaFluegel"/>
<use href="#klimaFluegel" transform="rotate(120)"/>
<use href="#klimaFluegel" transform="rotate(240)"/>
</g>
<circle class="nabe" r="1.9"/>
</g>
<title>Klima und Lüftung steuern</title>
</g>
</svg>
<p class="skoda-hinweis">
Ladeanschluss und Innenraum antippen, um zu steuern
</p>
</div>
<div class="col-md-7">
<div class="skoda-werte">
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite"></span></div>
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung"></span></div>
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit"></span></div>
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText"></span></div>
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm"></span></div>
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima"></span></div>
<!-- Alles, was sich nicht ins Bild zeichnen lässt. -->
<div class="skoda-daten">
<div class="skoda-kennzahlen">
<div class="kennzahl" id="skodaKachelLeistung">
<span class="zeichen"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i></span>
<span class="text">
<span class="k">Ladeleistung</span>
<span class="v" id="skodaLeistung"></span>
<span class="neben" id="skodaLadeart"></span>
</span>
</div>
<div class="kennzahl" id="skodaKachelRestzeit">
<span class="zeichen"><i class="bi bi-hourglass-split" aria-hidden="true"></i></span>
<span class="text">
<span class="k">Restzeit</span>
<span class="v" id="skodaRestzeit"></span>
<span class="neben" id="skodaFertig"></span>
</span>
</div>
<div class="kennzahl">
<span class="zeichen"><i class="bi bi-speedometer2" aria-hidden="true"></i></span>
<span class="text">
<span class="k">Kilometer&shy;stand</span>
<span class="v" id="skodaKm"></span>
</span>
</div>
<div class="kennzahl" id="skodaKachelKlima">
<span class="zeichen"><i class="bi bi-fan" id="skodaKlimaZeichen" aria-hidden="true"></i></span>
<span class="text">
<span class="k">Klima</span>
<span class="v" id="skodaKlima"></span>
</span>
</div>
</div>
<!-- Woher der Strom gerade kommt: der Balken teilt die
Ladeleistung in den Anteil, den die PV deckt, und den
Rest aus dem Netz. -->
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
<div class="d-flex justify-content-between small text-secondary mb-1">
<span>Ladestrom</span><span id="skodaQuelleText"></span>
Rest aus dem Netz. Steht nur da, solange geladen wird -
ohne Ladung hat der Balken nichts zu teilen. -->
<div class="skoda-quelle" id="skodaQuelleBox" hidden>
<div class="kopf">
<span class="skoda-marke">Ladestrom</span>
<span class="summe" id="skodaQuelleText"></span>
</div>
<div class="skoda-balken">
<div class="teil-pv" id="skodaAnteilPV"></div>
<div class="teil-netz" id="skodaAnteilNetz"></div>
</div>
<div class="legende">
<span><i class="pkt pv"></i>Sonne <b id="skodaQuellePV"></b></span>
<span><i class="pkt netz"></i>Netz <b id="skodaQuelleNetz"></b></span>
</div>
</div>
</div>
<!-- Was das Fahrzeug in Woche, Monat und Jahr gefahren ist.
Gerechnet aus dem Kilometerstand, nicht aus den erkannten
Fahrten - siehe ajax/skoda.php, ?was=strecke. -->
<div class="skoda-fahrleistung">
<span class="skoda-marke">Fahrleistung</span>
<div class="reihe" id="skodaFahrleistung"></div>
</div>
<!-- Zustandszeile: benennt in Worten, was die Farben im Bild
zeigen, und hebt beim Überfahren das Teil hervor. -->
<div class="skoda-zustand">
<span class="skoda-marke">Zustand</span>
<div class="skoda-teile" id="skodaTeile"></div>
</div>
</div>
</div>
</div>
</div>
+16 -17
View File
@@ -64,23 +64,6 @@
<!-- /.card -->
</div>
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
<div class="card p-0 mb-4" style="width:100%">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Statistik dieses Jahr</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
</div>
</div>
<!-- /.card -->
</div>
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
<div class="card p-0 mb-4" style="width:100%">
<div class="card-header pb-0 pt-1">
@@ -118,6 +101,22 @@
</div>
</div>
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
<div class="card p-0 mb-4" style="width:100%">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Statistik dieses Jahr</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Gefuellt von baueJahresstatistik() aus js/solar/jahresstatistik.js -
dieselbe Tabelle wie auf der Historienseite, nur einspaltig. -->
<div class="card-body statistik" id="Stats-Year"></div>
</div>
<!-- /.card -->
</div>
<!-- Consumption Graph-->
<div class="col-lg-12">