Files
Smart-Dashboard/js/solar/grundriss.js
T
adminandClaude Opus 5 8d626abc6d 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>
2026-09-15 11:11:10 +02:00

535 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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();
});