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>
614 lines
25 KiB
PHP
614 lines
25 KiB
PHP
<?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.
|
||
*
|
||
* Seit September 2026 ist sie nur noch ein Weg unter mehreren: das
|
||
* Außengelände ist eine Etage wie jede andere, und ihr Grundriss darf ein
|
||
* beliebiges hochgeladenes Bild sein (Einstellungen -> Grundriss). Diese Seite
|
||
* hilft nur, eines in Schrägsicht zu erzeugen.
|
||
*/
|
||
|
||
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"), ",") ?> ×
|
||
<?= rtrim(rtrim(number_format($hoehe, 1, ",", ""), "0"), ",") ?> m</b> –
|
||
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 – 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 – 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> – in <code>aussenFlaechen()</code></h2>
|
||
<textarea id="ausgabe" readonly spellcheck="false"></textarea>
|
||
|
||
<p class="quelle">
|
||
Luftbild: <b>Datenquelle Bayerische Vermessungsverwaltung</b> –
|
||
<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> – " + x + "," + y
|
||
+ " (" + (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 += " · 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>
|