Files
Smart-Dashboard/tools/lageplan.php
T
adminandClaude Opus 5 97b5c78fda Bewaesserungsanzeige loest sich vom alten Regenskript
Die Anzeigen im Realtime-SVG und im Bewaesserungs-Modal lasen ihre
Vorschau aus Gartenwasser/AutoWatering/<zone>/RainStatus, das
auto_watering.py stuendlich veroeffentlicht hat. Solange das so blieb,
konnte das Skript nicht abgeschaltet werden, obwohl die Entscheidung
laengst in den Automatiken des Runners steht - und dessen Schwellen
haetten dann neben denen im Skript gestanden, ohne dass ein Unterschied
auffaellt.

Statt des Plans zeigen die Anzeigen jetzt, ob die Zone heute schon
gelaufen ist. Die Regenautomatik loest zum Sonnenaufgang aus; wer
spaeter auf das Dashboard schaut, will ohnehin wissen, was passiert ist,
nicht was vorgesehen war. Die Angabe kommt aus dem retained
WateringToday der Ventilsteuerung, das die Karte schon fuer "Heute"
auswertet - eine Bewaesserung von Hand zaehlt damit genauso, was hier
richtig ist.

Im SVG steht dafuer ein Haekchen statt der Stoppuhr, das Schild im
Modal sagt "heute bewaessert" statt "heute geplant", und die
Planungszeilen in der Automatik-Karte entfallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:33:15 +02:00

