Automatismen als Zeitleiste des Tages, Liste bleibt umschaltbar

Die Uebersicht auf der Startseite zeigt die Automatiken jetzt als
Zeitleiste: eine Bahn je Geraeteart, die Spur ist der Tag. Der Umschalter
"Zeitleiste | Liste" oben in der Karte fuehrt zur bisherigen Tabelle
zurueck und wird im Browser gemerkt.

Jede Automatik erscheint genau einmal, nach festen Regeln
(restricted/zeitleiste.php), auch kuenftige:
- feste Uhrzeit oder Sonnenstand: Punkt; Zeitfenster mit Messwert: Balken;
  Kette: hinter ihrem Ausloeser, als gestrichelter Bogen verbunden
- ohne jede Zeit: Band "Jederzeit"; pausiert: Band "Pausiert"
- an diesem Tag nicht dran (Wochentag, Ferien, Feiertag, Vorabend, wie im
  Runner gerechnet): gestrichelt mit Grund, nie ausgeblendet
- Bahn aus den geschalteten Geraeten ueber bedienform(), Mehrheit gewinnt;
  Bewaesserung am Geraetetyp
- Zaehler "x von y" im Kopf

Dazu: Tag vor/zurueck, Etagen einzeln einblendbar (gemerkt), Jetzt-Linie
und Nachtschatten aus solarLog.daylight (spaetere Tage vom selben
Kalendertag eines Vorjahres), gelaufene Eintraege mit Uhrzeit aus
automation_log, Klick oeffnet den Editor. Schmal nur Symbole in den Bahnen
und Start bei der aktuellen Uhrzeit.

