/* ========================================================================== 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; let links = px(von) - 8; let rechts = a.art === "fenster" ? Math.max(px(bis), px(von) + lang) : px(von) + lang; // Am rechten Rand steht das Etikett links vom Punkt bzw. endet am // Balkenende - sonst ragte es aus der Spur und der Rahmen bekaeme einen // Scrollbalken fuer ein paar Buchstaben. const nachLinks = px(von) + lang > breite - 4; if (nachLinks && a.art !== "fenster") { links = px(von) - lang; rechts = px(von) + 8; } if (nachLinks && a.art === "fenster") { links = Math.min(px(von), px(bis) - lang) - 8; rechts = px(bis); } return { a, links, rechts, nachLinks, 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 = '
' + '
' + '' + '" + '' + "
" + '
' + '' + d.etagen.map(e => { const an = alleEtagen || zeitleiste.etagen.has(e.code); const anzahl = d.automatiken.filter(a => a.floor === e.code).length; return '"; }).join("") + "
" + '
' + ' ' + sichtbar.length + " von " + d.automatiken.length + (pausiert.length ? ' · ' + pausiert.length + " pausiert" : "") + "
"; // --- 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 ? '
' + '
' : ""; const jetzt = d.jetzt !== null ? '
' : ""; html += '
'; // Stundenachse mit Sonnenmarken html += '
'; for (let h = 0; h <= 24; h += 3) { const rand = h === 0 ? " anfang" : h === 24 ? " ende" : ""; html += '' + h + ""; } if (d.sonne.auf !== null) { const geschaetzt = d.sonne.genau ? "" : " (Vorjahr)"; html += '' + ''; } if (d.jetzt !== null) html += '' + zlUhr(d.jetzt) + ""; html += "
"; // Bahnen const bahnen = Object.keys(d.bahnen).filter(b => gelegt.some(a => a.bahn === b)); if (!bahnen.length) { html += '
Keine Automatik mit einer Zeit auf diesem Tag' + (zeitleiste.etagen ? " in den gewählten Etagen" : "") + ".
"; } bahnen.forEach(b => { const eintraege = gelegt.filter(a => a.bahn === b); const stapel = zlStapeln(eintraege, breite); const hoehe = stapel.zeilen * ZL_ZEILE + 8; html += '
' + '
' + zlText(d.bahnen[b].titel) + "" + '' + eintraege.length + "
" + '
' + nacht + jetzt; stapel.liste.forEach(e => { html += zlEintrag(e, d, prozent); }); html += "
"; }); html += '
'; // --- Bänder ohne Lage auf dem Tag if (jederzeit.length || pausiert.length) { html += '
'; if (jederzeit.length) { html += '
Jederzeit' + '
' + jederzeit.map(a => zlChip(a, d.bahnen[a.bahn] ? d.bahnen[a.bahn].symbol : "bi-dot")).join("") + "
"; } if (pausiert.length) { html += '
Pausiert' + '
' + pausiert.map(a => zlChip(a, "bi-pause-fill")).join("") + "
"; } html += "
"; } html += '
' + 'gelaufen' + 'kommt noch' + 'Zeitfenster, wartet auf Bedingung' + 'Kette' + 'an diesem Tag nicht dran' + "
"; 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" ? "" : ' ' + (a.art === "fenster" ? '' + zlText(a.laeufe[a.laeufe.length - 1]) + "" : ""); const etikett = '' + zlText(zlEtikettZeit(a)) + "" + zlText(a.name) + lauf + ""; if (a.art === "fenster") { // Läufe und die früheste Marke als Punkte auf dem Balken. let marken = ""; if (a.punkt !== null && a.punkt > a.von && !a.laeufe.length) { marken += ''; } a.laeufe.forEach(t => { const m = zlMinuteAus(t); marken += ''; }); return '"; } const m = a.laeufe.length ? zlMinuteAus(a.laeufe[a.laeufe.length - 1]) : a.punkt; return '"; } function zlChip(a, symbol) { return '"; } /** * 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); /* * Ankerpunkt eines Eintrags: beim Punkt die Marke, beim Fenster die * Laufmarke, sonst der Balkenanfang. Die Linie beginnt am Auslöser rechts * vom Anker und endet am Nachfolger links davon - so kommt sie an und * läuft nicht quer durch den Kreis. */ const anker = el => { const marke = el.querySelector(".zl-marke-innen.gelaufen") || el.querySelector(".zl-marke:not(.zl-marke-innen)"); const r = (marke || el.querySelector(".zl-balken") || el).getBoundingClientRect(); return marke ? { x: r.left + r.width / 2 - basis.left, y: r.top + r.height / 2 - basis.top, r: r.width / 2 } : { x: r.left - basis.left, y: r.top + r.height / 2 - basis.top, r: 0 }; }; 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 v = anker(vater), k = anker(kind); const x1 = v.x + (v.r ? v.r * 0.7 : 0), y1 = v.y + (v.r ? v.r * 0.7 : 0); const x2 = k.x - (k.r ? k.r + 1 : 0), y2 = k.y; // Erst senkrecht nach unten, dann waagerecht hinein: so trifft der Bogen // den Nachfolger von links, auch wenn der fast senkrecht darunter liegt. const d = "M" + x1 + " " + y1 + " C" + x1 + " " + (y1 + (y2 - y1) * 0.8) + " " + (x2 - Math.max(14, (x2 - x1) * 0.5)) + " " + y2 + " " + x2 + " " + y2; const paar = ' data-von="' + kind.dataset.kette + '" data-nach="' + kind.dataset.id + '"'; pfade += '' + '' + ''; }); 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)); } /* --- 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; const ding = t.closest("[data-id]"); if (ding) { openAutoActionModal("?action=editor&id=" + ding.dataset.id); return; } 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; zlKetteHervorheben(ding.dataset.id, typ === "mouseover"); })); // 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); });