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>
218 lines
8.3 KiB
JavaScript
218 lines
8.3 KiB
JavaScript
/*
|
|
* Gemeinsame Bausteine der Dashboard-Seiten.
|
|
* Wird vor solarMQTT.js / homeMQTT.js / heatMQTT.js / historyMQTT.js geladen.
|
|
*/
|
|
|
|
/** Sekunden als "HH:MM". */
|
|
String.prototype.toHHMM = function () {
|
|
var sec_num = parseInt(this, 10);
|
|
var hours = Math.floor(sec_num / 3600);
|
|
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
|
|
if (hours < 10) { hours = "0" + hours; }
|
|
if (minutes < 10) { minutes = "0" + minutes; }
|
|
return hours + ':' + minutes;
|
|
};
|
|
|
|
function powerToString(power) {
|
|
if (Math.abs(power) > 999) {
|
|
power = power / 1000;
|
|
return power.toPrecision(3) + " kW";
|
|
} else {
|
|
return Math.round(power) + " W";
|
|
}
|
|
}
|
|
|
|
function loadingHTML(msg) {
|
|
return "<div class='m-4'><div class='spinner-border' role='status'><span class='visually-hidden'>Loading...</span></div> " + msg + "</div>";
|
|
}
|
|
|
|
/**
|
|
* Diagrammdaten holen und eintragen.
|
|
* params Funktion, die {FROM, TO} liefert (Zeitraum aendert sich zur Laufzeit)
|
|
* sunrise true -> Sonnenstands-Markierungen fuer denselben Zeitraum
|
|
* Objekt {FROM, TO} -> fester Zeitraum
|
|
* autoUpdate alle 5 Minuten neu laden
|
|
* updateMode an chart.update() durchgereicht ("none" = ohne Animation)
|
|
*/
|
|
async function getData(chart, url, options = {}) {
|
|
const {
|
|
params = null,
|
|
sunrise = false,
|
|
autoUpdate = true,
|
|
updateMode = undefined,
|
|
refreshMs = 5 * 60 * 1000
|
|
} = options;
|
|
|
|
const range = params ? params() : null;
|
|
const query = range ? "?FROM=" + range.FROM + "&TO=" + range.TO : "";
|
|
|
|
try {
|
|
chart.options.events = []; // waehrend des Ladens keine Tooltips
|
|
const response = await fetch(url + query);
|
|
if (!response.ok) {
|
|
throw new Error(`Response status: ${response.status}`);
|
|
}
|
|
chart.data = await response.json();
|
|
|
|
if (sunrise) {
|
|
const sunRange = sunrise === true ? range : sunrise;
|
|
const response2 = await fetch("ajax/getSunrise.php?FROM=" + sunRange.FROM + "&TO=" + sunRange.TO);
|
|
if (!response2.ok) {
|
|
throw new Error(`Response status: ${response2.status}`);
|
|
}
|
|
chart.options.plugins.annotation.annotations = await response2.json();
|
|
}
|
|
chart.options.events = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
|
|
chart.update(updateMode);
|
|
} catch (error) {
|
|
console.log("getData " + url + ": " + error.message);
|
|
}
|
|
|
|
if (autoUpdate) {
|
|
setTimeout(function () { getData(chart, url, options); }, refreshMs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formular des Modals per POST abschicken.
|
|
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
|
*/
|
|
function submitFormAjax(event) {
|
|
let xmlhttp = new XMLHttpRequest();
|
|
|
|
xmlhttp.onreadystatechange = function () {
|
|
if (this.readyState === 4 && this.status === 200) {
|
|
setTimeout(function () { modalEV.hide(); }, 700);
|
|
if (xmlhttp.responseText) {
|
|
alert(xmlhttp.responseText);
|
|
}
|
|
}
|
|
};
|
|
|
|
let post = "";
|
|
if (event.currentTarget.id == "evStart/Stop") {
|
|
post = "evStart/Stop=" + event.currentTarget.innerHTML;
|
|
} else {
|
|
const form = document.getElementById('modalEV').querySelector('form');
|
|
for (const element of Array.from(form.elements)) {
|
|
if (!element.name) continue;
|
|
if (element.type == "radio" || element.type == "checkbox") {
|
|
if (element.checked) {
|
|
post += element.name + "=" + encodeURIComponent(element.value) + "&";
|
|
}
|
|
} else if (element.type == "button") {
|
|
if (!element.classList.contains("accordion-button")) {
|
|
post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&";
|
|
}
|
|
} else {
|
|
post += element.name + "=" + encodeURIComponent(element.value) + "&";
|
|
}
|
|
}
|
|
}
|
|
|
|
xmlhttp.open("POST", event.currentTarget.contentURL, true);
|
|
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
|
document.getElementById('modalEV').querySelector('.modal-body').innerHTML = loadingHTML("Änderungen werden übernommen...");
|
|
xmlhttp.send(post);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Schieberegler im Modal mit seiner Beschriftung verbinden.
|
|
* label(wert) liefert den anzuzeigenden Text.
|
|
*/
|
|
function bindModalSlider(slider, span, label) {
|
|
slider.oninput = function () {
|
|
const min = Number(slider.min);
|
|
const max = Number(slider.max);
|
|
const percent = (Number(slider.value) - min) * 100 / (max - min);
|
|
slider.style.setProperty("--background-size", `${percent}%`);
|
|
|
|
// Beschriftung mitlaufen lassen, an den Raendern etwas gebremst
|
|
let margin = percent;
|
|
if (margin < 10) {
|
|
margin = margin - 0.4 * margin;
|
|
} else if (margin < 85) {
|
|
margin = margin - 4;
|
|
} else {
|
|
margin = margin - 4 - 0.5 * (margin - 85);
|
|
}
|
|
span.setAttribute('style', 'margin-left:' + margin + '%;');
|
|
span.innerHTML = label(slider.value);
|
|
};
|
|
}
|
|
|
|
/** Meteoblue-Meteogramm einhängen, sofern die Seite einen Platz dafür hat. */
|
|
// Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der
|
|
// Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer
|
|
// Breitenaenderung nicht neu; unterhalb von etwa 600 px ueberlagern sich
|
|
// Wochentage und Stundenachse zu einem unlesbaren Streifen. Auf schmalen
|
|
// Schirmen wird es deshalb in dieser Breite geladen und als Ganzes
|
|
// verkleinert - dieselbe Ansicht wie am Rechner, nur kleiner. Ist die Karte
|
|
// breiter, wird gleich in Kartenbreite geladen und nichts skaliert.
|
|
const METEOGRAM_BREITE = 640;
|
|
const METEOGRAM_HOEHE = 500;
|
|
|
|
function mountMeteogram() {
|
|
const ziel = document.getElementById("meteogram");
|
|
if (!ziel) return;
|
|
// loading="lazy": das Meteogramm ist ein vollstaendiges Fremd-Dokument und
|
|
// liegt unterhalb der Falz - es wird erst geladen, wenn man hinscrollt.
|
|
const stil = window.getComputedStyle(ziel);
|
|
const innenBreite = ziel.clientWidth - parseFloat(stil.paddingLeft) - parseFloat(stil.paddingRight);
|
|
const ladeBreite = Math.max(METEOGRAM_BREITE, Math.round(innenBreite) || 0);
|
|
ziel.innerHTML =
|
|
"<div class='meteogram-buehne'>"
|
|
+ "<iframe loading='lazy' src='" + meteogramURL + "' frameborder='0' scrolling='no'"
|
|
+ " allowtransparency='true'"
|
|
+ " sandbox='allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox'"
|
|
+ " style='width:" + ladeBreite + "px;height:" + METEOGRAM_HOEHE + "px;border:0;overflow:hidden;'></iframe>"
|
|
+ "</div>"
|
|
+ "<!-- DO NOT REMOVE THIS LINK -->"
|
|
+ "<a href='https://www.meteoblue.com/de/wetter/woche/index' target='_blank' rel='noopener'></a>";
|
|
|
|
const buehne = ziel.querySelector(".meteogram-buehne");
|
|
const rahmen = buehne.querySelector("iframe");
|
|
// Zuschnitt und Skalierung stehen hier und nicht im Stylesheet: sonst
|
|
// haengt die Darstellung an einer womoeglich veralteten solar.css im
|
|
// Browser-Cache, und der breit geladene Rahmen ragte ungeschnitten aus
|
|
// der Karte heraus.
|
|
buehne.style.overflow = "hidden";
|
|
rahmen.style.display = "block";
|
|
rahmen.style.transformOrigin = "0 0";
|
|
let letzteBreite = 0;
|
|
const anpassen = function () {
|
|
const breite = buehne.clientWidth;
|
|
if (breite === 0 || breite === letzteBreite) return;
|
|
letzteBreite = breite;
|
|
const faktor = Math.min(1, breite / ladeBreite);
|
|
rahmen.style.transform = "scale(" + faktor + ")";
|
|
buehne.style.height = Math.round(METEOGRAM_HOEHE * faktor) + "px";
|
|
};
|
|
anpassen();
|
|
// Nur auf Breitenaenderungen reagieren - die Hoehe setzt anpassen() selbst,
|
|
// sonst riefe sich der Beobachter gegenseitig auf.
|
|
if (window.ResizeObserver) {
|
|
new ResizeObserver(anpassen).observe(buehne);
|
|
} else {
|
|
window.addEventListener("resize", anpassen);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wetterkacheln aktualisieren (restricted/weather_cards.html).
|
|
* value(name) liefert den Wert zu weatherStation/<name> - die Seiten legen
|
|
* die MQTT-Werte unterschiedlich ab, deshalb der Zugriff über eine Funktion.
|
|
*/
|
|
function updateWeatherCards(value) {
|
|
const windDir = document.getElementById("windDir");
|
|
if (!windDir) return; // Seite ohne Wetterkacheln
|
|
windDir.style.transform = "rotate(" + (90 + Number(value("windDeg"))) + "deg)";
|
|
document.getElementById("windSpd").innerHTML = value("avgWindspeed");
|
|
document.getElementById("humidity").innerHTML = value("hum");
|
|
document.getElementById("temp").innerHTML = value("tempAmb");
|
|
document.getElementById("windDirGust").style.transform = "rotate(" + (90 + Number(value("gustDeg"))) + "deg)";
|
|
document.getElementById("windGust").innerHTML = value("maxgust");
|
|
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
|
|
}
|