Auf einer Probeseite mit echten Daten geprueft: nichts ragt ueber, keine
ueberlappenden Etiketten, alle 22 Automatiken genau einmal, Filter,
Tageswechsel und Umschalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:12:15 +02:00
co-authored by Claude Opus 5
parent 8d626abc6d
commit 93c34bf069
8 changed files with 1297 additions and 11 deletions
+17 -2
View File
@@ -51,6 +51,7 @@ smart/
│ ├── skoda.php weather.php settings.php logs.php ← die acht Seitenvorlagen
│ ├── rooms.php Etagen und Räume lesen (homeMesh), Kachelvorgabe, Symbole
│ ├── grundriss.php Etagen, Räume, Kachelpositionen, Bilder ändern (Reiter Grundriss)
│ ├── zeitleiste.php Automatiken eines Tages: Lage, Bahn, ob dran, Läufe
│ ├── meshdb.php meshDb(): Verbindung zu homeMesh
│ ├── costs.php Preistabellen + solarDb()
│ ├── automations.php Automatiken, Gerätekatalog
@@ -182,7 +183,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|---|---|---|---|---|
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
@@ -324,6 +325,19 @@ Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
vor dem Löschen (`frageNachfolger`).
**`zeitleiste.js`** — die Automatismen als Zeitleiste eines Tages, neben der
bisherigen Liste (Umschalter oben in der Karte, gemerkt im Browser). Eine Bahn
je Geräteart, abgeleitet aus den geschalteten Geräten über `bedienform()` — die
Mehrheit gewinnt, Bewässerung wird am Gerätetyp erkannt. Die Lage rechnet
`restricted/zeitleiste.php` nach festen Regeln: feste Uhrzeit und Sonnenstand
als Punkt, Zeitfenster mit Messwert als Balken, Ketten hinter ihrem Auslöser,
ohne jede Zeit unter „Jederzeit“, pausierte unter „Pausiert“. Ob eine
Automatik an einem Tag dran ist, wird wie im Runner gerechnet (Wochentage,
Ferien, Feiertage, Vorabend); nicht dran heißt gestrichelt, nie ausgeblendet.
Der Zähler „x von y“ macht sichtbar, dass keine fehlt. Etagen lassen sich
einzeln einblenden. Sonnenzeiten kommen aus `solarLog.daylight`, für spätere
Tage vom selben Kalendertag eines Vorjahres.
**`grundriss.js`** — der Reiter Grundriss: Etagen, Räume, Grundrissbilder und
der Plan-Editor, in dem Kacheln gezogen und gesetzt werden. Eigene Datei, weil
`settings.js` schon für vier Reiter reicht; es nutzt `textSchuetzen()` von dort.
@@ -346,7 +360,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
| Endpunkt | Aufruf | liefert / tut |
|---|---|---|
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
| `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) |
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
| `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) |
@@ -371,6 +385,7 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
| Datei | Zuständig für | Wichtige Funktionen |
|---|---|---|
| `rooms.php` | Etagen und Räume lesen (`homeMesh`), Kachelvorgabe, Symbole | `grundriss()`, `allRooms()`, `roomsOnFloor()`, `floorsWithPlan()`, `standardEtage()`, `etageBild()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `bootstrapIcons()`, `kachelIcon()` |
| `zeitleiste.php` | Automatiken eines Tages für die Zeitleiste | `zeitleiste()`, `zlTagPasst()`, `zlSonne()`, `zlBahn()` |
| `grundriss.php` | Etagen, Räume, Kachelpositionen und Grundrissbilder ändern | `grundrissStand()`, `etageSpeichern()`, `etagenReihenfolge()`, `etageLoeschen()`, `etageBildSpeichern()`, `raumSpeichern()`, `raumPosition()`, `raumLoeschen()` |
| `meshdb.php` | Verbindung zu `homeMesh` | `meshDb()` |
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
+11
View File
@@ -83,6 +83,17 @@ if ($action === "werte") {
jsonAntwort(["werte" => stateValues($ids)]);
}
// --- Zeitleiste ----------------------------------------------------------
// Alle Automatiken eines Tages mit ihrer Lage, siehe restricted/zeitleiste.php.
if ($action === "zeitleiste") {
require_once("../restricted/zeitleiste.php");
try {
jsonAntwort(zeitleiste(strval($_GET["datum"] ?? date("Y-m-d"))));
} catch (Throwable $e) {
jsonAntwort(["error" => "Zeitleiste nicht abrufbar: " . $e->getMessage()], 500);
}
}
// --- Uebersichtstabelle --------------------------------------------------
if ($action === "list") {
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
+346
View File
@@ -2739,3 +2739,349 @@ input[type='range']::-webkit-slider-runnable-track {
}
.gr-raum-stand { color: var(--ton-schrift-3, #6f7a81); font-size: .8rem; }
.gr-raum .btn.gr-platzieren { padding: 0 .45rem; font-size: .75rem; }
/* ===========================================================================
Automatismen als Zeitleiste (js/solar/zeitleiste.js)
Eine Bahn je Geraeteart, die Spur ist der Tag von 0 bis 24 Uhr. Die Nacht
liegt als leichter Schatten dahinter, jetzt ist eine duenne Linie in der
Sonnenfarbe. Jede Bahn traegt ihre Akzentfarbe als --zl-farbe; Punkte,
Balken und Etiketten nehmen sie auf.
=========================================================================== */
.zl {
--zl-linie: var(--ton-linie, rgba(138, 148, 158, .2));
--zl-farbe: #9aa6ad;
--zl-farbe-rgb: 154, 166, 173;
}
.zl-bahn-licht { --zl-farbe: #f0b429; --zl-farbe-rgb: 240, 180, 41; }
.zl-bahn-beschattung { --zl-farbe: #5fb0dc; --zl-farbe-rgb: 95, 176, 220; }
.zl-bahn-heizung { --zl-farbe: #e8836a; --zl-farbe-rgb: 232, 131, 106; }
.zl-bahn-bewaesserung { --zl-farbe: #4cc18a; --zl-farbe-rgb: 76, 193, 138; }
.zl-bahn-schalter { --zl-farbe: #b59cf0; --zl-farbe-rgb: 181, 156, 240; }
/* Kopf */
.zl-kopf {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .6rem 1rem;
margin-bottom: .9rem;
}
.zl-tag .btn.zl-tag-name { min-width: 9.5rem; font-variant-numeric: tabular-nums; }
.zl-etagen { display: flex; flex-wrap: wrap; gap: .3rem; }
.zl-chip {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .2rem .65rem;
border: 1px solid var(--zl-linie);
border-radius: 999px;
background: transparent;
color: var(--ton-schrift-2, #9aa6ad);
font: 500 .8rem var(--font-anzeige, inherit);
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.zl-chip:hover { border-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); color: var(--ton-schrift, #e8ede9); }
.zl-chip.an {
background: rgba(0, 120, 143, .22);
border-color: #00788F;
color: var(--ton-schrift, #e8ede9);
}
.zl-chip.leer { opacity: .55; }
.zl-chip-zahl {
font-size: .68rem;
padding: 0 .35rem;
border-radius: 999px;
background: rgba(138, 148, 158, .18);
color: var(--ton-schrift-2, #9aa6ad);
}
.zl-zahl {
margin-left: auto;
font-size: .8rem;
color: var(--ton-schrift-2, #9aa6ad);
white-space: nowrap;
}
.zl-zahl-leise { color: var(--ton-schrift-3, #6f7a81); }
/* Raster */
.zl-rahmen {
overflow-x: auto;
border-radius: var(--ton-radius-klein, .5rem);
}
.zl-raster { position: relative; }
.zl-zeile {
display: grid;
grid-template-columns: var(--zl-kopf) minmax(0, 1fr);
}
.zl-bahn .zl-spur { border-top: 1px solid var(--zl-linie); }
.zl-bahn-kopf {
position: sticky;
left: 0;
z-index: 3;
display: flex;
align-items: flex-start;
gap: .45rem;
padding: .6rem .6rem 0 0;
background: var(--ton-flaeche, #23282c);
font: 500 .82rem var(--font-anzeige, inherit);
color: var(--ton-schrift, #e8ede9);
}
.zl-bahn-kopf .bi { color: var(--zl-farbe); font-size: .95rem; line-height: 1.1; }
/* Schmal nur das Symbol; der Name steht im Hinweis beim Antippen der Eintraege. */
.zl-rahmen.schmal .zl-bahn-kopf { justify-content: center; padding-right: 0; }
.zl-rahmen.schmal .zl-bahn-kopf span { display: none; }
.zl-bahn-zahl {
margin-left: auto;
font-size: .68rem;
color: var(--ton-schrift-3, #6f7a81);
}
.zl-spur {
position: relative;
min-height: 38px;
/* Alle drei Stunden eine Linie, jede volle Stunde ein Hauch. */
background-image:
linear-gradient(to right, rgba(138, 148, 158, .16) 1px, transparent 1px),
linear-gradient(to right, rgba(138, 148, 158, .06) 1px, transparent 1px);
background-size: 12.5% 100%, 4.1667% 100%;
}
.zl-nacht {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, .24);
pointer-events: none;
}
.zl-jetzt {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 1.5px solid var(--ton-sonne, #f0b429);
opacity: .8;
z-index: 2;
pointer-events: none;
}
/* Achse */
.zl-achse .zl-bahn-kopf { padding-top: 0; }
.zl-achse .zl-spur { height: 26px; min-height: 0; background: none; }
.zl-stunde {
position: absolute;
bottom: 4px;
transform: translateX(-50%);
font-size: .7rem;
color: var(--ton-schrift-3, #6f7a81);
font-variant-numeric: tabular-nums;
}
.zl-stunde.anfang { transform: none; }
.zl-stunde.ende { transform: translateX(-100%); }
.zl-sonne {
position: absolute;
top: -2px;
transform: translateX(-50%);
font-size: .78rem;
color: var(--ton-sonne, #f0b429);
opacity: .75;
cursor: help;
}
.zl-jetzt-marke {
position: absolute;
top: 1px;
transform: translateX(-50%);
padding: 0 .35rem;
border-radius: 999px;
background: var(--ton-sonne, #f0b429);
color: #1b1f22;
font: 600 .66rem var(--font-anzeige, inherit);
z-index: 2;
}
/* Eintraege */
.zl-ding {
position: absolute;
height: 24px;
display: flex;
align-items: center;
cursor: pointer;
z-index: 1;
}
.zl-ding:hover { z-index: 4; }
.zl-marke {
flex: none;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--zl-farbe);
background: var(--ton-flaeche, #23282c);
box-sizing: border-box;
}
.zl-punkt { transform: translateX(-6px); }
.zl-punkt.links { flex-direction: row-reverse; transform: translateX(calc(-100% + 6px)); }
.zl-etikett {
margin-left: 6px;
padding: .1rem .5rem;
border-radius: 6px;
border: 1px solid transparent;
background: rgba(var(--zl-farbe-rgb), .1);
color: var(--ton-schrift, #e8ede9);
font-size: .75rem;
line-height: 1.35;
white-space: nowrap;
transition: background .15s, border-color .15s;
}
.zl-punkt.links .zl-etikett { margin-left: 0; margin-right: 6px; }
.zl-etikett b {
font-weight: 600;
margin-right: .4rem;
color: var(--zl-farbe);
font-variant-numeric: tabular-nums;
}
.zl-etikett .bi-check2 { color: var(--ton-gut, #4caf7d); }
.zl-ding:hover .zl-etikett {
background: rgba(var(--zl-farbe-rgb), .2);
border-color: rgba(var(--zl-farbe-rgb), .45);
}
/* Zustaende */
.zl-ding.gelaufen .zl-marke { background: var(--zl-farbe); }
.zl-ding.vorbei { opacity: .5; }
.zl-ding.vorbei .zl-etikett b { color: var(--ton-schrift-2, #9aa6ad); }
.zl-ding.aktiv .zl-marke { box-shadow: 0 0 0 3px rgba(var(--zl-farbe-rgb), .25); }
.zl-ding.nichtdran .zl-marke { border-style: dashed; border-color: var(--ton-schrift-3, #6f7a81); background: transparent; }
.zl-ding.nichtdran .zl-etikett {
background: transparent;
border: 1px dashed var(--zl-linie);
color: var(--ton-schrift-3, #6f7a81);
}
.zl-ding.nichtdran .zl-etikett b { color: var(--ton-schrift-3, #6f7a81); }
/* Zeitfenster: der Balken traegt Laeufe und die frueheste Marke. */
.zl-fenster { min-width: 10px; }
.zl-balken {
position: absolute;
left: 0;
right: 0;
top: 8px;
height: 8px;
border-radius: 4px;
background: rgba(var(--zl-farbe-rgb), .22);
border: 1px solid rgba(var(--zl-farbe-rgb), .4);
}
.zl-fenster .zl-etikett {
position: relative;
margin-left: 0;
background: var(--ton-flaeche, #23282c);
border-color: rgba(var(--zl-farbe-rgb), .3);
}
/* Am rechten Rand endet das Etikett am Balkenende statt dahinter. */
.zl-fenster.links { justify-content: flex-end; }
.zl-lauf {
margin-left: .25rem;
color: var(--ton-gut, #4caf7d);
font-variant-numeric: tabular-nums;
}
.zl-fenster.nichtdran .zl-balken { background: transparent; border-style: dashed; border-color: var(--zl-linie); }
.zl-marke-innen {
position: absolute;
top: 50%;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
}
/* Ketten */
.zl-ketten {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
z-index: 0;
overflow: visible;
}
.zl-ketten path {
fill: none;
stroke: rgba(154, 166, 173, .55);
stroke-width: 1.2;
stroke-dasharray: 3 3;
}
.zl-leer {
padding: 1.2rem 0 .6rem 132px;
color: var(--ton-schrift-3, #6f7a81);
font-size: .85rem;
}
/* Baender */
.zl-baender {
display: flex;
flex-direction: column;
gap: .45rem;
margin-top: .8rem;
padding-top: .7rem;
border-top: 1px solid var(--zl-linie);
}
.zl-band {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
align-items: start;
}
.zl-band-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
@media (max-width: 575.98px) {
.zl-band { grid-template-columns: minmax(0, 1fr); gap: .3rem; }
.zl-zahl { margin-left: 0; }
}
.zl-band-titel {
display: inline-flex;
align-items: center;
gap: .4rem;
padding-top: .2rem;
font: 500 .82rem var(--font-anzeige, inherit);
color: var(--ton-schrift-2, #9aa6ad);
}
.zl-band-chip {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .2rem .6rem;
border-radius: 6px;
border: 1px solid var(--zl-linie);
background: rgba(138, 148, 158, .07);
color: var(--ton-schrift, #e8ede9);
font-size: .75rem;
cursor: pointer;
}
.zl-band-chip:hover { border-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); }
.zl-band-pause .zl-band-chip { color: var(--ton-schrift-2, #9aa6ad); }
.zl-band-chip.nichtdran { border-style: dashed; }
.zl-band-etage {
font-size: .64rem;
padding: 0 .3rem;
border-radius: 4px;
background: rgba(138, 148, 158, .16);
color: var(--ton-schrift-3, #6f7a81);
}
/* Legende */
.zl-legende {
display: flex;
flex-wrap: wrap;
gap: .4rem 1.1rem;
margin-top: .8rem;
font-size: .72rem;
color: var(--ton-schrift-3, #6f7a81);
}
.zl-legende span { display: inline-flex; align-items: center; gap: .4rem; }
.zl-legende i { display: inline-block; }
.zl-l-punkt { width: 10px; height: 10px; border-radius: 50%; border: 2px solid #5fb0dc; }
.zl-l-punkt.gelaufen { background: #5fb0dc; }
.zl-l-punkt.nichtdran { border-style: dashed; border-color: var(--ton-schrift-3, #6f7a81); }
.zl-l-balken { width: 22px; height: 7px; border-radius: 4px; background: rgba(95, 176, 220, .22); border: 1px solid rgba(95, 176, 220, .4); }
.zl-l-kette { width: 22px; border-top: 1.2px dashed rgba(154, 166, 173, .7); }
/* Ansicht umschalten: eine Klasse mehr als Bootstrap (solar.css laedt vorher). */
.zl-ansicht .btn.active {
background: rgba(0, 120, 143, .25);
border-color: #00788F;
color: var(--ton-schrift, #e8ede9);
}
+1 -1
View File
@@ -102,7 +102,7 @@ if (isset($_GET["addUser"])) {
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]],
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]],
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/heatMQTT.js"]],
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
+3
View File
@@ -1396,6 +1396,9 @@ function automationRequest(action, body) {
* ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung.
*/
function refreshAutomations(floor) {
// Die Zeitleiste zeigt dieselben Automatiken - nach Speichern, Pausieren
// oder Loeschen muss sie genauso nachziehen wie die Tabelle.
if (typeof zeitleisteLaden === "function") zeitleisteLaden();
const etagen = floor ? [floor]
: Array.from(document.querySelectorAll("#actions-tabContent .tab-pane"))
.map(pane => pane.id.replace(/^actions-/, ""));
+484
View File
@@ -0,0 +1,484 @@
/* ==========================================================================
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 liste = 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);
const zeilen = [];
liste.forEach(e => {
let z = zeilen.findIndex(ende => ende + 6 <= e.links);
if (z < 0) { z = zeilen.length; zeilen.push(0); }
zeilen[z] = e.rechts;
e.zeile = z;
});
return { liste, 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="zl-l-punkt 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 etikett = '<span class="zl-etikett"><b>' + zlText(zlEtikettZeit(a)) + "</b>" + zlText(a.name) + lauf + "</span>";
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 += '<span class="zl-marke zl-marke-innen" style="left:' + ((a.punkt - a.von) / Math.max(1, a.bis - a.von) * 100).toFixed(2) + '%"></span>';
}
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 + (e.nachLinks ? " links" : "") + '" data-id="' + a.id + '" 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);
let pfade = "";
raster.querySelectorAll(".zl-punkt[data-kette]").forEach(kind => {
const vater = raster.querySelector('.zl-ding[data-id="' + kind.dataset.kette + '"]');
if (!vater) return;
const mv = (vater.querySelector(".zl-marke") || vater).getBoundingClientRect();
const mk = kind.querySelector(".zl-marke").getBoundingClientRect();
const x1 = mv.left + mv.width / 2 - basis.left, y1 = mv.top + mv.height / 2 - basis.top;
const x2 = mk.left + mk.width / 2 - basis.left, y2 = mk.top + mk.height / 2 - basis.top;
const bogen = Math.max(18, Math.abs(y2 - y1) * 0.5);
pfade += '<path d="M' + x1 + " " + y1 + " C" + (x1 + bogen) + " " + y1 + " " + (x2 - bogen) + " " + y2 + " " + x2 + " " + y2 + '"/>';
});
svg.innerHTML = pfade;
}
/* --- 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;
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();
}
});
// 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);
});
+16 -8
View File
@@ -203,16 +203,23 @@ require_once(__DIR__ . "/reiter.php");
</div>
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
<div class="card p-0 mb-4" style="width:100%">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Automatismen</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i>
</button>
<div class="card-header pb-1 pt-1 d-flex align-items-center gap-2 flex-wrap">
<h3 class="card-title me-auto">Automatismen</h3>
<!-- Zwei Ansichten derselben Automatiken. Die Zeitleiste zeigt den
Tag (js/solar/zeitleiste.js), die Liste ist die bisherige
Tabelle je Etage. Welche offen ist, merkt sich der Browser. -->
<div class="btn-group btn-group-sm zl-ansicht" role="group" aria-label="Ansicht">
<button type="button" class="btn btn-outline-secondary" data-ansicht="zeitleiste">
<i class="bi bi-bar-chart-steps me-1"></i>Zeitleiste</button>
<button type="button" class="btn btn-outline-secondary" data-ansicht="liste">
<i class="bi bi-list-ul me-1"></i>Liste</button>
</div>
<button type="button" class="btn btn-sm btn-success zl-neu" title="Neue Automatik">
<i class="bi bi-plus-lg"></i></button>
</div>
<div class="card-body pb-1">
<div class="card-body pb-2">
<div id="zeitleiste" class="zl" hidden></div>
<div id="automatikListe">
<?php
/*
* Eine Etage, ein Reiter. Gezeichnet von reiterLeiste() aus
@@ -244,6 +251,7 @@ require_once(__DIR__ . "/reiter.php");
<?= reiterFlaecheZu() ?>
<?php endforeach; ?>
</div>
</div><!-- /automatikListe -->
</div>
</div>
</div>
+419
View File
@@ -0,0 +1,419 @@
<?php
/**
* Die Automatiken eines Tages als Zeitleiste.
*
* Die Übersicht als Tabelle beantwortete nicht, was man eigentlich wissen
* will: läuft der Tag stimmig, und warum ist etwas noch nicht gefahren? Die
* Zeitleiste legt deshalb jede Automatik auf die Stunde, zu der sie greift -
* und zwar ausnahmslos jede, auch künftige, nach festen Regeln:
*
* feste Uhrzeit (um / ab 07:00) Punkt
* Sonnenauf-/-untergang ± Versatz Punkt, an diesem Tag ausgerechnet
* Kette (andere Automatik + 00:10) Punkt hinter dem Auslöser
* Zeitfenster und Messwerte Balken über das Fenster
* gar keine Zeit, nur Messwerte "jederzeit"
*
* Die Bahn - die Zeile, in der sie steht - ergibt sich aus den Geräten, die
* sie schaltet: dieselbe Einordnung wie im Raum-Modal (bedienform() in
* roomControls.php), die Mehrheit gewinnt. Bewässerung ist dort "sonstiges"
* und wird hier am Gerätetyp erkannt.
*
* Ob eine Automatik an diesem Tag überhaupt dran ist, wird genau so
* gerechnet wie im Runner (tag_passt und gemeinter_tag in
* autoaction_runner.py) - sonst hieße "heute nicht" hier etwas anderes als
* dort. Ausgeblendet wird trotzdem nichts: wer nicht dran ist, steht
* gestrichelt da, mit Grund.
*
* Gerechnet wird nur die Lage auf dem Tag, nicht, ob eine Bedingung gerade
* erfüllt ist. Das entscheidet der Runner; hier steht, ob sie gelaufen ist
* (automation_log) und wann sie frühestens könnte.
*/
require_once(__DIR__ . "/automations.php");
require_once(__DIR__ . "/roomControls.php");
require_once(__DIR__ . "/costs.php"); // solarDb() für die Sonnenzeiten
/** Reihenfolge der Bahnen und wie sie heißen. */
function zeitleisteBahnen()
{
return [
"licht" => ["titel" => "Licht", "symbol" => "bi-lightbulb"],
"beschattung" => ["titel" => "Beschattung", "symbol" => "bi-window-split"],
"heizung" => ["titel" => "Heizung", "symbol" => "bi-thermometer-half"],
"bewaesserung" => ["titel" => "Bewässerung", "symbol" => "bi-droplet"],
"schalter" => ["titel" => "Schalter", "symbol" => "bi-toggle-on"],
"sonstiges" => ["titel" => "Sonstiges", "symbol" => "bi-three-dots"],
];
}
/** "07:30" -> 450, "-00:30" wird von aufrufender Seite behandelt. */
function zlMinuten($text)
{
if (!preg_match('/^(\d{1,2}):(\d{2})/', trim(strval($text)), $t)) {
return null;
}
return intval($t[1]) * 60 + intval($t[2]);
}
/** 450 -> "07:30" */
function zlUhrzeit($m)
{
$m = (($m % 1440) + 1440) % 1440;
return sprintf("%02d:%02d", intdiv($m, 60), $m % 60);
}
/** Ferien und Feiertag an einem Datum aus calendar_days. */
function zlKalender($datum)
{
static $cache = [];
if (isset($cache[$datum])) {
return $cache[$datum];
}
$stand = ["feiertag" => null, "ferien" => null];
$stmt = meshDb()->prepare("SELECT holiday, vacation FROM calendar_days WHERE date = ?");
$stmt->bind_param("s", $datum);
$stmt->execute();
$z = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($z) {
$stand = ["feiertag" => $z["holiday"], "ferien" => $z["vacation"]];
}
return $cache[$datum] = $stand;
}
/**
* Ist die Automatik an diesem Datum dran? Gibt [ja/nein, Grund] zurück.
* Dieselbe Reihenfolge wie tag_passt(): erst öffnen, dann sperren.
*/
function zlTagPasst($a, $datum)
{
$tag = new DateTimeImmutable($datum);
if (!empty($a["next_day"])) {
$tag = $tag->modify("+1 day");
}
$kal = zlKalender($tag->format("Y-m-d"));
$bit = 1 << (intval($tag->format("N")) - 1); // Montag = Bit 0
$vorabend = !empty($a["next_day"]) ? "morgen " : "";
$passt = (bool)($a["weekdays"] & $bit);
if ($kal["feiertag"] && $a["on_holiday"] == 2) $passt = true;
if ($kal["ferien"] && $a["on_vacation"] == 2) $passt = true;
if (!$passt) {
$namen = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
$tage = [];
for ($i = 0; $i < 7; $i++) {
if ($a["weekdays"] & (1 << $i)) $tage[] = $namen[$i];
}
$extra = [];
if ($a["on_holiday"] == 2) $extra[] = "Feiertage";
if ($a["on_vacation"] == 2) $extra[] = "Ferien";
$liste = implode(", ", array_merge($tage, $extra));
return [false, ($vorabend ? "nur am Vorabend von " : "nur ") . ($liste ?: "an keinem Tag")];
}
if ($kal["feiertag"] && $a["on_holiday"] == 0) {
return [false, $vorabend . "Feiertag (" . $kal["feiertag"] . ")"];
}
if ($kal["ferien"] && $a["on_vacation"] == 0) {
return [false, $vorabend . "Ferien (" . $kal["ferien"] . ")"];
}
return [true, ""];
}
/**
* Sonnenauf- und -untergang eines Datums in Minuten, wie im Runner.
*
* solarLog.daylight kennt nur heute, morgen und die Vergangenheit. Für einen
* späteren Tag gilt derselbe Kalendertag eines Vorjahres - die Sonne
* verschiebt sich von Jahr zu Jahr um weniger als eine Minute, und so braucht
* es keinen Standort, der in einem anderen Haus wieder falsch wäre. "genau"
* sagt, ob es der Eintrag des Tages selbst war.
*/
function zlSonne($datum)
{
try {
$db = solarDb();
$stmt = $db->prepare("SELECT sunrise, sunset, date = ? AS genau FROM daylight
WHERE date = ? OR DATE_FORMAT(date, '%m-%d') = DATE_FORMAT(?, '%m-%d')
ORDER BY genau DESC, date DESC LIMIT 1");
$stmt->bind_param("sss", $datum, $datum, $datum);
$stmt->execute();
$z = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($z) {
return ["auf" => zlMinuten($z["sunrise"]), "unter" => zlMinuten($z["sunset"]),
"genau" => (bool)$z["genau"]];
}
} catch (Throwable $e) {
// ohne Sonnenzeiten eben ohne - die betroffenen stehen dann unter "jederzeit"
}
return ["auf" => null, "unter" => null, "genau" => false];
}
/**
* Die Bahn einer Automatik: welche Art Gerät sie überwiegend schaltet.
* $geraete ist command_id => Gerät aus deviceCatalog().
*/
function zlBahn($aktionen, $geraete)
{
$zaehler = [];
foreach ($aktionen as $akt) {
$d = $geraete[$akt["command_id"]] ?? null;
if (!$d) {
continue;
}
$form = bedienform($d);
if (stripos($d["type"], "bewässer") !== false || stripos($d["type"], "bewaesser") !== false
|| stripos($d["url"], "Gartenwasser") !== false) {
$form = "bewaesserung";
}
if (!isset(zeitleisteBahnen()[$form])) {
$form = "sonstiges";
}
$zaehler[$form] = ($zaehler[$form] ?? 0) + 1;
}
if (!$zaehler) {
return "sonstiges";
}
// Bei Gleichstand gewinnt die Bahn, die weiter oben steht.
$reihe = array_flip(array_keys(zeitleisteBahnen()));
uksort($zaehler, function ($x, $y) use ($zaehler, $reihe) {
return [$zaehler[$y], $reihe[$x]] <=> [$zaehler[$x], $reihe[$y]];
});
return array_key_first($zaehler);
}
/** Was die Automatik tut, kurz: "Wozi Fensterfront, Küche Tür +5: Auf". */
function zlSatz($aktionen, $geraete, $kommandos)
{
$namen = [];
$was = [];
foreach ($aktionen as $akt) {
$d = $geraete[$akt["command_id"]] ?? null;
$namen[] = $d ? $d["label"] : "?";
$voll = $kommandos[$akt["command_id"]] ?? "";
$was[] = strpos($voll, ": ") !== false ? substr($voll, strpos($voll, ": ") + 2) : $voll;
}
$namen = array_values(array_unique($namen));
$wer = implode(", ", array_slice($namen, 0, 2)) . (count($namen) > 2 ? " +" . (count($namen) - 2) : "");
$was = array_values(array_unique($was));
return $wer . ": " . (count($was) === 1 ? $was[0] : implode(" / ", array_slice($was, 0, 2)));
}
/**
* Die Zeitleiste eines Datums (YYYY-MM-DD).
*/
function zeitleiste($datum)
{
$db = meshDb();
$d = DateTimeImmutable::createFromFormat("Y-m-d", $datum) ?: new DateTimeImmutable("today");
$datum = $d->format("Y-m-d");
$sonne = zlSonne($datum);
// Geräte je Kommando, einmal für alle.
$geraete = [];
foreach (deviceCatalog() as $g) {
foreach ($g["commands"] as $c) {
$geraete[$c["id"]] = $g;
}
}
$kommandos = commandNames();
$namesState = stateNames();
// Messwerte, die eine Zeit tragen: Uhrzeit/Datum (Logic), Sonnenstand,
// Automatik-Auslöser. Alles andere ist ein Messwert, auf den gewartet wird.
$zeitStates = [];
$res = $db->query("SELECT s.id, s.url, a.url AS actor_url, t.type
FROM actor_states s JOIN actors a ON a.id = s.actor_id
LEFT JOIN state_types t ON t.id = s.state_type
WHERE a.url IN ('Logic', 'Automatik')");
while ($z = $res->fetch_assoc()) {
$zeitStates[intval($z["id"])] = $z;
}
// Läufe an diesem Tag.
$laeufe = [];
$stmt = $db->prepare("SELECT automation_id, ts, result FROM automation_log
WHERE ts >= ? AND ts < ? + INTERVAL 1 DAY AND result IN ('fired', 'forced')
ORDER BY ts");
$stmt->bind_param("ss", $datum, $datum);
$stmt->execute();
$r = $stmt->get_result();
while ($z = $r->fetch_assoc()) {
$laeufe[intval($z["automation_id"])][] = substr($z["ts"], 11, 5);
}
$stmt->close();
$ids = array_map("intval", array_column($db->query("SELECT id FROM automations")->fetch_all(MYSQLI_ASSOC), "id"));
$liste = [];
foreach ($ids as $id) {
$a = loadAutomation($id);
if (!$a) {
continue;
}
$von = zlMinuten($a["window_from"]) ?? 0;
$bis = zlMinuten($a["window_to"]) ?? 1439;
$ganzerTag = $von === 0 && $bis >= 1439;
// Je ODER-Gruppe: Zeitpunkt, frühester Beginn, spätestes Ende,
// Kette, wartet auf Messwert. Innerhalb der Gruppe UND.
$gruppen = [];
foreach ($a["conditions"] as $c) {
$g = &$gruppen[$c["group"]];
$g = $g ?? ["punkt" => null, "start" => null, "ende" => null, "kette" => null,
"versatz" => 0, "sensor" => false, "sonne" => false];
$st = $zeitStates[$c["state_id"]] ?? null;
$op = $c["operator"];
if ($st && $st["actor_url"] === "Logic" && $st["type"] === "time") {
$m = zlMinuten($c["value"]);
if ($op === "=") $g["punkt"] = $m;
elseif ($op === ">=") $g["start"] = max($g["start"] ?? 0, $m);
elseif ($op === "<") $g["ende"] = min($g["ende"] ?? 1440, $m);
} elseif ($st && $st["actor_url"] === "Logic" && $st["type"] === "deltatime") {
$basis = $st["url"] === "sunrise" ? $sonne["auf"] : $sonne["unter"];
$versatz = zlMinuten($c["value"]) ?? 0;
if ($basis === null) {
$g["sensor"] = true;
continue;
}
$m = $basis + (substr($op, -1) === "-" ? -$versatz : $versatz);
$g["sonne"] = true;
if ($op === "+" || $op === "-") $g["punkt"] = $m;
elseif (strpos($op, ">=") === 0) $g["start"] = max($g["start"] ?? 0, $m);
else $g["ende"] = min($g["ende"] ?? 1440, $m);
} elseif ($st && $st["actor_url"] === "Automatik" && preg_match('/^auto:(\d+)$/', $st["url"], $t)) {
$g["kette"] = intval($t[1]);
$g["versatz"] = zlMinuten($c["value"]) ?? 0;
} elseif ($st && $st["actor_url"] === "Logic") {
// Datum: ein Tagesfilter, keine Lage auf dem Tag.
} else {
$g["sensor"] = true;
}
unset($g);
}
[$dran, $grund] = zlTagPasst($a, $datum);
$eintrag = [
"id" => $id,
"name" => $a["name"],
"floor" => $a["floor"],
"enabled" => $a["enabled"],
"dran" => $dran,
"grund" => $grund,
"bahn" => zlBahn($a["actions"], $geraete),
"satz" => zlSatz($a["actions"], $geraete, $kommandos),
"wenn" => describeConditions($a["conditions"], $namesState),
"laeufe" => $laeufe[$id] ?? [],
"art" => "jederzeit",
"punkt" => null,
"von" => null,
"bis" => null,
"kette" => null,
"versatz" => 0,
"sonne" => false,
"wartet" => false,
];
$punkte = array_filter(array_column($gruppen, "punkt"), fn($m) => $m !== null);
$ketten = array_filter($gruppen, fn($g) => $g["kette"] !== null);
$eintrag["wartet"] = (bool)array_filter($gruppen, fn($g) => $g["sensor"]);
$eintrag["sonne"] = (bool)array_filter($gruppen, fn($g) => $g["sonne"]);
if ($punkte) {
$eintrag["art"] = "punkt";
$eintrag["punkt"] = min($punkte);
} elseif ($ketten) {
$k = reset($ketten);
$eintrag["art"] = "kette";
$eintrag["kette"] = $k["kette"];
$eintrag["versatz"] = $k["versatz"];
// Die eigenen Grenzen der Kette - aufgelöst wird unten, wenn der
// Auslöser bekannt ist.
$eintrag["_von"] = max($von, $k["start"] ?? $von);
$eintrag["_bis"] = min($bis, $k["ende"] ?? $bis);
} else {
$anfaenge = [];
$enden = [];
$zeitlich = false;
foreach ($gruppen as $g) {
if ($g["start"] !== null || $g["ende"] !== null) $zeitlich = true;
$anfaenge[] = max($von, $g["start"] ?? $von);
$enden[] = min($bis, $g["ende"] ?? $bis);
}
if ($zeitlich || !$ganzerTag) {
$eintrag["art"] = "fenster";
$eintrag["von"] = $anfaenge ? min($anfaenge) : $von;
$eintrag["bis"] = $enden ? max($enden) : $bis;
// Der früheste zeitlich bestimmte Beginn, falls es einen gibt -
// bei "Sonnenuntergang + 30 oder dunkel" die Sonnenmarke.
$marken = array_filter(array_column($gruppen, "start"), fn($m) => $m !== null);
if ($marken) {
$eintrag["punkt"] = min($marken);
}
}
}
$liste[$id] = $eintrag;
}
/*
* Ketten auflösen. Die Lage hängt am Auslöser:
*
* Auslöser lief schon Punkt bei dessen Zeit + Versatz
* Auslöser hat einen Punkt Punkt dort + Versatz
* Auslöser ist ein Fenster Fenster ab dessen Beginn + Versatz
*
* Jeweils nicht vor dem eigenen Fensterbeginn und nicht nach dem eigenen
* Ende. Mehrstufig (Wecker -> Rollos -> Schlafzimmer), deshalb reihum.
*/
$aufgeloest = [];
for ($runde = 0; $runde < 6; $runde++) {
foreach ($liste as $id => $e) {
if ($e["art"] !== "kette" || isset($aufgeloest[$id])) {
continue;
}
$vater = $liste[$e["kette"]] ?? null;
if (!$vater || ($vater["art"] === "kette" && !isset($aufgeloest[$e["kette"]]))) {
continue;
}
$aufgeloest[$id] = true;
$eigenVon = $e["_von"];
$eigenBis = $e["_bis"];
if ($vater["laeufe"]) {
$liste[$id]["punkt"] = max($eigenVon, zlMinuten(end($vater["laeufe"])) + $e["versatz"]);
} elseif ($vater["punkt"] !== null && $vater["art"] !== "fenster") {
$liste[$id]["punkt"] = max($eigenVon, $vater["punkt"] + $e["versatz"]);
} elseif ($vater["art"] === "fenster") {
$liste[$id]["art"] = "fenster";
$liste[$id]["von"] = max($eigenVon, $vater["von"] + $e["versatz"]);
$liste[$id]["bis"] = max($liste[$id]["von"], $eigenBis);
} else {
$liste[$id]["art"] = "jederzeit"; // Auslöser ohne Lage: dann eben ohne
}
// Eine Kette erbt, ob der Tag passt: läuft der Wecker nicht, kommt auch der Nachfolger nicht.
if (!$vater["dran"] && $liste[$id]["dran"]) {
$liste[$id]["dran"] = false;
$liste[$id]["grund"] = "Auslöser „" . $vater["name"] . "“ ist nicht dran";
}
}
}
foreach ($liste as $id => $e) {
if ($e["art"] === "kette" && $e["punkt"] === null) {
$liste[$id]["art"] = "jederzeit"; // Kreis oder fehlender Auslöser
}
unset($liste[$id]["_von"], $liste[$id]["_bis"]);
}
$etagen = [];
foreach (grundriss()[0] as $code => $e) {
$etagen[] = ["code" => $code, "label" => $e["label"]];
}
$heute = (new DateTimeImmutable("today"))->format("Y-m-d");
return [
"datum" => $datum,
"heute" => $datum === $heute,
"jetzt" => $datum === $heute ? intval(date("G")) * 60 + intval(date("i")) : null,
"sonne" => $sonne,
"bahnen" => zeitleisteBahnen(),
"etagen" => $etagen,
"automatiken" => array_values($liste),
];
}