610 lines
25 KiB
PHP
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.
<?php
/**
* Messblatt für den Lageplan des Außengeländes.
*
* OG, EG und UG haben je ein Rendering in assets/img - Puppenhausschnitt von
* schräg oben. Für das Außengelände gibt es so ein Bild nicht: die
* Planungssoftware kennt nur das einzelne Gebäude, nicht das Grundstück. Der
* Plan für AG entsteht deshalb als Vektor, gezeichnet aus der Geometrie in
* restricted/aussenplan.php - und die entsteht hier.
*
* Abgeklickt wird auf dem amtlichen Luftbild (DOP20, 20 cm Bodenauflösung).
* Daneben steht die Vorschau in derselben Schrägsicht von Südwesten, in der
* die Etage später gezeichnet wird: nur dort sieht man, ob Terrasse OG und
* Terrasse EG wirklich auseinandergehen.
*
* Warum das überhaupt geht: die Projektion in aussenplan.php ist eine
* Militärprojektion. Sie lässt den Grundriss maßhaltig, also reichen die
* Koordinaten aus der Draufsicht plus eine Höhe je Fläche. Eine echte
* Perspektive müsste man neu vermessen.
*
* Warum nicht einfach am Bild abmessen: die Zeichenfläche ist 400 × 300 groß
* und hat mit Metern nichts zu tun. Wer das im Kopf umrechnet, verrechnet
* sich - und zwar in jeder Ecke anders.
*
* Das Luftbild kommt live vom WMS der Bayerischen Vermessungsverwaltung, es
* liegt also keine Kopie im Repository. Der Ausschnitt lässt sich über die
* Adresszeile verschieben, ohne diese Datei anzufassen:
*
* tools/lageplan.php?lat=47.5781&lon=10.2628&breite=70
*
* "breite" ist die Kantenlänge des Ausschnitts in Metern; die Höhe ergibt
* sich aus dem Seitenverhältnis 4:3 der Zeichenfläche.
*
* Aufruf: http://<host>/smart/tools/lageplan.php
*
* Bewusst eine eigene Seite und kein Eintrag im Menü: sie wird gebraucht,
* wenn sich draußen etwas ändert, und das ist selten - genau wie
* kachelpositionen.php, mit der sie sich die Bauart teilt.
*/
require_once(__DIR__ . "/../helper.php");
require_once(__DIR__ . "/../restricted/rooms.php");
require_once(__DIR__ . "/../restricted/aussenplan.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
/** Welche Etage hier vermessen wird. */
const ETAGE = "AG";
/** Das Luftbild. CC BY 4.0 - die Namensnennung steht unten auf der Seite. */
const WMS_URL = "https://geoservices.bayern.de/od/wms/dop/v1/dop20";
const WMS_LAYER = "by_dop20c";
// Wie viele Bildpunkte je Einheit der Zeichenfläche geholt werden. Drei ist
// scharf genug zum Abklicken und bleibt weit unter der Grenze des Dienstes.
const WMS_SCHAERFE = 3;
/**
* Mittelpunkt des Ausschnitts. Dieselben Koordinaten stehen als Vorgabe in
* gatherRainData.py im SolarManager, von wo die Regendaten geholt werden -
* es ist derselbe Ort.
*/
$lat = isset($_GET["lat"]) ? floatval($_GET["lat"]) : 47.5781;
$lon = isset($_GET["lon"]) ? floatval($_GET["lon"]) : 10.2628;
$breite = isset($_GET["breite"]) ? floatval($_GET["breite"]) : 70.0;
$breite = max(20.0, min(400.0, $breite));
$hoehe = $breite * PLAN_HOEHE / PLAN_BREITE;
/**
* WGS84 nach UTM Zone 32 Nord (EPSG:25832).
*
* Der WMS kann auch mit Längen- und Breitengraden umgehen, aber dann ist ein
* quadratischer Ausschnitt keiner mehr: ein Grad Länge ist auf dieser Breite
* nur zwei Drittel so lang wie ein Grad Breite. In UTM ist ein Meter ein
* Meter, und der Maßstab der Seite stimmt ohne Umrechnung.
*/
function nachUtm32($lonGrad, $latGrad)
{
$a = 6378137.0;
$f = 1 / 298.257223563;
$k0 = 0.9996;
$e2 = $f * (2 - $f);
$ep2 = $e2 / (1 - $e2);
$lon0 = deg2rad(32 * 6 - 183);
$lat = deg2rad($latGrad);
$lon = deg2rad($lonGrad);
$N = $a / sqrt(1 - $e2 * pow(sin($lat), 2));
$T = pow(tan($lat), 2);
$C = $ep2 * pow(cos($lat), 2);
$A = cos($lat) * ($lon - $lon0);
$M = $a * ((1 - $e2 / 4 - 3 * $e2 ** 2 / 64 - 5 * $e2 ** 3 / 256) * $lat
- (3 * $e2 / 8 + 3 * $e2 ** 2 / 32 + 45 * $e2 ** 3 / 1024) * sin(2 * $lat)
+ (15 * $e2 ** 2 / 256 + 45 * $e2 ** 3 / 1024) * sin(4 * $lat)
- (35 * $e2 ** 3 / 3072) * sin(6 * $lat));
$x = $k0 * $N * ($A + (1 - $T + $C) * $A ** 3 / 6
+ (5 - 18 * $T + $T ** 2 + 72 * $C - 58 * $ep2) * $A ** 5 / 120) + 500000.0;
$y = $k0 * ($M + $N * tan($lat) * ($A ** 2 / 2
+ (5 - $T + 9 * $C + 4 * $C ** 2) * $A ** 4 / 24
+ (61 - 58 * $T + $T ** 2 + 600 * $C - 330 * $ep2) * $A ** 6 / 720));
return [$x, $y];
}
list($mx, $my) = nachUtm32($lon, $lat);
$bbox = sprintf("%.1f,%.1f,%.1f,%.1f",
$mx - $breite / 2, $my - $hoehe / 2,
$mx + $breite / 2, $my + $hoehe / 2);
$luftbild = WMS_URL . "?" . http_build_query([
"SERVICE" => "WMS", "VERSION" => "1.3.0", "REQUEST" => "GetMap",
"LAYERS" => WMS_LAYER, "STYLES" => "", "CRS" => "EPSG:25832",
"BBOX" => $bbox,
"WIDTH" => (int) (PLAN_BREITE * WMS_SCHAERFE),
"HEIGHT" => (int) (PLAN_HOEHE * WMS_SCHAERFE),
"FORMAT" => "image/jpeg",
]);
/**
* Die Flächen, die abgeklickt werden.
*
* Die Räume kommen aus der Raumtabelle - ein neuer AG-Raum in rooms.php
* taucht hier von selbst auf. Davor stehen Haus und Hof: sie sind keine Räume
* und tragen keine Kachel, aber ohne sie wäre der Plan ein paar Flecken im
* Nichts. Das Haus ist zusätzlich der Umriss, an dem sich beim Zeichnen alles
* andere ausrichtet, deshalb steht es ganz vorn.
*
* "z" ist die Höhe über Gelände, "hoehe" die Bauhöhe darüber. Die Vorgaben
* sind das, was am häufigsten stimmt: eine ebenerdige, flache Fläche.
*/
$flaechen = [
["schluessel" => "haus", "name" => "Haus", "raum" => null,
"z" => 0, "hoehe" => 6, "dach" => 3],
["schluessel" => "hof", "name" => "Hof", "raum" => null,
"z" => 0, "hoehe" => 0, "dach" => 0],
];
foreach (allRooms() as $raum) {
if ($raum["floor"] !== ETAGE) {
continue;
}
// Was auf der OG-Ebene liegt, steht drei Meter höher - die Traufe liegt
// auf sechs Metern, bei zwei Geschossen ist das die halbe Höhe. Der
// Carport OG heisst zwar OG, steht aber auf dem Boden; deshalb entscheidet
// die Bauart und nicht der Name.
$obenAuf = in_array($raum["mqtt"], ["Veranda OG", "Terrasse OG"], true);
$mitDach = in_array($raum["mqtt"], ["Carport OG", "Garage"], true);
$flaechen[] = [
"schluessel" => $raum["id"],
"name" => $raum["mqtt"],
"raum" => $raum,
"z" => $obenAuf ? 3.0 : 0.0,
"hoehe" => $mitDach ? 2.5 : 0.0,
"dach" => 0,
];
}
// Genug unterscheidbare Farben für die Bedienung; gezeichnet wird später mit
// den Farben aus aussenFarbe().
$stifte = ["#ffbe3c", "#35b6cf", "#7ed957", "#ff7a7a", "#c58cff", "#ffe066",
"#6ee7c8", "#ff9f5c", "#9ec5ff", "#f78fd0", "#b5e48c"];
foreach ($flaechen as $i => $f) {
$flaechen[$i]["stift"] = $stifte[$i % count($stifte)];
$flaechen[$i]["farbe"] = aussenFarbe($f["name"]);
}
?><!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lageplan Außengelände</title>
<style>
:root {
--grund: #fbfbfa; --schrift: #1f2328; --leise: #6b7280;
--linie: #d8d8d4; --karte: #ffffff; --akzent: #00788f;
}
@media (prefers-color-scheme: dark) {
:root {
--grund: #16181c; --schrift: #e8eaed; --leise: #9aa0a6;
--linie: #2f333a; --karte: #1e2126; --akzent: #35b6cf;
}
}
body {
margin: 0; padding: 1.5rem 1rem 3rem;
background: var(--grund); color: var(--schrift);
font: 15px/1.55 -apple-system, "Segoe UI", Roboto, sans-serif;
}
.huelle { max-width: 1000px; margin: 0 auto; }
h1 { font-size: 1.35rem; margin: 0 0 .3rem; }
.vorwort { color: var(--leise); margin: 0 0 1.2rem; max-width: 62ch; }
.vorwort code, .quelle code {
background: var(--karte); border: 1px solid var(--linie);
border-radius: .25rem; padding: .05rem .3rem; font-size: .88em;
}
.etage {
background: var(--karte); border: 1px solid var(--linie);
border-radius: .6rem; padding: .8rem; margin-bottom: 1.2rem;
}
.nebeneinander { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
@media (max-width: 720px) { .nebeneinander { grid-template-columns: 1fr; } }
.nebeneinander h3 {
font-size: .82rem; font-weight: 600; color: var(--leise);
margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .04em;
}
.plan {
display: block; width: 100%; height: auto; border-radius: .4rem;
touch-action: none; background: #0b0d10;
}
#plan { cursor: crosshair; }
.achsen text { fill: #ffffffaa; font: 7px sans-serif; }
.stift { stroke: #ffbe3c; stroke-width: .4; stroke-dasharray: 3 3; }
.name { font: 6px sans-serif; text-anchor: middle; paint-order: stroke;
stroke: #000a; stroke-width: 1.8; }
.waehler { display: flex; flex-wrap: wrap; gap: .4rem; margin: .8rem 0 .2rem; }
.waehler button {
font: inherit; font-size: .88rem; cursor: pointer;
background: var(--karte); color: var(--schrift);
border: 1px solid var(--linie); border-radius: .35rem;
padding: .25rem .6rem; display: flex; align-items: center; gap: .4rem;
}
.waehler button[aria-pressed="true"] { border-color: var(--akzent); box-shadow: 0 0 0 1px var(--akzent); }
.waehler .tupfen { width: .7rem; height: .7rem; border-radius: 50%; }
.waehler .zahl { color: var(--leise); font-variant-numeric: tabular-nums; }
.hoehen { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
margin: .7rem 0 0; font-size: .88rem; color: var(--leise); }
.hoehen input {
font: inherit; width: 4.2rem; padding: .15rem .35rem; text-align: right;
background: var(--grund); color: var(--schrift);
border: 1px solid var(--linie); border-radius: .3rem;
}
.knoepfe { display: flex; flex-wrap: wrap; gap: .4rem; margin: .6rem 0 0; }
.knoepfe button {
font: inherit; font-size: .88rem; cursor: pointer; padding: .25rem .6rem;
background: var(--karte); color: var(--schrift);
border: 1px solid var(--linie); border-radius: .35rem;
}
.hinweis { color: var(--leise); font-size: .88rem; margin: .5rem 0 0; min-height: 1.4em; }
.hinweis b { color: var(--schrift); font-variant-numeric: tabular-nums; }
h2 { font-size: .95rem; margin: 1.4rem 0 .4rem; font-weight: 600; }
textarea {
width: 100%; box-sizing: border-box; min-height: 11rem; resize: vertical;
background: var(--karte); color: var(--schrift);
border: 1px solid var(--linie); border-radius: .4rem; padding: .6rem;
font-family: ui-monospace, Consolas, monospace; font-size: .82rem; line-height: 1.5;
}
.quelle { color: var(--leise); font-size: .82rem; margin: 1.6rem 0 0; }
.quelle a { color: var(--akzent); }
</style>
</head>
<body>
<div class="huelle">
<h1>Lageplan Außengelände</h1>
<p class="vorwort">
Links das Luftbild von oben, rechts die Vorschau in der Schrägsicht von
Südwesten, in der die Etage später gezeichnet wird. Der Ausschnitt zeigt
<b><?= rtrim(rtrim(number_format($breite, 1, ",", ""), "0"), ",") ?> &times;
<?= rtrim(rtrim(number_format($hoehe, 1, ",", ""), "0"), ",") ?> m</b> &ndash;
eine Einheit sind <?= number_format($breite / PLAN_BREITE, 2, ",", "") ?> m.
Wähle eine Fläche, klick ihre Ecken der Reihe nach ab und stell darunter ihre
Höhe ein; <b>Backspace</b> nimmt den letzten Punkt zurück. Unten stehen die
fertigen Zeilen für <code>restricted/aussenplan.php</code>.
Passt der Ausschnitt nicht, verschieb ihn über <code>?lat=</code>,
<code>?lon=</code> und <code>?breite=</code> in der Adresszeile.
</p>
<section class="etage">
<div class="nebeneinander">
<div>
<h3>Draufsicht &ndash; hier wird geklickt</h3>
<svg viewBox="0 0 <?= PLAN_BREITE ?> <?= PLAN_HOEHE ?>" class="plan" id="plan">
<defs>
<pattern id="raster" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M10 0H0V10" fill="none" stroke="#ffffff22" stroke-width=".4"/>
</pattern>
</defs>
<image href="<?= htmlspecialchars($luftbild, ENT_QUOTES) ?>" x="0" y="0"
width="<?= PLAN_BREITE ?>" height="<?= PLAN_HOEHE ?>"/>
<rect x="0" y="0" width="<?= PLAN_BREITE ?>" height="<?= PLAN_HOEHE ?>" fill="url(#raster)"/>
<g class="achsen">
<?php for ($v = 50; $v < PLAN_BREITE; $v += 50): ?>
<text x="<?= $v ?>" y="9" text-anchor="middle"><?= $v ?></text>
<?php endfor; ?>
<?php for ($v = 50; $v < PLAN_HOEHE; $v += 50): ?>
<text x="4" y="<?= $v + 3 ?>"><?= $v ?></text>
<?php endfor; ?>
</g>
<g id="gezeichnet"></g>
<g id="stift" style="display:none">
<line class="stift" x1="<?= -PLAN_BREITE ?>" x2="<?= PLAN_BREITE ?>"/>
<line class="stift" y1="<?= -PLAN_HOEHE ?>" y2="<?= PLAN_HOEHE ?>"/>
</g>
</svg>
</div>
<div>
<h3>Schrägsicht von Südwesten &ndash; so wird es aussehen</h3>
<svg viewBox="0 0 <?= PLAN_BREITE ?> <?= PLAN_HOEHE ?>" class="plan" id="schraeg"></svg>
</div>
</div>
<div class="waehler" id="waehler"></div>
<div class="hoehen">
<span id="hoehenName"></span>
<label>Höhe über Gelände <input type="number" id="feldZ" step="0.5" min="0" max="20"> m</label>
<label>Bauhöhe <input type="number" id="feldH" step="0.5" min="0" max="20"> m</label>
<label id="dachHuelle">Dach darüber <input type="number" id="feldDach" step="0.5" min="0" max="20"> m</label>
</div>
<div class="knoepfe">
<button type="button" id="btnZurueck">Punkt zurück</button>
<button type="button" id="btnLeeren">Diese Fläche leeren</button>
<button type="button" id="btnAllesLeeren">Alles verwerfen</button>
</div>
<p class="hinweis" id="hinweis">Fläche wählen und die erste Ecke anklicken …</p>
</section>
<h2>Für <code>restricted/aussenplan.php</code> &ndash; in <code>aussenFlaechen()</code></h2>
<textarea id="ausgabe" readonly spellcheck="false"></textarea>
<p class="quelle">
Luftbild: <b>Datenquelle Bayerische Vermessungsverwaltung</b> &ndash;
<a href="https://www.geodaten.bayern.de" target="_blank" rel="noopener">www.geodaten.bayern.de</a>,
DOP20 (20 cm), <a href="https://creativecommons.org/licenses/by/4.0/deed.de"
target="_blank" rel="noopener">CC BY 4.0</a>. Live vom WMS geholt, es liegt keine
Kopie im Repository. Ausschnitt <code><?= htmlspecialchars($bbox) ?></code> in EPSG:25832.
</p>
</div>
<script>
// Der Server schreibt hier hinein, was in aussenplan.php steht - damit
// Vorschau und spaetere Zeichnung dieselbe Projektion benutzen.
const PLAN_B = <?= PLAN_BREITE ?>, PLAN_H = <?= PLAN_HOEHE ?>, RAND = <?= PLAN_RAND ?>;
const WINKEL = <?= PLAN_WINKEL ?>, STAUCHUNG = <?= PLAN_STAUCHUNG ?>,
UEBERHOEHUNG = <?= PLAN_UEBERHOEHUNG ?>;
const METER_JE_EINHEIT = <?= json_encode(round($breite / PLAN_BREITE, 4)) ?>;
const FLAECHEN = <?= json_encode($flaechen, JSON_UNESCAPED_UNICODE) ?>;
// Abgeklicktes ueberlebt einen Neuladen - neun Flaechen sind Arbeit, die
// man nicht wegen eines Fehlklicks auf F5 noch einmal machen will.
const SPEICHER = "lageplan-<?= ETAGE ?>";
let daten = {};
try { daten = JSON.parse(localStorage.getItem(SPEICHER)) || {}; } catch (e) { daten = {}; }
FLAECHEN.forEach(f => {
const d = daten[f.schluessel] || {};
daten[f.schluessel] = {
punkte: Array.isArray(d.punkte) ? d.punkte : [],
z: typeof d.z === "number" ? d.z : f.z,
hoehe: typeof d.hoehe === "number" ? d.hoehe : f.hoehe,
dach: typeof d.dach === "number" ? d.dach : f.dach
};
});
let aktiv = FLAECHEN[0].schluessel;
const svg = document.getElementById("plan");
const gezeichnet = document.getElementById("gezeichnet");
const schraeg = document.getElementById("schraeg");
const stift = document.getElementById("stift");
const hinweis = document.getElementById("hinweis");
const flaeche = s => FLAECHEN.find(f => f.schluessel === s);
const mitte = l => {
const s = l.reduce((a, p) => [a[0] + p[0], a[1] + p[1]], [0, 0]);
return [s[0] / l.length, s[1] / l.length];
};
function element(name, attribute) {
const e = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [k, v] of Object.entries(attribute)) e.setAttribute(k, v);
return e;
}
// --- Draufsicht ---------------------------------------------------------
function zeichnePlan() {
gezeichnet.innerHTML = "";
FLAECHEN.forEach(f => {
const liste = daten[f.schluessel].punkte;
if (!liste.length) return;
const zug = liste.map(p => p.join(",")).join(" ");
// Unter drei Ecken ist es ein Linienzug und keine Flaeche - ein Polygon
// saehe nach etwas aus, was es noch nicht ist.
gezeichnet.appendChild(element(liste.length > 2 ? "polygon" : "polyline", {
points: zug, fill: liste.length > 2 ? f.stift + "44" : "none",
stroke: f.stift, "stroke-width": f.schluessel === aktiv ? 1.1 : 0.7,
"stroke-linejoin": "round"
}));
liste.forEach(p => gezeichnet.appendChild(
element("circle", { cx: p[0], cy: p[1], r: 1.2, fill: f.stift })));
});
}
// --- Schraegsicht -------------------------------------------------------
//
// Dieselben Formeln wie aussenProjektor() und aussenEinpassung() in
// restricted/aussenplan.php. Massgeblich ist der PHP-Code: von dort kommen
// spaeter die gezeichnete Etage und die Kachelpositionen. Hier geht es nur
// darum, waehrend des Abklickens zu sehen, ob die Paare auseinandergehen.
function projiziere(x, y, z) {
const w = WINKEL * Math.PI / 180, c = Math.cos(w), s = Math.sin(w);
return [x * c - y * s, (x * s + y * c) * STAUCHUNG - z * UEBERHOEHUNG];
}
function inMeter(punkt) {
return [punkt[0] * METER_JE_EINHEIT, punkt[1] * METER_JE_EINHEIT];
}
function koerper() {
return FLAECHEN
.filter(f => daten[f.schluessel].punkte.length > 2)
.map(f => {
const d = daten[f.schluessel];
return { f: f, ecken: d.punkte.map(inMeter), z: d.z, hoehe: d.hoehe, dach: d.dach };
});
}
function einpassung(liste) {
const xs = [], ys = [];
liste.forEach(k => k.ecken.forEach(([x, y]) => {
[k.z, k.z + k.hoehe + k.dach].forEach(z => {
const p = projiziere(x, y, z); xs.push(p[0]); ys.push(p[1]);
});
}));
if (!xs.length) return null;
const bx = Math.max(...xs) - Math.min(...xs), by = Math.max(...ys) - Math.min(...ys);
const m = Math.min(bx > 0 ? (PLAN_B - 2 * RAND) / bx : Infinity,
by > 0 ? (PLAN_H - 2 * RAND) / by : Infinity);
const ms = isFinite(m) ? m : 1;
return { ms: ms,
dx: (PLAN_B - bx * ms) / 2 - Math.min(...xs) * ms,
dy: (PLAN_H - by * ms) / 2 - Math.min(...ys) * ms };
}
function dunkler(hex, faktor) {
const n = parseInt(hex.slice(1), 16);
return "rgb(" + [n >> 16, (n >> 8) & 255, n & 255]
.map(v => Math.round(v * faktor)).join(",") + ")";
}
function zeichneSchraeg() {
schraeg.innerHTML = "";
const liste = koerper();
const e = einpassung(liste);
if (!e) {
schraeg.appendChild(element("text", { x: PLAN_B / 2, y: PLAN_H / 2,
fill: "#9aa0a6", "font-size": 9, "text-anchor": "middle" }))
.textContent = "Noch keine Fläche mit drei Ecken.";
return;
}
const P = (x, y, z) => {
const p = projiziere(x, y, z);
return [p[0] * e.ms + e.dx, p[1] * e.ms + e.dy];
};
const stuecke = liste.map(k => {
const tiefe = Math.min(...k.ecken.map(([x, y]) => P(x, y, 0)[1]));
const unten = k.ecken.map(([x, y]) => P(x, y, k.z));
const oben = k.ecken.map(([x, y]) => P(x, y, k.z + k.hoehe));
const teile = [];
if (k.hoehe > 0) {
for (let i = 0; i < k.ecken.length; i++) {
const j = (i + 1) % k.ecken.length;
teile.push({ pkt: [unten[i], unten[j], oben[j], oben[i]],
farbe: dunkler(k.f.farbe, .62) });
}
}
teile.push({ pkt: oben, farbe: k.f.farbe });
if (k.dach > 0 && k.ecken.length === 4) {
const c = k.ecken;
const f1 = P((c[0][0] + c[3][0]) / 2, (c[0][1] + c[3][1]) / 2, k.z + k.hoehe + k.dach);
const f2 = P((c[1][0] + c[2][0]) / 2, (c[1][1] + c[2][1]) / 2, k.z + k.hoehe + k.dach);
teile.push({ pkt: [oben[0], oben[1], f2, f1], farbe: "#b5563a" });
teile.push({ pkt: [oben[3], oben[2], f2, f1], farbe: dunkler("#b5563a", .78) });
}
return { tiefe: tiefe, teile: teile, name: k.f.name, mitte: mitte(oben) };
}).sort((a, b) => a.tiefe - b.tiefe);
stuecke.forEach(s => s.teile.forEach(t => schraeg.appendChild(element("polygon", {
points: t.pkt.map(p => p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" "),
fill: t.farbe, stroke: "#ffffff30", "stroke-width": .4
}))));
stuecke.forEach(s => {
const t = element("text", { x: s.mitte[0].toFixed(1), y: (s.mitte[1] + 2).toFixed(1),
class: "name", fill: "#fff" });
t.textContent = s.name;
schraeg.appendChild(t);
});
}
// --- Bedienung ----------------------------------------------------------
function zeichneWaehler() {
const ziel = document.getElementById("waehler");
ziel.innerHTML = "";
FLAECHEN.forEach(f => {
const b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-pressed", f.schluessel === aktiv ? "true" : "false");
b.innerHTML = "<span class='tupfen' style='background:" + f.stift + "'></span>"
+ f.name + " <span class='zahl'>" + daten[f.schluessel].punkte.length + "</span>";
b.onclick = () => { aktiv = f.schluessel; alles(); };
ziel.appendChild(b);
});
const d = daten[aktiv];
document.getElementById("hoehenName").innerHTML = "<b>" + flaeche(aktiv).name + "</b>:";
document.getElementById("feldZ").value = d.z;
document.getElementById("feldH").value = d.hoehe;
document.getElementById("feldDach").value = d.dach;
// Ein Dach hat nur, was auch eine Bauhoehe hat - sonst schwebte es.
document.getElementById("dachHuelle").style.display = d.hoehe > 0 ? "" : "none";
}
function schreibeAusgabe() {
const zeilen = [];
FLAECHEN.forEach(f => {
const d = daten[f.schluessel];
if (d.punkte.length < 3) return;
const ecken = d.punkte.map(inMeter)
.map(p => "[" + p[0].toFixed(1) + ", " + p[1].toFixed(1) + "]").join(", ");
zeilen.push(' ["raum" => ' + (f.raum ? '"' + f.raum.mqtt + '"' : "null")
+ ', "z" => ' + d.z + ', "hoehe" => ' + d.hoehe + ', "dach" => ' + d.dach
+ ',\n "farbe" => "' + f.farbe + '",\n'
+ ' "ecken" => [' + ecken + ']],');
});
document.getElementById("ausgabe").value = zeilen.length
? zeilen.join("\n")
: "Noch nichts abgeklickt - eine Fläche braucht mindestens drei Ecken.";
}
function alles() {
zeichnePlan();
zeichneSchraeg();
zeichneWaehler();
schreibeAusgabe();
try { localStorage.setItem(SPEICHER, JSON.stringify(daten)); } catch (e) { /* egal */ }
}
function stelle(ereignis) {
const r = svg.getBoundingClientRect();
return [
Math.round((ereignis.clientX - r.left) / r.width * PLAN_B * 10) / 10,
Math.round((ereignis.clientY - r.top) / r.height * PLAN_H * 10) / 10
];
}
svg.addEventListener("pointermove", e => {
const [x, y] = stelle(e);
stift.style.display = "";
const zeilen = stift.querySelectorAll("line");
zeilen[0].setAttribute("y1", y); zeilen[0].setAttribute("y2", y);
zeilen[1].setAttribute("x1", x); zeilen[1].setAttribute("x2", x);
const liste = daten[aktiv].punkte;
let text = "<b>" + flaeche(aktiv).name + "</b> &ndash; " + x + "," + y
+ " &nbsp;(" + (x * METER_JE_EINHEIT).toFixed(1) + " m, "
+ (y * METER_JE_EINHEIT).toFixed(1) + " m)";
if (liste.length) {
const l = liste[liste.length - 1];
const d = Math.hypot(x - l[0], y - l[1]) * METER_JE_EINHEIT;
text += " &nbsp;&middot;&nbsp; letzte Kante <b>" + d.toFixed(1) + " m</b>";
}
hinweis.innerHTML = text;
});
svg.addEventListener("pointerleave", () => {
stift.style.display = "none";
hinweis.textContent = "Fläche wählen und die erste Ecke anklicken …";
});
svg.addEventListener("click", e => { daten[aktiv].punkte.push(stelle(e)); alles(); });
document.addEventListener("keydown", e => {
if (e.key === "Backspace" && e.target.tagName !== "INPUT") {
e.preventDefault(); daten[aktiv].punkte.pop(); alles();
}
});
["feldZ", "feldH", "feldDach"].forEach((id, i) => {
document.getElementById(id).addEventListener("input", e => {
const wert = parseFloat(e.target.value);
daten[aktiv][["z", "hoehe", "dach"][i]] = isNaN(wert) ? 0 : wert;
zeichneSchraeg(); schreibeAusgabe();
if (i === 1) document.getElementById("dachHuelle").style.display =
daten[aktiv].hoehe > 0 ? "" : "none";
try { localStorage.setItem(SPEICHER, JSON.stringify(daten)); } catch (err) { /* egal */ }
});
});
document.getElementById("btnZurueck").onclick = () => { daten[aktiv].punkte.pop(); alles(); };
document.getElementById("btnLeeren").onclick = () => { daten[aktiv].punkte = []; alles(); };
document.getElementById("btnAllesLeeren").onclick = () => {
if (!confirm("Alle abgeklickten Flächen verwerfen?")) return;
FLAECHEN.forEach(f => { daten[f.schluessel].punkte = []; });
alles();
};
alles();
</script>
</body>
</html>