Einstellungen -> Grundriss: Etagen, Raeume und Kacheln am Plan pflegen

Neuer Reiter mit Plan-Editor: Etagen anlegen, umbenennen, sortieren,
loeschen und als Standard markieren; Grundrissbild hochladen; Raeume mit
Name, Kuerzel und Thermostat-Zweig anlegen und bearbeiten; Kacheln im Plan
ziehen oder einen Raum ohne Kachel mit "Platzieren" und einem Klick setzen.
Loest tools/kachelpositionen.php ab - Raster, Geisterkachel und die
Umrechnung Mitte -> linke obere Ecke sind von dort uebernommen.

- restricted/grundriss.php: alle Pruefungen und Schreibzugriffe. Eine
  Etage mit Raeumen oder Automatiken laesst sich nicht loeschen, die
  Ablehnung nennt die Namen. Raeume loeschen laesst ihre Geraete ohne Raum.
- Grundrissbilder nach tiles/grundriss/ (beschreibbar, nicht im Git),
  nur PNG/JPEG/WebP, am Inhalt geprueft; SVG bewusst nicht. Prüfsumme im
  Dateinamen, alte hochgeladene Bilder werden weggeraeumt.
- ajax/settings.php: ?action=grundriss und sieben POST-Aktionen, jede
  antwortet mit dem ganzen neuen Stand.
- "Werte" springt in den Reiter Home-Kacheln und oeffnet die Kachel;
  Home-Kacheln und Geraete laden nach einer Grundriss-Aenderung nach.
- Startseite ohne Etage mit platziertem Raum: Hinweis auf die
  Einstellungen statt einer leeren Zeichenflaeche.
- README: Grundriss als Inhalt statt Code, neue Dateien und Endpunkte,
  Reihenfolge der Schema-Dateien fuer ein neues Haus.

