/* ========================================================================== Reiter "Grundriss": Etagen, Räume und wo ihre Kacheln sitzen. Vorher stand das alles in restricted/rooms.php, und eine Kachel setzte man, indem man in tools/kachelpositionen.php die Stelle ablas und die Zahlen in die Datei schrieb. Hier geschieht beides am Plan selbst: Kacheln werden gezogen, ein Raum ohne Kachel mit "Platzieren" und einem Klick gesetzt. Von dort übernommen sind Raster, Geisterkachel und die Umrechnung: gezeigt wird auf die Mitte, gespeichert die linke obere Ecke. Geprüft wird nichts, was der Server nicht auch prüft (restricted/grundriss.php). Jede Aktion antwortet mit dem ganzen neuen Stand; die Maske zeichnet ihn einfach neu. Nutzt textSchuetzen() aus settings.js - beide Dateien stehen auf derselben Seite, settings.js zuerst. ========================================================================== */ let grundrissModell = null; // Antwort von ?action=grundriss const grundrissZustand = { etage: null, // gewählte Etage (code) oder "" für "neue Etage" raum: null, // gewählter Raum (nr), 0 = neuer Raum, null = keiner platzieren: null, // nr des Raums, der gerade auf den Plan gesetzt wird ziehen: null, // { nr, dx, dy, x, y, bewegt } während eines Zugs }; /* --- Ableitungen --------------------------------------------------------- */ function grEtage(code) { return (grundrissModell.etagen || []).find(e => e.code === code) || null; } function grRaum(nr) { return (grundrissModell.raeume || []).find(r => r.nr === nr) || null; } function grRaeumeDerEtage(code) { return (grundrissModell.raeume || []).filter(r => r.floor === code); } function grMass() { return grundrissModell.mass; } /** * Lange Namen brechen am letzten Leerzeichen um - wie auf der Startseite * (restricted/home.php), damit die Kachel hier genauso breit wirkt wie dort. */ function grNamenszeilen(name) { const luecke = name.lastIndexOf(" "); return name.length > 8 && luecke > 0 ? [name.slice(0, luecke), name.slice(luecke + 1)] : [name]; } /** Zeigerposition in Koordinaten der Zeichenfläche. */ function grPunkt(svg, ereignis) { const p = svg.createSVGPoint(); p.x = ereignis.clientX; p.y = ereignis.clientY; return p.matrixTransform(svg.getScreenCTM().inverse()); } /** Linke obere Ecke auf die Zeichenfläche begrenzen - wie der Server. */ function grBegrenzt(x, y) { const m = grMass(); return { x: Math.round(Math.max(0, Math.min(m.planBreit - m.kachelBreit, x))), y: Math.round(Math.max(0, Math.min(m.planHoch - m.kachelHoch, y))), }; } /* --- Zeichnen ------------------------------------------------------------ */ function zeichneGrundriss() { const ziel = document.getElementById("grundrissFlaeche"); if (!ziel || !grundrissModell) return; const etagen = grundrissModell.etagen || []; if (grundrissZustand.etage === null || (grundrissZustand.etage !== "" && !grEtage(grundrissZustand.etage))) { grundrissZustand.etage = etagen.length ? (grundrissModell.standard || etagen[0].code) : ""; } const code = grundrissZustand.etage; const etage = grEtage(code); let html = '
'; etagen.forEach(e => { html += '"; }); html += '
'; if (!etagen.length && code === "") { html += '

Noch keine Etage angelegt. Eine Etage ' + "braucht ein Kürzel (so steht sie im Menü und in den Etagenschaltern), einen Namen " + "und später ein Grundrissbild.

