diff --git a/src/app/components/BuildingConfigurator/BuildingConfigurator.tsx b/src/app/components/BuildingConfigurator/BuildingConfigurator.tsx index ef12e52..b4a948d 100644 --- a/src/app/components/BuildingConfigurator/BuildingConfigurator.tsx +++ b/src/app/components/BuildingConfigurator/BuildingConfigurator.tsx @@ -2,32 +2,16 @@ // Owns all application state and handlers; delegates rendering to sub-components. import React, { useState, useRef, useEffect, useMemo } from 'react'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { - Download, Upload, X, Building2, RotateCcw, Check, AlertTriangle, - Flame, Zap, Droplets, Gauge, LayoutDashboard, SlidersHorizontal, -} from 'lucide-react'; -import { BuildingVisualization, VIEW_ORDER } from './configure/visualization/BuildingVisualization'; +import { VIEW_ORDER } from './configure/visualization/BuildingVisualization'; import type { BuildingElement, FaceGroup } from './configure/model/buildingElements'; -import { - elementToGroup, - isElementEditable, - normalizeElementRecord, - faceFromAzimuth, -} from './configure/model/buildingElements'; +import { elementToGroup, normalizeElementRecord } from './configure/model/buildingElements'; import { type RoofConfig, DEFAULT_ROOF_CONFIG } from './configure/model/roof'; -import { SegmentedControl, ConfiguratorStyles, ScrollHintContainer, HeatingDeltaBadge } from './shared/ui'; -import { cn } from '../../../lib/utils'; import { type EnergyTotals, type LoadDataPoint } from '../../lib/loadProfile'; import { DEFAULT_ELEMENTS, DEFAULT_GENERAL, computeTotalFloorArea } from './shared/buildingDefaults'; -import type { BuildingState, ThermalSummary } from '../../lib/buemAdapter'; -import { - formatCoordinates, - exportToBuemGeojson, - importBuildingData, -} from '../../lib/buemAdapter'; +import type { BuildingState } from '../../lib/buemAdapter'; +import { exportToBuemGeojson, importBuildingData } from '../../lib/buemAdapter'; import { runBuildingSimulation } from '../../lib/buemApi'; import type { IgnisState, IgnisInputs, IgnisFieldMetadata } from '../../lib/ignisAdapter'; import { @@ -51,163 +35,18 @@ import { } from './shared/snapshotUtils'; import { getThermalRatingFromDemand } from '@/app/config/thermalRatingStandards'; import type { ElementGroupKey } from './shared/elementListUtils'; -import { BuildingSnapshotAside } from './overview/BuildingSnapshotAside'; -import { EnergyEnvelopeColumn } from './overview/EnergyEnvelopeColumn'; -import { SurfaceGroupSelector } from './configure/surfaces/SurfaceGroupSelector'; -import { SurfaceGroupGrid } from './configure/surfaces/SurfaceGroupGrid'; -import { SurfaceGroupEditor } from './configure/surfaces/SurfaceGroupEditor'; -import { BuildingEditor } from './configure/building/BuildingEditor'; -import { PvSurfaceManager } from './configure/pv/PvSurfaceManager'; -import { BatteryEditor } from './configure/pv/BatteryEditor'; +import { buildNewSurface, isRoofConfig } from './shared/surfaceFactory'; +import { formatKwh, computeEnergyTotals, baselineHeatingKwh } from './shared/energyTotals'; import { createSurfacePvConfig, DEFAULT_BATTERY_CONFIG } from './shared/buildingDefaults'; import type { PvConfig, BatteryConfig } from './shared/buildingDefaults'; import { TECH_REGISTRY, VISIBLE_TECHS } from '../../config/techRegistry'; import type { TechNavItem } from '../../config/techRegistry'; -const SURFACE_DEFAULTS: Record> = { - wall: { type: 'wall', area: 12, uValue: 0.24, gValue: null, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, - window: { type: 'window', area: 2.4, uValue: 1.3, gValue: 0.6, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, - door: { type: 'door', area: 2.1, uValue: 1.8, gValue: null, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, - roof: { type: 'roof', area: 18, uValue: 0.18, gValue: null, tilt: 35, azimuth: 180, source: 'custom', customMode: true }, - floor: { type: 'floor', area: 18, uValue: 0.30, gValue: null, tilt: 0, azimuth: 0, source: 'custom', customMode: true }, -}; - -function surfaceTypeLabel(type: BuildingElement['type']): string { - if (type === 'roof') return 'Roof'; - if (type === 'floor') return 'Floor'; - if (type === 'door') return 'Door'; - if (type === 'window') return 'Window'; - return 'Wall'; -} - -function buildSurfaceLabel(type: BuildingElement['type'], elements: Record): string { - const next = Object.values(elements).filter((el) => el.type === type).length + 1; - return `Custom ${surfaceTypeLabel(type)} ${next}`; -} - -function buildSurfaceId(type: BuildingElement['type'], elements: Record): string { - const base = `custom_${type}`; - let idx = 1; - while (elements[`${base}_${idx}`]) idx += 1; - return `${base}_${idx}`; -} - -function buildNewSurface(type: BuildingElement['type'], elements: Record): BuildingElement { - const seed = Object.values(elements).find((el) => el.type === type && isElementEditable(el)) - ?? Object.values(elements).find((el) => el.type === type) - ?? null; - - return { - id: buildSurfaceId(type, elements), - label: buildSurfaceLabel(type, elements), - ...(seed - ? { - type, - area: seed.area, - uValue: seed.uValue, - gValue: seed.gValue, - tilt: seed.tilt, - azimuth: seed.azimuth, - source: 'custom' as const, - customMode: true, - } - : SURFACE_DEFAULTS[type]), - }; -} - -function isRoofConfig(value: unknown): value is RoofConfig { - return !!value - && typeof value === 'object' - && 'type' in value - && 'surfaces' in value - && Array.isArray((value as RoofConfig).surfaces) - && 'from3DData' in value; -} - - - -// --- Energy totals helper ----------------------------------------------------- - -/** Formats a kWh figure with precision scaled to its magnitude. */ -function formatKwh(v: number): string { - const abs = Math.abs(v); - if (abs >= 100) return abs.toFixed(0); - if (abs >= 1) return abs.toFixed(1); - return abs.toFixed(2); -} - -/** - * Computes fixed annual energy totals from the full hourly timeseries. - * Falls back to the model thermal summary, then to placeholder dashes. - * Unit is always kWh — independent of chart resolution. - */ -function computeEnergyTotals( - timeseries: LoadDataPoint[] | null, - thermalSummary: ThermalSummary | null, -): EnergyTotals { - if (timeseries && timeseries.length > 0) { - return { - heating: formatKwh(timeseries.reduce((s, p) => s + p.heating, 0)), - electricity: formatKwh(timeseries.reduce((s, p) => s + p.electricity, 0)), - hotwater: formatKwh(timeseries.reduce((s, p) => s + p.hotwater, 0)), - unit: 'kWh', - }; - } - if (thermalSummary) { - return { - heating: thermalSummary.heatingKwh.toFixed(0), - electricity: thermalSummary.electricityKwh.toFixed(0), - hotwater: thermalSummary.coolingKwh.toFixed(0), - unit: 'kWh', - }; - } - return { electricity: '—', heating: '—', hotwater: '—', unit: 'kWh' }; -} - -/** - * Raw (unformatted) BuEM baseline annual heating figure, in kWh — the "last - * full simulation" reference point that a live ignis recalculation is - * compared against. Same source priority as computeEnergyTotals, but returns - * a number for arithmetic rather than a display string. - */ -function baselineHeatingKwh( - timeseries: LoadDataPoint[] | null, - thermalSummary: ThermalSummary | null, -): number | null { - if (timeseries && timeseries.length > 0) { - return timeseries.reduce((s, p) => s + p.heating, 0); - } - if (thermalSummary) return thermalSummary.heatingKwh; - return null; -} - -// --- Energy items config (used in the configure view's demand mini panel) ----- - -const ENERGY_ITEMS = [ - { key: 'heating', label: 'Heating', Icon: Flame, iconBg: 'bg-orange-500/20', iconColor: 'text-orange-400', valueColor: 'text-orange-300' }, - { key: 'electricity', label: 'Electricity', Icon: Zap, iconBg: 'bg-yellow-500/20', iconColor: 'text-yellow-400', valueColor: 'text-yellow-300' }, - { key: 'hotwater', label: 'Hot Water', Icon: Droplets, iconBg: 'bg-blue-500/20', iconColor: 'text-blue-400', valueColor: 'text-blue-300' }, -] as const; - -// --- Direction label helper --------------------------------------------------- - - -// --- Header icon button (local — only used in this file) ---------------------- - -function HeaderBtn({ - onClick, children, tooltip, -}: { onClick?: () => void; children: React.ReactNode; tooltip?: string }) { - return ( - - ); -} +import { ConfiguratorHeader } from './layout/ConfiguratorHeader'; +import { ConfiguratorFooter } from './layout/ConfiguratorFooter'; +import { CloseConfirmDialog } from './layout/CloseConfirmDialog'; +import { OverviewLayout } from './layout/OverviewLayout'; +import { ConfigureLayout } from './layout/ConfigureLayout'; // --- Component ---------------------------------------------------------------- @@ -267,7 +106,6 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur const [panelView, setPanelView] = useState('building'); /** The group type currently driving the surface-group grid in the center panel. */ const [activeGroupType, setActiveGroupType] = useState(null); - /** Whether the roof-type accordion is expanded while editing a roof surface. */ // Per-surface PV configurations — keyed by element ID. const [surfacePvConfigs, setSurfacePvConfigs] = useState>({}); // True when a roof-type change removed surfaces that had PV installed. @@ -788,7 +626,6 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur } }; - const handleReset = () => { setElements(initialElements); setGeneralRaw(initialGeneral); @@ -1026,414 +863,129 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur return (
- - - {/* ── Header ── */} -
-
-
- -
-
-
-

