Einstellungsseite, Skoda-Seite und Karte

Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 21:21:27 +02:00
co-authored by Claude Opus 5
parent c9a9e16b21
commit d285ec27a0
26 changed files with 4862 additions and 190 deletions
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* Modell-Schicht fuer die Preistabellen der Einstellungsseite.
*
* Eine Preistabelle ist eine Zeitreihe: ab einem Stichtag gelten neue Preise,
* bis der naechste Eintrag beginnt. Mehr steht dort auch nicht - insbesondere
* kein Enddatum. Das gab es frueher als eigene Spalte, und weil es eigenstaendig
* gepflegt wurde, konnte es etwas anderes behaupten als der naechste Stichtag:
* in gridCosts liefen zwei Zeilen ueber das erste Quartal 2023, der JOIN in
* ajax/getStats.php fand fuer jeden Messwert zwei Preise, und der
* Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der Statistik.
*
* Ohne die Spalte kann das nicht mehr passieren: zwei Zeitraeume koennen sich
* nicht ueberlappen, weil der eine endet, wo der andere beginnt, und eine
* Luecke kann es auch nicht geben. Die juengste Zeile hat kein Ende und gilt
* weiter, bis ein neuer Preis eingetragen wird - so verhaelt sich ein Tarif ja
* auch. Wo die Statistik das Ende braucht, holt sie es sich mit
* LEAD(active_date) beim naechsten Stichtag.
*
* Was bleibt: vor dem aeltesten Stichtag gibt es keinen Preis. Messwerte, die
* davor liegen, fallen aus der Statistik heraus - dagegen hilft nur hinsehen,
* siehe preisLuecke().
*/
require_once(__DIR__ . "/mysql.php");
/** Verbindung zur Messwert-Datenbank. */
function solarDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Die bearbeitbaren Preistabellen.
*
* Je Feld:
* faktor rechnet zwischen Speicher- und Anzeigeeinheit um. Gespeichert
* wird in Euro, angezeigt und eingegeben in Cent - so, wie es auf
* der Rechnung steht. 0,3163 EUR/kWh sind 31,63 ct/kWh; niemand
* tippt gern vier Nachkommastellen. Vorgabe 1, also unveraendert.
* stellen Nachkommastellen der Anzeige, zugleich die Schrittweite im Feld.
* max Obergrenze. Was darueber liegt, ist ein Tippfehler und keine
* Preiserhoehung.
*
* Nicht jedes Feld ist ein Preis. Der Energieinhalt des Gases stand frueher
* als 6.58 im SQL, die Benzinersparnis als Teiler 24.5 - beides gehoert neben
* den Preis, weil es am Brennstoff und am Fahrzeug haengt und nicht an der
* Physik. Beim Auto aber nicht als "kWh je Liter": diese Zahl steht auf keinem
* Datenblatt. Eingetragen werden die beiden Verbraeuche, die man kennt, und
* die Statistik rechnet daraus, wieviele Liter eine geladene kWh ersetzt.
*
* Kommt spaeter eine weitere Tabelle desselben Zuschnitts dazu, reicht hier
* ein Eintrag - Endpunkt, Maske und Speicherlogik bleiben unveraendert.
*/
function preisReihen()
{
return [
"strom" => [
"titel" => "Strompreise",
"hinweis" => "Arbeitspreis, Einspeisevergütung und Grundpreis, wie sie in die "
. "Jahresstatistik auf der Historienseite eingehen.",
"tabelle" => "gridCosts",
"symbol" => "bi-plug",
"felder" => [
"cost" => ["titel" => "Arbeitspreis", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"gain" => ["titel" => "Einspeisung", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"base" => ["titel" => "Grundpreis", "einheit" => "€/Jahr",
"faktor" => 1, "stellen" => 2, "max" => 5000],
],
],
"gas" => [
"titel" => "Gaspreise",
"hinweis" => "Womit die Ersparnis der Heizung gerechnet wird: was das Gas kostet, "
. "das der Heizstab bei Sonnenüberschuss ersetzt.",
"tabelle" => "gasCosts",
"symbol" => "bi-fire",
"felder" => [
"cost" => ["titel" => "Gaspreis", "einheit" => "ct/l",
"faktor" => 100, "stellen" => 2, "max" => 1000],
"kwhPerLitre" => ["titel" => "Energieinhalt", "einheit" => "kWh/l",
"faktor" => 1, "stellen" => 2, "max" => 20],
],
],
"sprit" => [
"titel" => "Benzinpreise",
"hinweis" => "Für „Benzin gespart“ und „Ersparnis Benzin“: der Preis an der Säule "
. "und was die beiden Autos verbrauchen — daraus ergibt sich, wieviele "
. "Liter eine geladene Kilowattstunde ersetzt.",
"tabelle" => "fuelCosts",
"symbol" => "bi-fuel-pump",
"felder" => [
"cost" => ["titel" => "Benzinpreis", "einheit" => "€/l",
"faktor" => 1, "stellen" => 3, "max" => 10],
"lPer100km" => ["titel" => "Verbrenner", "einheit" => "l/100 km",
"faktor" => 1, "stellen" => 1, "max" => 50],
"kwhPer100km" => ["titel" => "Elektro", "einheit" => "kWh/100 km",
"faktor" => 1, "stellen" => 1, "max" => 100],
],
],
];
}
/** Beschreibung einer Reihe, oder null bei unbekanntem Schluessel. */
function preisReihe($key)
{
$reihen = preisReihen();
return isset($reihen[$key]) ? $reihen[$key] : null;
}
/**
* Spaltenbreiten einer Zeile im Bootstrap-Raster (zusammen zwoelf).
*
* Damit Kopfzeile und Felder aus derselben Quelle kommen: das PHP zeichnet die
* Ueberschriften, das JavaScript die Zeilen, und beide holen sich die Breiten
* hier.
*
* Datum, Ende und Papierkorb nehmen sich so wenig wie moeglich - das Ende ist
* nur noch Text, der Papierkorb ein Knopf. Was uebrig bleibt, teilen sich die
* Felder. Geht die Teilung nicht auf, bekommt der Rest das Feld mit der
* laengsten Einheit: hinter "kWh/100 km" bleiben von einer Sechstelspalte
* keine 50 Pixel fuer die Zahl, hinter "EUR/l" ist reichlich Platz.
*
* Ab vier Feldern wird es eng; die Zeile bricht dann um, was haesslicher, aber
* nicht kaputt ist.
*/
function preisSpalten($felder)
{
$felder = array_values($felder);
$anzahl = max(1, count($felder));
$rand = $anzahl >= 3 ? ["ab" => 2, "bis" => 2, "aktion" => 1]
: ["ab" => 3, "bis" => 3, "aktion" => 1];
$rest = 12 - $rand["ab"] - $rand["bis"] - $rand["aktion"];
$breiten = array_fill(0, $anzahl, max(1, intdiv($rest, $anzahl)));
$uebrig = $rest - array_sum($breiten);
if ($uebrig > 0) {
$laengen = array_map(function ($f) { return mb_strlen($f["einheit"]); }, $felder);
$breiten[array_search(max($laengen), $laengen)] += $uebrig;
}
return $rand + ["felder" => $breiten];
}
/**
* Alle Zeitraeume einer Reihe, aeltester zuerst, Werte in Anzeigeeinheit.
*
* Geliefert wird nur der Stichtag; wo eine Zeile endet, ergibt sich aus der
* naechsten und rechnet die Maske selbst aus.
*/
function preiseLaden($key)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
$spalten = array_keys($reihe["felder"]);
$res = solarDb()->query("SELECT active_date, " . implode(", ", $spalten)
. " FROM " . $reihe["tabelle"] . " ORDER BY active_date");
if (!$res) {
throw new RuntimeException(solarDb()->error);
}
$zeilen = [];
while ($row = $res->fetch_assoc()) {
$zeile = ["ab" => $row["active_date"]];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$zeile[$spalte] = round(floatval($row[$spalte]) * $feld["faktor"], $feld["stellen"]);
}
$zeilen[] = $zeile;
}
return $zeilen;
}
/** Datum in der Form JJJJ-MM-TT, sonst null. */
function preisDatum($wert)
{
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) {
return null;
}
$datum = DateTimeImmutable::createFromFormat("!Y-m-d", $wert);
// createFromFormat schluckt den 31. Februar und macht den 3. Maerz daraus;
// der Rueckvergleich faengt das ab.
return ($datum && $datum->format("Y-m-d") === $wert) ? $datum : null;
}
/** Tag der aeltesten Messung in EnergyFlow, als "JJJJ-MM-TT". */
function ersteMessung()
{
static $tag = null;
if ($tag === null) {
$res = solarDb()->query("SELECT MIN(datetime) FROM EnergyFlow");
$zeile = $res ? $res->fetch_row() : null;
$tag = ($zeile && $zeile[0]) ? substr($zeile[0], 0, 10) : "";
}
return $tag;
}
/**
* Beginnt die Reihe zu spaet? Dann fehlt den aeltesten Messwerten der Preis.
*
* Das ist die einzige Luecke, die es noch geben kann - alle anderen hat das
* Weglassen der Spalte end_date erledigt. Auftreten kann sie, wenn jemand den
* aeltesten Zeitraum loescht.
*/
function preisLuecke($zeilen)
{
$erste = ersteMessung();
if (!$erste || !$zeilen) {
return "";
}
$stichtag = $zeilen[0]["ab"];
return $stichtag > $erste ? $erste : "";
}
/**
* Eine ganze Reihe schreiben: pruefen und ersetzen.
*
* Ersetzt statt abzugleichen. Die Tabellen haben eine Handvoll Zeilen und
* keinen kuenstlichen Schluessel - der Stichtag selbst ist der
* Primaerschluessel, und beim Verschieben eines Stichtags waere ein Abgleich
* mehr Buchhaltung als Gewinn. Alles laeuft in einer Transaktion, es gibt also
* keinen Moment, in dem die Statistik auf eine halbe Tabelle blickt.
*/
function preiseSpeichern($key, $zeilen)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
if (!is_array($zeilen) || !$zeilen) {
throw new InvalidArgumentException("Es muss mindestens ein Zeitraum bleiben.");
}
$spalten = array_keys($reihe["felder"]);
// --- Pruefen --------------------------------------------------------
$geprueft = [];
foreach ($zeilen as $zeile) {
$ab = preisDatum($zeile["ab"] ?? "");
if (!$ab) {
throw new InvalidArgumentException("Ein Zeitraum hat kein gültiges Startdatum.");
}
$satz = ["ab" => $ab];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$wert = $zeile[$spalte] ?? null;
if (!is_numeric($wert)) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y") . " ist keine Zahl.");
}
$wert = floatval($wert);
if ($wert < 0 || $wert > $feld["max"]) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y")
. " liegt außerhalb des Erwartbaren (0 bis " . $feld["max"] . " "
. $feld["einheit"] . ").");
}
$satz[$spalte] = $wert / $feld["faktor"];
}
$geprueft[] = $satz;
}
// Nach Stichtag sortieren; zwei gleiche Stichtage kann die Tabelle nicht
// halten (Primaerschluessel) und die Statistik nicht deuten.
usort($geprueft, function ($a, $b) { return $a["ab"] <=> $b["ab"]; });
for ($i = 1; $i < count($geprueft); $i++) {
if ($geprueft[$i]["ab"] == $geprueft[$i - 1]["ab"]) {
throw new InvalidArgumentException(
"Zwei Zeiträume beginnen am " . $geprueft[$i]["ab"]->format("d.m.Y") . ".");
}
}
// --- Schreiben ------------------------------------------------------
$db = solarDb();
$db->begin_transaction();
try {
if (!$db->query("DELETE FROM " . $reihe["tabelle"])) {
throw new RuntimeException($db->error);
}
$platzhalter = implode(", ", array_fill(0, count($spalten) + 1, "?"));
$stmt = $db->prepare("INSERT INTO " . $reihe["tabelle"]
. " (active_date, " . implode(", ", $spalten) . ")"
. " VALUES (" . $platzhalter . ")");
if (!$stmt) {
throw new RuntimeException($db->error);
}
foreach ($geprueft as $satz) {
$werte = [$satz["ab"]->format("Y-m-d")];
foreach ($spalten as $spalte) {
$werte[] = $satz[$spalte];
}
$stmt->bind_param("s" . str_repeat("d", count($spalten)), ...$werte);
if (!$stmt->execute()) {
throw new RuntimeException($stmt->error);
}
}
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return count($geprueft);
}
/**
* Grundpreis Strom fuer ein Kalenderjahr, in Euro.
*
* Der Grundpreis ist der einzige Posten, der nicht an einem Messwert haengt -
* er faellt auch an, wenn nichts verbraucht wird. Deshalb steht er nicht im
* grossen SUM-Ausdruck der Statistik, sondern wird hier eigens gerechnet:
* jeder Zeitraum steuert seinen Anteil an den Tagen des Jahres bei. Das ist
* unabhaengig davon, wieviele Messwerte tatsaechlich aufgezeichnet wurden -
* 2022 hat merklich Luecken, und ein fehlender Nachmittag darf den Grundpreis
* nicht senken.
*
* LEAD() holt den naechsten Stichtag; einen Tag davor endet der Zeitraum. Die
* juengste Zeile hat keinen Nachfolger und laeuft bis zum Jahresende weiter.
*/
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
$stmt = solarDb()->prepare(
"WITH p AS (SELECT active_date, base,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt
FROM gridCosts)
SELECT SUM(base * (DATEDIFF(LEAST(IFNULL(DATE_SUB(folgt, INTERVAL 1 DAY), ?), ?),
GREATEST(active_date, ?)) + 1) / ?)
FROM p
WHERE active_date <= ? AND (folgt IS NULL OR folgt > ?)");
$stmt->bind_param("sssiss", $bis, $bis, $von, $tage, $bis, $von);
$stmt->execute();
$wert = $stmt->get_result()->fetch_row()[0];
return $wert === null ? 0.0 : floatval($wert);
}
+5
View File
@@ -62,6 +62,11 @@
<?php if ($page["charts"]): ?>
<?php include __DIR__ . "/charts.html"; ?>
<?php endif; ?>
<?php if ($page["leaflet"]): ?>
<!-- Leaflet 1.9.4, BSD 2-Clause. Liegt wie alle anderen Bibliotheken lokal;
die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. -->
<script src="js/leaflet.js"></script>
<?php endif; ?>
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
<?php foreach ($page["scripts"] as $script): ?>
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
+20
View File
@@ -44,6 +44,12 @@ if (!isset($_GET["floor"]))
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
<!--end::Required Plugin(AdminLTE)-->
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
<?php if ($page["leaflet"]): ?>
<link rel="stylesheet" href="css/leaflet.css" />
<?php endif; ?>
<!--end::Karte-->
</head>
<!--end::Head-->
@@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))
<p>Wetter</p>
</a>
</li>
<li class="nav-item">
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
<i class="nav-icon bi bi-ev-front"></i>
<p>Škoda</p>
</a>
</li>
</ul>
</li>
<li class="nav-item menu-open">
@@ -170,6 +182,14 @@ if (!isset($_GET["floor"]))
</li>
</ul>
</li>
<!-- Einstellungen stehen fuer sich: kein Dashboard,
sondern die Parameter dahinter. -->
<li class="nav-item">
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
<i class="nav-icon bi bi-gear"></i>
<p>Einstellungen</p>
</a>
</li>
</ul>
<!--end::Sidebar Menu-->
</nav>
+1 -1
View File
@@ -74,7 +74,7 @@ $statistics = [
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="<?= $id ?>">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
</div>
</div>
<!-- /.card -->
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
*
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
* vorhandenen etwas davon merken.
*
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
* gaebe es die erste Zeile als PHP und jede weitere als JavaScript, und die
* beiden Fassungen liefen frueher oder spaeter auseinander. Aus demselben
* Grund kommen die Spaltenbreiten aus preisSpalten(): die Kopfzeile hier und
* die Felder dort muessen dieselben sein.
*/
require_once(__DIR__ . "/costs.php");
$reihen = preisReihen();
$daten = [];
$fehler = "";
try {
foreach ($reihen as $key => $reihe) {
$daten[$key] = [
"felder" => $reihe["felder"],
"spalten" => preisSpalten($reihe["felder"]),
"zeilen" => preiseLaden($key),
];
}
} catch (Throwable $e) {
$fehler = $e->getMessage();
}
?>
<!--begin::App Main-->
<main class="app-main">
<!--begin::App Content Header-->
<div class="app-content-header">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h3 class="mb-0">Einstellungen</h3>
</div>
</div>
</div>
</div>
<!--end::App Content Header-->
<!--begin::App Content-->
<div class="app-content">
<div class="container-fluid">
<?php if ($fehler): ?>
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
<?php endif; ?>
<?php foreach ($reihen as $key => $reihe): ?>
<?php $breiten = preisSpalten($reihe["felder"]); ?>
<div class="row">
<div class="col-12 col-xxl-9">
<div class="card mb-4 preis-karte" data-reihe="<?= htmlspecialchars($key) ?>">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi <?= $reihe["symbol"] ?> me-2"></i><?= htmlspecialchars($reihe["titel"]) ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool preis-neu" title="Zeitraum hinzufügen">
<i class="bi bi-plus-lg"></i>
</button>
<button type="button" class="btn btn-tool preis-zurueck" title="Änderungen verwerfen">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body pb-2">
<p class="text-secondary small mb-3"><?= htmlspecialchars($reihe["hinweis"]) ?></p>
<!-- Spaltenköpfe nur auf breiten Schirmen; auf dem Handy trägt
jedes Feld seine eigene Beschriftung. -->
<div class="row g-2 d-none d-xl-flex preis-kopf text-secondary small fw-semibold">
<div class="col-xl-<?= $breiten["ab"] ?>">Gültig ab</div>
<div class="col-xl-<?= $breiten["bis"] ?>">Gültig bis</div>
<?php foreach (array_values($reihe["felder"]) as $i => $feld): ?>
<div class="col-xl-<?= $breiten["felder"][$i] ?>"><?= htmlspecialchars($feld["titel"]) ?></div>
<?php endforeach; ?>
<div class="col-xl-<?= $breiten["aktion"] ?>"></div>
</div>
<div class="preis-zeilen"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-3 preis-neu">
<i class="bi bi-plus-lg me-1"></i>Zeitraum hinzufügen
</button>
</div>
<div class="card-footer d-flex align-items-center gap-3">
<button type="button" class="btn btn-primary preis-speichern">Speichern</button>
<span class="preis-hinweis small"></span>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!--end::App Content-->
</main>
<!--end::App Main-->
<script>
// Ausgangsstand der Preistabellen. Werte in der Anzeigeeinheit (ct statt
// Euro); umgerechnet wird in restricted/costs.php - an genau einer Stelle.
const preisDaten = <?= json_encode($daten, JSON_UNESCAPED_UNICODE) ?>;
// Aelteste Messung in EnergyFlow. Beginnt eine Preisreihe spaeter, fehlt
// den Werten davor der Preis - dann warnt die Maske.
const ersteMessung = <?= json_encode(ersteMessung()) ?>;
</script>
+383
View File
@@ -0,0 +1,383 @@
<?php
/*
* Škoda-Dashboard.
*
* Zwei Datenwege, bewusst getrennt:
*
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand und
* Wallbox-Leistung kommen laufend über solarManager/# und
* aktualisieren die Anzeige sofort.
*
* ajax/skoda.php Alles, was nur in der Tabelle steht - Türen, Fenster,
* Licht, Kilometerstand, Position, Klima - sowie sämtliche
* Auswertungen. Wird beim Laden und danach minütlich geholt.
*
* Solange kein Fahrzeug angebunden ist, bleibt die Tabelle leer; jeder
* Abschnitt zeigt dann seinen Leerzustand statt eines Fehlers.
*
* Die Fahrzeuggrafik ist eine maßstäbliche Draufsicht des Enyaq:
* 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.
*/
?>
<!--begin::App Main-->
<main class="app-main">
<div class="app-content-header">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col-sm-8">
<h3 class="mb-0">Škoda</h3>
</div>
<div class="col-sm-4 text-sm-end">
<span class="skoda-alter text-secondary" id="skodaAlter">&nbsp;</span>
</div>
</div>
</div>
</div>
<div class="app-content">
<div class="container-fluid">
<!-- ================================================ Zustand ===== -->
<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="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">
<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">
<!-- 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>
</svg>
</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>
</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>
</div>
<div class="skoda-balken">
<div class="teil-pv" id="skodaAnteilPV"></div>
<div class="teil-netz" id="skodaAnteilNetz"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ================================================= Ladungen ==== -->
<div class="row">
<div class="col-xl-8 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">Verlauf der letzten Ladung</h3>
<div class="card-tools">
<span class="text-secondary small" id="skodaKurveZeit"></span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaKurve"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 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">Ladungen</h3>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaLadungen"></div>
</div>
</div>
</div>
</div>
<!-- ============================================== Akkugesundheit == -->
<div class="row">
<div class="col-xl-8 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">Akkugesundheit</h3>
<div class="card-tools">
<span class="text-secondary small">kWh je 100 Ladestandsprozent</span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaGesundheit"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 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">Befund</h3>
</div>
<div class="card-body">
<div class="skoda-befund" id="skodaBefund">
<p class="text-secondary mb-0">Noch keine ausgewerteten Ladungen.</p>
</div>
<p class="text-secondary small mb-0 mt-3">
Gemessen wird am Wallbox-Zähler, die Ladeverluste sind also
enthalten. Absolut ist der Wert eine Näherung — aussagekräftig
ist sein Verlauf, denn ein alternder Akku bräuchte <em>weniger</em>
Kilowattstunden für dieselben hundert Prozentpunkte.
</p>
</div>
</div>
</div>
</div>
<!-- ============================================ Standort ========== -->
<!-- Karte auf Leaflet. Die Kacheln kommen nicht unmittelbar von
OpenStreetMap, sondern über ajax/tile.php aus dem eigenen
Zwischenspeicher auf dem NAS - siehe dort, warum.
Die Fahrtlinien sind gestrichelt, und zwar mit Absicht: die API
liefert nur die letzte bekannte PARKposition, also Start und Ziel.
Die tatsächlich gefahrene Strecke kennt niemand, und eine
durchgezogene Linie auf einer Straßenkarte würde genau das
behaupten. -->
<div class="row">
<div class="col-xl-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">Standort und Fahrten</h3>
<div class="card-tools">
<button type="button" id="skodaKarteHeim" class="btn btn-tool"
title="Auf den aktuellen Standort zentrieren">
<i class="bi bi-geo-alt"></i>
</button>
<a href="#" id="skodaOsmLink" class="btn btn-tool" target="_blank"
rel="noopener noreferrer" title="Auf OpenStreetMap öffnen">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</div>
<div class="card-body p-2">
<div id="skodaKarte" class="skoda-karte"></div>
<div class="d-flex justify-content-between align-items-center px-2 pt-2 pb-1">
<span class="text-secondary small" id="skodaStandort"></span>
<span class="skoda-kartenlegende">
<span><i class="pkt jetzt"></i>jetzt</span>
<span><i class="pkt halt"></i>Halt</span>
<span><i class="lin"></i>Luftlinie</span>
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-5 d-flex align-items-stretch">
<!-- Die Klasse zeigt-kwh / zeigt-prozent auf dieser Kachel
entscheidet, welche Einheit die Zeilen anzeigen. Beide Werte
stehen im Markup, umgeschaltet wird allein per CSS - so muss
beim Wechsel nichts neu gebaut werden, und die Auswahl
übersteht auch die minütliche Auffrischung der Liste. -->
<div class="card mb-4 p-0 w-100 zeigt-kwh" id="skodaFahrtenKachel">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrten</h3>
<div class="card-tools d-flex align-items-center gap-2">
<!-- Text kommt aus zeigeVerbrauchsbasis(): der Verbrauch in
Kilowattstunden ist nicht gemessen, sondern aus dem
Ladestand und der eigenen Ladehistorie gerechnet. Womit
genau, steht im Titel dieses Elements. -->
<span class="text-secondary small" id="skodaVerbrauchBasis"></span>
<span class="skoda-umschalter" id="skodaEinheit" role="group"
aria-label="Einheit des Verbrauchs">
<button type="button" data-einheit="kwh" aria-pressed="true">kWh</button>
<button type="button" data-einheit="prozent" aria-pressed="false">%</button>
</span>
</div>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaFahrten"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!--end::App Main-->
+1 -1
View File
@@ -74,7 +74,7 @@
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="Stats-Year">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
</div>
</div>