Beide Fenster lesen den Zustand live aus MQTT und uebernehmen jede Wahl sofort (bedienfenster.js). submitFormAjax entfaellt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
579 lines
20 KiB
JavaScript
579 lines
20 KiB
JavaScript
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) {
|
||
// Alle Fenster der Solar-Seite uebernehmen jede Aenderung sofort -
|
||
// "Save changes" braucht hier keines mehr.
|
||
document.getElementById("modalSaveBtn").style.display = "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...");
|
||
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;
|
||
|
||
// Aufbau und Logik der Fenster: ladefenster.js und bedienfenster.js.
|
||
switch(type){
|
||
case "CarEG":
|
||
case "CarOG":
|
||
ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
|
||
break;
|
||
case "heater":
|
||
heizfensterStarten(modalBodyElement.querySelector(".heiz"), contentURL);
|
||
break;
|
||
case "watering":
|
||
wasserfensterStarten(modalBodyElement.querySelector(".wasser"), contentURL);
|
||
break;
|
||
}
|
||
|
||
})
|
||
.catch(error => {
|
||
modalBodyElement.innerHTML = error.message;
|
||
});
|
||
}
|
||
|
||
// Die Zonensymbole im Bewaesserungskreis; das Fenster aktualisiert sich
|
||
// selbst (bedienfenster.js).
|
||
function updateWateringStatus() {
|
||
wateringSymboleSetzen(svgElement);
|
||
}
|
||
|
||
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(" ");
|
||
}
|