"; } html += '
' + '
' + (etage ? grPlan(etage) : grPlanLeer()) + "
" + '
' + grEtageFormular(etage) + (etage ? grRaumListe(etage) + grRaumFormular(etage) : "") + "
" + '
'; ziel.innerHTML = html; } function grPlanLeer() { return '
Erst die Etage speichern, dann ' + "lässt sich ihr Grundriss hochladen und bestücken.
"; } /** Der Plan einer Etage: Bild, Raster, Kacheln, Geisterkachel. */ function grPlan(etage) { const m = grMass(); const raeume = grRaeumeDerEtage(etage.code).filter(r => r.x !== null); let svg = '
' + '' + '' + ''; if (etage.bild) { // Dieselbe Einpassung wie auf der Startseite: das Bild füllt die // Zeichenfläche, ohne verzerrt zu werden (preserveAspectRatio "meet"). svg += ''; } else { svg += '' + "Noch kein Grundrissbild"; } svg += ''; raeume.forEach(r => { const zeilen = grNamenszeilen(r.name); const ys = zeilen.length === 2 ? [9, 15.5] : [11.5]; svg += '' + '' + zeilen.map((z, i) => '' + textSchuetzen(z) + "").join("") + ""; }); svg += '
'; const hinweis = grundrissZustand.platzieren ? "Auf die Stelle klicken, an der die Mitte der Kachel „" + textSchuetzen((grRaum(grundrissZustand.platzieren) || {}).name || "") + "“ liegen soll. Esc bricht ab." : "Kacheln lassen sich ziehen; ein Klick wählt den Raum. Die Zeichenfläche ist " + m.planBreit + " × " + m.planHoch + ", eine Kachel " + m.kachelBreit + " × " + m.kachelHoch + "."; return svg + '

' + hinweis + "

"; } /** Formular der gewählten Etage - oder einer neuen. */ function grEtageFormular(etage) { const neu = !etage; const etagen = grundrissModell.etagen || []; const i = neu ? -1 : etagen.findIndex(e => e.code === etage.code); let html = '
' + '
' + (neu ? "Neue Etage" : "Etage") + "
" + '
' + '
' + '
' + '
' + '
' + "
" + '
' + '
' + '
' + ''; if (!neu) { html += '' + '' + '' + ''; } html += "
"; if (!neu && etage.automatiken.length) { html += '

' + etage.automatiken.length + " Automatik(en) stehen unter dieser Etage.

"; } return html + "
"; } /** Die Räume der Etage, mit und ohne Kachel. */ function grRaumListe(etage) { const raeume = grRaeumeDerEtage(etage.code); let html = '
Räume
'; if (!raeume.length) { html += '

Noch keine Räume auf dieser Etage.

'; } html += '
'; raeume.forEach(r => { const aktiv = r.nr === grundrissZustand.raum; html += '
' + '" + (r.x !== null ? '' : '') + "
"; }); html += '
'; return html; } /** Formular des gewählten Raums, sofern einer gewählt ist. */ function grRaumFormular(etage) { const nr = grundrissZustand.raum; if (nr === null) return ""; const r = nr === 0 ? { nr: 0, floor: etage.code, name: "", kuerzel: "", thermostat: "", x: null, geraete: 0 } : grRaum(nr); if (!r) return ""; const etagenAuswahl = (grundrissModell.etagen || []).map(e => '").join(""); const zweige = '' + (grundrissModell.zweige || []).map(z => '"; let html = '
' + '
' + (nr === 0 ? "Neuer Raum" : "Raum") + "
" + '' + '' + '
' + '
' + '
" + '
' + '
' + '' + '' + zweige + '
Unter diesem MQTT-Zweig meldet das Thermostat, z. B. Raumtemp/EG/Bad. ' + "Er trägt Heizsymbol, Temperaturregler und die Vorgabewerte der Kachel.
" + '
' + ''; if (nr !== 0) { if (r.x !== null) { html += '' + ''; } html += ''; } html += "
"; if (nr !== 0 && r.geraete) { html += '

' + r.geraete + " Gerät(e) zugeordnet.