{buildingLabel} · {buildingType}

- - {workspaceView === 'overview' ? : } - {workspaceView === 'overview' ? 'Overview' : 'Configure'} - -
-

{formatCoordinates(coordinates[0], coordinates[1])}

-
-
-
- setMode(v as 'basic' | 'expert')} - /> - -
- - fileInputRef.current?.click()} tooltip="Import BUEM or legacy JSON"> - -
- {onClose && ( - setShowCloseDialog(true)} tooltip="Close"> - )} -
-
+ fileInputRef.current?.click()} + fileInputRef={fileInputRef} + onUploadChange={handleUpload} + onRequestClose={onClose ? () => setShowCloseDialog(true) : undefined} + /> {/* ── Content ── */}
- {workspaceView === 'overview' ? ( - // ── Overview layout: snapshot sidebar + energy/envelope column ── -
- - setUploadError(null)} - elements={elements} - baselineElements={baselineRef.current.elements} - roofConfig={roofConfig} - isActive={workspaceView === 'overview'} - buildingId={buildingLabel} - initialTimeseries={modelTimeseries ?? thematicData?.timeseries ?? buildingData?.timeseries ?? null} - mode={mode} - installedTechIds={installedTechIds} - pvSummary={pvSummary} - onToggleTech={handleTechToggle} - onOpenTech={handleTechnologyOpen} - /> -
+ setUploadError(null), + elements, + baselineElements: baselineRef.current.elements, + roofConfig, + isActive: workspaceView === 'overview', + buildingId: buildingLabel, + initialTimeseries: modelTimeseries ?? thematicData?.timeseries ?? buildingData?.timeseries ?? null, + mode, + installedTechIds, + pvSummary, + onToggleTech: handleTechToggle, + onOpenTech: handleTechnologyOpen, + }} + /> ) : ( - // ── Configure layout: preview + demand (left) | group editor + selector (right) ── -
- - {/* ── Left column: 3D preview + preliminary energy demand ── */} - - - {/* ── Right column: group editor (main) + group selector (narrow sidebar) ── */} -
- - {/* Center panel — building editor or surface editor */} -
- {uploadError && ( -
-

{uploadError}

- -
- )} - {/* PV invalidation warning — shown after a roof type change removes PV surfaces */} - {pvInvalidated && ( -
-

- One or more roof surfaces with PV installed were replaced by the new roof type. - Please reassign PV to the updated roof surfaces. -

- -
- )} - -
- {panelView === 'building' ? ( - - ) : panelView === 'surface-group' && activeGroupType ? ( - activeGroupType === 'roof' ? ( - // Roof: type picker (no card grid) + embedded editor when selected - updateSurfacePv(selectedId, patch)} - onDeleteSurface={deleteSurface} - mode={mode} - embedded - /> - ) : undefined} - /> - ) : selectedId ? ( - // Non-roof with surface selected: pure editor, no card grid - updateSurfacePv(selectedId, patch)} - onDeleteSurface={deleteSurface} - mode={mode} - /> - ) : ( - // No surface selected yet — prompt -
-

Select a surface

-

- Pick a surface from the list on the right to configure it. -

-
- ) - ) : panelView === 'technology-pv' ? ( - - ) : panelView === 'technology-battery' ? ( - - ) : null} -
-
- - {/* Panel selector column */} -
- - - -
- -
- -
+ setUploadError(null)} + pvInvalidated={pvInvalidated} + onClearPvInvalidated={() => setPvInvalidated(false)} + panelView={panelView} + selectedId={selectedId} + panel={{ + panelView, + activeGroupType, + selectedId, + elements, + mode, + surfaceEditorTab, + surfacePvConfigs, + general, + setGen, + ignis, + ignisFieldMetadata, + onIgnisFieldChange: handleIgnisFieldChange, + onIgnisVariantSelect: handleIgnisVariantSelect, + onIgnisReset: handleIgnisReset, + onIgnisPeriodOverride: handleIgnisPeriodOverride, + onUpdateElement: updateElement, + onRenameElement: renameElement, + onUpdateSurfacePv: updateSurfacePv, + onDeleteSurface: deleteSurface, + onSelectSurface: handleElementSelect, + onCreateSurface: createSurface, + onApplyRoofType: handleApplyRoofType, + pvInstalledSurfaces, + totalPvCapacityKw, + onEditPvSurface: handleEditPvSurface, + batteryConfig, + onUpdateBattery: updateBattery, + }} + selector={{ + elements, + activeGroupType, + onSelectGroupType: handleGroupTypeSelect, + onCreateSurface: createSurface, + buildingSubtitle: `${general.buildingType || buildingType}${general.floorArea ? ` · ${computeTotalFloorArea(general.floorArea, general.storeys).toFixed(0)} m²` : ''}`, + buildingSelected: panelView === 'building', + onSelectBuilding: handleBuildingSelect, + selectedSurfaceId: selectedId, + onSelectSurface: handleElementSelect, + onDeleteSurface: deleteSurface, + surfacePvConfigs, + techNavItems: buildTechNavItems(), + }} + /> )} -
- {/* ── Footer: reset / apply ── */} -
-
-
- - -
-
-
+
- {/* ── Close confirmation dialog ── */} - - - - -
- {hasUnsavedChanges && } - - {hasUnsavedChanges ? 'Unsaved Changes' : 'Close Configurator'} - -
- -
- {hasUnsavedChanges ? ( - <> -

- You have unsaved changes to this building configuration. What would you like to do? -

-
-

- Closing without saving will discard all modifications made since the last Apply. -

-
- - ) : ( -

- Close the building configurator and return to the map? -

- )} -
- -
- - {hasUnsavedChanges && ( - - )} - -
-
-
-
+ { handleRecalculate(); onClose?.(); setShowCloseDialog(false); }} + onDiscardOrClose={() => { onClose?.(); setShowCloseDialog(false); }} + />
); } diff --git a/src/app/components/BuildingConfigurator/configure/surfaces/ElementList.tsx b/src/app/components/BuildingConfigurator/configure/surfaces/ElementList.tsx deleted file mode 100644 index 97a680b..0000000 --- a/src/app/components/BuildingConfigurator/configure/surfaces/ElementList.tsx +++ /dev/null @@ -1,221 +0,0 @@ -// Sidebar element list for the Configure view. -// Shows surface groups in a 2-column grid. Expanding one group collapses -// the rest to compact chips at the top; the expanded group fills the -// remaining height with a scrollable surface table. -// -// Bidirectional sync: when selectedGroup changes (from viz click) the matching -// group type auto-expands and rows belonging to that face are highlighted. - -import { useState, useEffect } from 'react'; -import { ChevronDown } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { ELEMENT_DOTS, ScrollHintContainer } from '@/app/components/BuildingConfigurator/shared/ui'; -import type { BuildingElement, FaceGroup } from '@/app/components/BuildingConfigurator/configure/model/buildingElements'; -import { faceFromAzimuth } from '@/app/components/BuildingConfigurator/configure/model/buildingElements'; -import type { RoofConfig } from '@/app/components/BuildingConfigurator/configure/model/roof'; -import { - ElementGroupKey, - ELEMENT_GROUP_LABELS, - getGroupedElements, - getRoofGroupInfo, -} from '@/app/components/BuildingConfigurator/shared/elementListUtils'; - -export interface ElementListProps { - elements: Record; - /** Which specific element row is highlighted for the right panel. */ - selectedId: string | null; - /** Which group is highlighted from the viz — drives auto-expand and face highlighting. */ - selectedGroup: FaceGroup | null; - /** Called when the user clicks a specific element row. */ - onSelect: (id: string) => void; - roofConfig: RoofConfig; -} - -/** 8-point direction label matching faceFromAzimuth — ensures list and 3D preview always agree. */ -function surfaceDirection(el: BuildingElement): string { - if (el.type === 'roof' && el.tilt <= 10) return 'Top'; - if (el.type === 'floor') return 'Base'; - const MAP: Record = { - north_wall: 'N', northeast_wall: 'NE', - east_wall: 'E', southeast_wall: 'SE', - south_wall: 'S', southwest_wall: 'SW', - west_wall: 'W', northwest_wall: 'NW', - }; - return MAP[faceFromAzimuth(el.azimuth)] ?? '—'; -} - -/** Returns true when an element belongs to the currently selected face group. */ -function elementMatchesGroup(el: BuildingElement, group: FaceGroup): boolean { - if (el.type !== group.type) return false; - if (group.type === 'roof') { - if (group.elementId) return el.id === group.elementId; - return (el.tilt <= 10 ? 'roof' : faceFromAzimuth(el.azimuth)) === group.face; - } - if (group.type === 'floor') return true; - return faceFromAzimuth(el.azimuth) === group.face; -} - -/** Grouped, collapsible list of building surfaces for quick selection. */ -export function ElementList({ elements, selectedId, selectedGroup, onSelect, roofConfig }: ElementListProps) { - const [activeType, setActiveType] = useState(null); - - const grouped = getGroupedElements(elements); - const roofInfo = getRoofGroupInfo(roofConfig); - const types = (Object.keys(grouped) as ElementGroupKey[]).filter((t) => grouped[t].length > 0); - - // Auto-expand the group type that matches an incoming viz selection - useEffect(() => { - if (!selectedGroup) return; - const incoming = selectedGroup.type as ElementGroupKey; - if (types.includes(incoming)) { - setActiveType(incoming); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedGroup]); - - const toggleGroup = (type: ElementGroupKey) => - setActiveType((prev) => (prev === type ? null : type)); - - const groupDesc = (type: ElementGroupKey) => { - const items = grouped[type]; - const totalArea = items.reduce((s, el) => s + el.area, 0).toFixed(1); - return type === 'roof' && roofInfo.description - ? `${totalArea} m² · ${roofInfo.description}` - : `${totalArea} m²`; - }; - - /** Whether a row should be highlighted — either directly selected or matches the active face group. */ - const isRowHighlighted = (el: BuildingElement): boolean => { - if (selectedId === el.id) return true; - if (selectedGroup) return elementMatchesGroup(el, selectedGroup); - return false; - }; - - // ── When one group is active: chips + expanded card ────────────────────────── - - if (activeType) { - const items = grouped[activeType]; - const chipTypes = types.filter((t) => t !== activeType); - - return ( -
- - {/* Inactive groups as compact chips */} - {chipTypes.length > 0 && ( -
- {chipTypes.map((type) => { - const hasSelected = grouped[type].some((el) => el.id === selectedId); - return ( - - ); - })} -
- )} - - {/* Expanded group — fills remaining height */} -
- - - - - - - - - - - - - - - {items.map((el) => ( - onSelect(el.id)} - > - - - - - - - ))} - -
SurfaceDirAreaU-valueAzimuth
-
- - {el.label} -
-
{surfaceDirection(el)}{el.area.toFixed(1)}{el.uValue.toFixed(2)}{Math.round(el.azimuth)}°
-
-
-
- ); - } - - // ── No group active: 2-column grid of group cards ──────────────────────────── - - return ( -
- {types.map((type) => { - const items = grouped[type]; - const hasSelected = items.some((el) => el.id === selectedId); - return ( - - ); - })} -
- ); -} diff --git a/src/app/components/BuildingConfigurator/configure/surfaces/ElementPanel.tsx b/src/app/components/BuildingConfigurator/configure/surfaces/ElementPanel.tsx deleted file mode 100644 index 890e890..0000000 --- a/src/app/components/BuildingConfigurator/configure/surfaces/ElementPanel.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import React from 'react'; -import { X } from 'lucide-react'; -import { - T, NumberInput, RangeSlider, FieldRow, FieldLabel, - TypeBadge, ConfigSection, SegmentedControl, -} from '@/app/components/BuildingConfigurator/shared/ui'; -import { RoofConfigurator } from '@/app/components/BuildingConfigurator/configure/roof/RoofConfigurator'; -import type { BuildingElement } from '@/app/components/BuildingConfigurator/configure/model/buildingElements'; -import type { RoofConfig } from '@/app/components/BuildingConfigurator/configure/model/roof'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function fmtR(u: number) { return u > 0 ? (1 / u).toFixed(3) : '∞'; } - -function azimuthLabel(az: number) { - if (az < 22.5 || az >= 337.5) return 'North-facing'; - if (az < 67.5) return 'NE-facing'; - if (az < 112.5) return 'East-facing'; - if (az < 157.5) return 'SE-facing'; - if (az < 202.5) return 'South-facing'; - if (az < 247.5) return 'SW-facing'; - if (az < 292.5) return 'West-facing'; - return 'NW-facing'; -} - -function tiltLabel(t: number) { - if (t <= 5) return 'Horizontal'; - if (t <= 25) return 'Slightly pitched'; - if (t <= 55) return 'Pitched'; - if (t <= 80) return 'Steeply pitched'; - return 'Vertical'; -} - -// ─── Interactive compass widget ─────────────────────────────────────────────── - -function CompassWidget({ azimuth, onChange }: { azimuth: number; onChange: (v: number) => void }) { - const cx = 38, cy = 38, r = 26; - const rad = (azimuth - 90) * (Math.PI / 180); - const ax = cx + r * Math.cos(rad); - const ay = cy + r * Math.sin(rad); - - const handleClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const sx = 76 / rect.width; - const sy = 76 / rect.height; - const dx = (e.clientX - rect.left) * sx - cx; - const dy = (e.clientY - rect.top) * sy - cy; - let a = Math.atan2(dy, dx) * (180 / Math.PI) + 90; - if (a < 0) a += 360; - if (a >= 360) a -= 360; - onChange(Math.round(a)); - }; - - return ( -
- - - {[0, 90, 180, 270].map((deg) => { - const tr = (deg - 90) * (Math.PI / 180); - return ( - - ); - })} - - - - N - S - E - W - -
- { - const a = ((v % 360) + 360) % 360; - onChange(Math.round(a)); - }} - unit="°" min={0} max={359} step={1} - /> -

