/* Grundriss-Viewer · Phase 3 Dynamisches Layout aus Konfigurator-Daten. - Räume aus useKonfiguratorState (gehoben in App) - Squarified-Treemap-Algorithmus pro Stockwerk - Feature-Mapping zu Konfigurator-Modulen mit Preisen - WiFi-AP-Auto-Empfehlung skaliert mit Fläche */ const GV_PALETTE = { bg: "#071520", card: "#0D2030", accent: "#3ABDD0", soft: "#8DDBEA", text: "#ffffff", muted: "rgba(141,219,234,0.55)", line: "rgba(141,219,234,0.18)", dim: "rgba(141,219,234,0.25)", }; /* Modul-Metadaten — verbinden Konfigurator-Keys mit Anzeige */ const GV_MODULE_META = { licht: { lbl:"Lichtsteuerung", tier:"€€" }, beschattung: { lbl:"Beschattung / Raffstore", tier:"€€€" }, heizung: { lbl:"Heizungsregelung", tier:"€" }, klima: { lbl:"Klima / Lüftung", tier:"€€" }, zutritt: { lbl:"Zutritt / Sicherheit", tier:"€€" }, multimedia: { lbl:"Multiroom Audio", tier:"€€" }, sensorik: { lbl:"Sensorik (Präsenz/CO₂)", tier:"€" }, steckdosen: { lbl:"Schaltbare Steckdosen", tier:"€" }, energie: { lbl:"Energiemonitoring", tier:"€€" }, alarm: { lbl:"Einbruch / Brand", tier:"€€" }, }; /* AP-Modelle */ const GV_AP_MODELS = { small: { name:"WLAN AP · Indoor (Decke)", price: 220, radius: 8 }, large: { name:"WLAN AP · Indoor Long-Range", price: 320, radius: 11 }, }; /* ============================================================ Squarified-Treemap-Algorithmus Verteilt Räume eines Stockwerks proportional zu ihrer Fläche. ============================================================ */ function squarify(rooms, x, y, w, h) { if (rooms.length === 0) return []; if (rooms.length === 1) { return [{ ...rooms[0], rect: { x, y, w, h } }]; } // Sortiere nach Fläche absteigend const sorted = [...rooms].sort((a, b) => b.area - a.area); const totalArea = sorted.reduce((s, r) => s + r.area, 0); // Skalierung: Mappe Räume auf Pixel-Werte const scale = (w * h) / totalArea; const items = sorted.map(r => ({ ...r, value: r.area * scale })); return layoutSquarify(items, x, y, w, h); } function layoutSquarify(items, x, y, w, h) { const result = []; let remaining = [...items]; let curX = x, curY = y, curW = w, curH = h; while (remaining.length > 0) { const isHorizontal = curW >= curH; const shortSide = Math.min(curW, curH); // Greedy: füge Items zu Row hinzu, solange aspect ratio sich verbessert let row = []; let bestRatio = Infinity; let i = 0; while (i < remaining.length) { const candidate = [...row, remaining[i]]; const ratio = worstRatio(candidate, shortSide); if (ratio > bestRatio) break; bestRatio = ratio; row = candidate; i++; } // Layout der Row const rowSum = row.reduce((s, r) => s + r.value, 0); const longSide = rowSum / shortSide; let pos = 0; for (const r of row) { const segment = r.value / shortSide; if (isHorizontal) { result.push({ ...r, rect: { x: curX, y: curY + pos, w: longSide, h: segment } }); } else { result.push({ ...r, rect: { x: curX + pos, y: curY, w: segment, h: longSide } }); } pos += segment; } // Verschiebe verbleibenden Bereich if (isHorizontal) { curX += longSide; curW -= longSide; } else { curY += longSide; curH -= longSide; } remaining = remaining.slice(row.length); } return result; } function worstRatio(items, side) { if (items.length === 0) return Infinity; const sum = items.reduce((s, r) => s + r.value, 0); let max = 0; for (const r of items) { const a = (side * side * r.value) / (sum * sum); const b = (sum * sum) / (side * side * r.value); max = Math.max(max, a, b); } return max; } /* ============================================================ AP-Empfehlung berechnen ============================================================ */ function recommendAPs(rectsByFloor) { const aps = []; let idx = 1; for (const [floor, rects] of Object.entries(rectsByFloor)) { if (rects.length === 0) continue; const totalArea = rects.reduce((s, r) => s + (r.area || 0), 0); if (totalArea < 25) continue; // 1 AP / ~80m² (gerundet, mind. 1) const count = Math.max(1, Math.ceil(totalArea / 80)); const model = totalArea > 120 ? "large" : "small"; // Bounding box der Räume const minX = Math.min(...rects.map(r => r.rect.x)); const minY = Math.min(...rects.map(r => r.rect.y)); const maxX = Math.max(...rects.map(r => r.rect.x + r.rect.w)); const maxY = Math.max(...rects.map(r => r.rect.y + r.rect.h)); if (count === 1) { aps.push({ id: `ap-${floor}-${idx++}`, floor, model, x: (minX + maxX) / 2, y: (minY + maxY) / 2, }); } else { // Verteile entlang der längeren Achse const spanX = maxX - minX, spanY = maxY - minY; const horizontal = spanX >= spanY; for (let i = 0; i < count; i++) { const t = (i + 0.5) / count; aps.push({ id: `ap-${floor}-${idx++}`, floor, model, x: horizontal ? minX + t * spanX : (minX + maxX) / 2, y: horizontal ? (minY + maxY) / 2 : minY + t * spanY, }); } } } return aps; } /* ============================================================ Hauptkomponente ============================================================ */ function GrundrissViewerSection({ pathKey, kfgState }) { const rooms = kfgState?.rooms || []; const assignments = kfgState?.assignments || {}; const modules = kfgState?.modules || {}; const indoorRooms = rooms.filter(r => !r.outdoor); const floors = Array.from(new Set(indoorRooms.map(r => r.floor))); const [activeFloor, setActiveFloor] = React.useState(floors[0] || "EG"); const [hovered, setHovered] = React.useState(null); const [showWifi, setShowWifi] = React.useState(false); const [showLabels, setShowLabels] = React.useState(true); const [apDetail, setApDetail] = React.useState(null); React.useEffect(() => { if (floors.length && !floors.includes(activeFloor)) setActiveFloor(floors[0]); }, [floors.join(",")]); // Layout-Strategie pro Stockwerk: // 1. BASTING_GEOM (Demo-Plan): hardcoded Pixel-Koords des EFH-Basting-Plans // 2. Squarified-Treemap: für hochgeladene Pläne UND manuelle Erfassung. // Ordnet flächentreu an, Fenster/Türen werden generisch auf den // passenden Wand-Seiten platziert (windowSides/doorSide vom LLM). const VB_W = 1000, VB_H = 560, PAD = 50; const layoutByFloor = React.useMemo(() => { const out = {}; const BG = window.BASTING_GEOM; const BV = window.BASTING_GEOM_VIEWBOX; for (const f of floors) { const fr = indoorRooms.filter(r => r.floor === f); const allHaveBasting = BG && BV && fr.length > 0 && fr.every(r => BG[r.id]); if (allHaveBasting) { const sx = (VB_W - 2*PAD) / BV.w; const sy = (VB_H - 2*PAD) / BV.h; const s = Math.min(sx, sy); const offX = (VB_W - BV.w * s) / 2; const offY = (VB_H - BV.h * s) / 2; out[f] = fr.map(r => { const g = BG[r.id]; return { ...r, rect: { x: offX + g.x * s, y: offY + g.y * s, w: g.w * s, h: g.h * s, }}; }); } else { out[f] = squarify(fr, PAD, PAD, VB_W - 2 * PAD, VB_H - 2 * PAD); } } return out; }, [JSON.stringify(rooms.map(r => [r.id, r.area, r.floor]))]); const aps = React.useMemo(() => recommendAPs(layoutByFloor), [layoutByFloor]); const floorAPs = aps.filter(a => a.floor === activeFloor); const layout = layoutByFloor[activeFloor] || []; const hoveredRoom = layout.find(r => r.id === hovered); // Gesamtbudget über alle Räume const totalBudget = React.useMemo(() => { let total = 0; for (const r of indoorRooms) { const mods = assignments[r.id] || []; for (const m of mods) { total += modules[m]?.price?.(r) || 0; } } // + AP-Kosten falls Wifi aktiv if (showWifi) { for (const ap of aps) { total += GV_AP_MODELS[ap.model]?.price || 0; } } return total; }, [indoorRooms, assignments, modules, showWifi, aps]); // Empty State — Konfigurator noch nicht durchlaufen if (indoorRooms.length === 0) { return (
04 · GRUNDRISS-EXPLORER

Wo lebt was im Haus?

Erst Konfigurator durchlaufen

Sobald du oben Räume erfasst und Module zugeordnet hast, zeichnet sich hier dein persönlicher Grundriss — mit allen Smart-Funktionen pro Raum und vorgeschlagener WiFi-Abdeckung.

Zum Konfigurator
); } // Raum-Detail aufbereiten let detailData = null; if (hoveredRoom) { const activeMods = assignments[hoveredRoom.id] || []; const moduleKeys = Object.keys(modules); const features = moduleKeys.map(k => { const isActive = activeMods.includes(k); const meta = GV_MODULE_META[k] || { lbl: modules[k]?.label || k, tier:"€" }; const price = isActive ? (modules[k]?.price?.(hoveredRoom) || 0) : 0; const disabled = modules[k]?.requiresWindows && hoveredRoom.windows === 0; return { key: k, lbl: meta.lbl, tier: meta.tier, isActive, price, disabled }; }); const roomTotal = features.filter(f => f.isActive).reduce((s, f) => s + f.price, 0); detailData = { room: hoveredRoom, features, roomTotal, activeCount: activeMods.length }; } return (
04 · GRUNDRISS-EXPLORER

