Files
Smart-Dashboard/js/solar/solarMQTT.js
T
adminandClaude Opus 5 b0bac3d3ba Ladefenster neu: Zustand zuerst, Modus als Karten, Aenderungen sofort
Nach dem abgestimmten Entwurf: Ladestand als Ring mit Ziel, Leistung,
Sonnenanteil und fertig um; ein grosser Knopf, der sich nach der gemeldeten
Freigabe (frc) richtet - "Laden stoppen", "Ladung freigeben" (zurueck zum
Modus statt dauerhaft gesperrt), "Jetzt laden", ohne Auto ausgegraut.
Modus als Karten (Sofort, Sonne, Geplant), Planung nur bei "Geplant": mit
Ladestand des Autos als Ziel-Ladestand, sonst als zusaetzliche Prozent;
Abfahrt per Schnellwahl. Ladestrom als Segmente mit kW.

Jede Aenderung geht sofort als JSON an carSteuerung.php (frc, modus, strom,
plan) statt ueber "Save changes"; die Logik steht in js/solar/ladefenster.js.
Am 800er-Display zweispaltig ohne Scrollen.

Nebenbei: die Uebersicht las die Restladezeit aus carRemChrg, das der
Manager nicht mehr schickt - jetzt evRemChrgTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:29 +02:00