{azimuthLabel(azimuth)}

-
-
- ); -} - -// ─── Stat chip ──────────────────────────────────────────────────────────────── - -function StatChip({ label, value, unit }: { label: string; value: string; unit: string }) { - return ( -
-

{label}

-

{value}

-

{unit}

-
- ); -} - -// ─── U-value quality badge ──────────────────────────────────────────────────── - -function uValueQuality(u: number, type: string) { - const isWindow = type === 'window'; - const isDoor = type === 'door'; - - if (isWindow) { - if (u <= 0.8) return { label: 'Passive House', color: '#16a34a', bg: '#dcfce7' }; - if (u <= 1.1) return { label: 'Near-zero', color: '#65a30d', bg: '#ecfccb' }; - if (u <= 1.6) return { label: 'Good', color: '#ca8a04', bg: '#fef9c3' }; - if (u <= 2.0) return { label: 'Standard', color: '#ea580c', bg: '#ffedd5' }; - return { label: 'Poor', color: '#dc2626', bg: '#fee2e2' }; - } - if (isDoor) { - if (u <= 1.0) return { label: 'Excellent', color: '#16a34a', bg: '#dcfce7' }; - if (u <= 1.5) return { label: 'Good', color: '#65a30d', bg: '#ecfccb' }; - if (u <= 2.0) return { label: 'Standard', color: '#ca8a04', bg: '#fef9c3' }; - return { label: 'Poor', color: '#dc2626', bg: '#fee2e2' }; - } - // Opaque elements - if (u <= 0.10) return { label: 'Passive House', color: '#16a34a', bg: '#dcfce7' }; - if (u <= 0.20) return { label: 'Near-zero', color: '#65a30d', bg: '#ecfccb' }; - if (u <= 0.35) return { label: 'Good', color: '#ca8a04', bg: '#fef9c3' }; - if (u <= 0.50) return { label: 'Standard', color: '#ea580c', bg: '#ffedd5' }; - return { label: 'Poor', color: '#dc2626', bg: '#fee2e2' }; -} - -// ─── Main component ─────────────────────────────────────────────────────────── - -interface ElementPanelProps { - selectedId: string; - elements: Record; - onUpdate: (id: string, patch: Partial) => void; - onDeselect: () => void; - roofConfig: RoofConfig; - onRoofConfigChange: (config: RoofConfig) => void; -} - -export function ElementPanel({ - selectedId, elements, onUpdate, onDeselect, roofConfig, onRoofConfigChange, -}: ElementPanelProps) { - const el = elements[selectedId]; - if (!el) return null; - - const upd = (patch: Partial) => onUpdate(selectedId, patch); - const qual = uValueQuality(el.uValue, el.type); - const isRoof = el.type === 'roof'; - const isWindow = el.type === 'window'; - - return ( -
- {/* Header */} -
- - {el.label} - -
- -
- {/* Quick stats */} -
- - - - {isWindow && el.gValue !== null && ( - - )} -
- - {/* Area */} -
- Area - upd({ area: Math.max(0.1, v) })} - unit="m²" min={0.1} step={0.1} - /> -
- - {/* Thermal properties */} -
-

