/* ==========================================================================
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 = '
'
+ 'gelaufen'
+ 'kommt noch'
+ 'Zeitfenster, wartet auf Bedingung'
+ 'Kette'
+ 'an diesem Tag nicht dran'
+ "
";
// 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" ? ""
: ' ' + (a.art === "fenster" ? '' + zlText(a.laeufe[a.laeufe.length - 1]) + "" : "");
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" ? '' : "";
const etikett = '" + nichtdran + ""
+ zlText(zlEtikettZeit(a)) + "" + zlText(a.name) + lauf + "";
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 += '';
});
return '
";
karte.dataset.id = a.id;
// Erst oben links in die Zeitleiste legen und dort messen. Ohne Lage stand
// die Karte beim Messen unter allen Bahnen, ragte aus main.app-main, und
// Chrome gab main eine zweite Bildlaufleiste, die danach stehen blieb.
karte.style.left = "0px";
karte.style.top = "0px";
karte.style.visibility = "hidden";
ziel.appendChild(karte);
// Unter den Eintrag, sonst darüber; waagerecht in der Karte der Zeitleiste halten.
// Unten begrenzt nicht nur das Fenster, sondern auch main.app-main: AdminLTE
// gibt ihm overflow:auto bei Inhaltshöhe. Ragte die Karte unten aus main,
// bekam main eine eigene, zweite Bildlaufleiste - auch wenn die Karte
// noch ganz im Fenster lag.
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));
const huelle = ziel.closest("main");
const unten = Math.min(window.innerHeight, huelle ? huelle.getBoundingClientRect().bottom : Infinity);
const obenFrei = Math.max(0, huelle ? huelle.getBoundingClientRect().top : 0);
const platzUnten = unten - r.bottom - 12, platzOben = r.top - obenFrei - 6;
let oben = r.bottom - hoch.top + 6;
if (hoehe > platzUnten && (hoehe <= platzOben || platzOben > platzUnten)) oben = r.top - hoch.top - hoehe - 6;
karte.style.left = links + "px";
karte.style.top = oben + "px";
karte.style.visibility = "";
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 = '
Zeitleiste nicht abrufbar: ' + zlText(e.message) + "
";
}
}
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);
});