Backend mit einer Probe-Etage durchgespielt (auch Fehlerfaelle, Upload,
Aufraeumen); Oberflaeche auf einer Probeseite mit echten Daten und
Zeiger-Ereignissen geprueft: Ziehen, Antippen, Platzieren, neuer Raum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 11:11:10 +02:00
co-authored by Claude Opus 5
parent 62a8af3fc6
commit 8d626abc6d
12 changed files with 1365 additions and 255 deletions
+534
View File
@@ -0,0 +1,534 @@
/* ==========================================================================
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 = '<div class="gr-etagen">';
etagen.forEach(e => {
html += '<button type="button" class="btn btn-sm gr-etage' + (e.code === code ? " aktiv" : "") + '"'
+ ' data-code="' + textSchuetzen(e.code) + '" title="' + textSchuetzen(e.label) + '">'
+ '<span class="gr-etage-code">' + textSchuetzen(e.code) + "</span>"
+ '<span class="gr-etage-name">' + textSchuetzen(e.label) + "</span>"
+ (e.standard ? '<i class="bi bi-star-fill gr-etage-std" title="Standard-Etage"></i>' : "")
+ "</button>";
});
html += '<button type="button" class="btn btn-sm btn-outline-secondary gr-etage-neu' + (code === "" ? " active" : "") + '">'
+ '<i class="bi bi-plus-lg me-1"></i>Etage</button></div>';
if (!etagen.length && code === "") {
html += '<p class="small text-body-secondary mb-3">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.</p>";
}
html += '<div class="row g-3">'
+ '<div class="col-lg-8">' + (etage ? grPlan(etage) : grPlanLeer()) + "</div>"
+ '<div class="col-lg-4">' + grEtageFormular(etage)
+ (etage ? grRaumListe(etage) + grRaumFormular(etage) : "")
+ "</div></div>"
+ '<div class="gr-stand small mt-2"></div>';
ziel.innerHTML = html;
}
function grPlanLeer() {
return '<div class="gr-plan-huelle gr-plan-leer"><span>Erst die Etage speichern, dann '
+ "lässt sich ihr Grundriss hochladen und bestücken.</span></div>";
}
/** 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 = '<div class="gr-plan-huelle' + (grundrissZustand.platzieren ? " platzieren" : "") + '">'
+ '<svg class="gr-plan" viewBox="0 0 ' + m.planBreit + " " + m.planHoch + '">'
+ '<defs><pattern id="grRaster" width="10" height="10" patternUnits="userSpaceOnUse">'
+ '<path d="M10 0H0V10" fill="none" stroke="#ffffff1c" stroke-width=".4"/></pattern></defs>';
if (etage.bild) {
// Dieselbe Einpassung wie auf der Startseite: das Bild füllt die
// Zeichenfläche, ohne verzerrt zu werden (preserveAspectRatio "meet").
svg += '<image href="' + textSchuetzen(etage.bild) + '" x="0" y="0" width="' + m.planBreit
+ '" height="' + m.planHoch + '"/>';
} else {
svg += '<text x="' + m.planBreit / 2 + '" y="' + m.planHoch / 2 + '" class="gr-ohne-bild">'
+ "Noch kein Grundrissbild</text>";
}
svg += '<rect width="' + m.planBreit + '" height="' + m.planHoch + '" fill="url(#grRaster)" pointer-events="none"/>';
raeume.forEach(r => {
const zeilen = grNamenszeilen(r.name);
const ys = zeilen.length === 2 ? [9, 15.5] : [11.5];
svg += '<g class="gr-kachel' + (r.nr === grundrissZustand.raum ? " gewaehlt" : "") + '"'
+ ' data-nr="' + r.nr + '" transform="translate(' + r.x + "," + r.y + ')">'
+ '<rect width="' + m.kachelBreit + '" height="' + m.kachelHoch + '" rx="5"/>'
+ zeilen.map((z, i) => '<text x="' + m.kachelBreit / 2 + '" y="' + ys[i] + '">'
+ textSchuetzen(z) + "</text>").join("")
+ "</g>";
});
svg += '<g class="gr-geist" style="display:none"><rect width="' + m.kachelBreit + '" height="'
+ m.kachelHoch + '" rx="5"/></g></svg></div>';
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 + '<p class="small text-body-secondary mt-1 mb-0 gr-plan-hinweis">' + hinweis + "</p>";
}
/** 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 = '<div class="gr-block" data-block="etage">'
+ '<div class="gr-block-titel">' + (neu ? "Neue Etage" : "Etage") + "</div>"
+ '<div class="row g-2">'
+ '<div class="col-4"><label class="form-label small mb-0">Kürzel</label>'
+ '<input type="text" class="form-control form-control-sm" data-feld="code" maxlength="4"'
+ ' value="' + (neu ? "" : textSchuetzen(etage.code)) + '"' + (neu ? ' placeholder="EG"' : " readonly") + ' autocomplete="off"></div>'
+ '<div class="col-8"><label class="form-label small mb-0">Name</label>'
+ '<input type="text" class="form-control form-control-sm" data-feld="label" maxlength="40"'
+ ' value="' + (neu ? "" : textSchuetzen(etage.label)) + '" placeholder="Erdgeschoss"></div>'
+ "</div>"
+ '<div class="form-check mt-2"><input class="form-check-input" type="checkbox" id="grStandard" data-feld="standard"'
+ (!neu && etage.standard ? " checked" : "") + '>'
+ '<label class="form-check-label small" for="grStandard">Öffnet, wenn keine Etage gewählt ist</label></div>'
+ '<div class="gr-knoepfe">'
+ '<button type="button" class="btn btn-primary btn-sm gr-etage-speichern">Speichern</button>';
if (!neu) {
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-links" title="Nach vorn"'
+ (i <= 0 ? " disabled" : "") + '><i class="bi bi-arrow-left"></i></button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-rechts" title="Nach hinten"'
+ (i >= etagen.length - 1 ? " disabled" : "") + '><i class="bi bi-arrow-right"></i></button>'
+ '<label class="btn btn-outline-secondary btn-sm mb-0" title="PNG, JPEG oder WebP">'
+ '<i class="bi bi-image me-1"></i>' + (etage.bild ? "Bild tauschen" : "Bild hochladen")
+ '<input type="file" class="d-none gr-bild" accept="image/png,image/jpeg,image/webp"></label>'
+ '<button type="button" class="btn btn-sm gr-etage-weg" title="Etage löschen"><i class="bi bi-trash3"></i></button>';
}
html += "</div>";
if (!neu && etage.automatiken.length) {
html += '<p class="small text-body-secondary mb-0 mt-1">' + etage.automatiken.length
+ " Automatik(en) stehen unter dieser Etage.</p>";
}
return html + "</div>";
}
/** Die Räume der Etage, mit und ohne Kachel. */
function grRaumListe(etage) {
const raeume = grRaeumeDerEtage(etage.code);
let html = '<div class="gr-block" data-block="raeume"><div class="gr-block-titel">Räume</div>';
if (!raeume.length) {
html += '<p class="small text-body-secondary mb-2">Noch keine Räume auf dieser Etage.</p>';
}
html += '<div class="gr-raeume">';
raeume.forEach(r => {
const aktiv = r.nr === grundrissZustand.raum;
html += '<div class="gr-raum' + (aktiv ? " gewaehlt" : "") + '" data-nr="' + r.nr + '">'
+ '<button type="button" class="gr-raum-name">' + textSchuetzen(r.name)
+ (r.thermostat ? ' <i class="bi bi-thermometer-half text-body-secondary" title="' + textSchuetzen(r.thermostat) + '"></i>' : "")
+ "</button>"
+ (r.x !== null
? '<span class="gr-raum-stand" title="Kachel bei ' + r.x + " / " + r.y + '"><i class="bi bi-geo-alt-fill"></i></span>'
: '<button type="button" class="btn btn-outline-secondary btn-sm gr-platzieren'
+ (grundrissZustand.platzieren === r.nr ? " active" : "") + '">Platzieren</button>')
+ "</div>";
});
html += '</div><button type="button" class="btn btn-sm btn-outline-secondary mt-2 gr-raum-neu'
+ (grundrissZustand.raum === 0 ? " active" : "") + '"><i class="bi bi-plus-lg me-1"></i>Raum</button></div>';
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 =>
'<option value="' + textSchuetzen(e.code) + '"' + (e.code === r.floor ? " selected" : "") + ">"
+ textSchuetzen(e.code + " · " + e.label) + "</option>").join("");
const zweige = '<datalist id="grZweige">' + (grundrissModell.zweige || []).map(z =>
'<option value="' + textSchuetzen(z) + '">').join("") + "</datalist>";
let html = '<div class="gr-block" data-block="raum">'
+ '<div class="gr-block-titel">' + (nr === 0 ? "Neuer Raum" : "Raum") + "</div>"
+ '<label class="form-label small mb-0">Name</label>'
+ '<input type="text" class="form-control form-control-sm" data-feld="name" maxlength="40" value="'
+ textSchuetzen(r.name) + '" placeholder="Wohnzimmer">'
+ '<div class="row g-2 mt-0">'
+ '<div class="col-6"><label class="form-label small mb-0">Etage</label>'
+ '<select class="form-select form-select-sm" data-feld="floor">' + etagenAuswahl + "</select></div>"
+ '<div class="col-6"><label class="form-label small mb-0" title="Teil der Element-Ids auf der Startseite. Leer lassen: entsteht aus dem Namen.">Kürzel</label>'
+ '<input type="text" class="form-control form-control-sm" data-feld="kuerzel" maxlength="40" value="'
+ textSchuetzen(r.kuerzel) + '" placeholder="aus dem Namen" autocomplete="off"></div></div>'
+ '<label class="form-label small mb-0 mt-2">Thermostat-Zweig</label>'
+ '<input type="text" class="form-control form-control-sm" data-feld="thermostat" list="grZweige" value="'
+ textSchuetzen(r.thermostat || "") + '" placeholder="leer = kein Thermostat" autocomplete="off">' + zweige
+ '<div class="form-text small">Unter diesem MQTT-Zweig meldet das Thermostat, z. B. Raumtemp/EG/Bad. '
+ "Er trägt Heizsymbol, Temperaturregler und die Vorgabewerte der Kachel.</div>"
+ '<div class="gr-knoepfe">'
+ '<button type="button" class="btn btn-primary btn-sm gr-raum-speichern">Speichern</button>';
if (nr !== 0) {
if (r.x !== null) {
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-kachelwerte" title="Werte dieser Kachel bearbeiten">'
+ '<i class="bi bi-grid-1x2 me-1"></i>Werte</button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-vom-plan">Vom Plan</button>';
}
html += '<button type="button" class="btn btn-sm gr-raum-weg" title="Raum löschen"><i class="bi bi-trash3"></i></button>';
}
html += "</div>";
if (nr !== 0 && r.geraete) {
html += '<p class="small text-body-secondary mb-0 mt-1">' + r.geraete + " Gerät(e) zugeordnet.</p>";
}
return html + "</div>";
}
/* --- 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 = '<div class="alert alert-danger mb-0">Grundriss nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
/** 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();
});