- Thermal Properties - - {qual.label} - -

- - {/* U-value + quality badge */} -
-
- U-value - upd({ uValue: Math.max(0.01, v) })} - unit="W/m²K" min={0.01} max={10} step={0.01} - /> -
-
- - {/* R-value */} - { - if (v > 0) upd({ uValue: parseFloat((1 / v).toFixed(4)) }); - }} - unit="m²K/W" min={0.01} step={0.01} - /> - - {/* g-value — windows only */} - {isWindow && ( -
- - g-value (SHGC) - - upd({ gValue: Math.min(1, Math.max(0, v)) })} - unit="–" min={0} max={1} step={0.01} - /> -
- )} -
- - {/* Geometry */} -
-

- Geometry -

- - {/* Tilt */} -
-
- - Tilt - - upd({ tilt: Math.min(90, Math.max(0, v)) })} - unit="°" min={0} max={90} step={0.5} - width={90} - /> -
- upd({ tilt: v })} - /> -

{tiltLabel(el.tilt)}

-
- - {/* Azimuth */} -
- - Azimuth - - upd({ azimuth: v })} /> -
-
- - {/* Roof configurator */} - {isRoof && ( -
-
-

- Roof Geometry & PV Configuration -

-
-
- -
-
- )} -
-
- ); -} diff --git a/src/app/components/BuildingConfigurator/configure/surfaces/SurfaceGroupSelector.tsx b/src/app/components/BuildingConfigurator/configure/surfaces/SurfaceGroupSelector.tsx index cae0bb5..035e1e5 100644 --- a/src/app/components/BuildingConfigurator/configure/surfaces/SurfaceGroupSelector.tsx +++ b/src/app/components/BuildingConfigurator/configure/surfaces/SurfaceGroupSelector.tsx @@ -43,7 +43,7 @@ function dirLabel(el: BuildingElement): string { // ─── Component ──────────────────────────────────────────────────────────────── -interface SurfaceGroupSelectorProps { +export interface SurfaceGroupSelectorProps { elements: Record; activeGroupType: ElementGroupKey | null; onSelectGroupType: (type: ElementGroupKey) => void; diff --git a/src/app/components/BuildingConfigurator/layout/CloseConfirmDialog.tsx b/src/app/components/BuildingConfigurator/layout/CloseConfirmDialog.tsx new file mode 100644 index 0000000..775a81e --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/CloseConfirmDialog.tsx @@ -0,0 +1,85 @@ +// Confirmation dialog shown when the user tries to close the configurator, +// warning about unsaved changes and offering to save-and-close or discard. + +import React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface CloseConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + hasUnsavedChanges: boolean; + onSaveAndClose: () => void; + onDiscardOrClose: () => void; +} + +export function CloseConfirmDialog({ + open, onOpenChange, hasUnsavedChanges, onSaveAndClose, onDiscardOrClose, +}: CloseConfirmDialogProps) { + return ( + + + + +
+ {hasUnsavedChanges && } + + {hasUnsavedChanges ? 'Unsaved Changes' : 'Close Configurator'} + +
+ +
+ {hasUnsavedChanges ? ( + <> +

+ You have unsaved changes to this building configuration. What would you like to do? +

+
+

+ Closing without saving will discard all modifications made since the last Apply. +

+
+ + ) : ( +

+ Close the building configurator and return to the map? +

+ )} +
+ +
+ + {hasUnsavedChanges && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/app/components/BuildingConfigurator/layout/ConfiguratorFooter.tsx b/src/app/components/BuildingConfigurator/layout/ConfiguratorFooter.tsx new file mode 100644 index 0000000..c671e52 --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/ConfiguratorFooter.tsx @@ -0,0 +1,38 @@ +// Bottom bar of the configurator panel: reset draft / run recalculation. + +import React from 'react'; +import { RotateCcw, Check } from 'lucide-react'; + +export interface ConfiguratorFooterProps { + onReset: () => void; + onRecalculate: () => void; + isRunningSimulation: boolean; +} + +export function ConfiguratorFooter({ onReset, onRecalculate, isRunningSimulation }: ConfiguratorFooterProps) { + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/src/app/components/BuildingConfigurator/layout/ConfiguratorHeader.tsx b/src/app/components/BuildingConfigurator/layout/ConfiguratorHeader.tsx new file mode 100644 index 0000000..0cff712 --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/ConfiguratorHeader.tsx @@ -0,0 +1,98 @@ +// Top bar of the configurator panel: building identity, basic/expert toggle, +// overview/configure switch, and the export/import/close actions. + +import React from 'react'; +import { Download, Upload, X, Building2, LayoutDashboard, SlidersHorizontal } from 'lucide-react'; +import { SegmentedControl } from '../shared/ui'; +import { cn } from '@/lib/utils'; +import { formatCoordinates } from '../../../lib/buemAdapter'; + +function HeaderBtn({ + onClick, children, tooltip, +}: { onClick?: () => void; children: React.ReactNode; tooltip?: string }) { + return ( + + ); +} + +export interface ConfiguratorHeaderProps { + buildingLabel: string; + buildingType: string; + coordinates: [number, number]; + workspaceView: 'overview' | 'configure'; + onWorkspaceViewChange: (view: 'overview' | 'configure') => void; + mode: 'basic' | 'expert'; + onModeChange: (mode: 'basic' | 'expert') => void; + onDownload: () => void; + onUploadClick: () => void; + fileInputRef: React.RefObject; + onUploadChange: (e: React.ChangeEvent) => void; + onRequestClose?: () => void; +} + +export function ConfiguratorHeader({ + buildingLabel, buildingType, coordinates, workspaceView, onWorkspaceViewChange, + mode, onModeChange, onDownload, onUploadClick, fileInputRef, onUploadChange, onRequestClose, +}: ConfiguratorHeaderProps) { + return ( +
+
+
+ +
+
+
+

{buildingLabel} · {buildingType}

+ + {workspaceView === 'overview' ? : } + {workspaceView === 'overview' ? 'Overview' : 'Configure'} + +
+

{formatCoordinates(coordinates[0], coordinates[1])}

+
+
+ +
+ onModeChange(v as 'basic' | 'expert')} + /> + +
+ + + +
+ {onRequestClose && ( + + )} +
+
+ ); +} diff --git a/src/app/components/BuildingConfigurator/layout/ConfigureLayout.tsx b/src/app/components/BuildingConfigurator/layout/ConfigureLayout.tsx new file mode 100644 index 0000000..d9f91ca --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/ConfigureLayout.tsx @@ -0,0 +1,114 @@ +// Configure workspace: 3D preview + preliminary demand (left) | surface/tech +// editor + group selector (right). + +import React from 'react'; +import { BuildingVisualization } from '../configure/visualization/BuildingVisualization'; +import type { FaceGroup, BuildingElement } from '../configure/model/buildingElements'; +import { ScrollHintContainer } from '../shared/ui'; +import { SurfaceGroupSelector, type SurfaceGroupSelectorProps } from '../configure/surfaces/SurfaceGroupSelector'; +import { EnergyDemandCard, type EnergyDemandCardProps } from './EnergyDemandCard'; +import { ConfigurePanel, type ConfigurePanelProps } from './ConfigurePanel'; + +export interface ConfigureLayoutProps { + elements: Record; + selectedGroup: FaceGroup | null; + onSelectGroup: (group: FaceGroup) => void; + vizViewIndex: number; + onViewChange: (index: number) => void; + + energyDemand: EnergyDemandCardProps; + + uploadError: string | null; + onClearUploadError: () => void; + pvInvalidated: boolean; + onClearPvInvalidated: () => void; + + panelView: string; + selectedId: string | null; + panel: ConfigurePanelProps; + selector: SurfaceGroupSelectorProps; +} + +export function ConfigureLayout({ + elements, selectedGroup, onSelectGroup, vizViewIndex, onViewChange, + energyDemand, uploadError, onClearUploadError, pvInvalidated, onClearPvInvalidated, + panelView, selectedId, panel, selector, +}: ConfigureLayoutProps) { + return ( +
+ + {/* ── Left column: 3D preview + preliminary energy demand ── */} + + + {/* ── Right column: group editor (main) + group selector (narrow sidebar) ── */} +
+ + {/* Center panel — building editor or surface editor */} +
+ {uploadError && ( +
+

{uploadError}

+ +
+ )} + {/* PV invalidation warning — shown after a roof type change removes PV surfaces */} + {pvInvalidated && ( +
+

+ One or more roof surfaces with PV installed were replaced by the new roof type. + Please reassign PV to the updated roof surfaces. +

+ +
+ )} + +
+ +
+
+ + {/* Panel selector column */} +
+ + + +
+ +
+ +
+ ); +} diff --git a/src/app/components/BuildingConfigurator/layout/ConfigurePanel.tsx b/src/app/components/BuildingConfigurator/layout/ConfigurePanel.tsx new file mode 100644 index 0000000..91b2113 --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/ConfigurePanel.tsx @@ -0,0 +1,156 @@ +// Center panel of the configure workspace — routes to the building editor, +// surface group grid/editor, or a technology editor, based on panelView. + +import React from 'react'; +import type { BuildingElement } from '../configure/model/buildingElements'; +import { SurfaceGroupGrid } from '../configure/surfaces/SurfaceGroupGrid'; +import { SurfaceGroupEditor } from '../configure/surfaces/SurfaceGroupEditor'; +import { BuildingEditor } from '../configure/building/BuildingEditor'; +import { PvSurfaceManager } from '../configure/pv/PvSurfaceManager'; +import { BatteryEditor } from '../configure/pv/BatteryEditor'; +import type { PvConfig, BatteryConfig } from '../shared/buildingDefaults'; +import type { ElementGroupKey } from '../shared/elementListUtils'; +import type { IgnisState, IgnisInputs, IgnisFieldMetadata } from '../../../lib/ignisAdapter'; + +export interface ConfigurePanelProps { + panelView: string; + activeGroupType: ElementGroupKey | null; + selectedId: string | null; + elements: Record; + mode: 'basic' | 'expert'; + surfaceEditorTab: 'properties' | 'pv'; + surfacePvConfigs: Record; + + general: Record; + setGen: (key: string, value: any) => void; + ignis: IgnisState | null; + ignisFieldMetadata: IgnisFieldMetadata[]; + onIgnisFieldChange: (changes: Partial) => void; + onIgnisVariantSelect: (index: number) => void; + onIgnisReset: () => void; + onIgnisPeriodOverride: (period: string) => void; + + onUpdateElement: (id: string, patch: Partial) => void; + onRenameElement: (id: string, label: string) => void; + onUpdateSurfacePv: (surfaceId: string, patch: Partial) => void; + onDeleteSurface: (id: string) => void; + onSelectSurface: (elementId: string) => void; + onCreateSurface: (type: BuildingElement['type']) => void; + onApplyRoofType: (newRoofElements: Record) => void; + + pvInstalledSurfaces: { element: BuildingElement; pv: PvConfig }[]; + totalPvCapacityKw: number; + onEditPvSurface: (surfaceId: string) => void; + + batteryConfig: BatteryConfig; + onUpdateBattery: (patch: Partial) => void; +} + +export function ConfigurePanel({ + panelView, activeGroupType, selectedId, elements, mode, surfaceEditorTab, surfacePvConfigs, + general, setGen, ignis, ignisFieldMetadata, onIgnisFieldChange, onIgnisVariantSelect, onIgnisReset, onIgnisPeriodOverride, + onUpdateElement, onRenameElement, onUpdateSurfacePv, onDeleteSurface, onSelectSurface, onCreateSurface, onApplyRoofType, + pvInstalledSurfaces, totalPvCapacityKw, onEditPvSurface, + batteryConfig, onUpdateBattery, +}: ConfigurePanelProps) { + if (panelView === 'building') { + return ( + + ); + } + + if (panelView === 'surface-group' && activeGroupType) { + if (activeGroupType === 'roof') { + // Roof: type picker (no card grid) + embedded editor when selected + return ( + onUpdateSurfacePv(selectedId, patch)} + onDeleteSurface={onDeleteSurface} + mode={mode} + embedded + /> + ) : undefined} + /> + ); + } + + if (selectedId) { + // Non-roof with surface selected: pure editor, no card grid + return ( + onUpdateSurfacePv(selectedId, patch)} + onDeleteSurface={onDeleteSurface} + mode={mode} + /> + ); + } + + // No surface selected yet — prompt + return ( +
+

