/* ==========================================================================
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.
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 = '
'
+ '
';
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 = '";
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();
});