Files
Smart-Dashboard/js/solar/zeitleiste.js
T
adminandClaude Opus 5 2aadb667fb Zeitleiste: Karte je Eintrag mit Bearbeiten, Pausieren/Fortsetzen, Loeschen
Ein Klick auf einen Eintrag oeffnete bisher gleich den Editor - pausieren
und loeschen ging nur in der Liste. Jetzt oeffnet sich eine kleine Karte:
was die Automatik tut, wann, wie sie heute steht, die Bedingung, und die
Knoepfe Bearbeiten, Pausieren bzw. Fortsetzen und Loeschen. Auch die
Eintraege in den Baendern "Jederzeit" und "Pausiert" oeffnen sie.

Pausieren und Loeschen gehen ueber toggleAutomation und deleteAutomation
aus autoActionFuncs.js - dieselben Wege wie in der Liste, samt Rueckfrage
bei abhaengigen Automatiken. Die Karte schliesst per Kreuz, Esc oder Klick
daneben; solange sie offen ist, leuchtet die Kette des Eintrags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:43:11 +02:00

675 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
Automatismen als Zeitleiste eines Tages.
Die Daten rechnet der Server (restricted/zeitleiste.php): wo eine
Automatik auf dem Tag liegt, in welcher Bahn sie steht, ob sie an dem Tag
dran ist und wann sie gelaufen ist. Hier wird nur gezeichnet.
Jede Automatik erscheint genau einmal - in ihrer Bahn, unter "Jederzeit"
oder unter "Pausiert". Der Zähler oben sagt, wie viele es insgesamt sind;
fehlte eine, fiele das dort auf.
Die bisherige Tabelle bleibt als zweite Ansicht ("Liste"). Welche offen
ist und welche Etagen eingeblendet sind, merkt sich der Browser.
========================================================================== */
const zeitleiste = {
datum: null, // "YYYY-MM-DD", null = heute
daten: null, // Antwort von ?action=zeitleiste
etagen: null, // Set der eingeblendeten Etagen, null = alle
uhr: null,
};
const ZL_ZEILE = 30; // Höhe einer Zeile in einer Bahn, px
const ZL_KOPF = 132; // Breite der Bahnbeschriftung, px
const ZL_MIN_SPUR = 620; // schmaler wird die Spur nicht, der Rahmen scrollt dann
/* --- Kleinigkeiten ------------------------------------------------------- */
function zlSpeicher(schluessel, wert) {
try {
if (wert === undefined) return localStorage.getItem(schluessel);
localStorage.setItem(schluessel, wert);
} catch (e) { return null; }
return null;
}
function zlText(s) {
const d = document.createElement("div");
d.textContent = String(s === undefined || s === null ? "" : s);
return d.innerHTML.replace(/"/g, "&quot;");
}
function zlUhr(m) {
m = ((Math.round(m) % 1440) + 1440) % 1440;
return String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0");
}
function zlIso(d) {
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
function zlDatumText(iso, heute) {
const d = new Date(iso + "T12:00:00");
const tage = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
const kurz = tage[d.getDay()] + " " + String(d.getDate()).padStart(2, "0") + "." + String(d.getMonth() + 1).padStart(2, "0") + ".";
const morgen = new Date(); morgen.setDate(morgen.getDate() + 1);
if (heute) return "Heute · " + kurz;
if (iso === zlIso(morgen)) return "Morgen · " + kurz;
return kurz;
}
/** Textbreite in px, für das Stapeln - einmal ein Canvas, dann nur messen. */
function zlBreite(text) {
const c = zlBreite.c || (zlBreite.c = document.createElement("canvas").getContext("2d"));
c.font = "500 12px " + getComputedStyle(document.body).fontFamily;
return c.measureText(text).width;
}
/* --- Einordnen ----------------------------------------------------------- */
/** Was an Automatiken nach dem Etagenfilter übrig bleibt. */
function zlSichtbar() {
const alle = zeitleiste.daten.automatiken;
return zeitleiste.etagen ? alle.filter(a => zeitleiste.etagen.has(a.floor)) : alle;
}
/** Wo ein Eintrag auf dem Tag beginnt und endet, in Minuten. */
function zlLage(a) {
if (a.art === "fenster") return [a.von, a.bis];
const m = a.laeufe.length ? zlMinuteAus(a.laeufe[a.laeufe.length - 1]) : a.punkt;
return [m, m];
}
function zlMinuteAus(hhmm) {
const t = String(hhmm).split(":");
return Number(t[0]) * 60 + Number(t[1]);
}
/** Zustand eines Eintrags - bestimmt Farbe und Form. */
function zlZustand(a, d) {
if (!a.dran) return "nichtdran";
if (a.laeufe.length) return "gelaufen";
if (d.jetzt !== null) {
const ende = a.art === "fenster" ? a.bis : a.punkt;
if (ende !== null && ende < d.jetzt) return "vorbei";
const anfang = a.art === "fenster" ? a.von : a.punkt;
if (anfang !== null && anfang <= d.jetzt) return "aktiv";
}
return "kommt";
}
/**
* Einträge einer Bahn in Zeilen verteilen, wie im Kalender: jeder kommt in
* die erste Zeile, in der er nichts überdeckt. Gerechnet in px, weil das
* Etikett mitzählt - zwei Punkte zehn Minuten auseinander liegen zeitlich
* getrennt, ihre Beschriftungen aber übereinander.
*/
function zlStapeln(eintraege, breite) {
const px = m => m / 1440 * breite;
const alle = eintraege.map(a => {
const [von, bis] = zlLage(a);
const etikett = zlEtikettText(a);
const lang = zlBreite(etikett) + 26 + (a.dran ? 0 : 16); // Platz fürs Zeichen "nicht dran"
let links = px(von) - 8;
let rechts = a.art === "fenster" ? Math.max(px(bis), px(von) + lang) : px(von) + lang;
const ueber = px(von) + lang > breite - 4;
// Ein Punkt am rechten Rand trägt sein Etikett links von sich - das
// liest sich natürlich, die Marke bleibt, wo die Zeit ist.
const nachLinks = ueber && a.art !== "fenster";
if (nachLinks) { links = px(von) - lang; rechts = px(von) + 8; }
/*
* Ein Fenster-Etikett beginnt dagegen nie vor seinem Balken - sonst sähe
* "15:0018:31" aus, als finge es um halb elf an. Reicht der Platz nicht,
* wird der Name gekürzt (voll steht er im Hinweis); nur wenn nicht einmal
* die Uhrzeit Platz hat, rückt es so wenig wie nötig nach links.
*/
let kappe = null, versatz = 0;
if (ueber && a.art === "fenster") {
const rest = breite - 4 - px(von);
kappe = Math.max(96, rest);
versatz = Math.min(0, rest - kappe);
links = px(von) + versatz - 8;
rechts = Math.max(px(bis), px(von) + versatz + kappe);
}
return { a, links, rechts, nachLinks, kappe, versatz, etikett };
}).sort((x, y) => x.links - y.links);
/*
* Ketten bleiben beisammen: ein Nachfolger kommt in die erste freie Zeile
* direkt unter seinem Auslöser, nicht irgendwohin, wo zeitlich gerade
* Platz ist. Deshalb wird familienweise eingesetzt - erst der Auslöser,
* gleich danach seine Nachfolger - und je Zeile eine Liste belegter
* Strecken geführt statt nur ihres Endes, weil ein Nachfolger links von
* etwas liegen kann, das schon eingesetzt ist.
*/
const inBahn = new Map(alle.map(e => [e.a.id, e]));
const kinder = new Map();
alle.forEach(e => {
const v = e.a.kette;
if (v && inBahn.has(v)) {
if (!kinder.has(v)) kinder.set(v, []);
kinder.get(v).push(e);
}
});
const zeilen = []; // je Zeile: [[links, rechts], ...]
const frei = (z, e) => !(zeilen[z] || []).some(([l, r]) => l < e.rechts + 6 && e.links < r + 6);
const setzen = (e, ab) => {
let z = ab;
while (!frei(z, e)) z++;
(zeilen[z] = zeilen[z] || []).push([e.links, e.rechts]);
e.zeile = z;
(kinder.get(e.a.id) || []).forEach(k => { if (k.zeile === undefined) setzen(k, z + 1); });
};
alle.filter(e => !(e.a.kette && inBahn.has(e.a.kette))).forEach(e => setzen(e, 0));
alle.forEach(e => { if (e.zeile === undefined) setzen(e, 0); }); // Kreise, sicher ist sicher
return { liste: alle, zeilen: Math.max(1, zeilen.length) };
}
/**
* Die Zeit vorne im Etikett. Beim Punkt die Zeit des Punkts - nach einem
* Lauf also die echte. Beim Fenster immer das Fenster; der Lauf steht dann
* hinten mit Haken, sonst stünde "12:50" am Balkenanfang bei 09:30.
*/
function zlEtikettZeit(a) {
if (a.art === "fenster") return zlUhr(a.von) + "" + zlUhr(a.bis);
return a.laeufe.length ? a.laeufe[a.laeufe.length - 1] : zlUhr(a.punkt);
}
function zlEtikettText(a) {
const lauf = a.art === "fenster" && a.laeufe.length ? " ✓ " + a.laeufe[a.laeufe.length - 1] : (a.laeufe.length ? " ✓" : "");
return zlEtikettZeit(a) + " " + a.name + lauf;
}
/** Der Hinweis beim Überfahren: was, wann, wovon abhängig, wie steht es. */
function zlTitel(a, zustand) {
const zeilen = [a.name, a.satz];
if (a.art === "fenster") zeilen.push("Zeitfenster " + zlUhr(a.von) + "" + zlUhr(a.bis) + (a.punkt !== null ? ", frühestens " + zlUhr(a.punkt) : ""));
else if (a.punkt !== null) zeilen.push((a.art === "kette" ? "frühestens " : "um ") + zlUhr(a.punkt) + (a.sonne ? " (Sonnenstand)" : ""));
if (a.wenn) zeilen.push("Wenn: " + a.wenn);
const stand = {
gelaufen: "gelaufen um " + a.laeufe.join(", "),
nichtdran: "an diesem Tag nicht dran: " + a.grund,
vorbei: "nicht gelaufen" + (a.wartet ? " Bedingung war nicht erfüllt" : ""),
aktiv: a.wartet ? "wartet auf die Bedingung" : "steht an",
kommt: "kommt noch",
}[zustand];
zeilen.push("Stand: " + stand);
return zeilen.join("\n");
}
/* --- Zeichnen ------------------------------------------------------------ */
function zeitleisteZeichnen() {
const ziel = document.getElementById("zeitleiste");
const d = zeitleiste.daten;
if (!ziel || !d || ziel.hidden) return;
const sichtbar = zlSichtbar();
const aktiv = sichtbar.filter(a => a.enabled);
const pausiert = sichtbar.filter(a => !a.enabled);
const jederzeit = aktiv.filter(a => a.art === "jederzeit");
const gelegt = aktiv.filter(a => a.art !== "jederzeit" && (a.punkt !== null || a.von !== null));
// --- Kopf: Tag, Etagen, Zähler
const alleEtagen = !zeitleiste.etagen;
let html = '<div class="zl-kopf">'
+ '<div class="btn-group btn-group-sm zl-tag" role="group">'
+ '<button type="button" class="btn btn-outline-secondary" data-tag="-1" title="Tag davor"><i class="bi bi-chevron-left"></i></button>'
+ '<button type="button" class="btn btn-outline-secondary zl-tag-name" data-tag="0" title="Zurück zu heute">'
+ zlText(zlDatumText(d.datum, d.heute)) + "</button>"
+ '<button type="button" class="btn btn-outline-secondary" data-tag="1" title="Tag danach"><i class="bi bi-chevron-right"></i></button>'
+ "</div>"
+ '<div class="zl-etagen" role="group" aria-label="Etagen">'
+ '<button type="button" class="zl-chip' + (alleEtagen ? " an" : "") + '" data-etage="">Alle</button>'
+ d.etagen.map(e => {
const an = alleEtagen || zeitleiste.etagen.has(e.code);
const anzahl = d.automatiken.filter(a => a.floor === e.code).length;
return '<button type="button" class="zl-chip' + (an && !alleEtagen ? " an" : "") + (anzahl ? "" : " leer") + '"'
+ ' data-etage="' + zlText(e.code) + '" title="' + zlText(e.label) + '">'
+ zlText(e.code) + '<span class="zl-chip-zahl">' + anzahl + "</span></button>";
}).join("")
+ "</div>"
+ '<div class="zl-zahl" title="Jede Automatik steht genau einmal: in ihrer Bahn, unter Jederzeit oder unter Pausiert.">'
+ '<i class="bi bi-eye"></i> ' + sichtbar.length + " von " + d.automatiken.length
+ (pausiert.length ? ' <span class="zl-zahl-leise">· ' + pausiert.length + " pausiert</span>" : "")
+ "</div></div>";
// --- Raster. Schmal (Handy) traegt die Bahn nur ihr Symbol - der Name
// nähme sonst ein Drittel der Breite, und die Spur scrollt ohnehin.
const rahmenBreite = ziel.clientWidth || 800;
const schmal = rahmenBreite < 560;
const kopf = schmal ? 40 : ZL_KOPF;
const breite = Math.max(ZL_MIN_SPUR, rahmenBreite - kopf - 2);
const altRahmen = ziel.querySelector(".zl-rahmen");
const altScroll = altRahmen ? altRahmen.scrollLeft : null;
const prozent = m => (m / 1440 * 100).toFixed(3) + "%";
const nacht = d.sonne.auf !== null
? '<div class="zl-nacht" style="left:0;width:' + prozent(d.sonne.auf) + '"></div>'
+ '<div class="zl-nacht" style="left:' + prozent(d.sonne.unter) + ';right:0"></div>'
: "";
const jetzt = d.jetzt !== null ? '<div class="zl-jetzt" style="left:' + prozent(d.jetzt) + '"></div>' : "";
html += '<div class="zl-rahmen' + (schmal ? " schmal" : "") + '"><div class="zl-raster" style="--zl-kopf:' + kopf + "px;min-width:" + (breite + kopf) + 'px">';
// Stundenachse mit Sonnenmarken
html += '<div class="zl-zeile zl-achse"><div class="zl-bahn-kopf"></div><div class="zl-spur">';
for (let h = 0; h <= 24; h += 3) {
const rand = h === 0 ? " anfang" : h === 24 ? " ende" : "";
html += '<span class="zl-stunde' + rand + '" style="left:' + prozent(h * 60) + '">' + h + "</span>";
}
if (d.sonne.auf !== null) {
const geschaetzt = d.sonne.genau ? "" : " (Vorjahr)";
html += '<span class="zl-sonne" style="left:' + prozent(d.sonne.auf) + '" title="Sonnenaufgang ' + zlUhr(d.sonne.auf) + geschaetzt + '"><i class="bi bi-sunrise"></i></span>'
+ '<span class="zl-sonne" style="left:' + prozent(d.sonne.unter) + '" title="Sonnenuntergang ' + zlUhr(d.sonne.unter) + geschaetzt + '"><i class="bi bi-sunset"></i></span>';
}
if (d.jetzt !== null) html += '<span class="zl-jetzt-marke" style="left:' + prozent(d.jetzt) + '">' + zlUhr(d.jetzt) + "</span>";
html += "</div></div>";
// Bahnen
const bahnen = Object.keys(d.bahnen).filter(b => gelegt.some(a => a.bahn === b));
if (!bahnen.length) {
html += '<div class="zl-leer">Keine Automatik mit einer Zeit auf diesem Tag'
+ (zeitleiste.etagen ? " in den gewählten Etagen" : "") + ".</div>";
}
bahnen.forEach(b => {
const eintraege = gelegt.filter(a => a.bahn === b);
const stapel = zlStapeln(eintraege, breite);
const hoehe = stapel.zeilen * ZL_ZEILE + 8;
html += '<div class="zl-zeile zl-bahn zl-bahn-' + b + '">'
+ '<div class="zl-bahn-kopf"><i class="bi ' + d.bahnen[b].symbol + '"></i><span>' + zlText(d.bahnen[b].titel) + "</span>"
+ '<span class="zl-bahn-zahl">' + eintraege.length + "</span></div>"
+ '<div class="zl-spur" style="height:' + hoehe + 'px">' + nacht + jetzt;
stapel.liste.forEach(e => { html += zlEintrag(e, d, prozent); });
html += "</div></div>";
});
html += '<svg class="zl-ketten" aria-hidden="true"></svg></div></div>';
// --- Bänder ohne Lage auf dem Tag
if (jederzeit.length || pausiert.length) {
html += '<div class="zl-baender">';
if (jederzeit.length) {
html += '<div class="zl-band"><span class="zl-band-titel"><i class="bi bi-infinity"></i>Jederzeit</span>'
+ '<div class="zl-band-chips">'
+ jederzeit.map(a => zlChip(a, d.bahnen[a.bahn] ? d.bahnen[a.bahn].symbol : "bi-dot")).join("") + "</div></div>";
}
if (pausiert.length) {
html += '<div class="zl-band zl-band-pause"><span class="zl-band-titel"><i class="bi bi-pause-circle"></i>Pausiert</span>'
+ '<div class="zl-band-chips">' + pausiert.map(a => zlChip(a, "bi-pause-fill")).join("") + "</div></div>";
}
html += "</div>";
}
html += '<div class="zl-legende">'
+ '<span><i class="zl-l-punkt gelaufen"></i>gelaufen</span>'
+ '<span><i class="zl-l-punkt"></i>kommt noch</span>'
+ '<span><i class="zl-l-balken"></i>Zeitfenster, wartet auf Bedingung</span>'
+ '<span><i class="zl-l-kette"></i>Kette</span>'
+ '<span><i class="bi bi-calendar-x zl-nichtdran"></i>an diesem Tag nicht dran</span>'
+ "</div>";
ziel.innerHTML = html;
zlKettenZeichnen(ziel);
// Muss gescrollt werden, steht die Leiste beim ersten Zeichnen bei "jetzt"
// (an einem anderen Tag beim Morgen) statt bei Mitternacht. Beim
// Neuzeichnen bleibt sie, wo man sie hingeschoben hat.
const rahmen = ziel.querySelector(".zl-rahmen");
if (rahmen && rahmen.scrollWidth > rahmen.clientWidth) {
if (altScroll !== null && zeitleiste.gescrollt) {
rahmen.scrollLeft = altScroll;
} else {
const ziel_m = d.jetzt !== null ? d.jetzt - 120 : 5 * 60;
rahmen.scrollLeft = Math.max(0, ziel_m / 1440 * breite);
zeitleiste.gescrollt = true;
}
}
}
/** Ein Eintrag in einer Bahn. */
function zlEintrag(e, d, prozent) {
const a = e.a;
const zustand = zlZustand(a, d);
const oben = 4 + e.zeile * ZL_ZEILE;
const titel = zlText(zlTitel(a, zustand));
const lauf = zustand !== "gelaufen" ? ""
: ' <i class="bi bi-check2"></i>' + (a.art === "fenster" ? '<span class="zl-lauf">' + zlText(a.laeufe[a.laeufe.length - 1]) + "</span>" : "");
const kappe = e.kappe ? ' style="max-width:' + Math.round(e.kappe) + "px" + (e.versatz ? ";margin-left:" + Math.round(e.versatz) + "px" : "") + '"' : "";
// "Nicht dran" hat ein eigenes Zeichen vorn im Etikett - bei Punkt und
// Fenster dasselbe, und dasselbe wie in der Legende.
const nichtdran = zustand === "nichtdran" ? '<i class="bi bi-calendar-x zl-nichtdran"></i>' : "";
const etikett = '<span class="zl-etikett' + (e.kappe ? " gekappt" : "") + '"' + kappe + ">" + nichtdran + "<b>"
+ zlText(zlEtikettZeit(a)) + "</b>" + zlText(a.name) + lauf + "</span>";
if (a.art === "fenster") {
// Läufe als Punkte auf dem Balken. Der früheste Zeitpunkt im Fenster
// (etwa Sonnenuntergang + 30) bekommt keine Marke mehr: sie lag meist
// unter dem Etikett und sah dort wie ein Zustandszeichen aus. Er steht
// im Hinweis ("frühestens 19:01").
let marken = "";
a.laeufe.forEach(t => {
const m = zlMinuteAus(t);
marken += '<span class="zl-marke zl-marke-innen gelaufen" style="left:' + ((m - a.von) / Math.max(1, a.bis - a.von) * 100).toFixed(2) + '%"></span>';
});
return '<div class="zl-ding zl-fenster ' + zustand + '" data-id="' + a.id + '"'
+ (a.kette ? ' data-kette="' + a.kette + '"' : "") + ' title="' + titel + '"'
+ ' style="top:' + oben + "px;left:" + prozent(a.von) + ";width:" + prozent(Math.max(10, a.bis - a.von)) + '">'
+ '<span class="zl-balken">' + marken + "</span>" + etikett + "</div>";
}
const m = a.laeufe.length ? zlMinuteAus(a.laeufe[a.laeufe.length - 1]) : a.punkt;
return '<div class="zl-ding zl-punkt ' + zustand + (e.nachLinks ? " links" : "") + '" data-id="' + a.id + '"'
+ (a.kette ? ' data-kette="' + a.kette + '"' : "") + ' title="' + titel + '"'
+ ' style="top:' + oben + "px;left:" + prozent(m) + '">'
+ '<span class="zl-marke' + (a.sonne ? " sonne" : "") + '"></span>' + etikett + "</div>";
}
function zlChip(a, symbol) {
return '<button type="button" class="zl-band-chip' + (a.dran ? "" : " nichtdran") + '" data-id="' + a.id + '"'
+ ' title="' + zlText(a.satz + (a.wenn ? "\nWenn: " + a.wenn : "")) + '">'
+ '<i class="bi ' + symbol + '"></i>' + zlText(a.name)
+ '<span class="zl-band-etage">' + zlText(a.floor) + "</span></button>";
}
/**
* Ketten als gestrichelte Bögen vom Auslöser zum Nachfolger. Erst nach dem
* Einsetzen, weil die Lage beider Punkte aus dem Layout kommt.
*/
function zlKettenZeichnen(ziel) {
const raster = ziel.querySelector(".zl-raster");
const svg = ziel.querySelector(".zl-ketten");
if (!raster || !svg) return;
const basis = raster.getBoundingClientRect();
svg.setAttribute("width", basis.width);
svg.setAttribute("height", basis.height);
/*
* Die Linie verlässt den Auslöser lotrecht nach unten und biegt mit einer
* kleinen Rundung von links in den Nachfolger ein.
*
* Start: beim Punkt unter der Marke; beim Fenster unter der Laufmarke,
* sonst unter dem Balkenanfang. Ziel: beim Punkt links an der Marke, beim
* Fenster am Balkenanfang. Liegt der Nachfolger links vom Auslöser (etwa
* weil er früher beginnen darf), kommt die Linie von oben an.
*/
const rect = el => {
const r = el.getBoundingClientRect();
return { l: r.left - basis.left, r: r.right - basis.left, t: r.top - basis.top, b: r.bottom - basis.top };
};
const start = el => {
const marke = el.querySelector(".zl-marke-innen.gelaufen") || el.querySelector(".zl-marke:not(.zl-marke-innen)");
if (marke) { const m = rect(marke); return { x: (m.l + m.r) / 2, y: m.b }; }
const b = rect(el.querySelector(".zl-balken") || el), ding = rect(el);
return { x: b.l + 6, y: ding.b - 2 };
};
const ankunft = el => {
const marke = el.querySelector(".zl-marke:not(.zl-marke-innen)");
const m = rect(marke || el.querySelector(".zl-balken") || el);
return { x: m.l, y: (m.t + m.b) / 2, oben: m.t, mitte: (m.l + m.r) / 2 };
};
let pfade = "";
raster.querySelectorAll(".zl-ding[data-kette]").forEach(kind => {
const vater = raster.querySelector('.zl-ding[data-id="' + kind.dataset.kette + '"]');
if (!vater) return;
const s = start(vater), z = ankunft(kind);
const x1 = Math.round(s.x) + 0.5, y1 = Math.round(s.y);
let d, x2, y2;
if (z.x - x1 >= 10) {
// lotrecht hinunter, Rundung, waagerecht hinein
x2 = Math.round(z.x) - 1; y2 = Math.round(z.y);
const rund = Math.min(10, (y2 - y1) / 2, (x2 - x1) / 2);
d = "M" + x1 + " " + y1 + " V" + (y2 - rund) + " Q" + x1 + " " + y2 + " " + (x1 + rund) + " " + y2 + " H" + x2;
} else {
// Nachfolger liegt links oder genau darunter: von oben anfahren
x2 = Math.round(z.mitte) + 0.5; y2 = Math.round(z.oben) - 1;
const mitte = y1 + (y2 - y1) / 2;
d = "M" + x1 + " " + y1 + " V" + mitte + " H" + x2 + " V" + y2;
}
const paar = ' data-von="' + kind.dataset.kette + '" data-nach="' + kind.dataset.id + '"';
pfade += '<path class="zl-kette-grund" d="' + d + '"/>'
+ '<path class="zl-kette"' + paar + ' d="' + d + '"/>'
+ '<circle class="zl-kette-ende"' + paar + ' cx="' + x2 + '" cy="' + y2 + '" r="2.4"/>';
});
svg.innerHTML = pfade;
}
/** Beim Überfahren die Kette des Eintrags hervorheben - in beide Richtungen. */
function zlKetteHervorheben(id, an) {
document.querySelectorAll('#zeitleiste .zl-ketten [data-von="' + id + '"], #zeitleiste .zl-ketten [data-nach="' + id + '"]')
.forEach(el => el.classList.toggle("hell", an));
}
/* --- Karte zu einem Eintrag ---------------------------------------------- */
/**
* Ein Klick auf einen Eintrag öffnet eine kleine Karte statt gleich den
* Editor: was die Automatik tut, wann, wie sie heute steht - und die drei
* Dinge, die man damit tun will. Pausieren und Löschen gab es vorher nur in
* der Liste.
*
* Pausieren und Löschen gehen über dieselben Funktionen wie dort
* (toggleAutomation, deleteAutomation in autoActionFuncs.js) - samt der
* Rückfrage, was mit abhängigen Automatiken geschieht. Beide rufen danach
* refreshAutomations(), und das lädt die Zeitleiste mit.
*/
function zlKarteOeffnen(anker, id) {
const ziel = document.getElementById("zeitleiste");
const d = zeitleiste.daten;
const a = d && d.automatiken.find(x => x.id === Number(id));
if (!ziel || !a) return;
zlKarteSchliessen();
const zustand = a.enabled ? zlZustand(a, d) : "pausiert";
const bahn = d.bahnen[a.bahn] || d.bahnen.sonstiges;
const wann = a.art === "fenster"
? zlUhr(a.von) + "" + zlUhr(a.bis) + (a.punkt !== null && a.punkt > a.von ? ", frühestens " + zlUhr(a.punkt) : "")
: a.art === "jederzeit" ? "jederzeit, wenn die Bedingung eintritt"
: (a.art === "kette" || a.wartet ? "frühestens " : "um ") + zlUhr(a.punkt) + (a.sonne ? " (Sonnenstand)" : "");
const stand = {
gelaufen: ["bi-check2-circle", "gut", "gelaufen um " + a.laeufe.join(", ")],
nichtdran: ["bi-calendar-x", "leise", "nicht dran: " + a.grund],
vorbei: ["bi-dash-circle", "leise", "nicht gelaufen" + (a.wartet ? " Bedingung war nicht erfüllt" : "")],
aktiv: ["bi-hourglass-split", "", a.wartet ? "wartet auf die Bedingung" : "steht an"],
kommt: ["bi-clock", "", "kommt noch"],
pausiert: ["bi-pause-circle", "leise", "pausiert"],
}[zustand];
const karte = document.createElement("div");
karte.className = "zl-karte zl-bahn-" + a.bahn;
karte.setAttribute("role", "dialog");
karte.innerHTML =
'<div class="zl-karte-kopf"><i class="bi ' + bahn.symbol + '"></i>'
+ '<span class="zl-karte-name">' + zlText(a.name) + "</span>"
+ '<span class="zl-band-etage">' + zlText(a.floor) + "</span>"
+ '<button type="button" class="btn-close btn-close-white zl-karte-zu" aria-label="Schließen"></button></div>'
+ '<div class="zl-karte-zeile"><i class="bi bi-lightning-charge"></i>' + zlText(a.satz) + "</div>"
+ '<div class="zl-karte-zeile"><i class="bi bi-clock-history"></i>' + zlText(wann) + "</div>"
+ '<div class="zl-karte-zeile zl-karte-stand ' + stand[1] + '"><i class="bi ' + stand[0] + '"></i>' + zlText(stand[2]) + "</div>"
+ (a.wenn ? '<div class="zl-karte-wenn">Wenn: ' + zlText(a.wenn) + "</div>" : "")
+ '<div class="zl-karte-knoepfe">'
+ '<button type="button" class="btn btn-sm btn-outline-secondary" data-tun="bearbeiten"><i class="bi bi-pencil-square me-1"></i>Bearbeiten</button>'
+ (a.enabled
? '<button type="button" class="btn btn-sm btn-outline-secondary" data-tun="umschalten"><i class="bi bi-pause-fill me-1"></i>Pausieren</button>'
: '<button type="button" class="btn btn-sm btn-success" data-tun="umschalten"><i class="bi bi-play-fill me-1"></i>Fortsetzen</button>')
+ '<button type="button" class="btn btn-sm zl-karte-weg" data-tun="loeschen" title="Löschen"><i class="bi bi-trash3"></i></button>'
+ "</div>";
karte.dataset.id = a.id;
ziel.appendChild(karte);
// Unter den Eintrag, sonst darüber; waagerecht in der Karte der Zeitleiste halten.
const hoch = ziel.getBoundingClientRect();
const r = anker.getBoundingClientRect();
const breit = karte.offsetWidth, hoehe = karte.offsetHeight;
let links = r.left - hoch.left;
links = Math.max(0, Math.min(links, ziel.clientWidth - breit));
let oben = r.bottom - hoch.top + 6;
if (r.bottom + hoehe + 12 > window.innerHeight && r.top - hoehe - 6 > 0) oben = r.top - hoch.top - hoehe - 6;
karte.style.left = links + "px";
karte.style.top = oben + "px";
zlKetteHervorheben(a.id, true);
karte.addEventListener("click", function (ereignis) {
const knopf = ereignis.target.closest("[data-tun]");
if (ereignis.target.closest(".zl-karte-zu")) { zlKarteSchliessen(); return; }
if (!knopf) return;
const tun = knopf.dataset.tun;
zlKarteSchliessen();
if (tun === "bearbeiten") openAutoActionModal("?action=editor&id=" + a.id);
if (tun === "umschalten") toggleAutomation(a.id);
if (tun === "loeschen") deleteAutomation(a.id, a.name);
});
}
function zlKarteSchliessen() {
document.querySelectorAll("#zeitleiste .zl-karte").forEach(k => {
zlKetteHervorheben(k.dataset.id, false);
k.remove();
});
}
/* --- Laden und Bedienen -------------------------------------------------- */
async function zeitleisteLaden() {
const ziel = document.getElementById("zeitleiste");
if (!ziel) return;
const datum = zeitleiste.datum || zlIso(new Date());
try {
const antwort = await fetch("./ajax/AutoAction.php?action=zeitleiste&datum=" + datum,
{ headers: { "Requested-With-Ajax": "ajax" }, cache: "no-store" });
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
zeitleiste.daten = d;
// Etagen, die es nicht mehr gibt, fallen aus dem Filter.
if (zeitleiste.etagen) {
const codes = new Set(d.etagen.map(e => e.code));
zeitleiste.etagen = new Set([...zeitleiste.etagen].filter(c => codes.has(c)));
if (!zeitleiste.etagen.size) zeitleiste.etagen = null;
}
zeitleisteZeichnen();
} catch (e) {
ziel.innerHTML = '<div class="alert alert-danger mb-0">Zeitleiste nicht abrufbar: ' + zlText(e.message) + "</div>";
}
}
function zlAnsicht(welche) {
const zl = document.getElementById("zeitleiste");
const liste = document.getElementById("automatikListe");
if (!zl || !liste) return;
zl.hidden = welche !== "zeitleiste";
liste.hidden = welche === "zeitleiste";
document.querySelectorAll(".zl-ansicht [data-ansicht]").forEach(k =>
k.classList.toggle("active", k.dataset.ansicht === welche));
zlSpeicher("automatik.ansicht", welche);
if (welche === "zeitleiste") {
if (zeitleiste.daten) zeitleisteZeichnen(); else zeitleisteLaden();
}
}
function zlEtagenMerken() {
zlSpeicher("automatik.etagen", zeitleiste.etagen ? [...zeitleiste.etagen].join(",") : "");
}
document.addEventListener("DOMContentLoaded", function () {
const ziel = document.getElementById("zeitleiste");
if (!ziel) return;
const gemerkt = zlSpeicher("automatik.etagen");
zeitleiste.etagen = gemerkt ? new Set(gemerkt.split(",").filter(Boolean)) : null;
if (zeitleiste.etagen && !zeitleiste.etagen.size) zeitleiste.etagen = null;
document.querySelectorAll(".zl-ansicht [data-ansicht]").forEach(k =>
k.addEventListener("click", () => zlAnsicht(k.dataset.ansicht)));
zlAnsicht(zlSpeicher("automatik.ansicht") === "liste" ? "liste" : "zeitleiste");
// Neue Automatik: in der ersten eingeblendeten Etage, sonst der Standard.
const neu = document.querySelector(".zl-neu");
if (neu) neu.addEventListener("click", function () {
let etage = typeof homeStandardEtage !== "undefined" ? homeStandardEtage : "";
if (zeitleiste.etagen && zeitleiste.daten) {
const erste = zeitleiste.daten.etagen.find(e => zeitleiste.etagen.has(e.code));
if (erste) etage = erste.code;
}
const liste = document.getElementById("automatikListe");
if (liste && !liste.hidden) {
const offen = document.querySelector("#actions-tabContent .tab-pane.active");
if (offen) etage = offen.id.replace(/^actions-/, "");
}
openAutoActionModal("?action=editor&floor=" + encodeURIComponent(etage));
});
ziel.addEventListener("click", function (ereignis) {
const t = ereignis.target;
if (t.closest(".zl-karte")) return; // die Karte bedient sich selbst
const ding = t.closest("[data-id]");
if (ding) {
const offen = ziel.querySelector(".zl-karte");
if (offen && offen.dataset.id === ding.dataset.id) { zlKarteSchliessen(); return; }
zlKarteOeffnen(ding, ding.dataset.id);
return;
}
zlKarteSchliessen();
const tag = t.closest("[data-tag]");
if (tag) {
const schritt = Number(tag.dataset.tag);
if (schritt === 0) {
zeitleiste.datum = null;
} else {
const d = new Date((zeitleiste.datum || zlIso(new Date())) + "T12:00:00");
d.setDate(d.getDate() + schritt);
zeitleiste.datum = zlIso(d) === zlIso(new Date()) ? null : zlIso(d);
}
zeitleisteLaden();
return;
}
const chip = t.closest("[data-etage]");
if (chip && zeitleiste.daten) {
const code = chip.dataset.etage;
if (!code) {
zeitleiste.etagen = null;
} else if (!zeitleiste.etagen) {
// Aus "alle" heraus blendet ein Klick genau diese Etage ein.
zeitleiste.etagen = new Set([code]);
} else {
zeitleiste.etagen.has(code) ? zeitleiste.etagen.delete(code) : zeitleiste.etagen.add(code);
if (!zeitleiste.etagen.size || zeitleiste.etagen.size === zeitleiste.daten.etagen.length) {
zeitleiste.etagen = null;
}
}
zlEtagenMerken();
zeitleisteZeichnen();
}
});
["mouseover", "mouseout"].forEach(typ => ziel.addEventListener(typ, function (ereignis) {
const ding = ereignis.target.closest(".zl-ding");
if (!ding || (ereignis.relatedTarget && ding.contains(ereignis.relatedTarget))) return;
const offen = ziel.querySelector(".zl-karte");
if (offen && offen.dataset.id === ding.dataset.id) return; // die offene Karte haelt ihre Kette hell
zlKetteHervorheben(ding.dataset.id, typ === "mouseover");
}));
// Ausserhalb der Zeitleiste geklickt oder Esc: Karte zu.
document.addEventListener("click", function (ereignis) {
if (!ziel.contains(ereignis.target)) zlKarteSchliessen();
});
document.addEventListener("keydown", function (ereignis) {
if (ereignis.key === "Escape") zlKarteSchliessen();
});
// Neu zeichnen, wenn sich die Breite ändert - Stapeln und Ketten hängen daran.
let warte = null;
if (typeof ResizeObserver !== "undefined") {
let letzteBreite = 0;
new ResizeObserver(() => {
if (Math.abs(ziel.clientWidth - letzteBreite) < 4) return;
letzteBreite = ziel.clientWidth;
clearTimeout(warte);
warte = setTimeout(zeitleisteZeichnen, 120);
}).observe(ziel);
}
// Heute rückt die Jetzt-Linie weiter, und neue Läufe kommen dazu.
zeitleiste.uhr = setInterval(() => {
if (!ziel.hidden && !zeitleiste.datum && !document.hidden) zeitleisteLaden();
}, 5 * 60 * 1000);
});