Select a surface

+

+ Pick a surface from the list on the right to configure it. +

+
+ ); + } + + if (panelView === 'technology-pv') { + return ( + + ); + } + + if (panelView === 'technology-battery') { + return ( + + ); + } + + return null; +} diff --git a/src/app/components/BuildingConfigurator/layout/EnergyDemandCard.tsx b/src/app/components/BuildingConfigurator/layout/EnergyDemandCard.tsx new file mode 100644 index 0000000..8a17393 --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/EnergyDemandCard.tsx @@ -0,0 +1,77 @@ +// Preliminary energy demand card shown under the 3D preview in the configure +// workspace — live heating/electricity/hot water + thermal efficiency figures. + +import React from 'react'; +import { Gauge } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { HeatingDeltaBadge } from '../shared/ui'; +import { ENERGY_ITEMS } from '../shared/energyTotals'; +import type { EnergyTotals } from '../../../lib/loadProfile'; + +export interface EnergyDemandCardProps { + displayEnergyTotals: EnergyTotals; + thermalRating: { label: string; color: string }; + avgUValue: number; +} + +export function EnergyDemandCard({ displayEnergyTotals, thermalRating, avgUValue }: EnergyDemandCardProps) { + return ( +
+
+
+

+ Preliminary energy demand +