725 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let solarMqttClient;
let solarSvgUpdatePending = false;
const solarMQTT = {
getMQTT: function () {
const id = Math.random().toString(36).substring(7);
const topic = "#";
const connection = "wss://mqtt.nas.el-wa.org:443"
mqttsolarTreeDone = false;
// const connection = "ws://username:password@37.97.203.138:8083" // Works
// const connection = "wss://public:public@public.cloud.shiftr.io" // Works
const client = mqtt.connect(connection, {
rejectUnauthorized: false,
});
solarMqttClient = client;
client.on("message", messageReceived);
client.on("connect", function () {
client.subscribe("solarManager/#");
client.subscribe("wattpilot/properties/lmo/state");
client.subscribe("wattpilot/properties/ftt/state");
client.subscribe("wattpilot/properties/fte/state");
client.subscribe("wattpilot/properties/amp/state");
client.subscribe("wattpilot/properties/car/state");
client.subscribe("go-eCharger/270003/amp");
client.subscribe("go-eCharger/270003/ate");
client.subscribe("go-eCharger/270003/lmo");
client.subscribe("go-eCharger/270003/att");
client.subscribe("go-eCharger/270003/car");
client.subscribe("weatherStation/#");
client.subscribe("Gartenwasser/vorn/status");
client.subscribe("Gartenwasser/vorn/Timers");
client.subscribe("Gartenwasser/vorn/LastWatering");
client.subscribe("Gartenwasser/vorn/WateringToday");
client.subscribe("Gartenwasser/vorn/LastWateringDay");
client.subscribe("Gartenwasser/hinten/status");
client.subscribe("Gartenwasser/hinten/Timers");
client.subscribe("Gartenwasser/hinten/LastWatering");
client.subscribe("Gartenwasser/hinten/WateringToday");
client.subscribe("Gartenwasser/hinten/LastWateringDay");
});
client.on("error", function (error) {
//alert("MQTT Error: " + error);
});
client.on('end', function () {
setTimeout(getMQTT, 5000);
alert("MQTT Disconnected, try to reconnect in 5 secs.");
})
function getNestedProp(obj, path) {
return path.split('/').reduce((acc, key) => acc && acc[key], obj);
}
function setNestedProp(obj, path, value) {
var schema = obj; // a moving reference to internal objects within obj
var pList = path.split('/');
var len = pList.length;
for (var i = 0; i < len - 1; i++) {
var elem = pList[i];
if (!schema[elem]) schema[elem] = {}
schema = schema[elem];
}
schema[pList[len - 1]] = value;
}
function messageReceived(topic, message) {
mqttData[topic] = message;
if (topic === "Gartenwasser/vorn/Timers" || topic === "Gartenwasser/hinten/Timers") {
wateringTimersLastSeen[topic] = Date.now();
}
if (topic.startsWith("Gartenwasser/") && (topic.endsWith("/status") || topic.endsWith("/Timers") || topic.endsWith("/LastWatering")
|| topic.endsWith("/WateringToday") || topic.endsWith("/LastWateringDay"))) {
updateWateringStatus();
}
if (topic == "solarManager/P_Load") {
if (!solarSvgUpdatePending) {
solarSvgUpdatePending = true;
setTimeout(function () {
solarSvgUpdatePending = false;
solarSVG.updateValuesMQTT(mqttData);
}, 1000);
}
}else if(topic == "weatherStation/windDeg"){
setTimeout(function () { updateWeatherCards(name => mqttData["weatherStation/" + name]) }, 200); //give the object tree some time to build up and receive all values
}
}
},
publish: function (topic, payload, callback) {
if (!solarMqttClient || !solarMqttClient.connected) {
throw new Error("MQTT ist noch nicht verbunden.");
}
solarMqttClient.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload), callback);
}
}
// Die Knoten im eingebetteten Realtime-SVG bleiben ueber die Lebensdauer der
// Seite bestehen. updateValuesMQTT fasst 104 davon an - bis zu einmal pro
// Sekunde -, deshalb werden sie einmal nachgeschlagen und dann behalten.
const svgElements = {};
function svgElement(id) {
let node = svgElements[id];
if (node === undefined) {
node = svgElements[id] = document.getElementById(id);
}
return node;
}
// Nur schreiben, wenn sich der Wert wirklich geaendert hat. Jeder Schreibzugriff
// macht Style und Paint des Elements ungueltig - bei einem gefilterten Element
// wird dabei sein Filter neu gerastert. Gemessen waren rund 71 % der Zugriffe
// pro Update reine Wiederholungen desselben Werts; die entfallen damit.
const letzterWert = new Map();
function schreibe(schluessel, wert, anwenden) {
const s = String(wert);
if (letzterWert.get(schluessel) === s) return;
letzterWert.set(schluessel, s);
anwenden(s);
}
// textContent statt innerHTML: setzt einen Textknoten, ohne den HTML-Parser zu bemuehen.
function setText(id, wert) { schreibe("t:" + id, wert, s => { svgElement(id).textContent = s; }); }
function setHTML(id, markup) { schreibe("h:" + id, markup, s => { svgElement(id).innerHTML = s; }); }
function setAttr(id, name, wert) { schreibe("a:" + id + ":" + name, wert, s => { svgElement(id).setAttribute(name, s); }); }
function setDisplay(id, wert) { schreibe("d:" + id, wert, s => { svgElement(id).style.display = s; }); }
// Punktgroesse einer Flusslinie setzen und die Animation anhalten, wenn kein
// Fluss anliegt. --w ist der Durchmesser, die CSS-Regel macht daraus den Radius
// aller Punkte der Gruppe. will-change nur solange die Gruppe laeuft: es fordert
// eine eigene Compositor-Ebene an, und die kostet Speicher.
function setFlowWidth(id, width) {
const flow = svgElement(id);
// Eine negative Breite heisst "kein Fluss in dieser Richtung" - die
// Aufrufer rechnen 55*(1-exp(x)), was bei umgekehrtem Vorzeichen negativ
// wird. Frueher zeichnete eine negative stroke-width schlicht nichts.
// Math.max haelt das bei; Math.abs wuerde stattdessen einen Fluss in der
// falschen Richtung mit voller Staerke anzeigen.
const breite = Math.max(0, width);
const sichtbar = breite >= 0.5;
flow.style.setProperty("--w", breite + "px");
flow.style.animationPlayState = sichtbar ? "running" : "paused";
flow.style.willChange = sichtbar ? "transform" : "auto";
}
// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und die Anlage-Ansicht
// (js/solar/anlage.js). Bis September 2026 stand hier updateValuesMQTT fuer das
// feste realtime.svg - jeder Kreis mit eigenen Zeilen. Was gezeigt wird, steht
// jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js).
let energiefluss = null;
let anlage = null;
let speicher = null;
const solarSVG = {
updateCnt: 99,
/**
* Beide Zwischenspeicher verwerfen - noetig, wenn die Uebersicht neu
* gezeichnet wird (Wechsel quer/hochkant). Nur die Knoten zu vergessen
* reicht nicht: letzterWert wuerde dann jeden Schreibzugriff als
* "unveraendert" ueberspringen und die frischen Elemente behielten ihre
* Platzhalter.
*/
fillElementArray: function () {
for (const id in svgElements) delete svgElements[id];
letzterWert.clear();
},
updateValuesMQTT: function (mqttData) {
if (energiefluss) energiefluss.aktualisieren(mqttData);
// Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit.
if (anlage) anlage.aktualisieren(mqttData);
if (speicher) speicher.aktualisieren(mqttData);
}
};
/** Energiefluss aufbauen und den Umschalter Uebersicht/Anlage verdrahten. */
function energieflussStarten() {
const behaelter = document.getElementById("energiefluss");
if (!behaelter || typeof Energiefluss === "undefined") return;
const zeigeAnsicht = function (name) {
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
const an = knopf.dataset.enfAnsicht === name;
knopf.classList.toggle("active", an);
knopf.setAttribute("aria-selected", an ? "true" : "false");
});
document.querySelectorAll("[data-enf-ansicht-flaeche]").forEach(function (flaeche) {
const an = flaeche.dataset.enfAnsichtFlaeche === name;
flaeche.hidden = !an;
if (an) {
flaeche.classList.remove("enf-rein");
void flaeche.offsetWidth;
flaeche.classList.add("enf-rein");
}
});
};
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
knopf.addEventListener("click", function () { zeigeAnsicht(knopf.dataset.enfAnsicht); });
});
// Anzeige-Modus (festes Display, js/solar/anzeige.js): immer quer.
const anzeigeModus = document.body.classList.contains("anzeige");
energiefluss = new Energiefluss(behaelter, {
hochkantUnter: anzeigeModus ? 0 : undefined,
einstellungen: typeof energieflussEinstellungen !== "undefined" ? energieflussEinstellungen : {},
onKlick: function (knoten) {
if (knoten.klick.ansicht) zeigeAnsicht(knoten.klick.ansicht);
else if (knoten.klick.modal) openModal(knoten.klick.modal);
},
onGezeichnet: function () {
solarSVG.fillElementArray();
if (typeof updateWateringStatus === "function") updateWateringStatus();
},
});
energiefluss.zeichnen();
const anlageBehaelter = document.getElementById("anlage");
if (anlageBehaelter && typeof Anlage !== "undefined") {
anlage = new Anlage(anlageBehaelter, { hochkantUnter: anzeigeModus ? 0 : undefined });
anlage.zeichnen();
}
const speicherBehaelter = document.getElementById("speicher");
if (speicherBehaelter && typeof Speicher !== "undefined") {
speicher = new Speicher(speicherBehaelter);
speicher.zeichnen();
}
}
var chartSettings = {
type: 'line',
options: {
animation: true,
plugins: {
annotation: {
common: { type: 'box', drawTime: 'beforeDatasetsDraw', yScaleID: 'y-axis-0', backgroundColor: 'rgba(255, 255, 255, 0.05)', init: true },
annotations: []
},
tooltip: {
position: 'nearest',
pointStyle: "circle",
boxWidth: 4,
usePointStyle: true,
callbacks: {
footer: function (tooltipItems){return ""},
},
},
legend: {
position: "bottom",
labels: {
pointStyleWidth: 10,
usePointStyle: true,
pointStyle: "line",
}
},
},
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
scales: {
x: {
adapters: {
date: {
locale: "DE-de"
}
},
ticks: {
},
type: 'timestack',
},
y: {
stacked: true,
display: true,
min: 0,
position: 'left',
ticks: {
callback: value => `${value / 1000}kW`,
},
/*title: {
display: true,
text: "Leistung"
}*/
},
y1: {
stacked: false,
display: true,
position: 'right',
min: 0,
max: 100,
ticks: {
callback: value => `${value}%`,
},
/*title: {
display: true,
text: "Ladestand"
},*/
data:{}
}
}
}
};
var forecastChartSettings = {
type: 'bar',
options: {
animation: true,
plugins: {
tooltip: {
position: 'nearest',
pointStyle: "circle",
boxWidth: 4,
usePointStyle: true,
callbacks: {
footer: function (tooltipItems){return ""},
},
},
legend: {
position: "bottom",
labels: {
pointStyleWidth: 10,
usePointStyle: true,
pointStyle: "line",
}
},
},
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
scales: {
x: {
adapters: {
date: {
locale: "DE-de"
}
},
ticks: {
},
type: 'timestack',
timestack:{
right_floating_tick_thres: 0.3,
format_style: {month: 'long'},
}
},
y: {
stacked: false,
display: true,
position: 'left',
ticks: {
callback: value => `${value / 1000}kW`,
},
/*title: {
display: true,
text: "Leistung"
}*/
},
}
}
};
var modalEV = new bootstrap.Modal(document.getElementById('modalEV'), {
keyboard: false
});
var chartData = {};
var mqttData = {};
var timeFrom = -24;
var timeTo = 12;
const dayRange = () => ({ FROM: timeFrom, TO: timeTo });
const consChart = new Chart(
document.querySelector('#consumption-chart'),
Object.assign({}, chartSettings)
);
const prodChart = new Chart(
document.querySelector('#production-chart'),
Object.assign({}, chartSettings)
);
const foreChart = new Chart(
document.querySelector('#forecast-chart'),
Object.assign({}, forecastChartSettings)
);
function updateCharts(){
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, autoUpdate: false, updateMode: "none" });
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, autoUpdate: false, updateMode: "none" });
}
function prevDay(){
timeFrom -= 24;
timeTo -= 24;
var now = new Date();
now.setDate(now.getDate()+1+(timeFrom/24));
var day = ("0" + now.getDate()).slice(-2);
var month = ("0" + (now.getMonth() + 1)).slice(-2);
var selected = now.getFullYear()+"-"+(month)+"-"+(day);
document.getElementById("DatePickerCons").value = selected;
document.getElementById("DatePickerProd").value = selected;
updateCharts();
}
function changeDay(e){
const date1 = new Date(e.target.value);
const date2 = new Date();
const difference = Math.floor((date1.getTime() - date2.getTime()) / (1000*60*60*24)) *24;
timeFrom = difference;
timeTo = difference+36;
updateCharts();
//.then((ret) => enableEvents());
}
function nextDay(){
timeFrom += 24;
timeTo += 24;
var now = new Date();
now.setDate(now.getDate()+1+(timeFrom/24));
var day = ("0" + now.getDate()).slice(-2);
var month = ("0" + (now.getMonth() + 1)).slice(-2);
var selected = now.getFullYear()+"-"+(month)+"-"+(day);
document.getElementById("DatePickerCons").value = selected;
document.getElementById("DatePickerProd").value = selected;
updateCharts();
}
document.addEventListener('readystatechange', function () {
if (event.target.readyState === "complete") {
solarMQTT.getMQTT();
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" });
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" });
getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" });
// Dieselbe Statistik wie auf der Historienseite, nur das laufende Jahr:
// hier ist sie Beiwerk, dort der Zweck der Seite.
baueJahresstatistik(document.getElementById("Stats-Year"),
{ spalten: 1, werkzeuge: false, fussnoten: false });
energieflussStarten();
mountMeteogram();
}
});
function openModal(type) {
// Das Ladefenster und die Bewaesserung uebernehmen jede Aenderung sofort -
// "Save changes" braucht nur noch der Heizstab.
document.getElementById("modalSaveBtn").style.display = type === "heater" ? "" : "none";
switch(type){
case "CarEG":
document.getElementById("modal-title").innerHTML = "Autoladung EG";
contentURL = "ajax/carEG.php";
break;
case "CarOG":
document.getElementById("modal-title").innerHTML = "Autoladung OG";
contentURL = "ajax/carOG.php";
break;
case "heater":
document.getElementById("modal-title").innerHTML = "Steuerung Heizstab";
contentURL = "ajax/heater.php";
break;
case "watering":
document.getElementById("modal-title").innerHTML = "Gartenbewässerung";
contentURL = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
break;
default:
document.getElementById("modal-title").innerHTML = "Fehler";
contentURL = "";
break;
}
modalBodyElement = document.getElementById('modal-body');
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax);
document.getElementById("modalSaveBtn").contentURL = contentURL;
modalEV.show();
fetch(contentURL, {
method: 'GET',
headers: {
'X-Requested-From-Modal': 'a',
'Requested-With-Ajax': 'ajax'
}
})
.then(response => response.text())
.then(html => {
modalBodyElement.innerHTML = html;
var slider = document.getElementById("modal-slider");
var span = document.getElementById("modal-slider-label");
switch(type){
case "CarEG":
case "CarOG":
// Alles Weitere in js/solar/ladefenster.js.
ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
break;
case "heater":
bindModalSlider(slider, span, value => value == 0 ? "Automatik" : (value / 10) + " kW");
if(mqttData["solarManager/heatMode"] == "eco"){
document.getElementById("modal-slider").value = 0;
}else{
document.getElementById("modal-slider").value = Math.round(mqttData["solarManager/pHeat"]/100);
}
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
break;
case "watering":
initializeWateringModal();
break;
}
})
.catch(error => {
modalBodyElement.innerHTML = error.message;
});
}
function wateringCommand(topic, command, directTopic = false) {
const feedback = document.getElementById("watering-feedback");
const targetTopic = directTopic ? topic : topic + "/set";
const payload = typeof command === "string" ? command : JSON.stringify(command);
const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
const showFeedback = (message, type) => {
if (!feedback) return;
feedback.textContent = message;
feedback.className = "alert alert-" + type + " mb-3";
feedback.style.whiteSpace = "pre-line";
};
fetch(endpoint, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ topic: targetTopic, payload: payload })
}).then(async (response) => {
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler.");
showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success");
}).catch((error) => {
showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger");
});
}
function initializeWateringModal() {
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
const updateButton = () => {
const zone = wateringZones[button.dataset.wateringZone];
setzeZonenButton(button, wateringLabel(zone.topic, zone.valve));
};
button.addEventListener("click", () => {
const zone = wateringZones[button.dataset.wateringZone];
if (wateringLabel(zone.topic, zone.valve) === "aus") {
const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`);
const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30));
wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer });
} else {
wateringCommand(zone.topic, { valve: zone.valve, set: "close" });
}
});
updateButton();
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
const modes = button.dataset.wateringAuto.split(",");
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
topics.forEach((topic, index) => {
wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true);
});
});
updateWateringAutoButton(button);
});
updateWateringStatus();
}
function updateWateringStatus() {
wateringZoneList.forEach(([key, zone]) => {
const state = wateringLabel(zone.topic, zone.valve);
const paused = wateringIsPaused(zone.topic, zone.valve);
const heute = wateringDayTotal(zone.topic, "WateringToday", "wateringDurationTodaySecs", zone.valve, true);
// Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die
// Anzeige schaut, ist die Entscheidung laengst gefallen. Angezeigt wird
// deshalb nicht mehr, was geplant ist, sondern was passiert ist. Der
// Zaehler unterscheidet nicht, wer ausgeloest hat - eine Bewaesserung
// von Hand zaehlt genauso, und das ist so gewollt.
const bewaessert = state === "aus" && heute.sekunden > 0;
// Die Anzeigen liegen im Realtime-SVG und bleiben bestehen -> svgElement.
const status = svgElement(zone.statusId);
const icon = svgElement(zone.iconId);
if (status && icon) updateWateringIndicator(status, icon, zone.label, state, paused, bewaessert);
// Die Modal-Elemente werden bei jedem Oeffnen neu erzeugt -> nicht cachen.
setzeZonenButton(document.querySelector(`[data-watering-toggle][data-watering-zone='${key}']`), state);
setzeSchild(`[data-watering-state='${key}']`, state, paused, bewaessert);
const history = wateringHistory(zone.topic, zone.valve);
setzeZelle(`[data-watering-last='${key}']`, history.dateTime);
setzeZelle(`[data-watering-duration='${key}']`, history.duration);
const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false);
setzeZelle(`[data-watering-today='${key}']`, heute.label, heute.day && "Stand: " + heute.day);
setzeZelle(`[data-watering-prevday='${key}']`, vortag.label, vortag.day && "Bewässerungstag: " + vortag.day);
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
}
// Der Knopf zeigt die naechste Aktion, nicht den Zustand: laeuft die Zone,
// steht "Aus" darauf.
function setzeZonenButton(button, state) {
if (!button) return;
const beschriftung = state === "aus" ? "Ein" : "Aus";
if (button.textContent !== beschriftung) button.textContent = beschriftung;
button.classList.toggle("btn-success", state === "aus");
button.classList.toggle("btn-danger", state !== "aus");
}
// Zustandsschild der Zonenkarte, Farben wie im SVG: laufend gruen,
// pausiert gelb, heute schon bewaessert blau, sonst grau.
function setzeSchild(selektor, state, paused, bewaessert) {
const schild = document.querySelector(selektor);
if (!schild) return;
let text = "unbekannt";
let farbe = "bg-secondary";
if (state !== "--" && state !== "aus") {
text = paused ? "pausiert " + state : "läuft " + state;
farbe = paused ? "bg-warning text-dark" : "bg-success";
} else if (state === "aus") {
text = bewaessert ? "heute bewässert" : "aus";
farbe = bewaessert ? "bg-primary" : "bg-secondary";
}
if (schild.textContent !== text) schild.textContent = text;
const klasse = "badge " + farbe;
if (schild.className !== klasse) schild.className = klasse;
}
// Schreibt nur bei echter Aenderung - die Tabelle wird im Sekundentakt
// aktualisiert, ihre Werte aendern sich aber hoechstens minuetlich.
function setzeZelle(selektor, text, titel) {
const element = document.querySelector(selektor);
if (!element) return;
if (element.textContent !== text) element.textContent = text;
if (titel) {
if (element.getAttribute("title") !== titel) element.setAttribute("title", titel);
} else if (element.hasAttribute("title")) {
element.removeAttribute("title");
}
}
function updateWateringAutoButton(button) {
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
const modes = button.dataset.wateringAuto.split(",");
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
if (!button.dataset.wateringLabel) {
button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, "");
}
button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel;
button.classList.toggle("btn-outline-primary", !active);
button.classList.toggle("btn-outline-danger", active);
}
window.setInterval(updateWateringStatus, 1000);
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
}
function getRBGComponent(colRange, minCol, valRange, minVal, val) {
return Math.round(((val - minVal) / valRange) * colRange + minCol)
.toString(16)
.toUpperCase()
.padStart(2, '0');
}
function assignColor(minCol, maxCol, minVal, maxVal, val) {
var color = "";
var minR = parseInt(minCol.substring(1, 3), 16);
var maxR = parseInt(maxCol.substring(1, 3), 16);
var minG = parseInt(minCol.substring(3, 5), 16);
var maxG = parseInt(maxCol.substring(3, 5), 16);
var minB = parseInt(minCol.substring(5, 7), 16);
var maxB = parseInt(maxCol.substring(5, 7), 16);
var valsRange = maxVal - minVal;
var rangeG = maxG - minG;
var rangeR = maxR - minR;
var rangeB = maxB - minB;
if (val > maxVal)
val = maxVal;
else if (val < minVal)
val = minVal;
color = '#'
+ getRBGComponent(rangeR, minR, valsRange, minVal, val)
+ getRBGComponent(rangeG, minG, valsRange, minVal, val)
+ getRBGComponent(rangeB, minB, valsRange, minVal, val);
return color;
}
function describeArc(x, y, radius, startAngle, endAngle) {
if (endAngle > 288) {
endAngle = 288;
}
// Auf volle Grad runden: bei Radius 95 ist ein Grad rund 1,7 Einheiten,
// feiner sieht niemand. Dafuer bleibt das d-Attribut bei kleinen
// Schwankungen unveraendert und setAttr unterdrueckt den Schreibvorgang -
// sonst laesst jeder Zappler den Bogen samt Filter neu zeichnen.
startAngle = Math.round(startAngle);
endAngle = Math.round(endAngle);
var start = polarToCartesian(x, y, radius, endAngle);
var end = polarToCartesian(x, y, radius, startAngle);
var largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
// zwei Nachkommastellen genuegen und halten das Attribut kurz
var rnd = (v) => Math.round(v * 100) / 100;
return [
"M", rnd(start.x), rnd(start.y),
"A", radius, radius, 0, largeArcFlag, 0, rnd(end.x), rnd(end.y)
].join(" ");
}