"; } return html + "
"; } /* --- Sprechen mit dem Endpunkt ------------------------------------------- */ function grStand(text, klasse) { const ziel = document.querySelector("#grundrissFlaeche .gr-stand"); if (!ziel) return; ziel.className = "gr-stand small mt-2 " + (klasse || "text-body-secondary"); ziel.textContent = text; clearTimeout(grStand._uhr); if (klasse !== "text-danger") { grStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000); } } /** * Eine Aktion schicken, den neuen Stand übernehmen, neu zeichnen. * * Danach wissen die anderen Reiter nicht mehr, was gilt: neue Räume fehlen in * der Kachelliste und in der Raumauswahl der Geräte. Sie laden beim nächsten * Öffnen nach (grundrissGeaendert, siehe settings.js). */ async function grSenden(action, koerper, meldung) { try { const optionen = koerper instanceof FormData ? { method: "POST", body: koerper } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper) }; const antwort = await fetch("ajax/settings.php?action=" + action, optionen); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); grundrissModell = d.stand; window.grundrissGeaendert = true; zeichneGrundriss(); if (meldung) grStand(meldung, "text-success"); return d; } catch (e) { grStand(e.message, "text-danger"); return null; } } async function grundrissHolen() { const ziel = document.getElementById("grundrissFlaeche"); if (!ziel) return; try { const antwort = await fetch("ajax/settings.php?action=grundriss", { cache: "no-store" }); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); grundrissModell = d; zeichneGrundriss(); } catch (e) { ziel.innerHTML = '
Grundriss nicht abrufbar: ' + textSchuetzen(e.message) + "
"; } } /** Die Felder eines Blocks als Objekt. */ function grFelder(block) { const werte = {}; block.querySelectorAll("[data-feld]").forEach(f => { werte[f.dataset.feld] = f.type === "checkbox" ? f.checked : f.value; }); return werte; } /* --- Bedienung ----------------------------------------------------------- */ function grundrissBinden() { const flaeche = document.getElementById("grundrissFlaeche"); if (!flaeche) return; flaeche.addEventListener("click", async function (ereignis) { if (!grundrissModell) return; const t = ereignis.target; const etagenKnopf = t.closest(".gr-etage"); if (etagenKnopf) { grundrissZustand.etage = etagenKnopf.dataset.code; grundrissZustand.raum = null; grundrissZustand.platzieren = null; zeichneGrundriss(); return; } if (t.closest(".gr-etage-neu")) { grundrissZustand.etage = ""; grundrissZustand.raum = null; grundrissZustand.platzieren = null; zeichneGrundriss(); return; } if (t.closest(".gr-etage-speichern")) { const werte = grFelder(t.closest('[data-block="etage"]')); const neu = grundrissZustand.etage === ""; const d = await grSenden("etage-speichern", { code: werte.code, label: werte.label, standard: werte.standard, neu: neu }, "Etage gespeichert."); if (d && neu) { grundrissZustand.etage = String(werte.code).trim().toUpperCase(); zeichneGrundriss(); } return; } if (t.closest(".gr-etage-links") || t.closest(".gr-etage-rechts")) { const codes = grundrissModell.etagen.map(e => e.code); const i = codes.indexOf(grundrissZustand.etage); const j = i + (t.closest(".gr-etage-links") ? -1 : 1); if (i < 0 || j < 0 || j >= codes.length) return; [codes[i], codes[j]] = [codes[j], codes[i]]; grSenden("etagen-reihenfolge", { codes: codes }, "Reihenfolge gespeichert."); return; } if (t.closest(".gr-etage-weg")) { const e = grEtage(grundrissZustand.etage); if (!e || !confirm("Etage „" + e.label + "“ löschen?")) return; const d = await grSenden("etage-loeschen", { code: e.code }, "Etage gelöscht."); if (d) { grundrissZustand.etage = null; zeichneGrundriss(); } return; } const raumZeile = t.closest(".gr-raum"); if (t.closest(".gr-platzieren") && raumZeile) { const nr = Number(raumZeile.dataset.nr); grundrissZustand.platzieren = grundrissZustand.platzieren === nr ? null : nr; grundrissZustand.raum = nr; zeichneGrundriss(); return; } if (t.closest(".gr-raum-name") && raumZeile) { const nr = Number(raumZeile.dataset.nr); grundrissZustand.raum = grundrissZustand.raum === nr ? null : nr; grundrissZustand.platzieren = null; zeichneGrundriss(); return; } if (t.closest(".gr-raum-neu")) { grundrissZustand.raum = 0; grundrissZustand.platzieren = null; zeichneGrundriss(); const feld = flaeche.querySelector('[data-block="raum"] [data-feld="name"]'); if (feld) feld.focus(); return; } if (t.closest(".gr-raum-speichern")) { const werte = grFelder(t.closest('[data-block="raum"]')); werte.nr = grundrissZustand.raum || 0; const d = await grSenden("raum-speichern", werte, "Raum gespeichert."); if (d) { grundrissZustand.raum = d.nr; // Hat der Raum die Etage gewechselt, geht der Blick mit. const r = grRaum(d.nr); if (r && r.floor !== grundrissZustand.etage) grundrissZustand.etage = r.floor; zeichneGrundriss(); } return; } if (t.closest(".gr-vom-plan")) { grSenden("raum-position", { nr: grundrissZustand.raum, x: null, y: null }, "Kachel vom Plan genommen."); return; } if (t.closest(".gr-raum-weg")) { const r = grRaum(grundrissZustand.raum); if (!r) return; const frage = "Raum „" + r.name + "“ löschen?" + (r.geraete ? "\n\n" + r.geraete + " Gerät(e) stehen danach ohne Raum da." : ""); if (!confirm(frage)) return; const d = await grSenden("raum-loeschen", { nr: r.nr }, "Raum gelöscht."); if (d) { grundrissZustand.raum = null; zeichneGrundriss(); } return; } if (t.closest(".gr-kachelwerte")) { const r = grRaum(grundrissZustand.raum); if (r && typeof kachelOeffnen === "function") kachelOeffnen(r.floor, r.kuerzel); } }); // Das Bild geht los, sobald eine Datei gewählt ist - ein zweiter Klick auf // "Speichern" wäre hier nur eine Gelegenheit, es zu vergessen. flaeche.addEventListener("change", function (ereignis) { const feld = ereignis.target.closest(".gr-bild"); if (!feld || !feld.files.length) return; const formular = new FormData(); formular.append("code", grundrissZustand.etage); formular.append("bild", feld.files[0]); grStand("Bild wird hochgeladen …"); grSenden("etage-bild", formular, "Grundriss übernommen."); }); /* Ziehen und Platzieren. Pointer-Ereignisse, damit es mit Maus, Stift und Finger gleich geht; die Kachel folgt dem Zeiger, gespeichert wird beim Loslassen. */ flaeche.addEventListener("pointerdown", function (ereignis) { const kachel = ereignis.target.closest(".gr-kachel"); const svg = ereignis.target.closest(".gr-plan"); if (!kachel || !svg || grundrissZustand.platzieren) return; const r = grRaum(Number(kachel.dataset.nr)); if (!r) return; const p = grPunkt(svg, ereignis); grundrissZustand.ziehen = { nr: r.nr, dx: p.x - r.x, dy: p.y - r.y, x: r.x, y: r.y, bewegt: false, kachel: kachel, svg: svg }; kachel.setPointerCapture(ereignis.pointerId); kachel.classList.add("zieht"); ereignis.preventDefault(); }); flaeche.addEventListener("pointermove", function (ereignis) { const z = grundrissZustand.ziehen; if (z) { const p = grPunkt(z.svg, ereignis); const pos = grBegrenzt(p.x - z.dx, p.y - z.dy); if (pos.x !== z.x || pos.y !== z.y) z.bewegt = true; z.x = pos.x; z.y = pos.y; z.kachel.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")"); grStand("x " + pos.x + " · y " + pos.y); return; } if (grundrissZustand.platzieren) { const svg = ereignis.target.closest(".gr-plan"); const geist = flaeche.querySelector(".gr-geist"); if (!svg || !geist) return; const m = grMass(); const p = grPunkt(svg, ereignis); const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2); geist.style.display = ""; geist.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")"); } }); flaeche.addEventListener("pointerup", async function (ereignis) { const z = grundrissZustand.ziehen; if (z) { grundrissZustand.ziehen = null; z.kachel.classList.remove("zieht"); if (!z.bewegt) { // Nur angetippt: den Raum wählen statt ihn zu verschieben. grundrissZustand.raum = grundrissZustand.raum === z.nr ? null : z.nr; zeichneGrundriss(); return; } grSenden("raum-position", { nr: z.nr, x: z.x, y: z.y }, "Kachel gesetzt."); return; } if (grundrissZustand.platzieren) { const svg = ereignis.target.closest(".gr-plan"); if (!svg) return; const m = grMass(); const p = grPunkt(svg, ereignis); const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2); const nr = grundrissZustand.platzieren; grundrissZustand.platzieren = null; grSenden("raum-position", { nr: nr, x: pos.x, y: pos.y }, "Kachel gesetzt."); } }); document.addEventListener("keydown", function (ereignis) { if (ereignis.key === "Escape" && grundrissZustand.platzieren) { grundrissZustand.platzieren = null; zeichneGrundriss(); } }); const neuLaden = document.getElementById("grundrissNeuLaden"); if (neuLaden) neuLaden.addEventListener("click", grundrissHolen); } document.addEventListener("DOMContentLoaded", function () { grundrissBinden(); grundrissHolen(); });