+
+ {ENERGY_ITEMS.map(({ key, label, Icon, iconBg, iconColor, valueColor }) => { + const value = displayEnergyTotals[key as keyof EnergyTotals]; + return ( +
+
+
+ +
+ {label} +
+
+
+ + {value} + + {displayEnergyTotals.unit} + {key === 'heating' && ( + + )} +
+ {key === 'heating' && displayEnergyTotals.heatingPerM2 && ( +

{displayEnergyTotals.heatingPerM2} kWh/m²·a

+ )} +
+
+ ); + })} + + {/* Thermal efficiency — separated by subtle rule */} +
+
+
+ +
+ Thermal efficiency +
+
+ + {thermalRating.label} + + {avgUValue.toFixed(2)} W/m²K +
+
+
+

+ Will update live as surface properties change +

+
+
+
+ ); +} diff --git a/src/app/components/BuildingConfigurator/layout/OverviewLayout.tsx b/src/app/components/BuildingConfigurator/layout/OverviewLayout.tsx new file mode 100644 index 0000000..a0c17b9 --- /dev/null +++ b/src/app/components/BuildingConfigurator/layout/OverviewLayout.tsx @@ -0,0 +1,19 @@ +// Overview workspace: snapshot sidebar (left) + energy/envelope column (right). + +import React from 'react'; +import { BuildingSnapshotAside, type BuildingSnapshotAsideProps } from '../overview/BuildingSnapshotAside'; +import { EnergyEnvelopeColumn, type EnergyEnvelopeColumnProps } from '../overview/EnergyEnvelopeColumn'; + +export interface OverviewLayoutProps { + snapshot: BuildingSnapshotAsideProps; + envelope: EnergyEnvelopeColumnProps; +} + +export function OverviewLayout({ snapshot, envelope }: OverviewLayoutProps) { + return ( +
+ + +
+ ); +} diff --git a/src/app/components/BuildingConfigurator/shared/energyTotals.ts b/src/app/components/BuildingConfigurator/shared/energyTotals.ts new file mode 100644 index 0000000..2fd5559 --- /dev/null +++ b/src/app/components/BuildingConfigurator/shared/energyTotals.ts @@ -0,0 +1,66 @@ +// Derives the fixed annual energy totals (heating/electricity/hot water) shown +// in the overview hero and the configure workspace's preliminary demand card. + +import { Flame, Zap, Droplets } from 'lucide-react'; +import type { EnergyTotals, LoadDataPoint } from '../../lib/loadProfile'; +import type { ThermalSummary } from '../../lib/buemAdapter'; + +/** Formats a kWh figure with precision scaled to its magnitude. */ +export function formatKwh(v: number): string { + const abs = Math.abs(v); + if (abs >= 100) return abs.toFixed(0); + if (abs >= 1) return abs.toFixed(1); + return abs.toFixed(2); +} + +/** + * Computes fixed annual energy totals from the full hourly timeseries. + * Falls back to the model thermal summary, then to placeholder dashes. + * Unit is always kWh — independent of chart resolution. + */ +export function computeEnergyTotals( + timeseries: LoadDataPoint[] | null, + thermalSummary: ThermalSummary | null, +): EnergyTotals { + if (timeseries && timeseries.length > 0) { + return { + heating: formatKwh(timeseries.reduce((s, p) => s + p.heating, 0)), + electricity: formatKwh(timeseries.reduce((s, p) => s + p.electricity, 0)), + hotwater: formatKwh(timeseries.reduce((s, p) => s + p.hotwater, 0)), + unit: 'kWh', + }; + } + if (thermalSummary) { + return { + heating: thermalSummary.heatingKwh.toFixed(0), + electricity: thermalSummary.electricityKwh.toFixed(0), + hotwater: thermalSummary.coolingKwh.toFixed(0), + unit: 'kWh', + }; + } + return { electricity: '—', heating: '—', hotwater: '—', unit: 'kWh' }; +} + +/** + * Raw (unformatted) BuEM baseline annual heating figure, in kWh — the "last + * full simulation" reference point that a live ignis recalculation is + * compared against. Same source priority as computeEnergyTotals, but returns + * a number for arithmetic rather than a display string. + */ +export function baselineHeatingKwh( + timeseries: LoadDataPoint[] | null, + thermalSummary: ThermalSummary | null, +): number | null { + if (timeseries && timeseries.length > 0) { + return timeseries.reduce((s, p) => s + p.heating, 0); + } + if (thermalSummary) return thermalSummary.heatingKwh; + return null; +} + +/** Row config for the configure workspace's preliminary energy demand card. */ +export const ENERGY_ITEMS = [ + { key: 'heating', label: 'Heating', Icon: Flame, iconBg: 'bg-orange-500/20', iconColor: 'text-orange-400', valueColor: 'text-orange-300' }, + { key: 'electricity', label: 'Electricity', Icon: Zap, iconBg: 'bg-yellow-500/20', iconColor: 'text-yellow-400', valueColor: 'text-yellow-300' }, + { key: 'hotwater', label: 'Hot Water', Icon: Droplets, iconBg: 'bg-blue-500/20', iconColor: 'text-blue-400', valueColor: 'text-blue-300' }, +] as const; diff --git a/src/app/components/BuildingConfigurator/shared/surfaceFactory.ts b/src/app/components/BuildingConfigurator/shared/surfaceFactory.ts new file mode 100644 index 0000000..60087f7 --- /dev/null +++ b/src/app/components/BuildingConfigurator/shared/surfaceFactory.ts @@ -0,0 +1,68 @@ +// Builds a new surface element (id, label, seeded properties) when the user +// adds a custom wall/window/door/roof/floor from the configurator. + +import type { BuildingElement } from '../configure/model/buildingElements'; +import { isElementEditable } from '../configure/model/buildingElements'; +import type { RoofConfig } from '../configure/model/roof'; + +const SURFACE_DEFAULTS: Record> = { + wall: { type: 'wall', area: 12, uValue: 0.24, gValue: null, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, + window: { type: 'window', area: 2.4, uValue: 1.3, gValue: 0.6, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, + door: { type: 'door', area: 2.1, uValue: 1.8, gValue: null, tilt: 90, azimuth: 180, source: 'custom', customMode: true }, + roof: { type: 'roof', area: 18, uValue: 0.18, gValue: null, tilt: 35, azimuth: 180, source: 'custom', customMode: true }, + floor: { type: 'floor', area: 18, uValue: 0.30, gValue: null, tilt: 0, azimuth: 0, source: 'custom', customMode: true }, +}; + +function surfaceTypeLabel(type: BuildingElement['type']): string { + if (type === 'roof') return 'Roof'; + if (type === 'floor') return 'Floor'; + if (type === 'door') return 'Door'; + if (type === 'window') return 'Window'; + return 'Wall'; +} + +function buildSurfaceLabel(type: BuildingElement['type'], elements: Record): string { + const next = Object.values(elements).filter((el) => el.type === type).length + 1; + return `Custom ${surfaceTypeLabel(type)} ${next}`; +} + +function buildSurfaceId(type: BuildingElement['type'], elements: Record): string { + const base = `custom_${type}`; + let idx = 1; + while (elements[`${base}_${idx}`]) idx += 1; + return `${base}_${idx}`; +} + +/** Creates a new surface of the given type, seeded from an existing editable surface of the same type when one exists. */ +export function buildNewSurface(type: BuildingElement['type'], elements: Record): BuildingElement { + const seed = Object.values(elements).find((el) => el.type === type && isElementEditable(el)) + ?? Object.values(elements).find((el) => el.type === type) + ?? null; + + return { + id: buildSurfaceId(type, elements), + label: buildSurfaceLabel(type, elements), + ...(seed + ? { + type, + area: seed.area, + uValue: seed.uValue, + gValue: seed.gValue, + tilt: seed.tilt, + azimuth: seed.azimuth, + source: 'custom' as const, + customMode: true, + } + : SURFACE_DEFAULTS[type]), + }; +} + +/** Type guard for the legacy roof-config shape found in some imported JSON files. */ +export function isRoofConfig(value: unknown): value is RoofConfig { + return !!value + && typeof value === 'object' + && 'type' in value + && 'surfaces' in value + && Array.isArray((value as RoofConfig).surfaces) + && 'from3DData' in value; +} diff --git a/src/app/components/BuildingConfigurator/shared/ui.tsx b/src/app/components/BuildingConfigurator/shared/ui.tsx index be1f348..4a1d9d1 100644 --- a/src/app/components/BuildingConfigurator/shared/ui.tsx +++ b/src/app/components/BuildingConfigurator/shared/ui.tsx @@ -49,13 +49,6 @@ export const ELEMENT_DOTS: Record = { door: '#8a5a38', }; -// ─── ConfiguratorStyles — no-op; styles are now in theme.css ────────────────── - -/** @deprecated Styles are now in theme.css. This component is kept for compatibility. */ -export function ConfiguratorStyles() { - return null; -} - // ─── InfoTip ────────────────────────────────────────────────────────────────── export function InfoTip({ tip }: { tip: string }) {