Am Rechner liess die Karte die Bildlaufleiste erscheinen, die Zeitleiste wurde schmaler und der ResizeObserver zeichnete sie samt Karte neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
687 lines
32 KiB
JavaScript
687 lines
32 KiB
JavaScript
/* ==========================================================================
|
||
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, """);
|
||
}
|
||
|
||
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:00–18: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>";
|
||
|
||
// Eine offene Karte übersteht das Neuzeichnen. Sonst verschwand sie am
|
||
// Rechner gleich wieder: ragt sie unten aus der Seite, erscheint die
|
||
// Bildlaufleiste, die Zeitleiste wird schmaler, und der ResizeObserver
|
||
// zeichnet neu. Auf dem Handy liegt die Bildlaufleiste über der Seite.
|
||
const offeneKarte = ziel.querySelector(".zl-karte");
|
||
const offenId = offeneKarte ? offeneKarte.dataset.id : null;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
if (offenId !== null) {
|
||
const anker = ziel.querySelector('.zl-ding[data-id="' + offenId + '"]');
|
||
if (anker) zlKarteOeffnen(anker, offenId);
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
});
|