Dein Haus · live visualisiert

Räume aus deinem Konfigurator, automatisch flächentreu angeordnet. Hover zeigt deine gewählten Module und welche zusätzlich möglich wären. Mit dem WiFi-Layer siehst du eine vorgeschlagene AP-Platzierung.

{floors.length > 1 && (
{floors.map(f => { const count = indoorRooms.filter(r => r.floor === f).length; return ( ); })}
)}
{/* WiFi-Heatmap (unter Räumen) */} {showWifi && floorAPs.map(ap => { const radiusPx = (GV_AP_MODELS[ap.model]?.radius || 8) * 22; return ( ); })} {/* Räume */} {layout.map(r => { const isHovered = hovered === r.id; const modCount = (assignments[r.id] || []).length; const hasModules = modCount > 0; const fillColor = isHovered ? "rgba(58,189,208,0.12)" : hasModules ? "rgba(13,32,48,0.85)" : "rgba(13,32,48,0.5)"; const strokeColor = isHovered ? GV_PALETTE.accent : hasModules ? GV_PALETTE.line : GV_PALETTE.dim; const strokeW = isHovered ? 2 : 1; // Fenster auf den passenden Wand-Seiten platzieren. // windowSides ist Array von 'top'|'right'|'bottom'|'left'. // Pro Seite mehrere Fenster gleichmaessig verteilt. const wins = (r.windowSides || []).slice(0, 8); const sideGroups = { top: [], right: [], bottom: [], left: [] }; wins.forEach((s, i) => { if (sideGroups[s]) sideGroups[s].push(i); }); const winShapes = []; for (const side of ["top","right","bottom","left"]) { const n = sideGroups[side].length; if (n === 0) continue; const horiz = (side === "top" || side === "bottom"); const wallLen = horiz ? r.rect.w : r.rect.h; const winLen = Math.min(28, Math.max(10, wallLen / (n + 1) * 0.55)); for (let k = 0; k < n; k++) { const t = (k + 1) / (n + 1); let wx, wy, ww, wh; if (side === "top") { wx = r.rect.x + wallLen*t - winLen/2; wy = r.rect.y - 1.5; ww = winLen; wh = 3; } else if (side === "bottom") { wx = r.rect.x + wallLen*t - winLen/2; wy = r.rect.y + r.rect.h - 1.5; ww = winLen; wh = 3; } else if (side === "left") { wx = r.rect.x - 1.5; wy = r.rect.y + wallLen*t - winLen/2; ww = 3; wh = winLen; } else { wx = r.rect.x + r.rect.w - 1.5; wy = r.rect.y + wallLen*t - winLen/2; ww = 3; wh = winLen; } winShapes.push({ x: wx, y: wy, w: ww, h: wh, key: `${side}-${k}` }); } } // Tuer auf doorSide -- Anchor + Boegen vereinfacht const doorSide = r.doorSide; const doorLen = Math.min(18, Math.max(8, Math.min(r.rect.w, r.rect.h) * 0.18)); let doorRect = null; if (doorSide) { if (doorSide === "top") doorRect = { x: r.rect.x + r.rect.w*0.3 - doorLen/2, y: r.rect.y - 1.5, w: doorLen, h: 3 }; else if (doorSide === "bottom") doorRect = { x: r.rect.x + r.rect.w*0.3 - doorLen/2, y: r.rect.y + r.rect.h - 1.5, w: doorLen, h: 3 }; else if (doorSide === "left") doorRect = { x: r.rect.x - 1.5, y: r.rect.y + r.rect.h*0.3 - doorLen/2, w: 3, h: doorLen }; else doorRect = { x: r.rect.x + r.rect.w - 1.5, y: r.rect.y + r.rect.h*0.3 - doorLen/2, w: 3, h: doorLen }; } return ( setHovered(r.id)} onMouseLeave={() => setHovered(null)} style={{ cursor: "pointer" }}> {winShapes.map(w => ( ))} {doorRect && ( )} {showLabels && r.rect.w > 60 && r.rect.h > 36 && ( <> {r.name} {r.area}m² · {modCount} mod )} ); })} {/* AP-Symbole (über Räumen) */} {showWifi && floorAPs.map(ap => ( setApDetail(ap)} style={{ cursor: "pointer" }}> {ap.id.toUpperCase()} ))}
{/* Sidebar */}
{showWifi && (
WLAN SITE-SURVEY · EMPFEHLUNG {aps.length} APs auf {floors.length} Stockwerk{floors.length>1?"en":""} · Gesamtkosten €{aps.reduce((s,a) => s + (GV_AP_MODELS[a.model]?.price||0), 0).toLocaleString('de-AT')}
)}
); } function GvStyles() { return ( ); } window.GrundrissViewerSection = GrundrissViewerSection;