/** 2D-Grundriss-Ansicht v2: Plan von oben als SVG. * * Macht Pläne normgerecht beurteilbar. Gegenüber v1 neu: * - **Objektsymbole** (Architekten-Draufsicht) statt nur beschrifteter Boxen * (`symbole2d`), gefärbt nach Norm-Ampel bzw. Materialfarbe. * - **Wände wie im CAD-Plan** (`wandBaender`/`aussenPolygon`): das Raumpolygon * ist die **Innenkante** (nutzbare Raumfläche, bis an die der Solver Objekte * stellt), die Wandstärke wächst nach **aussen**; Ecken sind gehrungs- * verbunden (geteilte Aussenpunkte). Öffnungen sind **echte Aussparungen in * der Wandstärke** (Segmentierung via `wandLuecken`), nicht bloss Linien auf * der Wandoberfläche. * - **Layer-System v2** (`layer2d`): Darstellungs-Layer (globale Toggles) + * Objekt-Layer (jedes Möbel einzeln ein-/ausblendbar, CAD-artig). * - **Platzbedarf-Layer**: Clearance-Zonen (`type:"clearance"`-Regeln) als * Bewegungsflächen vor den Objekten – deckungsgleich mit der Ampel. * - **Messwerkzeug** (zwei Klicks → Distanz, Snap auf Wand-Ecken). * - **Direkte Interaktion**: gewähltes Objekt per Drag verschieben (5-cm-Raster) * und über einen Rotations-Griff in 15°-Schritten drehen. * * Footprint-Geometrie kommt aus `plan2d` (= `footprint()` von @fp/shared/rules), * also exakt deckungsgleich mit Solver, Interpreter, Symbolen und 3D-Viewer. * `interaktiv=false` (z.B. Schritt 3, Vorschau) rendert reine Anzeige ohne * Layer-Panel und ohne Editier-Interaktion (Platzbedarf-Layer bleibt aus). */ import { useEffect, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from "react"; import { frontDir, type Rule, type Vec2 } from "@fp/shared/rules"; import type { KatalogItem, Placement, Room } from "./api"; import type { Flaechen2D } from "./flaechen2d"; import { DARSTELLUNGS_LAYER, defaultLayers, objektSichtbar, toggleLayer, toggleObjekt, type LayerId, } from "./layer2d"; import { materialFarbe } from "./moebel3d"; import { aussenPolygon, clearanceRect, computeTransform, distanz, footprintPoints, innwardNormal, naechsteEcke, rasten, toScreen, toWorld, wandBaender, wandEcken, yawAusZeiger, } from "./plan2d.ts"; import { symbolScreenPrims } from "./symbole2d.ts"; import { THEME } from "./theme"; const SIZE = 1000; const PAD = 48; const WANDDICKE_FALLBACK = 0.1; // m, falls die Wand keine thickness trägt const SNAP_ECKE = 0.15; // m, Snapping-Radius Messwerkzeug const FARBE_VERLETZT = "#c0392b"; const FARBE_KNAPP = "#e67e22"; const FARBE_GESPERRT = "#7a7a7a"; const FARBE_GEWAEHLT = THEME.orange; const FARBE_NEUTRAL = "#2c2c28"; /** Reiner Planlook (Ampel-Layer aus): schwarzer Stift, weisse Schraffur. */ const FARBE_PLAN = "#1a1a1a"; // Wand-Look nach Referenzbild – unabhängig von der Ampel (Wände sind kein // Ampel-Objekt): helle Füllung + dünne dunklere Kante. const WAND_FUELLUNG_MASSIV = "#d3d3d3"; const WAND_FUELLUNG_LEICHT = "#e2e2e2"; const WAND_KANTE = "#9a9a9a"; const FENSTER_GLAS = "#6f8aa0"; /** CAD-dezente Tönung: Materialfarbe Richtung Weiss aufgehellt – der Plan-Look * bleibt ruhig (keine grellen Füllungen), das Material ist trotzdem ablesbar. */ function dezentton(hex: string, aufhellung = 0.45): string { const m = /^#([0-9a-f]{6})$/i.exec(hex); if (!m?.[1]) return hex; const n = Number.parseInt(m[1], 16); const k = (c: number): string => Math.round(c + (255 - c) * aufhellung) .toString(16) .padStart(2, "0"); return `#${k((n >> 16) & 0xff)}${k((n >> 8) & 0xff)}${k(n & 0xff)}`; } type Status = "verletzt" | "knapp"; /** Strichfarbe eines Symbols: Materialfarbe, von der Ampel überstimmt. */ function strichfarbe( item: KatalogItem, placement: Placement, status: Status | undefined, ampelAn: boolean, ): string { if (ampelAn) { if (status === "verletzt") return FARBE_VERLETZT; if (status === "knapp") return FARBE_KNAPP; if (placement.locked) return FARBE_GESPERRT; return materialFarbe(item.funktionsTyp); } // Ampel-Layer aus → reiner Planlook mit schwarzem Stift (Bryan 2026-07-07). return FARBE_PLAN; } /** Türschwenk als robuste Polylinie (Viertelkreis von der Wand ins Innere). */ function schwenkPunkte( a: Vec2, breite: number, n: Vec2, u: Vec2, t: ReturnType, ): string { const dir = Math.sign(u[0] * n[1] - u[1] * n[0]) || 1; // Drehsinn u → n (Welt) const schritte = 8; const pts: string[] = []; for (let k = 0; k <= schritte; k++) { const w = (dir * (Math.PI / 2) * k) / schritte; const c = Math.cos(w); const s = Math.sin(w); const wx = a[0] + (u[0] * c - u[1] * s) * breite; const wz = a[1] + (u[0] * s + u[1] * c) * breite; const [sx, sy] = toScreen([wx, wz], t); pts.push(`${sx.toFixed(1)},${sy.toFixed(1)}`); } return pts.join(" "); } /** Eine Öffnung IN der Wandstärke (die Wandfüllung ist an dieser Stelle bereits * ausgespart – hier kommt nur die Tür/Fenster-Signatur in die Lücke). * - Tür: Schwelle + Türblatt-Radius + Viertelkreis-Schwenk ins Rauminnere. * - Fenster: Glas über die volle Wandstärke (Rahmen innen/aussen + Mittellinie). */ function Oeffnung({ opening, room, t, }: { opening: Room["openings"][number]; room: Room; t: ReturnType; }) { const wall = room.shell.walls.find((w) => w.id === opening.hostWall); if (!wall) return null; const dx = wall.end[0] - wall.start[0]; const dz = wall.end[1] - wall.start[1]; const len = Math.hypot(dx, dz) || 1; const u: Vec2 = [dx / len, dz / len]; const dicke = (wall as { thickness?: number }).thickness ?? WANDDICKE_FALLBACK; // Normale ins Rauminnere (für den Türschwenk) und ihre Umkehrung nach aussen. // Die Wand wächst von der Innenkante (Achse = Raumpolygon-Linie, f=0) um die // volle Wandstärke nach aussen (f=1) – deckungsgleich mit `wandBaender`. const nIn = innwardNormal( wall.start as Vec2, wall.end as Vec2, room.shell.floor.polygon as Vec2[], ); const nAus: Vec2 = [-nIn[0], -nIn[1]]; const a: Vec2 = [wall.start[0] + u[0] * opening.offset, wall.start[1] + u[1] * opening.offset]; const b: Vec2 = [a[0] + u[0] * opening.width, a[1] + u[1] * opening.width]; // Punkt an Laibung `p`, um Anteil `f` der Wandstärke nach aussen versetzt. const bandPunkt = (p: Vec2, f: number): Vec2 => [ p[0] + nAus[0] * dicke * f, p[1] + nAus[1] * dicke * f, ]; const scr = (p: Vec2, f: number): [number, number] => toScreen(bandPunkt(p, f), t); const [aOx, aOy] = scr(a, 0); // Innenkante (Raumseite) an Laibung a const [aIx, aIy] = scr(a, 1); // Aussenkante an Laibung a const [bOx, bOy] = scr(b, 0); const [bIx, bIy] = scr(b, 1); if (opening.type === "door") { // Türblatt-Endpunkt (90° offen, ins Rauminnere) für die Radiuslinie. const [lx, ly] = toScreen([a[0] + nIn[0] * opening.width, a[1] + nIn[1] * opening.width], t); return ( {/* Schwelle: dünne Linie über die Wandstärke (innen→aussen) je Laibung */} {/* Türblatt (Radius) + Viertelkreis-Schwenk, ab der Innenkante ins Zimmer */} ); } // Fenster: Glas IN der Wand – Rahmenlinie an Innen- und Aussenkante (0 / volle // Wandstärke) + Mittellinie, alle entlang der Achse; Laibungen schliessen ab. const [mAx, mAy] = scr(a, 0.5); const [mBx, mBy] = scr(b, 0.5); return ( {/* Laibungskanten (schmale Querlinien) schliessen die Lücke sauber ab */} ); } /** Symbol eines Placements in Bildschirm-Koordinaten (oder null → Box-Fallback). */ function ObjektSymbol({ prims, farbe, schraffur, }: { prims: ReturnType; farbe: string; /** Ampel aus → geschlossene Formen mit weisser Schraffur füllen (Planlook). */ schraffur: boolean; }) { if (!prims) return null; return ( {prims.map((p, i) => { const dash = p.dash ? "5 4" : undefined; if (p.kind === "circle") { return ; } if (p.kind === "line") { return ; } return ; })} ); } /** Erste passende Clearance-Regel eines Funktionstyps (Bewegungsfläche vorn). */ function clearanceRegel(rules: readonly Rule[], funktionsTyp: string): Rule | undefined { return rules.find((r) => r.type === "clearance" && r.appliesTo === funktionsTyp); } /** Häufigster Wert einer Zahlenliste (für die Referenz-Wandstärke der Gehrung). */ function haeufigste(werte: number[]): number | undefined { if (werte.length === 0) return undefined; const zaehler = new Map(); for (const v of werte) zaehler.set(v, (zaehler.get(v) ?? 0) + 1); let beste = werte[0]; let max = 0; for (const [v, n] of zaehler) { if (n > max) { max = n; beste = v; } } return beste; } export function Viewer2D({ room, placements, catalog, rules = [], gewaehltId, statusById, onSelect, onMove, onRotate, interaktiv = false, flaechen2d = null, hervorgehobeneWand = null, }: { room: Room; placements: Placement[]; catalog: KatalogItem[]; /** Norm-Regeln (für den Platzbedarf-Layer: `type:"clearance"`). Optional. */ rules?: readonly Rule[]; gewaehltId: string | null; statusById: Map; onSelect: (id: string | null) => void; /** Item per Drag verschieben (absolute Welt-Position). Fehlt → kein Drag. */ onMove?: (id: string, world: [number, number]) => void; /** Item per Rotations-Griff drehen (absoluter Yaw in Grad). Fehlt → kein Griff. */ onRotate?: (id: string, yawDeg: number) => void; /** Layer-Panel + Editier-Interaktion (Drag/Rotate/Messen). Default: aus. */ interaktiv?: boolean; /** Aufgelöste Flächen-Darstellung (Layer «Flächen», Welle C): dezente Boden- * Tönung + Wandband-Farbtöne + Legende. `null` = exakt heutige Darstellung. */ flaechen2d?: Flaechen2D | null; /** Wand-Index (room.shell.walls) für das Einzelwand-Highlight (Flächen-Panel). */ hervorgehobeneWand?: number | null; }) { const byId = new Map(catalog.map((c) => [c.id, c])); const floor = room.shell.floor.polygon as Vec2[]; // CAD-Wände: das Raumpolygon ist die INNENKANTE (nutzbare Fläche), die // Wandstärke wächst nach AUSSEN. Für saubere (gehrungsverbundene) Ecken wird // das nach aussen versetzte Aussenpolygon einmal berechnet und je Eckpunkt der // Wände nachgeschlagen. Die Wandstärke ist im POC pro Raum einheitlich; als // Referenz für die Gehrung dient die häufigste Stärke (offene 0-Wände zählen // nicht). const wandDicken = room.shell.walls .map((w) => (w as { thickness?: number }).thickness ?? WANDDICKE_FALLBACK) .filter((d) => d > 1e-6); const refDicke = haeufigste(wandDicken) ?? WANDDICKE_FALLBACK; const aussen = aussenPolygon(floor, refDicke); const eckKey = (p: Vec2 | readonly [number, number]) => `${p[0].toFixed(4)},${p[1].toFixed(4)}`; const aussenKarte = new Map(floor.map((p, i) => [eckKey(p), aussen[i]])); // Der Transform muss die nach aussen gewachsenen Wände einschliessen, sonst // würden sie am SVG-Rand abgeschnitten – deshalb über das Aussenpolygon // (statt über den Boden) einpassen. Der Boden bleibt das volle Raumpolygon. const t = computeTransform(aussen, SIZE, PAD); const floorPts = floor.map((p) => toScreen(p, t).join(",")).join(" "); const svgRef = useRef(null); const [dragId, setDragId] = useState(null); const [rotId, setRotId] = useState(null); const bewegtRef = useRef(false); const [messModus, setMessModus] = useState(false); const [messPunkte, setMessPunkte] = useState([]); // Layer-System v2: globale Darstellungs-Layer + einzeln versteckte Objekte. const [sichtbareLayer, setSichtbareLayer] = useState>(defaultLayers); const [versteckteObjekte, setVersteckteObjekte] = useState>(() => new Set()); const [layerPanelOffen, setLayerPanelOffen] = useState(false); // Messwerkzeug/Interaktion enden bei Escape (und beim Verlassen von interaktiv). useEffect(() => { if (!interaktiv) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setMessModus(false); setMessPunkte([]); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [interaktiv]); // Pointer-Event (Bildschirm) → Welt (x,z): via SVG-CTM in viewBox-Einheiten, // dann toWorld. So stimmt das Mapping unabhängig von der gerenderten Grösse. const pointerWelt = (e: { clientX: number; clientY: number }): Vec2 | null => { const svg = svgRef.current; const ctm = svg?.getScreenCTM(); if (!svg || !ctm) return null; const lokal = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); return toWorld([lokal.x, lokal.y], t); }; const ecken = wandEcken(room.shell.walls); const svgClick = (e: MouseEvent) => { if (messModus) { const w = pointerWelt(e); if (!w) return; const p = naechsteEcke(w, ecken, SNAP_ECKE); setMessPunkte((prev) => (prev.length >= 2 ? [p] : [...prev, p])); return; } if (bewegtRef.current) { bewegtRef.current = false; // Klick nach Drag/Rotate: nicht abwählen. return; } onSelect(null); }; const pointerMove = (e: PointerEvent) => { if (rotId && onRotate) { const w = pointerWelt(e); const p = placements.find((pl) => pl.id === rotId); if (w && p) { bewegtRef.current = true; onRotate(rotId, yawAusZeiger(p.pose.pos as Vec2, w, 15)); } return; } if (dragId && onMove) { const w = pointerWelt(e); if (w) { bewegtRef.current = true; onMove(dragId, [rasten(w[0]), rasten(w[1])]); } } }; const pointerUp = (e: PointerEvent) => { if (!dragId && !rotId) return; svgRef.current?.releasePointerCapture(e.pointerId); setDragId(null); setRotId(null); }; const ampelAn = sichtbareLayer.ampel; // Flächen-Layer: nur wirksam, wenn eine aufgelöste Darstellung übergeben wurde – // Layer AUS oder fehlende Daten = exakt die bisherige Plan-Optik. const flaechenAn = sichtbareLayer.flaechen && !!flaechen2d; const bodenStruktur = flaechenAn && flaechen2d && flaechen2d.boden.spez.muster !== "uni" ? { kachel: Math.max(8, flaechen2d.boden.spez.masse_m * t.scale), gitter: flaechen2d.boden.spez.muster !== "parkett", fugen: flaechen2d.boden.spez.fugenfarbe, } : null; // Sichtbare Placements (Objekt-Layer): einzeln versteckte fliegen ganz raus – // inkl. ihres Platzbedarfs. const sichtbarePlacements = placements.filter((p) => objektSichtbar(versteckteObjekte, p.id)); return (
{interaktiv && ( setLayerPanelOffen((o) => !o)} messModus={messModus} onMessen={() => { setMessModus((m) => !m); setMessPunkte([]); }} /> )}
{interaktiv && layerPanelOffen && ( setSichtbareLayer((s) => toggleLayer(s, id))} placements={placements} byId={byId} versteckteObjekte={versteckteObjekte} gewaehltId={gewaehltId} onObjekt={(id) => setVersteckteObjekte((v) => toggleObjekt(v, id))} onSelect={onSelect} onSchliessen={() => setLayerPanelOffen(false)} /> )} {/* Weisse Schraffur (Architektenplan) für die Objektfüllung, wenn die Ampel-Ebene aus ist – weisser Grund + feine schwarze Diagonalen (Bryan 2026-07-07). */} {/* Fugen-Struktur des Boden-Materials (Flächen-Layer): feines Gitter (Fliesen/Stein) bzw. Dielenlinien (Parkett) im Massstab des Plans. */} {bodenStruktur && ( {bodenStruktur.gitter && ( )} )} {/* Boden: beige normal, im Planlook (Ampel aus) reinweiss. */} {/* Flächen-Layer: dezente Material-Tönung + Struktur über dem Boden. */} {flaechenAn && flaechen2d && ( {bodenStruktur && ( )} )} {/* Wände als gefüllte Polygone mit echter Wandstärke – pro Wand an den Öffnungen segmentiert (echte Aussparung in der Wandstärke). */} {sichtbareLayer.waende && room.shell.walls.map((w, wandIdx) => { const dicke = (w as { thickness?: number }).thickness ?? WANDDICKE_FALLBACK; if (dicke <= 1e-6) return null; // offene Seite (Grossraum): keine Wand const massiv = w.kind === "massiv"; // Flächen-Layer: Wandband im (dezenten) Material-Farbton der Wand; // Akzentwände werden weniger aufgehellt und bleiben so erkennbar. const wandFlaeche = flaechenAn ? flaechen2d?.waende[wandIdx] : undefined; const fuellung = wandFlaeche ? dezentton(wandFlaeche.farbe, wandFlaeche.akzent ? 0.2 : 0.42) : massiv ? WAND_FUELLUNG_MASSIV : WAND_FUELLUNG_LEICHT; // Einzelwand-Highlight (Flächen-Panel): dezente CI-orange Kante. const hervor = wandIdx === hervorgehobeneWand; const nIn = innwardNormal(w.start as Vec2, w.end as Vec2, floor); const nAus: Vec2 = [-nIn[0], -nIn[1]]; // Wand wächst nach aussen const eigene = room.openings .filter((o) => o.hostWall === w.id) .map((o) => ({ offset: o.offset, width: o.width })); const baender = wandBaender( w.start as Vec2, w.end as Vec2, nAus, dicke, eigene, aussenKarte.get(eckKey(w.start)), aussenKarte.get(eckKey(w.end)), ); return ( {baender.map((band, i) => ( toScreen(p, t).join(",")).join(" ")} fill={fuellung} stroke={hervor ? FARBE_GEWAEHLT : WAND_KANTE} strokeWidth={hervor ? 2.4 : 0.8} fillOpacity={massiv ? 1 : 0.85} /> ))} ); })} {sichtbareLayer.oeffnungen && room.openings.map((o) => )} {/* Platzbedarf-Layer: Bewegungsflächen (Clearance) vor den Objekten – deckungsgleich mit der Ampel; nur visualisieren, nicht prüfen. */} {sichtbareLayer.platzbedarf && sichtbarePlacements.map((p) => { const item = byId.get(p.catalogItemId); if (!item) return null; const regel = clearanceRegel(rules, item.funktionsTyp); if (!regel) return null; const tiefe = Number(regel.params?.depth ?? 0.6); const breite = Number(regel.params?.width ?? item.masse.w); const rect = clearanceRect( p.pose.pos as Vec2, item.masse.d, p.pose.yawDeg, tiefe, breite, ) .map((pt) => toScreen(pt, t).join(",")) .join(" "); return ( {regel.hinweis ?? "Bewegungsfläche (Platzbedarf)"} ); })} {/* Wandlängen-Beschriftung (Layer «Masse») */} {sichtbareLayer.masse && room.shell.walls.map((w) => { const laenge = distanz(w.start as Vec2, w.end as Vec2); if (laenge < 0.2) return null; const mid: Vec2 = [(w.start[0] + w.end[0]) / 2, (w.start[1] + w.end[1]) / 2]; const n = innwardNormal(w.start as Vec2, w.end as Vec2, floor); const [mx, my] = toScreen([mid[0] + n[0] * 0.14, mid[1] + n[1] * 0.14], t); return ( {laenge.toFixed(2)} m ); })} {/* Placements: Statusfüllung, Symbol/Box, Rahmen, Beschriftung */} {sichtbarePlacements.map((p) => { const item = byId.get(p.catalogItemId); if (!item) return null; const gewaehlt = p.id === gewaehltId; const wandobjekt = item.mount === "wand"; const status = statusById.get(p.id); const [lx, ly] = toScreen(p.pose.pos as Vec2, t); const fp = footprintPoints( p.pose.pos as Vec2, item.masse.w, item.masse.d, p.pose.yawDeg, t, ); const tint = ampelAn && status === "verletzt" ? FARBE_VERLETZT : ampelAn && status === "knapp" ? FARBE_KNAPP : "none"; const zeigeRahmen = gewaehlt || sichtbareLayer.boxen; const prims = symbolScreenPrims( item.funktionsTyp, p.pose.pos as Vec2, item.masse.w, item.masse.d, p.pose.yawDeg, t, item.modell3d, ); return ( { e.stopPropagation(); if (!bewegtRef.current) onSelect(p.id); }} onPointerDown={(e) => { e.stopPropagation(); onSelect(p.id); if (!interaktiv || p.locked || !onMove) return; bewegtRef.current = false; svgRef.current?.setPointerCapture(e.pointerId); setDragId(p.id); }} style={{ cursor: interaktiv && !p.locked ? "grab" : "pointer" }} > {/* Statusfüllung (Ampel) – auch ohne Boxen-Layer sichtbar */} {sichtbareLayer.objekte && (prims ? ( ) : !ampelAn ? ( // Fallback im Planlook: weisse Schraffur + schwarzer Rahmen. ) : ( // Fallback: bisherige beschriftete Box ))} {sichtbareLayer.beschriftung && ( {item.funktionsTyp} )} ); })} {/* Rotations-Griff am gewählten Objekt */} {interaktiv && onRotate && !messModus && ( { bewegtRef.current = false; svgRef.current?.setPointerCapture(e.pointerId); setRotId(id); }} /> )} {/* Messwerkzeug-Overlay */} {messModus && } {ampelAn && } {flaechenAn && flaechen2d && }
); } /** Rotations-Griff (kleiner Kreis vor dem Objekt): Drag = 15°-Raster, * Doppelklick = +90°. Nutzt `frontDir` für die Griff-Position. */ function RotationsGriff({ placements, byId, gewaehltId, t, onRotate, onDown, }: { placements: Placement[]; byId: Map; gewaehltId: string | null; t: ReturnType; onRotate: (id: string, yawDeg: number) => void; onDown: (id: string, e: PointerEvent) => void; }) { const p = placements.find((pl) => pl.id === gewaehltId); if (!p || p.locked) return null; const item = byId.get(p.catalogItemId); if (!item) return null; const center = p.pose.pos as Vec2; const fd = frontDir(p.pose.yawDeg); const abstand = item.masse.d / 2 + 0.2; // Meter vor die Front const griffWelt: Vec2 = [center[0] + fd[0] * abstand, center[1] + fd[1] * abstand]; const [cx, cy] = toScreen(center, t); const [gx, gy] = toScreen(griffWelt, t); return ( e.stopPropagation()} onPointerDown={(e) => { e.stopPropagation(); onDown(p.id, e); }} onDoubleClick={(e) => { e.stopPropagation(); onRotate(p.id, (p.pose.yawDeg + 90) % 360); }} > Drehen (ziehen = 15°, Doppelklick = +90°) ); } function MessOverlay({ punkte, t }: { punkte: Vec2[]; t: ReturnType }) { const screen = punkte.map((p) => toScreen(p, t)); const [a, b] = punkte; const [sa, sb] = screen; return ( {sa && sb && a && b && ( <> {distanz(a, b).toFixed(2)} m )} {screen.map(([x, y], i) => ( ))} ); } /** Minimale Werkzeugleiste über dem Plan: Layer-Panel öffnen + Messen. */ function Werkzeugleiste({ layerOffen, onLayer, messModus, onMessen, }: { layerOffen: boolean; onLayer: () => void; messModus: boolean; onMessen: () => void; }) { const knopf = (aktiv: boolean, farbe: string): CSSProperties => ({ borderRadius: 999, padding: "5px 12px", fontSize: 12, cursor: "pointer", border: `1px solid ${aktiv ? farbe : THEME.salbei}`, background: aktiv ? farbe : "#fff", color: aktiv ? "#fff" : THEME.salbei, }); return (
); } /** CAD-artiges Layer-Panel (Overlay oben rechts über dem Plan). Zwei Gruppen: * Darstellungs-Layer (globale Toggles) und Objekt-Layer (jedes Möbel einzeln * ein-/ausblendbar; Klick auf die Zeile wählt das Objekt). */ function LayerPanel({ sichtbareLayer, onLayer, placements, byId, versteckteObjekte, gewaehltId, onObjekt, onSelect, onSchliessen, }: { sichtbareLayer: Record; onLayer: (id: LayerId) => void; placements: Placement[]; byId: Map; versteckteObjekte: Set; gewaehltId: string | null; onObjekt: (id: string) => void; onSelect: (id: string | null) => void; onSchliessen: () => void; }) { return (
Layer
{/* Darstellungs-Layer */}

Darstellung

{DARSTELLUNGS_LAYER.map((l) => { const an = sichtbareLayer[l.id]; return ( ); })}
{/* Objekt-Layer */} {placements.length > 0 && (

Objekte ({placements.length})

{placements.map((p) => { const item = byId.get(p.catalogItemId); const sichtbar = !versteckteObjekte.has(p.id); const gewaehlt = p.id === gewaehltId; return (
); })}
)}
); } /** Kleine Material-Legende des Flächen-Layers (unten rechts, Plan-dezent). */ function FlaechenLegende({ eintraege }: { eintraege: { label: string; farbe: string }[] }) { const breite = 230; return ( {eintraege.map((e, i) => ( {e.label} ))} ); } function Legende() { const rows: [string, string][] = [ [THEME.gruen, "P1 / ok"], [FARBE_KNAPP, "knapp"], [FARBE_VERLETZT, "verletzt"], [FARBE_GESPERRT, "gesperrt"], ]; return ( {rows.map(([farbe, text], i) => ( {text} ))} ); }