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>
1175 lines
44 KiB
JavaScript
1175 lines
44 KiB
JavaScript
/*
|
||
* Škoda-Seite.
|
||
*
|
||
* Zwei Quellen, bewusst getrennt:
|
||
*
|
||
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand, Restladezeit und
|
||
* die Wallbox-Leistung kommen laufend über solarManager/# herein und
|
||
* aktualisieren die Anzeige sofort.
|
||
*
|
||
* Ajax Alles, was nur in der Tabelle skoda steht - Türen, Fenster, Licht,
|
||
* Klappen, Kilometerstand, Position, Klima, Ladeziel - sowie sämtliche
|
||
* Auswertungen. Beim Laden einmal, danach minütlich.
|
||
*
|
||
* Das Fahrzeug meldet Türen und Licht nur, wenn es sich meldet; zwischen zwei
|
||
* 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.
|
||
*/
|
||
|
||
var mqttData = {};
|
||
|
||
const solarMQTT = {
|
||
getMQTT: function () {
|
||
const connection = "wss://mqtt.nas.el-wa.org:443";
|
||
const client = mqtt.connect(connection, { rejectUnauthorized: false });
|
||
|
||
client.on("message", function (topic, message) {
|
||
mqttData[topic] = message;
|
||
if (topic.startsWith("solarManager/ev") || topic === "solarManager/P_PV"
|
||
|| topic === "solarManager/P_Grid") {
|
||
planeLiveAnzeige();
|
||
}
|
||
});
|
||
client.on("connect", function () { client.subscribe("solarManager/#"); });
|
||
client.on("error", function () { /* stille Wiederverbindung durch mqtt.js */ });
|
||
}
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ Hilfen */
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/** MQTT-Nutzlast als Zahl. Fehlt das Thema noch, kommt null. */
|
||
function mqZahl(name) {
|
||
const v = mqttData["solarManager/" + name];
|
||
if (v === undefined || v === null) return null;
|
||
const z = Number(String(v));
|
||
return Number.isFinite(z) ? z : null;
|
||
}
|
||
/** MQTT-Nutzlast als Text. */
|
||
function mqText(name) {
|
||
const v = mqttData["solarManager/" + name];
|
||
return (v === undefined || v === null) ? null : String(v);
|
||
}
|
||
|
||
const nf = (n, k = 0) => (n === null || n === undefined || !Number.isFinite(Number(n)))
|
||
? "–" : Number(n).toLocaleString("de-DE", { minimumFractionDigits: k, maximumFractionDigits: k });
|
||
|
||
/** Sekunden als "vor 3 min" / "vor 2 h 15 min" / "vor 4 Tagen". */
|
||
function seit(sekunden) {
|
||
if (sekunden === null || sekunden === undefined) return "";
|
||
const s = Math.max(0, Math.round(sekunden));
|
||
if (s < 90) return "vor " + s + " s";
|
||
const m = Math.round(s / 60);
|
||
if (m < 90) return "vor " + m + " min";
|
||
const h = Math.floor(s / 3600);
|
||
if (h < 36) return "vor " + h + " h " + Math.round((s - h * 3600) / 60) + " min";
|
||
return "vor " + Math.round(s / 86400) + " Tagen";
|
||
}
|
||
|
||
function zeitpunkt(iso, mitJahr = false) {
|
||
if (!iso) return "–";
|
||
const d = new Date(String(iso).replace(" ", "T"));
|
||
if (isNaN(d)) return "–";
|
||
return d.toLocaleString("de-DE", {
|
||
day: "2-digit", month: "2-digit", year: mitJahr ? "numeric" : undefined,
|
||
hour: "2-digit", minute: "2-digit"
|
||
});
|
||
}
|
||
|
||
/** Minuten als "1 h 35 min". */
|
||
function dauer(minuten) {
|
||
const m = Math.round(Number(minuten));
|
||
if (!Number.isFinite(m) || m <= 0) return "–";
|
||
if (m < 60) return m + " min";
|
||
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
||
}
|
||
|
||
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);
|
||
return antwort.json();
|
||
}
|
||
|
||
function leer(text) {
|
||
return '<div class="leer">' + text + "</div>";
|
||
}
|
||
|
||
/* ------------------------------------------------------- Zustand des Autos */
|
||
|
||
/*
|
||
* Die Zustandswörter der API in Anzeige übersetzen. UNKNOWN ist ausdrücklich
|
||
* kein Nein: das Fahrzeug sagt dann, dass es den Zustand selbst nicht kennt,
|
||
* und die Anzeige darf daraus kein beruhigendes "geschlossen" machen.
|
||
*/
|
||
const OFFEN_WERTE = ["OPEN", "OPENED", "TRUNK_OPENED", "NO", "UNLOCKED"];
|
||
|
||
const ZUSTANDSWORT = {
|
||
OPEN: "offen", OPENED: "offen", TRUNK_OPENED: "Kofferraum offen",
|
||
CLOSED: "zu", LOCKED: "verriegelt", UNLOCKED: "entriegelt",
|
||
ON: "an", OFF: "aus", YES: "ja", NO: "nein",
|
||
UNKNOWN: "unbekannt", UNSUPPORTED: "nicht vorhanden"
|
||
};
|
||
|
||
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" }
|
||
];
|
||
|
||
/**
|
||
* Die Teile in der Fahrzeuggrafik mit ihren Einträgen koppeln - dieselbe
|
||
* Wirkung wie umgekehrt, nur von der Grafik aus angestossen. Die Gruppen
|
||
* stehen fest im Markup, also genügt einmaliges Verdrahten beim Seitenaufbau.
|
||
*/
|
||
function koppleFahrzeugTeile() {
|
||
document.querySelectorAll("#skodaAuto .teil[id]").forEach(function (knoten) {
|
||
const id = knoten.id;
|
||
knoten.addEventListener("pointerenter", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(id, true);
|
||
});
|
||
knoten.addEventListener("pointerleave", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(id, false);
|
||
});
|
||
knoten.addEventListener("click", () => hervorheben(id, "um"));
|
||
knoten.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hervorheben(id, "um"); }
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ein Fahrzeugteil und seinen Eintrag gemeinsam hervorheben.
|
||
*
|
||
* @param an true / false, oder "um" zum Umschalten (Tippen)
|
||
*/
|
||
function hervorheben(id, an) {
|
||
const knoten = $(id);
|
||
const chip = document.querySelector('.skoda-teile .chip[data-teil="' + id + '"]');
|
||
const soll = (an === "um") ? !(knoten && knoten.classList.contains("hervor")) : an;
|
||
if (knoten) knoten.classList.toggle("hervor", soll);
|
||
if (chip) chip.classList.toggle("hervor", soll);
|
||
}
|
||
|
||
function zeichneTeile(stand) {
|
||
const leiste = $("skodaTeile");
|
||
if (!leiste) return;
|
||
leiste.innerHTML = "";
|
||
|
||
TEILE.forEach(function (t) {
|
||
const wert = stand ? stand[t.feld] : null;
|
||
const knoten = $(t.id);
|
||
const anzeige = (wert === null || wert === undefined || wert === "")
|
||
? "unbekannt"
|
||
: (ZUSTANDSWORT[wert] || String(wert).toLowerCase());
|
||
|
||
const licht = t.feld === "lights" && wert === "ON";
|
||
const offen = !licht && wert && OFFEN_WERTE.indexOf(wert) >= 0;
|
||
|
||
if (knoten) {
|
||
knoten.classList.toggle("offen", !!offen);
|
||
knoten.classList.toggle("an", !!licht);
|
||
}
|
||
|
||
const chip = document.createElement("span");
|
||
chip.className = "chip" + (offen ? " offen" : "") + (licht ? " an" : "");
|
||
chip.textContent = t.name + ": " + anzeige;
|
||
chip.dataset.teil = t.id;
|
||
|
||
/*
|
||
* Eintrag und Fahrzeugteil zeigen aufeinander, damit auch ohne
|
||
* Farbunterscheidung klar ist, was gemeint ist.
|
||
*
|
||
* Mit Maus genügt das Überfahren. Auf einem Berührungsgerät gibt es das
|
||
* nicht - dort bleibt ein :hover nach dem Antippen sogar hängen. Deshalb
|
||
* schaltet ein Tippen die Hervorhebung um, und zwar auf beiden Seiten.
|
||
*/
|
||
chip.addEventListener("pointerenter", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(t.id, true);
|
||
});
|
||
chip.addEventListener("pointerleave", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(t.id, false);
|
||
});
|
||
chip.addEventListener("click", () => hervorheben(t.id, "um"));
|
||
leiste.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Live-Block */
|
||
|
||
let livePlanung = null;
|
||
function planeLiveAnzeige() {
|
||
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
||
livePlanung = setTimeout(function () { livePlanung = null; zeichneLive(); }, 200);
|
||
}
|
||
|
||
let letzterStand = null; // letzte Zeile aus der Tabelle
|
||
|
||
function zeichneLive() {
|
||
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;
|
||
}
|
||
$("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 restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
|
||
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–";
|
||
|
||
$("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"]
|
||
};
|
||
const kz = $("skodaLadeZustand");
|
||
const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"];
|
||
kz.textContent = eintrag[0];
|
||
kz.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) {
|
||
schloss.textContent = "Schloss unbekannt";
|
||
schloss.className = "badge text-bg-secondary";
|
||
} else if (Number(zu) === 1) {
|
||
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.
|
||
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";
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Diagramme */
|
||
|
||
let kurveChart = null;
|
||
let gesundChart = null;
|
||
|
||
const achsenFarbe = () => getComputedStyle(document.body).getPropertyValue("--bs-secondary-color") || "#9aa0a6";
|
||
const netzFarbe = "rgba(128,138,148,0.18)";
|
||
|
||
function baueKurve() {
|
||
kurveChart = new Chart($("skodaKurve"), {
|
||
type: "line",
|
||
data: { datasets: [] },
|
||
options: {
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
interaction: { mode: "index", intersect: false },
|
||
scales: {
|
||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||
// braeuchte einen Datums-Adapter, den es hier nicht gibt.
|
||
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y: { title: { display: true, text: "kW", color: achsenFarbe() },
|
||
beginAtZero: true, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y1: { position: "right", min: 0, max: 100,
|
||
title: { display: true, text: "Ladestand %", color: achsenFarbe() },
|
||
grid: { drawOnChartArea: false }, ticks: { color: achsenFarbe() } }
|
||
},
|
||
plugins: { legend: { labels: { color: achsenFarbe() } } }
|
||
}
|
||
});
|
||
}
|
||
|
||
function baueGesundheit() {
|
||
gesundChart = new Chart($("skodaGesundheit"), {
|
||
type: "scatter",
|
||
data: { datasets: [] },
|
||
options: {
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
scales: {
|
||
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y: { title: { display: true, text: "kWh / 100 %", color: achsenFarbe() },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }
|
||
},
|
||
plugins: {
|
||
legend: { labels: { color: achsenFarbe() } },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (c) => {
|
||
const p = c.raw;
|
||
if (p.soc_von === undefined) return c.dataset.label + ": " + nf(c.parsed.y, 1);
|
||
return nf(c.parsed.y, 1) + " kWh/100 % (" + p.soc_von + " → " + p.soc_bis
|
||
+ " %, " + nf(p.wh / 1000, 2) + " kWh)";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Karte */
|
||
|
||
/*
|
||
* Leaflet-Karte mit den Kacheln aus dem eigenen Zwischenspeicher.
|
||
*
|
||
* Die Kachel-URL zeigt bewusst auf ajax/tile.php und nicht unmittelbar auf
|
||
* tile.openstreetmap.org: so verlässt die Parkposition - also die
|
||
* Wohnadresse - das Haus nur beim allerersten Betrachten einer Gegend, und
|
||
* einmal geholte Kacheln bleiben auch ohne Internet verfügbar. Warum das so
|
||
* gebaut ist und was die Kachelrichtlinie verlangt, steht in tile.php.
|
||
*
|
||
* Die Fahrtlinien sind gestrichelt. Die API liefert nur die letzte bekannte
|
||
* PARKposition, also Start und Ziel einer Fahrt; die gefahrene Strecke kennt
|
||
* niemand. Eine durchgezogene Linie auf einer Straßenkarte würde behaupten,
|
||
* das Auto sei genau dort langgefahren.
|
||
*/
|
||
|
||
const KARTE_ZOOM_MIN = 6; // muss zu ZOOM_MIN/ZOOM_MAX in tile.php passen
|
||
const KARTE_ZOOM_MAX = 18;
|
||
|
||
let karte = null; // die Leaflet-Karte, einmal angelegt
|
||
let karteEbene = null; // Marker und Linien, wird bei jeder Auffrischung ersetzt
|
||
let karteAusgerichtet = false; // beim ersten Mal auf die Daten zoomen, danach nicht mehr
|
||
|
||
/*
|
||
* Verknüpfung zwischen der Fahrtenliste und der Karte: Nummer der Fahrt im
|
||
* Feld letzteFahrten -> ihre Linie samt der beiden Stilvarianten. Wird bei
|
||
* jedem Füllen der Karte neu aufgebaut, weil die Linien dabei verworfen
|
||
* werden.
|
||
*/
|
||
let fahrtLinien = {};
|
||
|
||
/** Nummer der gerade gewählten Fahrt, oder null. */
|
||
let gewaehlteFahrt = null;
|
||
|
||
/*
|
||
* Die drei Zustände einer Fahrtlinie.
|
||
*
|
||
* Blau kennzeichnet die jüngste Fahrt, damit man sie ohne Suchen findet.
|
||
* Bernstein ist die Auswahl und muss das Blau überstimmen können - sonst
|
||
* bliebe unklar, welche Linie zur angeklickten Zeile gehört, sobald die
|
||
* jüngste gewählt ist. Bernstein steht auf dieser Seite ohnehin schon für
|
||
* "sieh hier hin": in derselben Farbe leuchten offene Türen am Fahrzeug.
|
||
*/
|
||
const FAHRT_STIL = {
|
||
normal: { color: "#8a949c", weight: 2, opacity: 0.55 },
|
||
juengste: { color: "#4b8fd6", weight: 3, opacity: 0.95 },
|
||
gewaehlt: { color: "#e0904f", weight: 5, opacity: 1 }
|
||
};
|
||
|
||
/**
|
||
* Den Stil einer Linie setzen. Die Auswahl gewinnt vor dem Überfahren, das
|
||
* Überfahren vor dem Grundzustand.
|
||
*
|
||
* Ohne diese eine Stelle für alle drei Zustände würde ein Mausklick auf eine
|
||
* gewählte Linie sie beim Verlassen wieder zurücksetzen - der Behandler für
|
||
* mouseout weiss sonst nichts von der Auswahl.
|
||
*/
|
||
function stilAnwenden(nummer, ueberfahren) {
|
||
const eintrag = fahrtLinien[nummer];
|
||
if (!eintrag) return;
|
||
if (nummer === gewaehlteFahrt) {
|
||
eintrag.linie.setStyle(FAHRT_STIL.gewaehlt).bringToFront();
|
||
} else if (ueberfahren) {
|
||
eintrag.linie.setStyle(Object.assign({}, eintrag.grund,
|
||
{ weight: eintrag.grund.weight + 2, opacity: 1 }));
|
||
} else {
|
||
eintrag.linie.setStyle(eintrag.grund);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Die Karte anlegen. Wird einmal beim Seitenaufbau gerufen; danach kümmert
|
||
* sich karteFuellen() um den Inhalt.
|
||
*/
|
||
function karteAufbauen() {
|
||
const feld = document.getElementById("skodaKarte");
|
||
if (!feld || typeof L === "undefined") return;
|
||
|
||
karte = L.map(feld, {
|
||
minZoom: KARTE_ZOOM_MIN,
|
||
maxZoom: KARTE_ZOOM_MAX,
|
||
zoomControl: true,
|
||
// Das Mausrad gehört auf dieser langen Seite dem Scrollen. Zoomen geht
|
||
// mit den Schaltflächen, per Doppelklick oder mit Strg und Mausrad.
|
||
scrollWheelZoom: false
|
||
}).setView([51.0, 10.0], 6);
|
||
|
||
// Leaflets Vorspann enthält von Haus aus eine Flaggengrafik. Die Nennung
|
||
// der Bibliothek bleibt, das Bild kommt weg.
|
||
karte.attributionControl.setPrefix(
|
||
'<a href="https://leafletjs.com" target="_blank" rel="noopener noreferrer">Leaflet</a>');
|
||
|
||
L.tileLayer("ajax/tile.php?z={z}&x={x}&y={y}", {
|
||
minZoom: KARTE_ZOOM_MIN,
|
||
maxZoom: KARTE_ZOOM_MAX,
|
||
// Die Richtlinie verlangt sichtbare Nennung - nicht verstecken.
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright" '
|
||
+ 'target="_blank" rel="noopener noreferrer">OpenStreetMap</a>-Mitwirkende'
|
||
}).addTo(karte);
|
||
|
||
karteEbene = L.layerGroup().addTo(karte);
|
||
|
||
const heim = document.getElementById("skodaKarteHeim");
|
||
if (heim) heim.addEventListener("click", karteAufStandort);
|
||
|
||
// Beim Anlegen steht das Kartenfeld noch im Seitenaufbau. Leaflet misst
|
||
// dann eine falsche Größe, lässt rechts einen leeren Streifen stehen und
|
||
// wählt beim Ausrichten eine zu grobe Zoomstufe. Ein Nachmessen, sobald das
|
||
// Layout steht, räumt beides aus.
|
||
setTimeout(() => karte && karte.invalidateSize(), 0);
|
||
}
|
||
|
||
/** Karte auf den aktuellen Standort zentrieren. */
|
||
function karteAufStandort() {
|
||
if (karte && letzterOrt && letzterOrt.lat) {
|
||
karte.setView([+letzterOrt.lat, +letzterOrt.lon], 15);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Marker und Linien neu setzen.
|
||
*
|
||
* Ausgerichtet wird nur beim ersten Mal. Sonst risse die minütliche
|
||
* Auffrischung dem Betrachter die Karte unter der Hand weg, sobald er
|
||
* irgendwo hingezoomt hat.
|
||
*/
|
||
function karteFuellen(fahrten, standort) {
|
||
if (!karte || !karteEbene) return;
|
||
karteEbene.clearLayers();
|
||
|
||
const punkte = [];
|
||
fahrtLinien = {};
|
||
|
||
// Fahrten von alt nach neu, damit die jüngste obenauf liegt. Die Nummer im
|
||
// Ursprungsfeld wird mitgeführt, damit die Fahrtenliste daneben ihre Zeile
|
||
// der passenden Linie zuordnen kann.
|
||
fahrten.slice().reverse().forEach(function (f, i, alle) {
|
||
if (!f.von_lat || !f.lat) return;
|
||
const nummer = fahrten.length - 1 - i;
|
||
const von = [+f.von_lat, +f.von_lon];
|
||
const nach = [+f.lat, +f.lon];
|
||
const jung = (i === alle.length - 1);
|
||
punkte.push(von, nach);
|
||
|
||
const grund = jung ? FAHRT_STIL.juengste : FAHRT_STIL.normal;
|
||
|
||
const linie = L.polyline([von, nach], Object.assign({
|
||
dashArray: "6 6" // gestrichelt: Luftlinie, keine Route
|
||
}, grund)).bindPopup(
|
||
"<b>" + textSicher(f.von || "?") + " → " + textSicher(f.nach || "?") + "</b><br>"
|
||
+ nf(f.km) + " km · " + zeitpunkt(f.ankunft, true)
|
||
);
|
||
linie.on("mouseover", () => stilAnwenden(nummer, true));
|
||
linie.on("mouseout", () => stilAnwenden(nummer, false));
|
||
linie.on("click", () => waehleFahrt(nummer, false));
|
||
karteEbene.addLayer(linie);
|
||
|
||
fahrtLinien[nummer] = { linie: linie, grund: grund, ecken: [von, nach] };
|
||
});
|
||
|
||
// Eine zuvor gewählte Fahrt nach dem Neuaufbau wieder hervorheben.
|
||
if (gewaehlteFahrt !== null) {
|
||
stilAnwenden(gewaehlteFahrt, false);
|
||
}
|
||
|
||
// Halte
|
||
fahrten.forEach(function (f) {
|
||
if (!f.lat) return;
|
||
karteEbene.addLayer(L.circleMarker([+f.lat, +f.lon], {
|
||
radius: 5, color: "#161a1d", weight: 1.5,
|
||
fillColor: "#8a949c", fillOpacity: 0.9
|
||
}).bindPopup(
|
||
"<b>" + textSicher(f.nach || "Halt") + "</b><br>"
|
||
+ zeitpunkt(f.ankunft, true) + "<br>"
|
||
+ nf(f.km) + " km gefahren"
|
||
));
|
||
});
|
||
|
||
// Aktueller Standort: heller Punkt mit Hof, damit er sich von den Halten
|
||
// abhebt, auch wenn er auf einem davon liegt.
|
||
if (standort && standort.lat) {
|
||
const hier = [+standort.lat, +standort.lon];
|
||
punkte.push(hier);
|
||
karteEbene.addLayer(L.circleMarker(hier, {
|
||
radius: 14, color: "#4caf7d", weight: 2, opacity: 0.45, fill: false
|
||
}));
|
||
karteEbene.addLayer(L.circleMarker(hier, {
|
||
radius: 7, color: "#161a1d", weight: 2,
|
||
fillColor: "#4caf7d", fillOpacity: 1
|
||
}).bindPopup(
|
||
"<b>" + textSicher(standort.address || "Aktuelle Position") + "</b><br>"
|
||
+ (standort.park_state === "IN_MOTION"
|
||
? "unterwegs – letzte bekannte Parkposition"
|
||
: "geparkt seit " + zeitpunkt(standort.datetime, true))
|
||
));
|
||
}
|
||
|
||
if (!karteAusgerichtet && punkte.length) {
|
||
karteAusgerichtet = true;
|
||
// Vor dem Ausrichten nachmessen: die Zoomstufe folgt aus der
|
||
// Containergröße, und die stimmt erst, wenn das Layout steht.
|
||
karte.invalidateSize();
|
||
// animate: false ist hier kein Schönheitsfehler, sondern Absicht. Eine
|
||
// Zoomfahrt von der Startansicht bis zum Ziel fordert jede Zwischenstufe
|
||
// an Kacheln an - bei einem Sprung über mehrere Stufen also ein Vielfaches
|
||
// dessen, was am Ende zu sehen ist. Alles davon ginge einmal nach draußen.
|
||
if (punkte.length === 1) {
|
||
karte.setView(punkte[0], 15, { animate: false });
|
||
} else {
|
||
// maxZoom, damit eine einzelne eng beieinanderliegende Gruppe nicht
|
||
// bis auf Hausnummernebene aufgezogen wird.
|
||
karte.fitBounds(L.latLngBounds(punkte), {
|
||
padding: [24, 24], maxZoom: 14, animate: false
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Eine Fahrt aus der Liste auf der Karte zeigen: Ausschnitt auf ihre beiden
|
||
* Enden, Linie hervorheben, Sprechblase öffnen.
|
||
*
|
||
* Ohne animate: false zöge die Karte über jede Zwischenstufe hinweg und
|
||
* fordert dabei deren Kacheln an - siehe die Begründung bei karteFuellen().
|
||
*/
|
||
/**
|
||
* Eine Fahrt auswählen - der einzige Weg dorthin, gleich ob der Klick in der
|
||
* Liste oder auf der Karte kam. Beide Seiten werden hier zusammen gesetzt,
|
||
* sonst laufen Zeilenmarkierung und Linienfarbe auseinander.
|
||
*
|
||
* @param nummer Nummer der Fahrt in letzteFahrten
|
||
* @param hinzoomen Ausschnitt auf die Fahrt legen. Beim Klick auf der Karte
|
||
* unerwünscht: dort sieht man sie ja schon, und der Sprung
|
||
* risse einem den selbst gewählten Ausschnitt weg.
|
||
*/
|
||
function waehleFahrt(nummer, hinzoomen) {
|
||
const eintrag = fahrtLinien[nummer];
|
||
if (!karte || !eintrag) return;
|
||
|
||
const vorher = gewaehlteFahrt;
|
||
gewaehlteFahrt = nummer;
|
||
if (vorher !== null && vorher !== nummer) {
|
||
stilAnwenden(vorher, false); // alte Auswahl zurücksetzen
|
||
}
|
||
stilAnwenden(nummer, false);
|
||
|
||
if (hinzoomen) {
|
||
karte.fitBounds(L.latLngBounds(eintrag.ecken), {
|
||
padding: [40, 40], maxZoom: 13, animate: false
|
||
});
|
||
}
|
||
eintrag.linie.openPopup();
|
||
markiereFahrtZeile(nummer);
|
||
}
|
||
|
||
/* Vom Betrachter gewählte Einheit: "kwh" oder "prozent". */
|
||
const EINHEIT_SPEICHER = "skodaVerbrauchEinheit";
|
||
|
||
/**
|
||
* Einheit umschalten. Sichtbar wird die Auswahl über eine Klasse auf der
|
||
* Kachel; die Zeilen selbst bleiben unberührt und überstehen damit die
|
||
* minütliche Auffrischung der Liste.
|
||
*
|
||
* Die Wahl liegt im Browser des Betrachters, nicht auf dem Server - sie geht
|
||
* niemanden sonst etwas an. Lesen und Schreiben stehen in try/catch, weil
|
||
* der Zugriff in privaten Fenstern und bei gesperrten Seitendaten wirft.
|
||
*/
|
||
function setzeEinheit(einheit, merken) {
|
||
const kachel = $("skodaFahrtenKachel");
|
||
if (!kachel) return;
|
||
const kwh = einheit !== "prozent";
|
||
kachel.classList.toggle("zeigt-kwh", kwh);
|
||
kachel.classList.toggle("zeigt-prozent", !kwh);
|
||
|
||
const schalter = $("skodaEinheit");
|
||
if (schalter) {
|
||
schalter.querySelectorAll("button").forEach(function (b) {
|
||
const aktiv = (b.dataset.einheit === "kwh") === kwh;
|
||
b.classList.toggle("aktiv", aktiv);
|
||
b.setAttribute("aria-pressed", aktiv ? "true" : "false");
|
||
});
|
||
}
|
||
if (merken) {
|
||
try { localStorage.setItem(EINHEIT_SPEICHER, kwh ? "kwh" : "prozent"); } catch (e) { }
|
||
}
|
||
}
|
||
|
||
/** Umschalter verdrahten und die zuletzt gewählte Einheit wiederherstellen. */
|
||
function koppleEinheitSchalter() {
|
||
const schalter = $("skodaEinheit");
|
||
if (!schalter) return;
|
||
schalter.addEventListener("click", function (e) {
|
||
const knopf = e.target.closest("button[data-einheit]");
|
||
if (knopf) setzeEinheit(knopf.dataset.einheit, true);
|
||
});
|
||
let gemerkt = null;
|
||
try { gemerkt = localStorage.getItem(EINHEIT_SPEICHER); } catch (e) { }
|
||
setzeEinheit(gemerkt || "kwh", false);
|
||
}
|
||
|
||
/**
|
||
* In der Überschrift der Fahrtenliste offenlegen, worauf die
|
||
* Kilowattstunden beruhen. Ohne das steht dort eine Zahl, deren Herkunft
|
||
* niemand mehr nachvollziehen kann - sie ist ja nicht gemessen, sondern aus
|
||
* dem Ladestand und der eigenen Ladehistorie gerechnet.
|
||
*/
|
||
function zeigeVerbrauchsbasis() {
|
||
const feld = $("skodaVerbrauchBasis");
|
||
if (!feld) return;
|
||
|
||
const schalter = $("skodaEinheit");
|
||
if (!letzteKapazitaet) {
|
||
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
||
feld.textContent = "";
|
||
feld.removeAttribute("title");
|
||
if (schalter) schalter.style.display = "none";
|
||
setzeEinheit("prozent", false);
|
||
return;
|
||
}
|
||
if (schalter) schalter.style.display = "";
|
||
|
||
const k = letzteKapazitaet;
|
||
feld.textContent = nf(k.je_soc_netto * 1000, 0) + " Wh je Prozent";
|
||
|
||
// 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: "
|
||
+ 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) + " %, "
|
||
+ (k.gemessen
|
||
? "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.");
|
||
}
|
||
|
||
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
||
function markiereFahrtZeile(nummer) {
|
||
const liste = $("skodaFahrten");
|
||
if (!liste) return;
|
||
liste.querySelectorAll(".zeile.gewaehlt").forEach(z => z.classList.remove("gewaehlt"));
|
||
const zeile = liste.querySelector('.zeile[data-fahrt="' + nummer + '"]');
|
||
if (zeile) {
|
||
zeile.classList.add("gewaehlt");
|
||
zeile.scrollIntoView({ block: "nearest" }); // bei Klick auf der Karte
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Text für eine Sprechblase entschärfen. Adressen kommen von einem fremden
|
||
* Dienst; ohne das ginge fremdes Markup ungefiltert ins Dokument.
|
||
*/
|
||
function textSicher(s) {
|
||
const knoten = document.createElement("div");
|
||
knoten.textContent = String(s);
|
||
return knoten.innerHTML;
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Abschnitte */
|
||
|
||
async function ladeLive() {
|
||
try {
|
||
const d = await hole("live");
|
||
letzterStand = d.stand;
|
||
zeichneTeile(d.stand);
|
||
zeichneLive();
|
||
|
||
const alterText = $("skodaAlter");
|
||
if (!d.stand) {
|
||
alterText.textContent = "Noch kein Fahrzeug angebunden";
|
||
} else {
|
||
alterText.textContent = "Stand des Fahrzeugs: " + seit(d.alter_s)
|
||
+ " (" + zeitpunkt(d.stand.datetime) + ")";
|
||
}
|
||
} catch (e) {
|
||
$("skodaAlter").textContent = "Daten nicht abrufbar";
|
||
}
|
||
}
|
||
|
||
async function ladeLadungen() {
|
||
try {
|
||
const d = await hole("ladungen", "&tage=180");
|
||
const liste = $("skodaLadungen");
|
||
if (!d.ladungen.length) { liste.innerHTML = leer("Noch keine Ladung aufgezeichnet."); return; }
|
||
liste.innerHTML = d.ladungen.map(function (l) {
|
||
const kwh = l.wh > 0 ? nf(l.wh / 1000, 1) + " kWh" : "auswärts";
|
||
return '<div class="zeile"><span class="wann">' + zeitpunkt(l.beginn)
|
||
+ '<span class="neben">' + l.soc_von + " → " + l.soc_bis + " % · "
|
||
+ (l.art || "–") + "</span></span>"
|
||
+ '<span class="was">' + kwh + '<span class="neben">'
|
||
+ (l.kw_spitze ? nf(l.kw_spitze, 1) + " kW" : "") + "</span></span></div>";
|
||
}).join("");
|
||
} catch (e) {
|
||
$("skodaLadungen").innerHTML = leer("Ladungen nicht abrufbar.");
|
||
}
|
||
}
|
||
|
||
async function ladeKurve() {
|
||
try {
|
||
const d = await hole("kurve");
|
||
if (!d.punkte || !d.punkte.length) {
|
||
$("skodaKurveZeit").textContent = "noch keine Ladung";
|
||
kurveChart.data.datasets = [];
|
||
kurveChart.update();
|
||
return;
|
||
}
|
||
$("skodaKurveZeit").textContent = zeitpunkt(d.von) + " – " + zeitpunkt(d.bis);
|
||
// Die Zeitachse 'timestack' rechnet mit Millisekunden, nicht mit
|
||
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
||
// stattdessen den heutigen Tag.
|
||
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
||
kurveChart.data.datasets = [
|
||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.wb_kw === null ? null : +p.wb_kw })) },
|
||
{ label: "Fahrzeug", yAxisID: "y", borderColor: "#7aa7c7", borderWidth: 2,
|
||
pointRadius: 0, borderDash: [4, 3], tension: .25,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.chg_kw === null ? null : +p.chg_kw })) },
|
||
{ label: "Ladestand", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||
pointRadius: 0, tension: .1,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) }
|
||
];
|
||
kurveChart.update();
|
||
} catch (e) {
|
||
$("skodaKurveZeit").textContent = "nicht abrufbar";
|
||
}
|
||
}
|
||
|
||
async function ladeGesundheit() {
|
||
try {
|
||
const d = await hole("gesundheit", "&tage=3650");
|
||
const befund = $("skodaBefund");
|
||
|
||
if (!d.ladungen.length) {
|
||
gesundChart.data.datasets = [];
|
||
gesundChart.update();
|
||
befund.innerHTML = '<p class="text-secondary mb-0">Noch keine auswertbare Ladung. '
|
||
+ 'Gezählt werden Ladungen an der eigenen Wallbox mit mindestens 20 Prozentpunkten Zuwachs.</p>';
|
||
return;
|
||
}
|
||
|
||
const punkte = d.ladungen.map(l => ({
|
||
x: new Date(String(l.beginn).replace(" ", "T")).getTime(), y: +l.je100,
|
||
soc_von: +l.soc_von, soc_bis: +l.soc_bis, wh: +l.wh
|
||
}));
|
||
|
||
const saetze = [{ label: "Ladung", data: punkte, backgroundColor: "#4caf7d", pointRadius: 4 }];
|
||
|
||
// Ausgleichsgerade, wenn genug Punkte da sind
|
||
if (d.trend) {
|
||
const t0 = punkte[0].x;
|
||
const t1 = punkte[punkte.length - 1].x;
|
||
const JAHR = 365.25 * 864e5;
|
||
saetze.push({
|
||
label: "Trend", type: "line", borderColor: "#9aa0a6", borderWidth: 1.5,
|
||
borderDash: [5, 4], pointRadius: 0, fill: false,
|
||
data: [{ x: t0, y: d.trend.achse },
|
||
{ x: t1, y: d.trend.achse + d.trend.steigung * (t1 - t0) / JAHR }]
|
||
});
|
||
}
|
||
gesundChart.data.datasets = saetze;
|
||
gesundChart.update();
|
||
|
||
// Befund in Worten
|
||
const t = d.trend;
|
||
let html = "";
|
||
if (t && t.prozent !== null) {
|
||
const eindeutig = Math.abs(t.steigung) > 2 * t.fehler;
|
||
html += '<div class="zahl">' + (t.prozent > 0 ? "+" : "−") + nf(Math.abs(t.prozent), 2)
|
||
+ '<span class="einheit">% pro Jahr</span></div>';
|
||
html += '<p class="mb-0 mt-2 ' + (eindeutig ? "text-warning" : "text-secondary") + '">'
|
||
+ (eindeutig
|
||
? "Der Wert wandert messbar."
|
||
: "Von null nicht zu unterscheiden — keine Alterung nachweisbar.")
|
||
+ "</p>";
|
||
html += "<dl>"
|
||
+ "<div><dt>Nachweisbar wäre ab</dt><dd>" + nf(t.nachweisbar, 1) + " % im Jahr</dd></div>"
|
||
+ "<div><dt>Beobachtungszeitraum</dt><dd>" + nf(t.spanne_jahre, 1) + " Jahre</dd></div>";
|
||
} else {
|
||
html += '<p class="text-secondary mb-0">Für einen Trend sind es noch zu wenige Ladungen.</p><dl>';
|
||
}
|
||
const j = d.jahre[d.jahre.length - 1];
|
||
if (j) {
|
||
html += "<div><dt>Median " + j.jahr + "</dt><dd>" + nf(j.median, 1) + " kWh/100 %</dd></div>";
|
||
}
|
||
html += "<div><dt>Ausgewertete Ladungen</dt><dd>" + d.ladungen.length + "</dd></div></dl>";
|
||
befund.innerHTML = html;
|
||
} catch (e) {
|
||
$("skodaBefund").innerHTML = '<p class="text-secondary mb-0">Auswertung nicht abrufbar.</p>';
|
||
}
|
||
}
|
||
|
||
let letzteFahrten = [];
|
||
let letzterOrt = null;
|
||
|
||
/*
|
||
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist, aus den eigenen
|
||
* Ladungen bestimmt und um die Ladeverluste bereinigt - siehe
|
||
* kapazitaetSchaetzen() in ajax/skoda.php. Bleibt null, solange es zu wenige
|
||
* Ladungen gibt; dann zeigt die Fahrtenliste nur Prozent.
|
||
*/
|
||
let letzteKapazitaet = null;
|
||
|
||
async function ladeFahrten() {
|
||
try {
|
||
const d = await hole("fahrten", "&tage=180");
|
||
letzteFahrten = d.fahrten || [];
|
||
letzterOrt = d.standort || null;
|
||
letzteKapazitaet = d.kapazitaet || null;
|
||
zeigeVerbrauchsbasis();
|
||
|
||
const liste = $("skodaFahrten");
|
||
if (!letzteFahrten.length) {
|
||
liste.innerHTML = leer("Noch keine Fahrt aufgezeichnet.");
|
||
} else {
|
||
// data-fahrt trägt die Nummer im Feld letzteFahrten. Darüber findet der
|
||
// Klick weiter unten die passende Linie auf der Karte.
|
||
liste.innerHTML = letzteFahrten.map(function (f, nummer) {
|
||
// Der Ladestandsverbrauch stimmt nur, wenn zwischendurch nicht
|
||
// geladen wurde - sonst ist er zu klein oder negativ.
|
||
const proz = Number(f.soc_ab);
|
||
const brauchbar = proz > 0 && f.km > 0;
|
||
const jeProzent = brauchbar ? proz / f.km * 100 : null;
|
||
|
||
/*
|
||
* Beide Masse werden ausgegeben, sichtbar ist je nach Umschalter nur
|
||
* eines. Die Prozent sind gemessen, die Kilowattstunden daraus
|
||
* gerechnet - wer der Umrechnung nicht traut, schaltet um.
|
||
*
|
||
* Ohne bekannte Kapazität gibt es keine Kilowattstunden - lieber
|
||
* weniger anzeigen als eine Zahl, die auf einem geratenen Akku beruht.
|
||
*/
|
||
let verbrauch = "";
|
||
if (brauchbar) {
|
||
if (letzteKapazitaet) {
|
||
verbrauch = '<span class="v-kwh">'
|
||
+ nf(jeProzent * letzteKapazitaet.je_soc_netto, 1) + " kWh/100 km</span>";
|
||
}
|
||
verbrauch += '<span class="v-prozent">' + nf(jeProzent, 1) + " %/100 km</span>";
|
||
}
|
||
|
||
const zeigbar = f.von_lat && f.lat; // ohne Koordinaten nichts zu zeigen
|
||
const teile = [];
|
||
if (brauchbar && letzteKapazitaet) {
|
||
teile.push(nf(proz * letzteKapazitaet.je_soc_netto, 1) + " kWh für die Fahrt");
|
||
}
|
||
if (brauchbar) {
|
||
teile.push(proz + " Prozentpunkte Ladestand");
|
||
}
|
||
if (zeigbar) {
|
||
teile.push("klicken, um die Fahrt auf der Karte zu zeigen");
|
||
}
|
||
const hinweis = teile.join(" – ");
|
||
return '<div class="zeile' + (zeigbar ? " anklickbar" : "") + '"'
|
||
+ ' data-fahrt="' + nummer + '"'
|
||
+ (hinweis ? ' title="' + textSicher(hinweis) + '"' : "")
|
||
+ (zeigbar ? ' tabindex="0" role="button"' : "")
|
||
+ '><span class="wann">' + zeitpunkt(f.ankunft)
|
||
+ '<span class="neben">' + textSicher(f.von || "?") + " → "
|
||
+ textSicher(f.nach || "?") + "</span></span>"
|
||
+ '<span class="was">' + nf(f.km) + " km"
|
||
+ '<span class="neben">' + verbrauch + "</span></span></div>";
|
||
}).join("");
|
||
}
|
||
|
||
const ort = $("skodaStandort");
|
||
if (letzterOrt) {
|
||
ort.textContent = (letzterOrt.park_state === "IN_MOTION" ? "unterwegs — zuletzt: " : "")
|
||
+ (letzterOrt.address || nf(letzterOrt.lat, 5) + ", " + nf(letzterOrt.lon, 5));
|
||
const link = $("skodaOsmLink");
|
||
if (link) {
|
||
link.href = "https://www.openstreetmap.org/?mlat=" + letzterOrt.lat
|
||
+ "&mlon=" + letzterOrt.lon + "#map=17/" + letzterOrt.lat + "/" + letzterOrt.lon;
|
||
}
|
||
} else {
|
||
ort.textContent = "Keine Position bekannt";
|
||
}
|
||
karteFuellen(letzteFahrten, letzterOrt);
|
||
} catch (e) {
|
||
$("skodaFahrten").innerHTML = leer("Fahrten nicht abrufbar.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Klicks in der Fahrtenliste auf die Karte legen.
|
||
*
|
||
* Der Behandler sitzt einmalig am Listenbehälter, nicht an den Zeilen: die
|
||
* werden bei jeder Auffrischung neu gebaut, einzeln angeheftete Behandler
|
||
* gingen dabei verloren.
|
||
*/
|
||
function koppleFahrtenListe() {
|
||
const liste = $("skodaFahrten");
|
||
if (!liste) return;
|
||
|
||
function ausloesen(ziel) {
|
||
const zeile = ziel.closest(".zeile.anklickbar");
|
||
if (!zeile) return;
|
||
waehleFahrt(Number(zeile.dataset.fahrt), true);
|
||
|
||
// Die Karte steht weiter unten als die angeklickte Zeile - ohne das
|
||
// passierte der Sprung ausserhalb des Blickfelds.
|
||
const feld = $("skodaKarte");
|
||
if (feld && feld.getBoundingClientRect().bottom > window.innerHeight) {
|
||
feld.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}
|
||
}
|
||
|
||
liste.addEventListener("click", (e) => ausloesen(e.target));
|
||
liste.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault(); // Leertaste soll nicht die Seite scrollen
|
||
ausloesen(e.target);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Steuerung */
|
||
|
||
/*
|
||
* Die Befehle sitzen an den Stellen im Fahrzeugbild, um die es geht: der
|
||
* Ladeanschluss steuert das Laden, der Innenraum Klima und Lüftung. Ein
|
||
* Antippen öffnet das Modal, das auch die Solarseite benutzt (#modalEV aus
|
||
* restricted/footer.php).
|
||
*
|
||
* Der Speichern-Knopf des Modals wird ausgeblendet - hier gibt es nichts zu
|
||
* speichern, jeder Knopf wirkt sofort.
|
||
*/
|
||
const STEUERUNG = {
|
||
laden: {
|
||
titel: "Laden",
|
||
zeile: "Startet oder stoppt den Ladevorgang am Fahrzeug. Die Wallbox "
|
||
+ "bleibt davon unberührt - sie gibt weiterhin frei, was sie freigibt.",
|
||
knoepfe: [
|
||
{ befehl: "laden_start", text: "Laden starten", stil: "success", zeichen: "play-fill" },
|
||
{ befehl: "laden_stop", text: "Laden stoppen", stil: "secondary", zeichen: "stop-fill" }
|
||
]
|
||
},
|
||
klima: {
|
||
titel: "Klima und Lüftung",
|
||
zeile: "Klimatisiert den Innenraum auf die eingestellte Temperatur oder "
|
||
+ "lüftet ihn durch. Beides geht auch ohne angestecktes Kabel.",
|
||
temperatur: true,
|
||
knoepfe: [
|
||
{ befehl: "klima_start", text: "Klima starten", stil: "info", zeichen: "thermometer-half" },
|
||
{ befehl: "klima_stop", text: "Klima stoppen", stil: "secondary", zeichen: "stop-fill" },
|
||
{ befehl: "lueftung_start", text: "Lüftung starten", stil: "info", zeichen: "wind" },
|
||
{ befehl: "lueftung_stop", text: "Lüftung stoppen", stil: "secondary", zeichen: "stop-fill" }
|
||
]
|
||
}
|
||
};
|
||
|
||
let steuerModal = null;
|
||
|
||
/** Die anklickbaren Stellen im Fahrzeugbild mit dem Modal verbinden. */
|
||
function koppleSteuerung() {
|
||
const knoten = document.querySelectorAll("#skodaAuto .steuer[data-steuer]");
|
||
if (!knoten.length || typeof bootstrap === "undefined") return;
|
||
steuerModal = new bootstrap.Modal(document.getElementById("modalEV"), { keyboard: true });
|
||
|
||
knoten.forEach(function (k) {
|
||
const oeffnen = function (e) {
|
||
e.stopPropagation(); // nicht zusätzlich die Hervorhebung umschalten
|
||
zeigeSteuerung(k.dataset.steuer);
|
||
};
|
||
k.addEventListener("click", oeffnen);
|
||
k.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); oeffnen(e); }
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */
|
||
function zeigeSteuerung(bereich) {
|
||
const b = STEUERUNG[bereich];
|
||
if (!b || !steuerModal) return;
|
||
|
||
document.getElementById("modal-title").textContent = b.titel;
|
||
const speichern = document.getElementById("modalSaveBtn");
|
||
if (speichern) speichern.style.display = "none";
|
||
|
||
const rumpf = document.getElementById("modal-body");
|
||
let html = '<div class="skoda-steuerung p-3">'
|
||
+ '<p class="text-secondary small">' + b.zeile + "</p>";
|
||
|
||
if (b.temperatur) {
|
||
html += '<label class="temperatur mb-3">Zieltemperatur'
|
||
+ '<span><input type="number" id="skodaKlimaTemp" class="form-control form-control-sm"'
|
||
+ ' min="16" max="29.5" step="0.5" value="21" inputmode="decimal"> °C</span></label>';
|
||
}
|
||
|
||
html += '<div class="knoepfe">';
|
||
b.knoepfe.forEach(function (k) {
|
||
html += '<button type="button" class="btn btn-outline-' + k.stil + '" data-befehl="'
|
||
+ k.befehl + '"><i class="bi bi-' + k.zeichen + '"></i> ' + k.text + "</button>";
|
||
});
|
||
html += "</div>";
|
||
|
||
html += '<div class="skoda-rueckmeldung mt-3" id="skodaBefehlAntwort" role="status" aria-live="polite"></div>';
|
||
html += '<p class="text-secondary small mb-0 mt-3">Vier Befehle je Stunde. '
|
||
+ "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.</p>";
|
||
if (bereich === "klima") {
|
||
html += '<p class="text-secondary small mb-0 mt-2">Die Standheizung fehlt bewusst: '
|
||
+ "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.</p>";
|
||
}
|
||
html += "</div>";
|
||
|
||
rumpf.innerHTML = html;
|
||
verdrahteBefehle(rumpf);
|
||
steuerModal.show();
|
||
}
|
||
|
||
/**
|
||
* Befehlsknöpfe im Modal verdrahten.
|
||
*
|
||
* Während ein Befehl unterwegs ist, werden alle Knöpfe gesperrt. Ein Befehl
|
||
* kostet eine Anfrage aus einem knappen Stundenkontingent - ein
|
||
* versehentlicher Doppelklick wäre die Hälfte davon.
|
||
*/
|
||
function verdrahteBefehle(bereich) {
|
||
const knoepfe = bereich.querySelectorAll("button[data-befehl]");
|
||
const meldung = bereich.querySelector("#skodaBefehlAntwort");
|
||
|
||
knoepfe.forEach(function (knopf) {
|
||
knopf.addEventListener("click", async function () {
|
||
const daten = new URLSearchParams({ befehl: knopf.dataset.befehl });
|
||
const feld = bereich.querySelector("#skodaKlimaTemp");
|
||
if (knopf.dataset.befehl === "klima_start" && feld) {
|
||
daten.set("temperatur", feld.value);
|
||
}
|
||
|
||
knoepfe.forEach(k => { k.disabled = true; });
|
||
meldung.className = "skoda-rueckmeldung mt-3 warte";
|
||
meldung.textContent = "Befehl wird gesendet …";
|
||
|
||
try {
|
||
const antwort = await fetch("ajax/skodaCmd.php", {
|
||
method: "POST", body: daten, cache: "no-store"
|
||
});
|
||
const d = await antwort.json();
|
||
meldung.className = "skoda-rueckmeldung mt-3 " + (d.ok ? "gut" : "schlecht");
|
||
meldung.textContent = d.text;
|
||
// Der Zustand ändert sich im Fahrzeug, nicht in der Tabelle. Ein
|
||
// Abruf gleich danach brächte noch den alten Stand; das Modul holt
|
||
// ihn nach seinem eigenen Takt.
|
||
if (d.ok) setTimeout(ladeLive, 20000);
|
||
} catch (e) {
|
||
meldung.className = "skoda-rueckmeldung mt-3 schlecht";
|
||
meldung.textContent = "Der Server hat nicht geantwortet.";
|
||
} finally {
|
||
knoepfe.forEach(k => { k.disabled = false; });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Start */
|
||
|
||
function alleLaden() {
|
||
ladeLive();
|
||
ladeLadungen();
|
||
ladeKurve();
|
||
ladeGesundheit();
|
||
ladeFahrten();
|
||
}
|
||
|
||
document.addEventListener("readystatechange", function () {
|
||
if (document.readyState !== "complete") return;
|
||
|
||
baueKurve();
|
||
baueGesundheit();
|
||
karteAufbauen();
|
||
koppleFahrtenListe();
|
||
koppleEinheitSchalter();
|
||
koppleFahrzeugTeile();
|
||
koppleSteuerung();
|
||
zeichneTeile(null);
|
||
zeichneLive();
|
||
alleLaden();
|
||
solarMQTT.getMQTT();
|
||
|
||
// 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(ladeGesundheit, 900000);
|
||
|
||
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
||
// sein Container eine andere Größe hat.
|
||
let umbruch = null;
|
||
window.addEventListener("resize", function () {
|
||
clearTimeout(umbruch);
|
||
umbruch = setTimeout(() => karte && karte.invalidateSize(), 200);
|
||
});
|
||
});
|