/** * House Plan Card — an interactive house plan as a native Lovelace card. * Configuration sources: * 1) SERVER (the houseplan integration, WS houseplan/config/get) — spaces, plans, * rooms, device overrides, virtual devices. Coordinates are NORMALIZED (0..1). * 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas. * The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage. */ import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit'; import { guard } from 'lit/directives/guard.js'; import { repeat } from 'lit/directives/repeat.js'; import './hp-dialog'; import type { HpDialog } from './hp-dialog'; import './hp-color-opacity'; import type { ColorPickerLabels } from './hp-color-opacity'; import './hp-help'; import './hp-device-preview'; import { EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor, type IconRule, type CompiledIconRule, } from './rules'; import { lqiColor, snapToGrid, snapSegment45, samePoint, pointInPolygon, markerIdForBinding, segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, isExact45Vector, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, contentUrl, snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint, isInteriorLightOpeningType, openingEntityReferences, filterOpeningEntityCandidates, poleOfInaccessibility, subst, averageLqi, fitView, declump, safeUrl, floorsOf, type FloorInfo, stateIcon, lightColorOf, parseRoomRef, diffNewDevices, resolveGlowValues, resolveGlowAppearance, glowAlpha, normalizeGlowColorOverride, isControllable, spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, DEFAULT_FILL_COLORS, customFillOf, roomCustomFillOf, DEFAULT_CUSTOM_FILL, type FillColors, type FillColorEntry, type ResolvedRoomFill, runServiceFor, RUN_TARGET_DOMAINS, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, referencedContentUrls, DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES, normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode, liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines, DECOR_TEXT_BASE, } from './logic'; import { resolveSafeResize, applySafeResize, clampSafeResize, validateSafeResize, coalesceResizeRooms, safeResizePointerDisplacement, polyIsSimple, areaM2, formatArea, MIN_ROOM_CM, type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions, type SafeResizePlan, type SafeResizeReason, type SafeResizeResolution, } from './resize'; import { placeResizeAreaLabel, resizeMeasuredEdges, type ResizeAreaPlacement, } from './resize-labels'; import { computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay, rayStops, resolveDayCycle, dayCycleFingerprint, type DayCycleState, rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, } from './sun'; import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render'; import { FURNITURE_GROUPS, furnitureOfGroup, furnitureSymbol, furnitureDefaultCm, furniturePathD, furnitureCorners, snapFurnitureToWall, cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup, } from './furniture'; import { degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations, setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits, wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry, innerContourForRoom, roomWallProfile, outsetContour, openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex, openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation, applyWallThicknessToNewRoom, drawWallPreviewD, linearWallJoinPatches, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals, normalizeWallIntervals, intervalCmAt, wallBodyNeedsSolid, wallHatchNeedsSolid, wallHatchStepUnits, HATCH_BASE_STEP_UNITS, type OpeningTunnelGeometry, type OpeningWallIndex, type LinearWallSegment, type WallEntry, type WallInterval, innerEdgeSpan, ownEdgeOffsets, thicknessCmAt, } from './wall-thickness'; import { resolveOpenCuts, resolveBoundaryTarget, snapOpenPoint, clampToEdgeEnds, jointsOnEdge, cutsToSpanEntries, syncOpenToFromCuts, applyThicknessOnClose, purgeOpeningsOnSpan, pointOnOpenCut, removeCut, rekeyOpenSpansAfterMove, clipOpenSpansToShared, sanitizeOpenSpans, entryToSeg, type OpenSpanEntry, type BoundaryTarget, } from './open-spans'; import { ContentSigner } from './signing'; import { resolveFixedFloor, resolveInitialSpace, settleBestEffort, type FixedFloorSelection, type InitialSpaceSelection, } from './initial-load'; import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection'; import { initialSpaceDisplayDraft, switchSpacePlanSource, touchSpaceDisplay, } from './space-dialog'; import { mdiHomeCityOutline } from '@mdi/js'; import { Affine, applyAffine, readVacTelemetry, autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid, vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM, VAC_TELEPORT_GAP_MS, VAC_STALE_MS, FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom, Pt as VacPt, type VacPath, type VacSourceCandidate, type VacSourceResolution, type VacSourceStatus, } from './vacuum'; import { buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity, areaTemp, areaHum, sourceValue, areaClimateMap, resolvedLightSources, resolvedLightState, resolvedLightStats, hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities, forcedLightEntityOf, resolveDeviceLightSettings, selectSpatialGlowSource, resolvedDeviceStateEntities, removedPlanBindings, isRemovedPlanEntity, deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls, removeMarkerControlReferences, rewriteMarkerControlReferences, markerControlWouldCycle, resolveIcon, type AreaClimate, } from './devices'; import { formatToggleConfirmation, formatToggleIntent, projectedTapAction, resolveToggleIntent, sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation, toggleEntityCandidates, unavailableToggleTargetNames, type ResolvedToggleIntent, type ResolvedToggleTarget, type ToggleNextEffect, type ToggleNoneReason, type ToggleSkipReason, } from './device-toggle'; import { toggleEntityWriteFields } from './marker-toggle-entity'; import { adoptVirtualLightServerSnapshot, applyVirtualLightEvent, reconcileVirtualLightSnapshot, virtualLightFingerprint, virtualLightSnapshot, virtualLightWire, type VirtualLightSnapshot, } from './virtual-light-state'; import type { OpeningCfg, PartitionOpeningHost, RoomCfg, RoomDraftCfg, PartitionCfg, WallColumnCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, MarkerValueBadge, ValueBadgePosition, ValueBadgeSource, } from './types'; import { COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody, directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings, geometryAllRings, intersectionPaths, partitionBody, polyclipPathD, pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodyParts, type PartitionOpeningCut, } from './physical-geometry'; import { hostedOpeningIntervalsOverlap, materializePartitionOpening, partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation, partitionOpeningFace, partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict, type PartitionOpeningOrphanReason, type ResolvedPartitionOpening, } from './partition-openings'; import { buildHiddenWallDiagnosticGeometry, buildPlanSnapGeometry, resolvePlanSnapResult, resolveStrictPlanSnap, type HiddenWallDiagnosticGeometry, type PlanSnapCandidate, type PlanSnapEndpoint, type PlanSnapGeometry, type PlanSnapSegment, } from './plan-snap-overlay'; import { atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs, findWallFaceAtPoint, normalizeUnifiedWallTool, wallChainSegments, chainSegmentCms, type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment, } from './wall-face-graph'; import { parameterOnPartition, planRoomDeletion } from './room-deletion'; import { planWallFaceRepair, repairMovesHostedPartition, type WallFaceRepairProposal, } from './wall-face-repair'; import { LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon, } from './light-visibility'; import './editor'; import './space-card'; import { cardStyles } from './styles'; import { editorSecondaryStyles } from './editor-secondary.styles'; import { EditorSecondaryController, type EditorSecondaryCopy, type EditorSecondaryModel, type EditorToolbarGroup, } from './editor-secondary'; import { fitInSquare, planRect, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame, spaceCenter, iconUnit, iconCqw, gridLevels, itemOf, snapPt, MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, SANE_LIMIT, GRID_PITCH, GRID_STEP_N, PLAN_SCALE_MIN, PLAN_SCALE_MAX, clampCanvasR, clampCanvasN, type ContentItem, type Rect, } from './space-geometry'; import { optimizePlans, type OptimizeReport } from './plan-optimizer'; import { adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel, fixedTopologyWallLineageHints, sanitizeRoomDraftPath, wallModelOffGridValueCount, WallSegmentModelError, } from './wall-segment-model'; import { snapNearAxisEndpoint } from './near-axis'; import type { SpaceReferenceRepairContext } from './space-reference-repair'; import { collectSpaceMarkerDependencies } from './space-deletion'; import { checkSpacePhysicalGeometry, checkOptimizeGeometry, geometryOpenCuts, geometryOpenings, geometryOpenPairs, geometryPartitionOpeningCuts, geometryRoomOpeningInputs, spacePhysicalGeometryFingerprint, type GeometryOpeningProjection, type OptimizeGeometryPreflightResult, } from './plan-geometry-preflight'; import { canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizePosition, formatLatticeShiftCm, } from './coordinate-canonicalization'; import { enqueueSerializedWrite } from './serialized-write-queue'; import { hasTranslation, langOf, t, type I18nKey } from './i18n'; import { CommandStack } from './command-stack'; import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend'; import { CONTINUITY_LONG_HIDDEN_MS, VisualContinuityController, contentFingerprint, subscribePageVisibility, visualFrameFingerprint, type PageVisibilitySignal, } from './visual-continuity'; import { PointerModalityController } from './pointer-modality'; import { entityVisualSample, entityVisualSamplesForDevice, type DeviceActivity, type DeviceVisualState, type EntityVisualSample, } from './device-visual'; import { activitySourceSignature, deviceA11yState, presentationClasses, resolveDevicePresentation, resolvePresentationSources, type ResolvedDevicePresentation, } from './device-presentation'; import { ACTIVITY_WINDOW_MS, advanceFiniteActivity, createFiniteActivityRuntime, resetFiniteActivityRuntime, stampFiniteActivity, type FiniteActivityRuntime, } from './activity-runtime'; import { recommendedValueBadgeSource, valueBadgeCandidates, valueBadgeSourceFromKey, valueBadgeSourceKey, valueBadgeTitle, valueBadgeWriteFields, type ValueBadgeCandidate, } from './device-value-badge'; import { createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions, type RenderDeviceSnapshot, } from './render-device-snapshot'; import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face'; import { effectiveDeviceBaseSize } from './device-marker-geometry'; import { ModeTransitionController, viewportFromViewBox, type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox, } from './mode-transition'; import { currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot, } from './labs'; import { ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss, projectPlanPoint, projectedFrame, unprojectFloorPoint, type ScenePoint, type ViewRect, } from './iso-projection'; import { buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoFloorGeometry, type IsoWallGeometry, } from './iso-walls'; import { buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration, type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel, } from './iso-openings'; import { acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses, fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot, openingEntityAvailable, refreshHaRegistries, renderOpeningEntityAvailable, resolveHaBindingStatus, type HaBindingStatus, type HaRegistrySnapshot, } from './ha-binding-status'; import type { DecorShape, DecorStyle } from './editors/decor/types'; import { DEFAULT_DECOR_STYLE, boxAnchors, boxCorners, clamp01, decorCmToUnits, decorStrokeUnits, decorStyleOf, decorStylePatch, decorUnitsToCm, mergeSnapGeometry, normalizeAngle, resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft, type DecorBox, type SnapGeometry, } from './editors/decor/geometry'; import { renderOpeningTunnelFills } from './render/opening-tunnels'; import { openingVisibleMetrics, renderOpeningVisibleGeometry, type OpeningFaceOffset, type OpeningVisibleSpec, } from './render/opening-symbol'; import { openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry, resolveOpeningPlacementResult, sameOpeningPlacementInput, type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType, } from './opening-placement'; import { buildOpeningDimensionContext, resolveOpeningDimensions, type OpeningDimension, type OpeningDimensionContext, } from './opening-dimensions'; import { safeStoredColor } from './color'; import { gridCellFieldToCm, gridCellFieldValue, gridVisualScale, gridVisualUnits, newSpaceCellCm, wallThickHoverHalfUnits, } from './grid-scale'; import { applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold, reorderSpaceIds, } from './space-order'; import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge'; const CARD_VERSION = '1.68.0-beta.1'; type ResizeLiveLabel = { kind: 'length'; x: number; y: number; text: string; edge: { a: [number, number]; b: [number, number] }; } | { kind: 'area'; roomId: string; x: number; y: number; text: string; placement: ResizeAreaPlacement; }; const DISPLAY_LABEL_KEYS: Record = { badge: 'display.badge', icon_ripple: 'display.icon_ripple', value: 'display.value', static_icon: 'display.static_icon', }; const DISPLAY_HINT_KEYS: Record = { badge: 'marker.display_hint_badge', icon_ripple: 'marker.display_hint_icon_ripple', value: 'marker.display_hint_value', static_icon: 'marker.display_hint_static_icon', }; /** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */ const DECOR_TEXT_CM_MAX = 2000; const CELL_CM_MIN = 0.1; const CELL_CM_MAX = 1000; /** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least * BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS * trailing-quiescence requirement (chrome still settling near the cap * extends the wait); BOOT_MAX_MS lifts the veil unconditionally. * BOOT_MIN_MS exceeds the old 600 ms window by a frame-latency margin: a * panel applied right at the window's edge (~590 ms) only materializes in * the stage height a couple of rAF/render frames later. */ const BOOT_MIN_MS = 700; const BOOT_QUIET_MS = 250; const BOOT_MAX_MS = 1200; /** AUD-1552-02: post-reveal grace during which late chrome shifts glide * (CSS height transition on the stage) instead of snapping. */ const BOOT_SOFT_MS = 1500; /** Numeric editor fields must consume the whole value: `50abc` is invalid, * not a surprisingly accepted 50. Decimal comma remains supported. */ const strictNumber = (value: string): number | null => { const text = String(value ?? '').trim().replace(',', '.'); if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null; const parsed = Number(text); return Number.isFinite(parsed) ? parsed : null; }; type LruRead = { hit: true; value: V } | { hit: false }; const lruRead = (cache: Map, key: K): LruRead => { if (!cache.has(key)) return { hit: false }; const value = cache.get(key)!; cache.delete(key); cache.set(key, value); return { hit: true, value }; }; const lruWrite = (cache: Map, key: K, value: V, limit: number): void => { cache.delete(key); cache.set(key, value); while (cache.size > limit) { const oldest = cache.keys().next().value as K | undefined; if (oldest === undefined) break; cache.delete(oldest); } }; /** DEV-B703-01: warm re-mount memo — MODULE scope, so it lives with the loaded * PAGE, not with any card instance. Lovelace re-creates card elements when * the websocket reconnects after a long-backgrounded tab; the fresh instance * used to run the whole first-open boot (veil + BOOT_MIN_MS + quiescence), * which reads as «план перезагрузился», even though the page (and HA's * chrome) never went anywhere. Within one loaded page the chrome IS already * settled, so the geometry the previous instance settled at is still valid: * remember it per (viewport size × card config) and let the next instance * open instantly in the final geometry, no veil at all. A window resize * between instances changes the key → miss → the full protective boot (the * only case where the chrome may genuinely re-settle). The config part of * the key keeps two DIFFERENT cards on one page from adopting each other's * header height (same config twice on one view is indistinguishable — and * then the heights match anyway). */ /** DEV-B703-03 — what the dead instance was LOOKING at. The header height * alone was not enough: `_view` (the pan) never left the instance, and the * EDITOR zoom is deliberately not persisted (`_saveZoom` is view-only) while * the editor MODE is (LS_NAV). So a re-mount inside an editor came back at * the view-mode zoom, and a panned view came back re-centred on the plan — * the owner's «чуть-чуть дёргается масштаб». The memo now carries the whole * viewport, so the restore is the same rect, not the same zoom number. */ type WarmViewport = { space: string; mode: 'view' | 'plan' | 'devices' | 'decor'; projection: 'flat' | 'iso'; activeLabsIso: boolean; logicalCenter: { x: number; y: number } | null; zoom: number; view: { x: number; y: number; w: number; h: number } | null; /** the view-mode viewport an editor was entered from (_viewModeSnap) */ snap: { space: string; zoom: number; cx?: number; cy?: number } | null; tool: MarkupTool; decorTool: DecorTool; showHidden: boolean; /** «показать дальние» changes _baseVb, so the restored view rect is only * the same rect if the frame it was clamped against is the same one */ showFar: boolean; selId: string | null; rszSel: string | null; decorSel: string | null; }; const expiredWarmViewport = (vp: WarmViewport | null): WarmViewport | null => { if (!vp || vp.mode === 'view') return vp; return { ...vp, mode: 'view', zoom: vp.snap?.space === vp.space ? vp.snap.zoom : vp.zoom, view: null, snap: null, tool: 'draw', decorTool: 'select', showHidden: false, selId: null, rszSel: null, decorSel: null, }; }; /** Which dialog was open and its draft (docs/WARM-REMOUNT.md §3). `data` is * the live draft OBJECT — the memo is module state, never serialised, so a * half-filled device dialog with its uploaded pdfs survives for free. */ type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'decorShape' | 'backdrop' | 'rules' | 'room' | 'info' | 'openingInfo'; type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any }; /** AUD-159B1-01: one entry per CARD PLACEMENT, not per key. Two cards with an * identical config on one view share the key, so the key alone cannot say * whose viewport this is; `place`/`idx` (the parent element the card was * mounted in, and its position among that parent's children) identify the * DOM slot, and `owner` the live instance sitting in it. A re-mount into the * same slot inherits the entry; a different card never does. */ type WarmEntry = { /** generation id of the instance that currently owns the slot */ owner: number; /** HA route where this placement was captured. */ path: string; /** the parent element the owner was mounted in (weak — never keep DOM alive) */ place: WeakRef | null; /** the owner's index among that parent's children */ idx: number; /** the owner is attached; a dead slot is a tombstone waiting for a successor */ live: boolean; hdrH: number; stageH: number; vp: WarmViewport | null; /** Last complete visual frame for #73; safe only inside the same placement. */ frameFingerprint: string; /** Metadata projection is immutable-by-replacement and avoids an empty * device layer on a same-document warm remount before registry refresh. */ devices: readonly DevItem[] | null; dlg: WarmDialog | null; /** when the instance that wrote `dlg` detached; 0 = it is still alive */ freed: number; /** the TTL timer that frees `dlg` once it can no longer be revived */ evict: number; }; const warmBoot = new Map(); let warmGen = 0; /** `location.pathname` is the dashboard AND the view path: two Lovelace views * never share a key, so a card that comes back on another view boots cold * instead of inheriting a stranger's viewport (AUD-159B1-01). The hash is * deliberately out — `#space=` is OUR deep link, not another placement. */ const warmBootKey = (config: unknown): string => `${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config ?? {})}`; /** A dialog is revived only if the instance that owned it died THIS long ago. * A Lovelace rebuild detaches and re-attaches within one task; a user who * walked off to another dashboard view and came back later must not be met * by a dialog they have long forgotten opening. */ let WARM_REVIVE_MS = 10000; /** The memo gains a key on every window RESIZE and never loses one; the * values used to be two numbers, and now they can hold a dialog draft (a * plan pdf among it). Keep the last few viewports — a stale entry only * costs the next card at that size a cold boot. */ const WARM_MAX_KEYS = 8; /** How many placements of ONE key are remembered. More identical cards than * this on one view and the oldest dead slot is dropped — it only costs that * placement a cold boot. */ const WARM_MAX_SLOTS = 4; /** Which slot of `list` belongs to the instance now claiming it, and may we * trust it with the viewport/dialog (`sure`) or only with the settled height? * Ranked, best first (AUD-159B1-01): * 4 — same parent, same index: literally the DOM slot we are standing in, * which is how Lovelace replaces a card (the predecessor may still be * attached for another task — that is the case the audit reproduced); * 0 — some OTHER placement whose owner is still attached: a neighbouring * card with an identical config, never ours to inherit; * 3 — same parent, shifted index, owner gone: still our placement; * 2 — a tombstone whose placement is gone with its subtree — the ordinary * Lovelace rebuild, where the container is rebuilt too. * A tie means two candidates are equally plausible: then only the settled * height is adopted, and it is the same for all of them anyway. */ const warmMatch = ( list: WarmEntry[], gen: number, place: Node | null, idx: number, ): { slot: WarmEntry | null; sure: boolean } => { const score = (s: WarmEntry): number => { const same = !!place && s.place?.deref() === place; if (same && s.idx === idx) return 4; if (s.live) return 0; return same ? 3 : 2; }; let best: WarmEntry | null = null; let bestScore = 0; let ties = 0; let newest: WarmEntry | null = null; for (const s of list) { if (s.owner === gen) continue; newest = s; const sc = score(s); if (sc <= 0) continue; if (sc > bestScore) { best = s; bestScore = sc; ties = 1; } else if (sc === bestScore) ties++; } if (!best || ties > 1) return { slot: best || newest, sure: false }; return { slot: best, sure: true }; }; /** Rotation step of a decor text block — the same 5° a device icon turns in * (marker dialog). Shift affects angle precision only, never position. */ const DT_ANGLE_STEP = 5; /** Line spacing of a multi-line label, in font sizes. */ const DT_LINE = 1.2; const LS_KEY = 'houseplan_card_layout_v1'; const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_NAV = 'houseplan_card_nav_v1'; // last space only; editor sessions never survive page navigation const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs) const LS_VIEW = 'houseplan_card_view_v1'; // presentation preference per space, Labs-only const POINTER_HOVER_TARGET_SELECTOR = 'hp-dialog, hp-help, hp-color-opacity, hp-device-preview'; const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0) /** Short semantic-event / direct-terminal-transition window. Event uses three sequential 1.1 s waves; motion cool-down itself never animates. */ /** * How finely the lit region is traced where nothing blocks the light. 96 steps * put the chord error at 0.05% of the radius — under a tenth of a pixel on a * wall tablet, and cheap because only unobstructed directions use them. */ const GLOW_ARC_STEPS = 96; /** * Width of the lit→unlit ramp along a shadow edge, in SCREEN pixels: the eye * reads a perfectly geometric edge as a cut-out, and a real penumbra is never * wider than a hair at this scale. Measured on screen on purpose, so zooming in * does not turn a hairline into a smear. */ const GLOW_EDGE_FEATHER_PX = 2; /** * Radial profile of a pool, as [offset %, share of the calibrated alpha]. * Monotonic all the way out: a lamp is brightest under itself and dies at its * radius. The centre keeps the full calibrated alpha, so nothing about the * brightness maths (docs/specs/067) changes — only where that alpha is spent. */ const GLOW_FALLOFF: readonly (readonly [number, number])[] = [ [0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0], ]; /** A source pool fades in/out without changing its final calibrated alpha. */ const GLOW_FADE_MS = 500; /** Smallest rectangle holding both (docs/CANVAS.md §4). */ const unionRect = (a: Rect, b: Rect): Rect => { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y }; }; /** #313: one Thickness-tool hit — a room interval or independent masonry. */ type WallThickSource = { kind: 'room' } | { kind: 'partition'; id: string } | { kind: 'draft'; id: string; segment: number }; type WallThickHit = { a: number[]; b: number[]; roomId: string; segs: number[][]; open: boolean; cm: number; source: WallThickSource; }; type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'boundary' | 'wallthick' | 'delroom'; type RoomFillFrame = { byRoom: Map; byId: Map; }; type WallFaceCandidate = WallGraphFace & { split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] }; consumeAllActive?: boolean; /** Face existed before this click; rejecting it must be a true no-op. */ existing?: boolean; repair?: WallFaceRepairProposal; }; type WallFaceDecision = { candidate: WallFaceCandidate; create: boolean; name?: string; area?: string | null; settings?: RoomCfg['settings']; }; type WallFaceBatch = { candidates: WallFaceCandidate[]; index: number; decisions: WallFaceDecision[]; activePath: number[][]; activeCms: number[]; activeDraftId: string | null; }; /** * The floor a source can see, and nothing else. One region means one clip: * a beam through a doorway, the room it lands in and the shadow of a column * are all the same computation, so they can never disagree with each other. */ type GlowClipGeometry = { lit: string[] }; const MARKUP_TOOLS = new Set([ 'select', 'draw', 'column', 'merge', 'split', 'resize', 'opening', 'boundary', 'wallthick', 'delroom', ]); /** Warm viewport is page-memory, so it may contain a tool name from the old bundle. */ const normalizeMarkupTool = (value: unknown): MarkupTool => { if (value === 'openwall' || value === 'closewall') return 'boundary'; // #173 replaces the public one-shot Partition tool with one Walls chain. // A warm page may still carry the old session token; reading it is inert. value = normalizeUnifiedWallTool(value); // Opening placement is valid only together with its explicit session-only // type preset. Warm viewport state does not persist that preset. if (value === 'opening') return 'draw'; return typeof value === 'string' && MARKUP_TOOLS.has(value as MarkupTool) ? value as MarkupTool : 'draw'; }; type BoundaryUiTarget = BoundaryTarget | { kind: 'blocked' }; type BoundaryPreview = | { kind: 'anchor'; point: number[] } | { kind: 'range'; seg: number[]; invalid: boolean } | { kind: 'restore'; seg: number[]; body: number[][] | null } | { kind: 'invalid'; point: number[] }; const MAX_ROOM_DRAFTS = 200; const MAX_DRAFT_POINTS = 500; const MAX_DRAFT_SEGMENTS = 2000; const MAX_PARTITIONS = 2000; const MAX_ROOMS = 400; const MAX_WALL_COLUMNS = 500; /** Everything whose topology or wall association changes in the plan editor. */ interface SpaceGeometryState { spaceId: string; rooms: any[]; openings?: OpeningCfg[]; walls?: WallEntry[]; wall_segments?: any[]; open_spans?: OpenSpanEntry[]; room_drafts?: RoomDraftCfg[]; partitions?: PartitionCfg[]; wall_columns?: WallColumnCfg[]; decor?: DecorShape[]; plan_transform: { plan_x?: number; plan_y?: number; plan_scale?: number; plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number; }; } /** Tools of the decor (background) editor. `furniture` is the library * (docs/FURNITURE.md): it opens a palette and places a symbol at real size. */ type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'erase'; const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const ev = new Event(type, { bubbles: true, composed: true }) as any; ev.detail = detail ?? {}; node.dispatchEvent(ev); }; const navigate = (path: string) => { history.pushState(null, '', path); fireEvent(window, 'location-changed', { replace: false }); }; /** * Debounce with `flush()` and `pending`. Both are load-bearing: a pending * config write MUST be flushed before the card adopts a server revision, * otherwise the edit is silently dropped (audit L2, 2026-07-27). */ interface Debounced void> { (...a: Parameters): void; flush(): void; cancel(): void; pending(): boolean; } const debounce = void>(fn: T, ms: number): Debounced => { let t: number | undefined; let last: Parameters | null = null; const wrapped = ((...a: Parameters) => { clearTimeout(t); last = a; t = window.setTimeout(() => { t = undefined; const args = last; last = null; if (args) fn(...args); }, ms); }) as Debounced; wrapped.flush = () => { if (t === undefined) return; clearTimeout(t); t = undefined; const args = last; last = null; if (args) fn(...args); }; wrapped.cancel = () => { clearTimeout(t); t = undefined; last = null; }; wrapped.pending = () => t !== undefined; return wrapped; }; /** * Capture the pointer for a drag, tolerating an inactive pointerId. * * `setPointerCapture` throws for synthetic events and for pointers some * browsers consider gone; that killed a drag outright. The opening pipeline * was hardened for this, the device/label/resize ones were not (audit * follow-up L4 sub-item) — now they all go through here. */ const capturePointer = (ev: PointerEvent): void => { try { (ev.target as Element | null)?.setPointerCapture?.(ev.pointerId); } catch { /* an inactive pointerId must never kill the drag */ } }; /** Ruler labels + the centre-magnet tick. Existing-opening DRAG carries its * legacy two labels; a new-opening PLACEMENT may additionally carry physical * line/tick geometry (#238). */ interface OpMeasureLabel { x: number; y: number; text: string; dimension?: OpeningDimension; } interface OpMeasure { labels: OpMeasureLabel[]; guide: { x: number; y: number; angle: number } | null; } type OpeningPlacementCandidate = Omit & { face: OpeningFaceOffset; measure: OpMeasure; }; type RenderOpening = OpeningCfg & { rx: number; ry: number; rlen: number; partitionHost?: ResolvedPartitionOpening; orphanReason?: PartitionOpeningOrphanReason; }; type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown }; class HouseplanCard extends LitElement { public hass?: any; private _config?: CardConfig; private _space = 'f1'; private _layout: Record = {}; private _serverStorage = false; private _loadOk = false; /** null until config/get answers; then mirrors auth.may_write for this user. */ private _serverCanWrite: boolean | null = null; private _loading = false; private _loadTries = 0; private _serverCfg: ServerConfig | null = null; private _cfgRev = 0; private _cfgContentFingerprint = ''; private _unsubCfg: (() => void) | null = null; private _unsubLayout: (() => void) | null = null; private _unsubVirtual: (() => void) | null = null; private _liveSyncAttempt: Promise | null = null; private _liveSyncGeneration = 0; private _liveSyncConnection: any = null; private _layoutRev = 0; private _layoutContentFingerprint = ''; private _virtualLights: VirtualLightSnapshot = virtualLightSnapshot(null); /** One-deep server snapshot; invalidated by the first later plan edit. */ private _canOptimizeUndo = false; private _undoKind: 'optimize' | 'import' | null = null; private _devices: DevItem[] = []; private _regSignature = ''; private _defPos: Record = {}; private _newSyncKey = ''; private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; } | null = null; /** Room whose physical perimeter is highlighted in View. The explicit * overlay is needed because thick wall bodies paint above room shapes. */ private _hoverRoom: { space: string; room: RoomCfg } | null = null; private readonly _pointerModality = new PointerModalityController( this, () => this._syncPointerHoverTargets(), ); private _pointerHoverObserver?: MutationObserver; private _devicePressAnimations = new Map(); private _selId: string | null = null; private _toast = ''; private _toastTimer?: number; // --- room markup editor --- /** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry * editing, devices = marker placement/config. Never persisted — every load * starts in view. */ private _mode: 'view' | 'plan' | 'devices' | 'decor' = 'view'; /** Editor mode from a same-route warm remount while can_write is unknown. */ private _pendingNavMode: 'plan' | 'devices' | 'decor' | null = null; // ---- decor (background) editor ---- private _decorTool: DecorTool = 'select'; private _decorStyle: DecorStyle = { ...DEFAULT_DECOR_STYLE }; private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null; private _decorMove: { id: string; start: number[]; orig: DecorShape; pid: number; moved: boolean; before: SpaceGeometryState | null; } | null = null; private _decorSel: string | null = null; /** Pending eraser-tool confirmation. Delete/Backspace remains the direct * command for an explicitly selected object. */ private _decorEraseConfirm: { id: string; kind: DecorShape['kind'] } | null = null; /** The text dialog. Live references are part of `text`; `pickerEntity` is * only transient UI state and is never persisted (docs/LIVE-TEXT.md). */ private _decorTextDialog: { id?: string; x: number; y: number; text: string; color: string; opacity: number; angle: string; sizeCm: number; pickerEntity?: string; /** Keep an old link when inline syntax cannot represent its attr/unit losslessly. */ preserveLegacy?: boolean; } | null = null; /** Style editor opened by double-clicking a non-text decor object. */ private _decorShapeDialog: { id: string; kind: 'line' | 'rect' | 'ellipse' | 'furniture'; color: string; opacity: number; widthCm: number; lineStyle?: 'solid' | 'dashed'; fill?: boolean; fillColor?: string; fillOpacity?: number; lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string; symbol?: string; } | null = null; private _backdropDialog: { widthCm: number; heightCm: number; angle: string } | null = null; /** Last textarea selection survives moving focus to the HA pickers. */ private _decorTextSelection: { start: number; end: number } = { start: 0, end: 0 }; /** * The furniture palette (docs/FURNITURE.md §3): which symbol is armed and at * what REAL size it will be placed. `w`/`h` are centimetres — the fields show * them in metres or feet, but the config's one true scale is `cell_cm`, and * a unit system is a display setting, never a stored one. */ private _furnPalette: { symbol: string; w: number; h: number } | null = null; /** The selected shape's own unrotated frame (text is measured from SVG). */ private _dtBox: { id: string; x: number; y: number; w: number; h: number } | null = null; /** * One live transform gesture for every selected decor kind. Lines use two * endpoint handles; text uses one physical font size; box kinds use the * same oriented resize/rotate controller. */ private _dtDrag: { id: string; kind: 'scale' | 'rotate'; pid: number; /** the pivot the block is scaled and rotated about (render units) */ ax: number; ay: number; /** distance / bearing of the pointer at the start, and the stored values */ r0: number; a0: number; textSizeCm0: number; angle0: number; /** box shapes: the dragged corner signs and the oriented box at drag start */ sgx?: number; sgy?: number; orig?: DecorBox; origShape: DecorShape; before: SpaceGeometryState | null; lineEnd?: 0 | 1; moved: boolean; } | null = null; /** * The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its * body, scaling it by a corner handle or rotating it by the upper handle. * `base` is the untransformed, centred rectangle the transform is measured * from, so a gesture never accumulates rounding of its own. */ private _bdDrag: { kind: 'move' | 'scale' | 'rotate'; pid: number; /** pointer down, render units */ sx: number; sy: number; /** the centred default rect (render units) — the transform's origin */ base: Rect; /** transform at pointer down */ p0: { dx: number; dy: number; sx: number; sy: number; angle: number }; /** the corner that STAYS PUT while scaling (render units) */ fx: number; fy: number; /** which way the dragged corner points from the fixed one (±1) */ sgx: number; sgy: number; rect0: DecorBox; before: SpaceGeometryState | null; moved: boolean; } | null = null; /** Edit tabs are offered to admins only (hass.user missing → assume admin). */ /** * Editor chrome (Plan/Devices/Background tabs, space +/gear). * Driven by the server `can_write` flag from `houseplan/config/get` so the * UI matches `auth.may_write` / the `admin_only` option (audit P0-4). * Until the server answers, fail CLOSED: missing `hass.user` must never * open the editors (`=== true`, not `!== false`). */ private get _canEdit(): boolean { if (!this._norm) return false; if (this._serverCanWrite === true) return true; if (this._serverCanWrite === false) return false; return this.hass?.user?.is_admin === true; } /** Legacy alias: markup machinery is active exactly in plan mode. */ private get _kiosk(): boolean { return !!this._config?.kiosk; } private _showKioskDots(): void { this._kioskDots = true; clearTimeout(this._kioskDotsTimer); this._kioskDotsTimer = window.setTimeout(() => (this._kioskDots = false), 2500); } /** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */ /** Which way the incoming plan moves when the active space changes. */ private _slide: '' | 'left' | 'right' = ''; private _slideTimer?: number; private _reducedMotion = false; private _motionMedia?: MediaQueryList; private _onMotionChange = (event: MediaQueryListEvent): void => { this._reducedMotion = event.matches; this._cancelDevicePressFeedback(); // A preference change to reduced motion is authoritative immediately: do // not leave a 220 ms camera/chrome tween running until its old deadline. if (event.matches && this._modeTransitionPreparing) { this._modeTransitionForceAtomic = true; } else if (event.matches && this._modeTransition.active) { this._cancelModeTransition(true); } this.requestUpdate(); }; /** Last editor kept in the collapsing chrome so leaving it can animate out. */ private _editorChromeMode: 'plan' | 'devices' | 'decor' = 'plan'; private _modeTransitionVisual: ModeVisualState | null = null; private _modeTransitionPreparing = false; /** A visibility/reduced-motion interruption during the measurement frame * must still publish the measured target atomically, without waiting for a * RAF that a hidden document may throttle. */ private _modeTransitionForceAtomic = false; private _modeTransitionRequest = 0; private _modeTransitionTargetZoom = 1; private _modeTransitionTargetCenterX: number | undefined; private _modeTransitionTargetCenterY: number | undefined; /** Last intended editor camera retained while an exit-to-View can still be * retargeted back to an editor. It is session-only, like `_viewModeSnap`. */ private _modeTransitionEditorCamera: { zoom: number; centerX?: number; centerY?: number; } | null = null; private readonly _modeTransition = new ModeTransitionController({ frame: (state) => this._applyModeTransitionFrame(state), settled: (state) => this._settleModeTransition(state), }); /** Explicit second-level toolbar group. No current tools are grouped yet; * the shared host/API prevents future editors from inventing dropdowns. */ private readonly _editorSecondary = new EditorSecondaryController({ root: () => this.renderRoot as ShadowRoot, requestUpdate: () => this.requestUpdate(), updateComplete: () => this.updateComplete, clearTip: () => { this._tip = null; }, }); private readonly _editorSecondaryCopy: EditorSecondaryCopy = { groupActive: (group, item) => this._t('editor.group_active', { group, item }), openGroup: (group) => this._t('editor.open_group', { group }), disabledAction: (action, reason) => this._t('editor.disabled_action', { action, reason }), }; private get _modeTransitionBusy(): boolean { return this._modeTransitionPreparing || this._modeTransition.active; } private _cssColor(value: string | null | undefined, fallback: string): string { const text = String(value || '').trim(); if (!text) return fallback; const probe = document.createElement('span'); probe.style.cssText = `position:absolute;visibility:hidden;color:${text}`; this.renderRoot.append(probe); const resolved = getComputedStyle(probe).color || fallback; probe.remove(); return resolved; } private _currentModeVisual(mode = this._mode as HouseplanMode): ModeVisualState | null { // During the one-frame target measurement the controller has not started // yet, but `_modeTransitionVisual` is already the exact painted endpoint. // A rapid second click must retarget from that frame instead of rebuilding // an approximation from the newly committed logical mode. const presented = this._modeTransition.presented || this._modeTransitionVisual; if (presented) return { ...presented, viewport: { ...presented.viewport, viewBox: { ...presented.viewport.viewBox }, } }; const stage = this._stageEl; if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null; const chrome = this.renderRoot.querySelector('.editorchrome') as HTMLElement | null; const view = this._viewOr(this._baseVb()); const paper = this.renderRoot.querySelector('.hp-paper') as SVGElement | null; const backdrop = this.renderRoot.querySelector('.hp-backdrop') as SVGElement | null; const zoomwrap = this.renderRoot.querySelector('.zoomwrap') as HTMLElement | null; const filter = zoomwrap ? getComputedStyle(zoomwrap).filter : ''; const brightness = Number(/brightness\(([^)]+)\)/.exec(filter)?.[1]); return { presentedMode: mode, editorChromeHeight: mode === 'view' ? 0 : chrome?.getBoundingClientRect().height || 0, stageWidth: stage.clientWidth, stageHeight: stage.clientHeight, viewport: viewportFromViewBox(view, stage.clientWidth), stageColor: getComputedStyle(stage).backgroundColor || 'rgb(255, 255, 255)', paperColor: paper ? getComputedStyle(paper).fill : 'rgb(255, 255, 255)', sceneBrightness: Number.isFinite(brightness) ? brightness : 1, architectureOpacity: mode === 'decor' ? 0.35 : 1, backdropOpacity: backdrop ? Number(getComputedStyle(backdrop).opacity) || 1 : 1, viewWeight: mode === 'view' ? 1 : 0, editorWeight: mode === 'view' ? 0 : 1, toolbarContentOpacity: mode === 'view' ? 0 : 1, }; } private _viewForModeTarget( zoom: number, centerX: number | undefined, centerY: number | undefined, stageWidth: number, stageHeight: number, ): ModeViewBox { const vb = this._baseVb(); const fit = fitView(vb, stageWidth / Math.max(1, stageHeight)); const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom)); const w = fit.w / z, h = fit.h / z; const cx = centerX ?? fit.x + fit.w / 2; const cy = centerY ?? fit.y + fit.h / 2; return this._clampView({ x: cx - w / 2, y: cy - h / 2, w, h }, fit); } private _targetStageColor(mode: HouseplanMode): string { if (mode !== 'view') return 'rgb(255, 255, 255)'; return this._cssColor(this._stageBg(this._spaceDisplayForRender()), this._cssColor('var(--ha-card-background, var(--card-background-color, #111))', 'rgb(17, 17, 17)')); } private _targetPaperColor(mode: HouseplanMode): string { if (mode !== 'view' || !this._spaceModel()?.bg) return 'rgb(255, 255, 255)'; return this._cssColor('var(--ha-card-background, var(--card-background-color, #111))', 'rgb(17, 17, 17)'); } private _targetBrightness(_mode: HouseplanMode): number { return 1; } private _applyModeTransitionFrame(state: ModeTransitionState): void { if (state.targetMode !== this._mode) return; this._modeTransitionVisual = state.presented; this._view = { ...state.presented.viewport.viewBox }; this.requestUpdate(); } private _settleModeTransition(state: ModeTransitionState): void { if (state.targetMode !== this._mode) return; const settledRequest = this._modeTransitionRequest; this._view = { ...state.to.viewport.viewBox }; this._zoom = this._modeTransitionTargetZoom; this._modeTransitionVisual = null; this._modeTransitionPreparing = false; this._lastValidStageSize = [state.to.stageWidth, state.to.stageHeight]; if (this._mode === 'view') { this._saveZoom(); this._viewModeSnap = null; this._modeTransitionEditorCamera = null; } this.requestUpdate(); void this.updateComplete.then(() => { if (!this.isConnected || settledRequest !== this._modeTransitionRequest || this._modeTransitionBusy || state.targetMode !== this._mode) return; // Natural responsive layout remains the final authority. A breakpoint, // font load or outer resize may land while ResizeObserver is suppressed // by the transition; reconcile it before the next visible interaction. const stage = this._stageEl; if (stage && stage.clientWidth > 0 && stage.clientHeight > 0 && (Math.abs(stage.clientWidth - state.to.stageWidth) > 0.5 || Math.abs(stage.clientHeight - state.to.stageHeight) > 0.5)) { const current = this._view; this._lastValidStageSize = [stage.clientWidth, stage.clientHeight]; this._applyView( this._zoom, current ? current.x + current.w / 2 : undefined, current ? current.y + current.h / 2 : undefined, ); this.requestUpdate(); } const focused = (this.renderRoot as ShadowRoot).activeElement as HTMLElement | null; const lostWithOutgoingUi = !focused || !focused.isConnected || !!focused.closest?.('.editorchrome, .stage'); if (lostWithOutgoingUi) { (this.renderRoot.querySelector('.modetab.active') as HTMLElement | null) ?.focus?.({ preventScroll: true }); } }); } private _cancelModeTransition(commitTarget = true): void { const hadControllerState = !!this._modeTransition.state; this._modeTransitionRequest++; this._modeTransitionPreparing = false; this._modeTransitionForceAtomic = false; this._modeTransition.cancel(commitTarget); // A preparing transition has no controller state to settle. Its measured // callback is invalidated above, so always discard the retained frame; // otherwise a space switch/recovery during measurement can leave inline // height/background/camera coordinates attached indefinitely. if (!commitTarget || !hadControllerState) this._modeTransitionVisual = null; } /** Adopt a mode from configuration/recovery without leaving a measured * transition alive. User navigation continues to go through `_setMode()`. */ private _adoptMode(mode: HouseplanMode): void { this._cancelModeTransition(false); this._mode = mode; if (mode !== 'view') this._editorChromeMode = mode; } /** Route departure cannot wait for a decorative frame that will never be * shown. Publish the pending View camera directly before warm persistence. */ private _commitViewModeAtomic( from: ModeVisualState | null, targetZoom: number, targetCenterX?: number, targetCenterY?: number, ): void { this._modeTransitionPreparing = false; this._modeTransitionVisual = null; this._modeTransitionForceAtomic = false; this._zoom = targetZoom; if (from) { const targetStageWidth = this._stageEl?.clientWidth || from.stageWidth; const targetStageHeight = Math.max(1, from.stageHeight + from.editorChromeHeight); this._view = this._viewForModeTarget( targetZoom, targetCenterX, targetCenterY, targetStageWidth, targetStageHeight, ); this._lastValidStageSize = [targetStageWidth, targetStageHeight]; } else { this._view = null; } this._viewModeSnap = null; this._modeTransitionEditorCamera = null; this._saveZoom(); this.requestUpdate(); } private _prepareModeTransition( request: number, from: ModeVisualState, targetMode: HouseplanMode, targetZoom: number, targetCenterX?: number, targetCenterY?: number, ): void { void this.updateComplete.then(() => { if (!this.isConnected || request !== this._modeTransitionRequest || this._mode !== targetMode) { // Only clean the generation that still owns the preparing flag. A // stale callback must not cancel a newer transition, but a config or // recovery mode adoption in the measurement window must never strand // the scene in its inert/busy state. if (request === this._modeTransitionRequest) { this._modeTransitionPreparing = false; this._modeTransitionVisual = null; this._modeTransitionForceAtomic = false; this.requestUpdate(); } return; } const chrome = this.renderRoot.querySelector('.editorchrome') as HTMLElement | null; const inner = chrome?.querySelector('.editorchrome-inner') as HTMLElement | null; const targetChromeHeight = targetMode === 'view' ? 0 : inner?.scrollHeight || inner?.getBoundingClientRect().height || 0; const totalHeight = Math.max(1, from.stageHeight + from.editorChromeHeight); const targetStageHeight = Math.max(1, totalHeight - targetChromeHeight); const targetStageWidth = this._stageEl?.clientWidth || from.stageWidth; if (targetStageWidth <= 0 || targetStageHeight <= 0) { this._modeTransitionPreparing = false; this._modeTransitionVisual = null; this._applyView(targetZoom, targetCenterX, targetCenterY); this.requestUpdate(); return; } const targetView = this._viewForModeTarget( targetZoom, targetCenterX, targetCenterY, targetStageWidth, targetStageHeight, ); const to: ModeVisualState = { presentedMode: targetMode, editorChromeHeight: targetChromeHeight, stageWidth: targetStageWidth, stageHeight: targetStageHeight, viewport: viewportFromViewBox(targetView, targetStageWidth), stageColor: this._targetStageColor(targetMode), paperColor: this._targetPaperColor(targetMode), sceneBrightness: this._targetBrightness(targetMode), architectureOpacity: targetMode === 'decor' ? 0.35 : 1, backdropOpacity: targetMode === 'decor' && this._decorTool !== 'backdrop' ? 0.5 : 1, viewWeight: targetMode === 'view' ? 1 : 0, editorWeight: targetMode === 'view' ? 0 : 1, toolbarContentOpacity: targetMode === 'view' ? 0 : 1, }; this._modeTransitionPreparing = false; const forceAtomic = this._modeTransitionForceAtomic; this._modeTransitionForceAtomic = false; this._modeTransition.start(from, to, targetMode, this._reducedMotion || forceAtomic ? 0 : 220); }); } /** An own `floor` property is deliberate, including invalid/null YAML. */ private get _hasFixedFloor(): boolean { return !!this._config && Object.prototype.hasOwnProperty.call(this._config, 'floor'); } /** * Resolve fixed-floor authority against one model snapshot. Cache may safely * prove an exact stable id, but it may not reject a missing id or resolve a * positional index until the integration has answered authoritatively. */ private _fixedFloorState(models = this._model, authoritative = this._loadOk): FixedFloorState { const value = this._config?.floor; const selection = resolveFixedFloor({ spaceIds: models.map((space) => space.id), hasFloor: this._hasFixedFloor, floor: value, }); if (!this._hasFixedFloor || authoritative) return selection; if (typeof value === 'number') { if (selection.kind === 'valid' || selection.kind === 'invalid' && selection.reason === 'out-of-range-index') { return { kind: 'pending', value }; } return selection; } if (selection.kind === 'valid') return selection; if (selection.kind === 'invalid' && selection.reason !== 'unknown-id') return selection; return { kind: 'pending', value }; } /** One boundary for every mutation of the active space. */ private _canCommitSpace(id: string, authority = false): boolean { if (authority || !this._hasFixedFloor) return true; const fixed = this._fixedFloorState(); return fixed.kind === 'valid' && fixed.id === id; } private _commitSpace(id: string, authority = false): boolean { if (!this._canCommitSpace(id, authority)) return false; if (id !== this._space) { this._clearTransientHover(true); this._cancelDevicePressFeedback(); } this._space = id; return true; } /** Change the space with the usual sideways transition. */ private _slideTo(id: string, dir: 'left' | 'right'): boolean { if (id === this._space) return true; if (!this._canCommitSpace(id)) return false; if (this._wallFaceBatch) this._roomDialogCancel(); if (this._mode === 'plan' && this._tool === 'draw' && !this._finishWallChain()) return false; this._cancelModeTransition(true); const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; if (this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId; this._commitSpace(id); this._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(true); this._tool = 'draw'; this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._selId = null; this._physicalSel = null; this._editorSecondary.closeForNavigation(); this._physicalDialog = null; this._physicalDrag = null; if (this._mode === 'plan' && this._tool === 'draw') this._resumeLastDraft(); this._restoreZoom(); if (reduce) return true; this._slide = dir; clearTimeout(this._slideTimer); this._slideTimer = window.setTimeout(() => { this._slideTimer = undefined; this._slide = ''; this.requestUpdate(); }, 190); this.requestUpdate(); return true; } /** Direct space tabs use the same motion as swipe/carousel navigation. */ // ---- reordering the space tabs (issue #220) ------------------------------ // // Mouse only, editors only: the same tabs switch spaces in View, where touch // is a first-class citizen, so a gesture here would compete with that tap. // docs/specs/220-space-tab-reorder.md §4.1, "Touch editor: not exposed". private get _canReorderTabs(): boolean { return canStartTabDrag({ canEdit: this._canEdit, kiosk: this._kiosk, mode: this._mode, pointerType: 'mouse', spaceCount: this._model.length, fixedFloor: this._hasFixedFloor, }); } private _tabPointerDown(event: PointerEvent, id: string): void { if (!canStartTabDrag({ canEdit: this._canEdit, kiosk: this._kiosk, mode: this._mode, pointerType: event.pointerType, spaceCount: this._model.length, fixedFloor: this._hasFixedFloor, })) return; // A mouse released past the edge of the panel fires neither pointerup nor // pointercancel on any tab, and the gesture would stay stuck mid-drag — // taking the next click with it, since _tabClick swallows clicks that // follow a drag (review CODE-REVIEW-220-r1, M1). // // Capture is the usual answer and this file uses it everywhere, but it is // not a guarantee: the browser grants it only for a live pointer, so a // gesture that starts any other way keeps no capture at all. The window // listener below is what actually closes the gesture; capture merely keeps // the moves flowing to the tab while the button is held. capturePointer(event); this._tabDragRelease = (release: PointerEvent) => this._tabPointerUp(release); window.addEventListener('pointerup', this._tabDragRelease); window.addEventListener('pointercancel', this._tabDragRelease); this._tabDrag = { id, pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false, targetId: null, placement: null, }; } /** Resolve a drop from screen coordinates, independent of captured event.target. */ private _tabDropTargetAt(clientX: number, clientY: number, sourceId: string): { targetId: string; placement: 'before' | 'after'; } | null { const ids = this._model.map((space) => space.id); const sourceIndex = ids.indexOf(sourceId); if (sourceIndex < 0) return null; const tabs = this.renderRoot.querySelectorAll('[data-hp="space-tab"]'); for (const tab of tabs) { const targetId = tab.dataset.id || ''; if (!targetId || targetId === sourceId) continue; const rect = tab.getBoundingClientRect(); if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) continue; const targetIndex = ids.indexOf(targetId); if (targetIndex < 0) return null; return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' }; } return null; } private _tabPointerMove(event: PointerEvent): void { const drag = this._tabDrag; if (!drag || drag.pointerId !== event.pointerId) return; if (!drag.moved && !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return; // Past the threshold the gesture is a drag: the click that would otherwise // follow is suppressed in _tabClick, and the panel shows where it lands. const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id); if (drag.moved && drag.targetId === target?.targetId && drag.placement === target?.placement) return; this._tabDrag = { ...drag, moved: true, targetId: target?.targetId || null, placement: target?.placement || null, }; } private _tabPointerUp(event: PointerEvent): void { const drag = this._tabDrag; if (drag && drag.pointerId !== event.pointerId) return; const target = event.type === 'pointerup' && drag?.moved ? this._tabDropTargetAt(event.clientX, event.clientY, drag.id) : null; if (event.type === 'pointerup' && drag?.moved) this._suppressNextTabClick(); this._endTabDrag(); if (!drag?.moved || !target) return; this._commitTabOrder(drag.id, target.targetId); } /** Drop the gesture and its window listeners, wherever the release happened. */ private _endTabDrag(): void { this._tabDrag = null; if (!this._tabDragRelease) return; window.removeEventListener('pointerup', this._tabDragRelease); window.removeEventListener('pointercancel', this._tabDragRelease); this._tabDragRelease = null; } /** A click that followed a real drag must not also switch the space. */ private _tabClick(id: string): void { if (this._tabSuppressClick) { this._tabSuppressClick = false; clearTimeout(this._tabSuppressClickTimer); this._tabSuppressClickTimer = undefined; return; } this._pickSpace(id); } /** Browser click follows pointerup in the same task; clear if none arrived. */ private _suppressNextTabClick(): void { this._tabSuppressClick = true; clearTimeout(this._tabSuppressClickTimer); this._tabSuppressClickTimer = window.setTimeout(() => { this._tabSuppressClick = false; this._tabSuppressClickTimer = undefined; }, 0); } /** * Write the new order — and, in the same write, the placement that used to * depend on it. * * A marker with neither an explicit space nor an area that names one renders * in whatever space sits first. Reordering would silently hand it to another * space, so the answer it has right now is written down first. This is the * whole reason the two changes may not be split into two saves. */ private _commitTabOrder(movedId: string, targetId: string): void { const cfg = this._serverCfg; if (!cfg || !this._canReorderTabs) return; const ids = this._model.map((space) => space.id); const order = reorderSpaceIds(ids, movedId, targetId); if (order === ids) return; // The area in force, not merely the one stored on the marker: a marker that // binds an HA device inherits its area from the registry, and such a marker // never depended on the order (review CODE-REVIEW-220-r1, H1). const areaById = new Map( this._devices.map((device) => [String(device.id), String(device.area || '')]), ); const pinned = markersNeedingPlacement( cfg.markers || [], Object.fromEntries( Object.entries(this._areaToSpace).map(([area, value]) => [area, value.space]), ), ids[0] || '', (markerId) => areaById.get(markerId) || '', ); if (pinned.length) { const byId = new Map(pinned.map((entry) => [entry.id, entry.space])); for (const marker of cfg.markers || []) { const space = byId.get(String((marker as any).id)); if (space) (marker as any).space = space; } } cfg.spaces = applySpaceOrder(cfg.spaces || [], order); this._saveConfig(); if (!this._tabOrderWarned) { this._tabOrderWarned = true; this._showToast(this._t('toast.space_order_changed')); } } private _pickSpace(id: string): void { this._endTabDrag(); if (id === this._space) return; const ids = this._model.map((sp) => sp.id); const from = ids.indexOf(this._space); const to = ids.indexOf(id); this._navApplied = true; this._showFar = false; // the hint is per space (docs/CANVAS.md §4.1) this._frame = null; if (this._slideTo(id, from >= 0 && to < from ? 'right' : 'left')) this._saveNav(); } private _cycleTick(): void { if (this._hasFixedFloor || !this._kiosk || !(Number(this._config?.cycle) > 0)) return; if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) { const ids = this._model.map((m) => m.id); const i = ids.indexOf(this._space); this._slideTo(ids[(i + 1) % ids.length], 'left'); this._showKioskDots(); } } private _syncCycleTimer(): void { clearInterval(this._cycleTimer); this._cycleTimer = undefined; if (!this.isConnected || this._hasFixedFloor || !this._config?.kiosk || !(Number(this._config.cycle) > 0)) return; this._cycleTimer = window.setInterval( () => this._cycleTick(), Number(this._config.cycle) * 1000, ); } /** Any edit mode is active (plan / devices / decor). */ private get _editing(): boolean { return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor'; } private get _markup(): boolean { return this._mode === 'plan'; } private _tool: MarkupTool = 'draw'; /** UX-04: one named, 50-step command history for every plan-geometry tool. */ private _geometryHistory = new CommandStack(50); /** Wall-thickness tool dialog (docs/WALL-THICKNESS.md). */ private _wallDialog: { a: number[]; b: number[]; value: string; roomId: string | null; // #313: the Thickness tool serves independent masonry too. `room` keeps // the historical shape (walls records + «apply to room»); the other two // write partition.cm / draft.segments[i].cm. The switch in // _wallThickApply is the single seam where #306 will later hang the // «zero turns a partition virtual» branch. source: WallThickSource; sx: number; sy: number; } | null = null; /** * Draw-toolbar thickness field (session). `null` until Plan is entered — * then primed to DRAW_WALL_DEFAULT_CM. Empty string = no thickness on commit. */ private _drawWallField: string | null = null; /** The saved draft currently continued by the Draw tool. */ private _activeDraftId: string | null = null; private _resumeDraftBySpace: Record = {}; /** One-click/two-click physical-object selection in the Plan editor. */ private _physicalSel: { kind: 'partition' | 'column' | 'draft'; id: string; segment?: number; } | null = null; private _physicalDialog: { kind: 'partition' | 'column' | 'draft'; id: string; cm: string; segment?: number; shape?: 'square' | 'circle'; angle?: string; length?: string; } | null = null; private _partitionDeleteDialog: { id: string; openings: OpeningCfg[] } | null = null; private _roomDeleteDialog: { roomId: string; name: string } | null = null; /** Drag preview is render-only; config is committed once on pointerup. */ private _physicalDrag: { pid: number; kind: 'partition' | 'column'; id: string; start: number[]; startClient: number[]; before: SpaceGeometryState | null; moved: boolean; base: PartitionCfg | WallColumnCfg; delta: number[]; } | null = null; private _physicalRotate: { pid: number; id: string; center: number[]; startAngle: number; baseAngle: number; angle: number; before: SpaceGeometryState | null; moved: boolean; } | null = null; private _physicalLastTap: { kind: 'partition' | 'column' | 'draft'; id: string; segment?: number; at: number; } | null = null; private _physicalPickCycle: { signature: string; index: number; x: number; y: number; at: number; } | null = null; private _wallUnionCache: { key: string; value: ReturnType; } | null = null; private _isoGeometryCache = new Map(); private _isoFallback = new Set(); private _openingTunnelCache: { key: string; value: Array; } | null = null; /** A few consumers use intentionally different open-cut projections in one * frame. Keep a tiny keyed pool so hit-testing cannot evict placement data * (and vice versa) on every pointer move. */ private _openingWallIndexCache = new Map(); private _openingPlacementIntervalsCache: { key: string; value: WallInterval[] } | null = null; private _openingDimensionContextCache: { key: string; value: OpeningDimensionContext; } | null = null; private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null; private _planStructuralGeometryCache: { key: string; value: PlanSnapGeometry } | null = null; private _hiddenWallDiagnosticCache: { key: string; value: HiddenWallDiagnosticGeometry; } | null = null; private _physicalBodiesCache: { key: string; drafts: number[][][]; partitions: number[][][]; columns: number[][][]; patches: number[][][]; all: number[][][]; } | null = null; /** Light cuts are type/floor-specific and differ from drawn masonry, but HA * state ticks must not rebuild independent-wall topology. */ private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null; private _cleanFloorCache = new Map(); private _glowClipCache = new Map(); /** Redacted, bounded dedupe for numerical floor fallbacks (#218). */ private _glowGeometryWarnings = new Set(); private _lightBarrierCache: { key: string; value: { occluders: LightSegment[]; floor: number[][][]; fingerprint: string; masonryGeometry: any; opaqueBodies: number[][][]; }; } | null = null; /** Freeze the SVG blur while a pinch/pan emits animation frames, then adopt * the final screen-space value after the gesture. */ private _glowFeatherUnits: number | null = null; /** Active pools survive an off transition until their 500 ms fade completes. */ private _glowRenderedSources = new Map(); private _glowLastAppearance = new Map(); private _glowEnteringSources = new Set(); private _glowEnterRafs = new Map(); private _glowFadeTimers = new Map(); private _glowFeatherSuspendUntil = 0; private _glowFeatherResumeTimer = 0; private _glowSourceSeq = 0; /** Pending/false uses the exact historical normal-layer fallback. */ private _glowScreenBlend = false; private _duplicateColumnId: string | null = null; private _duplicateColumnTimer = 0; // room resize tool (docs/RESIZE.md): selection and an immutable live preview private _rszSel: string | null = null; private _rszDrag: { pid: number; start: [number, number]; roomId: string; plan: SafeResizePlan; opts: SafeResizeOptions; rooms: { id: string; poly: number[][] }[]; openings: SafeOpeningIn[]; snap: string; moved: boolean; d: number; changed: string[]; rejectNotified: boolean; } | null = null; private _rszEligibilityCache: { key: string; values: Map; } | null = null; /** HP-1550-01: the live resize preview, kept OUT of _serverCfg (see _rszApplyPreview). */ private _rszPreview: { space: string; sp: any } | null = null; private _rszLive: ResizeLiveLabel[] | null = null; private _path: number[][] = []; // current outline (render units, vertices snapped to the grid) private _cursorPt: number[] | null = null; private _planSnapHover: { contextKey: string; candidate: PlanSnapCandidate | null; conflicts: PlanSnapEndpoint[]; } | null = null; private _mergeSel: string | null = null; /** Session-only explicit type/width chosen in the Opening sub-panel. */ private _openingPreset: OpeningPlacementPreset | null = null; /** Existing orphan whose next valid placement replaces only its host. */ private _openingRebindId: string | null = null; private _openingPresetRevision = 0; /** Last painted hover candidate. Click may reuse it only for the same input epoch. */ private _openingHoverCandidate: OpeningPlacementCandidate | null = null; /** Last pointer target rejected only because its independent-wall jamb was too small. */ private _openingJambBlockCm: number | null = null; private _openingDialog: { id?: string; // editing an existing opening type: 'door' | 'window' | 'gate' | 'passage'; lengthCm: number; /** Avoid opting a rounded legacy length into strict geometry on a binding-only edit. */ lengthTouched?: boolean; contact: string; lock: string; contactOpen?: boolean; contactFilter?: string; lockOpen?: boolean; lockFilter?: string; invert: boolean; flipH: boolean; flipV: boolean; host?: PartitionOpeningHost; x: number; y: number; angle: number; // render units (from the wall snap) } | null = null; private _openingInfo: OpeningCfg | null = null; private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean; before: SpaceGeometryState | null; } | null = null; // live ruler badges + the "centered on the wall" tick while an opening is dragged private _opMeasure: OpMeasure | null = null; private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null; /** Open-boundary tool: first click anchor on a shared wall (render units). */ private _openWallAnchor: { p: number[]; edge: number[]; aId: string; bId: string } | null = null; /** Pointer-specific hit widths must follow the gesture, not a global media query. */ private _boundaryPointerType = 'mouse'; private _boundaryRestoreGuard: { until: number; point: number[] } | null = null; private _boundaryTargetMemo: { key: string; value: BoundaryUiTarget } | null = null; private _boundaryPreviewMemo: { key: string; value: BoundaryPreview | null } | null = null; private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far // a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact private _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null; /** Pending #173 face decisions. Config remains untouched until the last one. */ private _wallFaceBatch: WallFaceBatch | null = null; private _wallRepairDiagnostic: WallFaceRepairProposal | null = null; /** Four exact structural entries; never keyed by HA state, hover or theme. */ private _wallFaceGraphCache: Array<{ key: string; value: WallFaceGraph }> = []; private _areaSel = ''; private _nameSel = ''; private _roomDialog = false; private _roomEditId: string | null = null; // gear on a room card (edit mode) private _roomFill: '' | 'none' | 'lqi' | 'light' | 'temp' | 'custom' = ''; // '' = inherit /** null = inherit the space custom color; value = explicit room override. */ private _roomCustomFill: FillColorEntry | null = null; private _roomTempSrc = ''; // '' = average private _roomHumSrc = ''; private _roomSrcOpen: 'temp' | 'hum' | null = null; private _roomSrcFilter = ''; private _roomNameScale = 1; private _roomLabelScale = 1; // plan zoom/pan (zoom is saved per space, locally) private _zoom = 1; private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates) private _zoomBySpace: Record = {}; /** * View-mode viewport remembered on entering an editor. Editor zoom is a * working tool (zoom in to grab a vertex), not the user's intention for * viewing — leaving any editor brings the view-mode viewport back. */ private _viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number } | null = null; private _pointers = new Map(); private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null; /** * What the current one-finger drag turned out to be, decided ONCE on the * first real movement and held until the finger lifts (see * `_stagePointerMove`). Only the kiosk has two candidates — a horizontal * drag there is the floor swipe; everywhere else a drag always pans. */ private _panLock: 'pan' | 'swipe' | null = null; private _pinchStart: { dist: number; zoom: number } | null = null; private _suppressClick = false; /** * Pointer events from an interactive child may stop before the stage sees * them. Track touch contacts on the card in capture phase, so the second * finger always turns the whole sequence into navigation and can never leave * a synthetic tap on a device, room link, opening badge or editor control. */ private _touchContacts = new Map(); private _touchSequenceMultitouch = false; private _touchClickBlockUntil = 0; /** Route occupied by this live card. Persistent navigation remembers only * the space; leaving this HA route ends the transient editor session. */ private _connectedPath = ''; private _routeDepartureHandled = false; private readonly _onLocationChanged = (): void => { if (this._connectedPath && location.pathname !== this._connectedPath) { this._leaveCardRoute(); } else if (location.pathname === this._connectedPath) { // Some HA routers keep the old view connected but hidden. Returning to // it re-arms the guard so a later departure ends the next edit session. this._routeDepartureHandled = false; } }; private readonly _touchGestureGuard = { capture: true, handleEvent: (ev: Event): void => this._guardTouchGesture(ev), }; private _roViewport?: ResizeObserver; private _roHdr?: ResizeObserver; private _onWinResize?: () => void; private _hdrH = 118; // measured px above the stage (see the observer in updated()) /** HP-1552: first-open boot veil. In normal (non-kiosk) mode the stage is * calc(100dvh - _hdrH), and _hdrH is measured from HA's chrome — which * finishes loading AFTER the card's first paint, so every late panel * nudged the height and the plan visibly jumped. The plan hides behind a * pulsing-house veil for a full protective window (AUD-1552-02: the old * early reveal on "two equal reads" let a panel landing at 400-600 ms * jump on a VISIBLE plan); the whole lifecycle restarts from * connectedCallback (AUD-1552-01: timers die on disconnect — a Lovelace * DOM rebuild mid-boot used to leave the plan hidden forever). * First open only; kiosk is 100dvh and never jumps. */ private _booting = true; private _bootFading = false; // veil kept one beat for the opacity-out private _bootSettling = false; private _bootSettleRaf = 0; private _bootTimer?: number; private _bootLastH = -1; private _bootStart = 0; private _bootLastChange = 0; // when the stage height last moved (quiescence clock) private _bootSoft = false; // post-reveal grace: late chrome shifts glide, not jump private _bootSoftTimer?: number; /** The accidental-tap guard: pending confirmation for a toggle/run tap. */ private _tapConfirm: { kind: 'toggle'; text: string; lines: string[]; initialIntent: ResolvedToggleIntent; deviceId: string; exec: () => void; } | { kind: 'run'; text: string; exec: () => void } | null = null; private _onboardingShown = false; // the auto space dialog is shown once per session private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null; /** Optimization preview plus the exact pair, so commit cannot differ from it. */ private _alignDialog: { report: OptimizeReport; config: any; layout: Record; preflight: OptimizeGeometryPreflightResult | null; /** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */ cm: number; /** the space that maximum belongs to, named only when there are several */ where: string; changed: boolean; busy: boolean; /** False by default; true only after the secondary preview action. */ removeLiveMissingPositions: boolean; } | null = null; private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; /** sun on the plan (docs/SUN.md) */ northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; busy: boolean; } | null = null; private _backupExportDialog: { kind: 'full' | 'space'; planOnly: boolean; busy: boolean; error: string; } | null = null; private _backupImportDialog: { filename: string; size: number; token: string; preview: any; expectedConfigRev: number; expectedLayoutRev: number; duplicatePolicy: 'skip' | 'virtual'; confirmMissing: boolean; busy: boolean; error: string; } | null = null; /** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */ private _sunRaysCache: { key: string; rays: SunRay[]; rims: number[][][][] } | null = null; /** Browser-local fallback lifecycle; real sun updates arrive through hass. */ private _dayCycleTimer = 0; private _dayCycleClockKey = ''; private _compassDrag = false; private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null; private _importQueue: string[] = []; // floor titles still to create private _importTotal = 0; private _rulesCompiledSrc = ''; private _rulesCompiled: CompiledIconRule[] | undefined; private _infoCard: DevItem | null = null; /** Native HA more-info last opened by this card, for disabled mid-dialog cleanup. */ private _nativeMoreInfoEntity: string | null = null; private _markerDialog: { devId?: string; // the icon being edited (if any) /** * Folder attachments are uploaded into while this dialog is open. For a NEW * icon there is no marker id yet; every one of them used to upload into a * shared `files/new/`, so two markers attaching `manual.pdf` ended up * pointing at the same bytes (HP-1454-02). A per-dialog id keeps them * apart, and the files are moved to the real marker id once the config * write is accepted — the same copy→save→cleanup order as a rebind. */ uploadId?: string; name: string; binding: string; // 'device:' | 'entity:' | 'virtual' | '' (not chosen yet) bindingMode: 'virtual' | 'ha'; bindingOpen: boolean; // the HA-list dropdown is expanded showEntities: boolean; // list entities of devices too bindingFilter: string; icon: string; // '' = auto autoIcon: string; // the icon the rules would give — picker placeholder display: DeviceDisplayMode; rippleColor: string; // '' = accent rippleSize: number; // in icon diameters; package default 1.5 size: number; // icon size multiplier angle: number; // icon rotation, degrees /** UI projection. A legacy persisted `cover` is shown as `toggle`. */ tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; /** Snapshot announced only after a user edit; live HA ticks do not rewrite it. */ tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; // 'run': automation./script./scene. entity id tapConfirm: boolean; // ask before toggle/run runFilter: string; controls: string[]; // entities this icon toggles as a group controlsFilter: string; glowRadius: string; // per-device glow radius in display units; '' = global default lightRole: 'auto' | 'always' | 'never'; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: 'auto' | 'color' | 'fixed'; glowColor: string; glowBrightness: number; // 1..100 for the fixed mode glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; useClimateTemp: boolean; // badge + room-average vote from climate current_temperature model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; // 'space#area' for a virtual one busy: boolean; } | null = null; private _spaceDialog: { mode: 'edit' | 'create'; spaceId?: string; title: string; planUrl: string | null; planFile: { ext: string; b64: string; aspect: number; name: string } | null; /** * The "already uploaded" list, its contents, and the aspect of whatever was * picked from it. Plans are never deleted for being unreferenced, which is * only a sane policy if they can be found again (docs/SCOPE.md). */ pickSaved?: boolean; saved?: { name: string; url: string; size: number; modified: number; used_by: string[] }[] | null; savedBusy?: boolean; savedAspect?: number; source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms showBorders: boolean; showNames: boolean; /** Create-only source-default guard; never persisted. Edit starts touched. */ displayTouched: boolean; hideDecor: boolean; // the decorative layer is not drawn outside its editor hideOpenings: boolean; // opening symbols are not drawn outside the plan editor roomColor: string; roomOpacity: number; // 0..1 bgColor: string | null; // background around the plan; null = inherit general bgMode: 'static' | 'daynight' | null; // plan background mode; null = inherit (docs/SUN.md) northDeg: number | null; // per-space compass override; null = inherit sunRays: boolean | null; // per-space wedges override; null = inherit fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'custom'; /** null keeps the optional persisted field absent and uses product default. */ customFill: FillColorEntry | null; glowEnabled: boolean; tempMin: number; tempMax: number; showLqi: boolean; cardFontScale: number; labelTemp: boolean; labelHum: boolean; labelLqi: boolean; labelLight: boolean; cellCm: number; // real-world cm represented by one grid cell /** Projected text is separate so an untouched imperial edit stays lossless. */ cellCmInput?: string; cellCmTouched?: boolean; /** Set only after Delete is activated with dependent active markers. */ deleteBlockers?: number; busy: boolean; } | null = null; private _keyHandler = (e: KeyboardEvent) => this._onKey(e); /** DEV-B703-03 warm re-mount: the dead instance's viewport, and the two * flags that keep the restore from being undone (the server load's centred * _restoreZoom) or applied twice (the dialog revival). */ private _warmVp: WarmViewport | null = null; private _warmVpArmed = false; private _warmLongReturn = false; private _warmRevivePending = false; private _warmReviveTimer?: number; /** AUD-159B1-01: this instance's identity in the memo — its generation, the * key it settled under and the slot (card placement) it owns. */ private _warmGen = ++warmGen; private _warmKey: string | null = null; private _warmSlot: WarmEntry | null = null; private _hashApplied = false; private _navApplied = false; // the saved space was restored (or the user navigated) private _labs: LabsSnapshot = { active: Object.freeze([]), space: '' }; private _labsUnsub?: () => void; private _viewPreference: Record = {}; private _renderProjection: 'flat' | 'iso' = 'flat'; // ---- kiosk (wall device) mode ---- private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 }; private _kioskDialog = false; /** * Previous entity states + the short event/terminal-transition window for * every marker. The map lives outside Lit state: hass ticks update it, one * timer repaints when the 3.3 s window closes, and the generation bit forces * a CSS event animation to restart on a rapid retrigger. */ private _activityRt = new Map(); /** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */ private _vacRt = new Map(); /** view signature of the previous vacuum render: a changed view (zoom, pan, space switch) or a tab return must TELEPORT the puck — animating left/top through a viewport change reads as «едет через весь план» (owner). */ private _vacViewKey = ''; private _vacLastView: { x: number; y: number; w: number; h: number } | null = null; private _vacRaf = 0; /** server-recorded runs per marker: {current, previous} in raw robot coords */ private _vacSrvTrails: Record = {}; private _unsubTrail?: () => void; private _vacJumpOnce = false; private _continuity = this._newContinuityController(); private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = []; private _continuityUnsub?: () => void; private _continuityEpoch = 0; private _continuityDataReady = true; private _continuityPaintToken = -1; private _continuityDisposed = false; private _renderSnapshotAt = Date.now(); private _hassSequence = 0; private _visibleDeviceSnapshot: RenderDeviceSnapshot | null = null; private _candidateDeviceSnapshot: RenderDeviceSnapshot | null = null; private _stagedDeviceSnapshotToken = -1; private _capturedSnapshotSequence = -1; private _capturedSnapshotDevices: DevItem[] | null = null; private _capturedSnapshotLayout: Record | null = null; private _capturedSnapshotActivity = ''; private _capturedSnapshotConfigEpoch = -1; private _capturedSnapshotVirtual = ''; private _lastValidStageSize: [number, number] | null = null; private _pendingRefitSize: [number, number] | null = null; private _refitRaf = 0; private _newContinuityController(): VisualContinuityController { return new VisualContinuityController(() => { this._resumeSettling = this._continuity.state !== 'steady'; this._continuityEpoch++; if (this.isConnected) this.requestUpdate(); }); } private _pageVisibility = (signal: PageVisibilitySignal): void => { this._continuity.visibility(signal); this._dayCycleVisibility(signal); if (signal.kind === 'hidden') { this._clearTransientHover(true); this._cancelDevicePressFeedback(); if (this._modeTransitionPreparing) { // There is no measured controller endpoint to commit yet. Keep the // already queued measurement but force its synchronous atomic path; // cancelling here would expose target chrome with the old camera. this._modeTransitionForceAtomic = true; } else { this._cancelModeTransition(true); } return; } this._vacJumpOnce = true; if (!signal.long) { const now = Date.now(); let expired = false; for (const runtime of this._activityRt.values()) { if (!runtime.flashKind || (runtime.expiresAt || runtime.flashTs + ACTIVITY_WINDOW_MS) > now) continue; runtime.flashTs = 0; runtime.flashKind = null; runtime.expiresAt = 0; expired = true; } if (expired) this.requestUpdate(); return; // preserve hover and the completed visual frame } // A long sleep may have frozen activity clocks. Recompute them as // one candidate without clearing the currently visible hover or frame, // and revalidate config+layout as one structural pair. if (Date.now() - this._renderSnapshotAt > 1000) this._continuity.note('device-snapshot-stale'); this._continuityDataReady = false; this._continuityPaintToken = -1; this._resumeSettling = true; if (!this._loading) void this._loadFromServer(); else this.requestUpdate(); }; private _resumeSettling = false; private _viewportInvalidAt = 0; private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams; drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams; fx: number; fy: number } } | null = null; /** Marker whose lazy «All cameras» candidate section is expanded. */ private _vacAllCamerasFor: string | null = null; /** One snapshot per currently open global-camera section; rebuilt on reopen. */ private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null; /** Proposed high-residual auto-calibration. Config remains untouched until Apply. */ private _vacCalConfirm: { markerId: string; source: string; mapId: string; matrix: Affine; rooms: number; error: string; } | null = null; private _kioskDots = false; private _kioskDotsTimer?: number; private _kioskHoldTimer?: number; private _cycleTimer?: number; private _cyclePausedUntil = 0; private _swipeStart: { x: number; y: number; id: number } | null = null; /** Live tab reorder: which tab is held, where it started, where it would land. */ private _tabDrag: { id: string; pointerId: number; x: number; y: number; moved: boolean; targetId: string | null; placement: 'before' | 'after' | null; } | null = null; /** Window-level release handler while a tab is held; see _tabPointerDown. */ private _tabDragRelease: ((event: PointerEvent) => void) | null = null; private _tabSuppressClick = false; private _tabSuppressClickTimer?: number; /** The positional-`floor` warning is worth saying once, not on every drop. */ private _tabOrderWarned = false; private _lastTap = 0; private get _labsIso(): boolean { return this._labs.active.includes('iso'); } private get _desiredProjection(): 'flat' | 'iso' { return this._mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso' ? 'iso' : 'flat'; } private _saveViewPreference(): void { try { localStorage.setItem(LS_VIEW, JSON.stringify(this._viewPreference)); } catch { /* private mode */ } } private _logicalViewCenter(projection: 'flat' | 'iso'): { x: number; y: number } | null { const view = this._view; if (!view) return null; const center: ScenePoint = [view.x + view.w / 2, view.y + view.h / 2]; const point = projection === 'iso' ? unprojectFloorPoint(center) : center; return { x: point[0], y: point[1] }; } private _convertProjectionView(from: 'flat' | 'iso', to: 'flat' | 'iso'): void { if (from === to) return; const logical = this._logicalViewCenter(from); this._view = null; const target = logical ? to === 'iso' ? projectPlanPoint([logical.x, logical.y], 0) : [logical.x, logical.y] as ScenePoint : null; this._applyView(this._zoom, target?.[0], target?.[1]); this._warmPatch({ vp: this._warmViewportState() }); this.requestUpdate(); } private _setProjection(projection: 'flat' | 'iso'): void { if (!this._labsIso || this._mode !== 'view') return; const from = this._effectiveProjection(); this._viewPreference = { ...this._viewPreference, [this._space]: projection }; if (projection === 'iso') { const retry = this._isoSceneKey(); if (retry) this._isoFallback.delete(retry); } this._saveViewPreference(); const to = this._effectiveProjection(); this._convertProjectionView(from, to); } private _onLabsSnapshot = (next: LabsSnapshot): void => { const from = this._effectiveProjection(); this._labs = next; const to = this._effectiveProjection(); this._convertProjectionView(from, to); this.requestUpdate(); }; /** Deep-link: read `#space=` from the URL (used by embedded houseplan-space-card). */ private _hashSpace(): string { return hashSpace(window.location.hash || ''); } private _onHashChange = (): void => { if (this._hasFixedFloor) return; const id = this._hashSpace(); if (id && this._model.find((sp) => sp.id === id) && id !== this._space) { if (this._wallFaceBatch) this._roomDialogCancel(); if (this._mode === 'plan' && this._tool === 'draw' && !this._finishWallChain()) return; if (this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId; this._commitSpace(id); this._selId = null; this._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(true); this._tool = 'draw'; this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; if (this._mode === 'plan' && this._tool === 'draw') this._resumeLastDraft(); this._restoreZoom(); this.requestUpdate(); } }; private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null; private _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number } | null = null; private _holdTimer?: number; private _holdFired = false; static properties = { _tabDrag: { state: true }, _hdrH: { state: true }, _booting: { state: true }, _bootFading: { state: true }, _bootSoft: { state: true }, _continuityEpoch: { state: true }, _tapConfirm: { state: true }, hass: { attribute: false }, _config: { state: true }, _space: { state: true }, _layout: { state: true }, _devices: { state: true }, _tip: { state: true }, _hoverRoom: { state: true }, _selId: { state: true }, _toast: { state: true }, _serverCfg: { state: true }, _mode: { state: true }, _tool: { state: true }, _wallDialog: { state: true }, _drawWallField: { state: true }, _activeDraftId: { state: true }, _physicalSel: { state: true }, _physicalDialog: { state: true }, _partitionDeleteDialog: { state: true }, _roomDeleteDialog: { state: true }, _physicalDrag: { state: true }, _physicalRotate: { state: true }, _duplicateColumnId: { state: true }, _rszSel: { state: true }, _rszLive: { state: true }, _opMeasure: { state: true }, _path: { state: true }, _cursorPt: { state: true }, _mergeSel: { state: true }, _openingPreset: { state: true }, _openingDialog: { state: true }, _openingInfo: { state: true }, _mergeDialog: { state: true }, _openWallAnchor: { state: true }, _splitSel: { state: true }, _decorTool: { state: true }, _decorStyle: { state: true }, _decorDraft: { state: true }, _decorSel: { state: true }, _decorEraseConfirm: { state: true }, _decorTextDialog: { state: true }, _decorShapeDialog: { state: true }, _backdropDialog: { state: true }, _furnPalette: { state: true }, _bdDrag: { state: true }, _dtBox: { state: true }, _dtDrag: { state: true }, _kioskDialog: { state: true }, _vacFit: { state: true }, _vacAllCamerasFor: { state: true }, _vacCalConfirm: { state: true }, _kioskDots: { state: true }, _areaSel: { state: true }, _nameSel: { state: true }, _roomDialog: { state: true }, _roomEditId: { state: true }, _roomFill: { state: true }, _roomCustomFill: { state: true }, _roomTempSrc: { state: true }, _roomHumSrc: { state: true }, _roomSrcOpen: { state: true }, _roomSrcFilter: { state: true }, _roomNameScale: { state: true }, _roomLabelScale: { state: true }, _spaceDialog: { state: true }, _infoCard: { state: true }, _rulesDialog: { state: true }, _settingsDialog: { state: true }, _alignDialog: { state: true }, _backupExportDialog: { state: true }, _backupImportDialog: { state: true }, _importDialog: { state: true }, _markerDialog: { state: true }, _zoom: { state: true }, _view: { state: true }, }; public connectedCallback(): void { this._connectedPath = location.pathname; this._routeDepartureHandled = false; window.addEventListener('location-changed', this._onLocationChanged); window.addEventListener('popstate', this._onLocationChanged); if (this._continuityDisposed) { this._continuity = this._newContinuityController(); this._continuityDisposed = false; this._continuityPaintToken = -1; } const resolvedBlend = resolvedSvgScreenBlend(this.ownerDocument); if (resolvedBlend !== undefined) this._glowScreenBlend = resolvedBlend; this._continuityUnsub?.(); this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility); super.connectedCallback(); this._pointerModality.connect(this.ownerDocument.defaultView); const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver; if (PointerHoverObserver) { this._pointerHoverObserver = new PointerHoverObserver((records) => { for (const record of records) { for (const node of record.addedNodes) this._syncPointerHoverSubtree(node); } }); this._pointerHoverObserver.observe(this.renderRoot, { childList: true, subtree: true }); } this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)'); this._reducedMotion = !!this._motionMedia?.matches; this._motionMedia?.addEventListener?.('change', this._onMotionChange); svgScreenBlendSupported(this.ownerDocument).then((supported) => { if (supported === this._glowScreenBlend) return; this._glowScreenBlend = supported; if (this.isConnected) this.requestUpdate(); }); if (this.hass) this._ensureHaRegistryAuthority(); window.addEventListener('keydown', this._keyHandler); // signatures expire (24 h); refresh well before that on long-lived screens this._signer.start(() => this.hass, () => referencedContentUrls(this._serverCfg)); this._syncCycleTimer(); window.addEventListener('hashchange', this._onHashChange); this._labsUnsub?.(); this._labsUnsub = subscribeLabs(CARD_VERSION, this._onLabsSnapshot); // AUD-1552-01: the boot-veil timers die in disconnectedCallback, so a // disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view // switch remounts the card) used to strand _booting=true with no watcher // — the plan stayed hidden forever. Restart the veil lifecycle from every // connect: a fresh watch (fresh clock, so the hard cap counts from the // reconnect) while booting, or the tail timers if we detached mid-fade. if (this._booting) this._bootWatch(); else if (this._bootFading) { clearTimeout(this._bootTimer); this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220); } if (this._bootSoft) { clearTimeout(this._bootSoftTimer); this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS); } // DEV-B703-02: a reattach mid-outage must keep revalidating — the retry // timer died in disconnectedCallback if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry(); // AUD-159B1-01: the placement is only knowable once we are IN the DOM. if (!this._warmSlot && this._config) this._warmAdopt(); if (this._loadOk) this._ensureLiveSyncSubscriptions(); // DEV-B703-03: one task later the element Lovelace replaced has detached // — only then is its open dialog ours to take over. if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) { this._warmRevivePending = true; this._warmReviveTimer = window.setTimeout(() => this._warmReviveDialog(), 0); } // If no card survived the document event, the placement tombstone still // carries its detach age and enters the same shared controller here. if (this._warmLongReturn) this._beginResumeSettle(); this._warmLongReturn = false; // Transient navigation/resume fields are intentionally not reactive. A // same-element reconnect keeps Lit's previous DOM, so explicitly repaint // after clearing those fields in disconnectedCallback. this.requestUpdate(); } public disconnectedCallback(): void { // HA normally changes the route before removing the old Lovelace tree. // The explicit event covers routers that keep that tree connected for a // beat; this fallback covers a direct remove after history navigation. if (this._connectedPath && location.pathname !== this._connectedPath) { this._leaveCardRoute(); } window.removeEventListener('location-changed', this._onLocationChanged); window.removeEventListener('popstate', this._onLocationChanged); this._continuityUnsub?.(); this._continuityUnsub = undefined; this._motionMedia?.removeEventListener?.('change', this._onMotionChange); this._motionMedia = undefined; if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; } if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; } if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; } this._dayCycleClockKey = ''; if (this._bootSettleRaf) { cancelAnimationFrame(this._bootSettleRaf); this._bootSettleRaf = 0; } this._bootSettling = false; for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints window.removeEventListener('keydown', this._keyHandler); // A tab drag holds window listeners for the length of the gesture. Losing // the card mid-drag — Lovelace rebuilding its tree, the user leaving the // view with the button still down — would leave them alive: the closure // keeps this instance (and its config) from being collected, and the next // pointerup anywhere on the page would make an invisible card write its // order (review CODE-REVIEW-220-r2/r3, F1). this._endTabDrag(); clearTimeout(this._tabSuppressClickTimer); this._tabSuppressClickTimer = undefined; this._tabSuppressClick = false; clearInterval(this._cycleTimer); clearTimeout(this._kioskDotsTimer); clearTimeout(this._kioskHoldTimer); clearTimeout(this._reloadRetry); clearTimeout(this._loadRetryTimer); this._loadRetryTimer = undefined; // a cleared id must not block a reschedule this._connHooked?.removeEventListener?.('ready', this._onConnReady); this._connHooked?.removeEventListener?.('disconnected', this._onConnLost); this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost); this._connHooked = null; this._haRegistryRelease?.(); this._haRegistryRelease = undefined; this._haRegistryConnection = null; this._signer.dispose(); clearTimeout(this._toastTimer); clearTimeout(this._slideTimer); this._modeTransition.dispose(); this._modeTransitionVisual = null; this._modeTransitionPreparing = false; this._modeTransitionForceAtomic = false; this._modeTransitionRequest++; this._slideTimer = undefined; // Navigation transitions are session-only and never survive a remount. this._slide = ''; clearTimeout(this._bootTimer); this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher clearTimeout(this._bootSoftTimer); this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown window.removeEventListener('hashchange', this._onHashChange); this._labsUnsub?.(); this._labsUnsub = undefined; clearTimeout(this._holdTimer); this._roViewport?.disconnect(); this._roViewport = undefined; this._roHdr?.disconnect(); this._roHdr = undefined; if (this._onWinResize) { window.removeEventListener('resize', this._onWinResize); this._onWinResize = undefined; } if (this._unsubCfg) { this._unsubCfg(); this._unsubCfg = null; } if (this._unsubLayout) { this._unsubLayout(); this._unsubLayout = null; } if (this._unsubVirtual) { this._unsubVirtual(); this._unsubVirtual = null; } if (this._unsubTrail) { this._unsubTrail(); this._unsubTrail = undefined; } this._liveSyncGeneration++; this._liveSyncAttempt = null; this._liveSyncConnection = null; clearTimeout(this._layoutSyncTimer); clearTimeout(this._duplicateColumnTimer); for (const timer of this._glowFadeTimers.values()) clearTimeout(timer); for (const raf of this._glowEnterRafs.values()) cancelAnimationFrame(raf); clearTimeout(this._glowFeatherResumeTimer); this._glowFeatherResumeTimer = 0; this._glowFeatherSuspendUntil = 0; this._glowFadeTimers.clear(); this._glowEnterRafs.clear(); this._glowEnteringSources.clear(); this._glowRenderedSources.clear(); this._glowLastAppearance.clear(); this._glowSourceSeq = 0; // DEV-B703-03: the last thing this instance was showing, then the // tombstone that lets exactly one successor adopt the open dialog. // AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still // TRUE. An instance that never got to consume its predecessor's dialog // has nothing of its own to record there, and the old order (clear the // flag, then snapshot) had it write `dlg: null` over somebody else's // unsaved draft — a second Lovelace rebuild inside one task destroyed // the draft before the first successor could restore it. Now the draft // simply travels down the chain until one of them lives long enough. this._warmSnapshot(); this._warmRevivePending = false; clearTimeout(this._warmReviveTimer); this._warmReviveTimer = undefined; this._warmRelease(); // A Boundary P1 is a local pointer gesture, not persisted editor state. // Never let it survive a card replacement/reconnect and unexpectedly turn // the user's first click after returning into P2. this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._clearPlanSnapHover(); this._clearOpeningPlacement(true); this._touchContacts.clear(); this._touchSequenceMultitouch = false; this._touchClickBlockUntil = 0; this._clearTransientHover(true); this._cancelDevicePressFeedback(); this._pointerHoverObserver?.disconnect(); this._pointerHoverObserver = undefined; this._pointerModality.disconnect(); this._editorSecondary.reset(); // R1: the rAF is the only normal owner that clears this flag. Reset only // after the disconnect snapshot (which must still skip unstable geometry), // so a same-element reattach can start fresh instead of keeping the veil. this._resumeSettling = false; this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80); this._continuity.dispose(); this._continuityDisposed = true; super.disconnectedCallback(); } private _onKey(e: KeyboardEvent): void { if (e.key === 'Escape' && this._vacFit) { this._vacFit = null; this._showToast(this._t('vac.cal_cancelled')); e.stopPropagation(); return; } if (e.key === 'Escape') { // close the topmost open dialog; info popups first, then editors if (this._tapConfirm) { this._tapConfirm = null; return; } if (this._vacCalConfirm) { this._vacCalConfirm = null; return; } if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; } if (this._openingInfo) { this._openingInfo = null; return; } if (this._infoCard) { this._closeInfoCard(); return; } if (this._rulesDialog) { this._rulesDialog = null; return; } if (this._alignDialog) { this._alignDialog = null; return; } if (this._backupImportDialog) { this._backupImportDialog = null; return; } if (this._backupExportDialog) { this._backupExportDialog = null; return; } if (this._settingsDialog) { this._settingsDialog = null; return; } if (this._markerDialog) { this._markerDialog = null; return; } if (this._openingDialog) { this._openingDialog = null; return; } if (this._physicalDialog) { this._physicalDialog = null; return; } if (this._backdropDialog) { this._backdropDialog = null; return; } if (this._decorShapeDialog) { this._decorShapeDialog = null; return; } if (this._decorTextDialog) { this._decorTextDialog = null; return; } if (this._spaceDialog && !this._roomDialog) { // same semantics as the dialog's Cancel: an import queue is abandoned this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; return; } if (this._editorSecondary.hasOpenGroup) { e.preventDefault(); this._editorSecondary.closeGroup(true); return; } } // At the window listener `target` may be retargeted to the card host by // Shadow DOM. The composed path still contains the actual focused field. const eventPath = e.composedPath?.() || [e.target]; const inField = eventPath.some((node: any) => node?.matches?.('input, textarea, select, [contenteditable="true"]'), ); // The secondary toolbar is a focusable control surface, not the canvas. // Delete/Backspace there must never fall through to the selected object. const inEditorSecondary = eventPath.some((node: any) => node?.classList?.contains?.('editor-secondary')); const mod = e.ctrlKey || e.metaKey; const key = e.key.toLowerCase(); // A Latin `key` names the shortcut the user actually pressed (including // QWERTZ/AZERTY). For a non-Latin layout use the physical code fallback, // so Russian «я» at KeyZ still works without making QWERTZ Ctrl+Z both // Undo (`key=z`) and Redo (`code=KeyY`) at the same time. const latinLetter = /^[a-z]$/.test(key); const isZ = key === 'z' || (!latinLetter && e.code === 'KeyZ'); const isY = key === 'y' || (!latinLetter && e.code === 'KeyY'); const redo = mod && ((isZ && e.shiftKey) || isY); const undo = mod && isZ && !e.shiftKey; if (this._mode === 'decor') { if ((undo || redo) && inField) return; if (redo) { e.preventDefault(); this._redoGeometry(); return; } if (undo) { e.preventDefault(); if (this._decorDraft) { this._decorDraft = null; return; } if (this._decorMove || this._dtDrag || this._bdDrag) { this._cancelDecorGesture(); return; } this._undoGeometry(); return; } if ((e.key === 'Delete' || e.key === 'Backspace') && this._decorSel && !inField && !inEditorSecondary) { e.preventDefault(); this._decorDeleteSel(); return; } if (e.key === 'Escape') { e.preventDefault(); if (this._decorDraft) this._decorDraft = null; else if (this._decorMove || this._dtDrag || this._bdDrag) this._cancelDecorGesture(); // The palette is one explicit surface: Escape closes it and returns // to Select in one step, regardless of whether a symbol was armed. else if (this._decorTool === 'furniture') { this._furnPalette = null; this._decorTool = 'select'; } else if (this._decorSel) this._decorSel = null; else if (this._decorTool !== 'select') this._decorTool = 'select'; else this._setMode('view'); } return; } if (!this._markup) return; if ((undo || redo) && inField) return; // keep native text-field history if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel && !inField && !inEditorSecondary) { e.preventDefault(); this._deletePhysicalSelection(); return; } if (redo) { e.preventDefault(); this._redoGeometry(); return; } if (undo) { e.preventDefault(); if (this._rszDrag) { this._rszCancelDrag(); return; } if (this._wallFaceBatch) { // The queue has not committed anything. Restore its terminal draft, // then make Ctrl/Cmd+Z visibly remove that last accepted point. this._roomDialogCancel(); if (this._activeDraftId && this._path.length > 1) this._undoActiveDraftPoint(); else this._undoPoint(); return; } // A crash-safe segment is a real geometry command, but Undo keeps the // surviving draft active so the visible contract remains “one point back”. if (this._tool === 'draw' && this._path.length) { if (this._activeDraftId && this._path.length > 1) this._undoActiveDraftPoint(); else this._undoPoint(); return; } if (this._tool === 'split' && this._splitSel?.pts?.length) { this._splitSel = { ...this._splitSel, pts: this._splitSel.pts.slice(0, -1) }; if (!this._splitSel.pts.length) this._cursorPt = null; return; } this._undoGeometry(); return; } if (e.key !== 'Escape') return; if (this._physicalDrag || this._physicalRotate) { e.preventDefault(); this._cancelPhysicalGesture(); return; } if (this._roomDialog) { e.preventDefault(); this._roomDialogCancel(); return; } if (this._tool === 'draw' && this._path.length) { e.preventDefault(); // Escape releases the active wall chain without making it destructive: // Ctrl/Cmd+Z remains the one-point undo path (#294). Reuse the exact // finish transaction used by a tool change, but keep `draw` selected. this._finishWallChain(); return; } if (this._physicalSel) { e.preventDefault(); this._physicalSel = null; return; } if (this._tool === 'resize') { e.preventDefault(); if (this._rszDrag) { // Esc mid-drag: the immutable preview is simply discarded this._rszCancelDrag(); return; } if (this._rszSel) this._rszSel = null; else this._tool = 'draw'; return; } // Esc walks back out of merge/split: point by point, then the room pick, // then the tool itself (back to the neutral draw tool) if (this._tool === 'split') { e.preventDefault(); if (this._splitSel?.pts?.length) { this._splitSel = { ...this._splitSel, pts: this._splitSel.pts.slice(0, -1) }; if (!this._splitSel.pts.length) this._cursorPt = null; } else if (this._splitSel) { this._splitSel = null; } else { this._tool = 'draw'; } return; } if (this._tool === 'merge') { e.preventDefault(); if (this._mergeSel) this._mergeSel = null; else this._tool = 'draw'; return; } if (this._wallDialog) { e.preventDefault(); this._wallDialog = null; return; } if (this._tool === 'boundary') { e.preventDefault(); if (this._openWallAnchor) this._cancelBoundaryAnchor(); else this._tool = 'draw'; return; } if (this._tool === 'opening' || this._tool === 'wallthick' || this._tool === 'delroom' || this._tool === 'column') { e.preventDefault(); if (this._tool === 'opening') this._clearOpeningPlacement(true); this._tool = 'draw'; } } /** Remove the last placed point; saved drafts stay structurally valid. */ private _undoPoint(): void { if (!this._path.length) return; if (this._contourClosed) { this._path = this._path.slice(0, -1); this._closingWallCm = null; return; } if (this._activeDraftId && this._path.length > 1 && this._curSpaceCfg) { const before = this._geometrySnapshot(); this._path = this._path.slice(0, -1); this._draftSegmentCms = this._draftSegmentCms.slice(0, -1); const sp = this._curSpaceCfg as any; const i = (sp.room_drafts || []).findIndex((d: any) => d.id === this._activeDraftId); if (i >= 0) { const previousSegments = Array.isArray(sp.room_drafts[i]?.segments) ? sp.room_drafts[i].segments : []; if (this._path.length < 2) { sp.room_drafts.splice(i, 1); if (!sp.room_drafts.length) delete sp.room_drafts; this._activeDraftId = null; } else { sp.room_drafts[i] = { id: this._activeDraftId, points: this._path.map((p) => [p[0] / NORM_W, p[1] / NORM_W]), // Undo removes the terminal edge; it must not rename every edge // that survived it. The v8 identity barrier only creates an id // for a genuinely new segment (#314). segments: this._draftSegmentCms.map((cm, index) => ({ ...(previousSegments[index] || {}), cm, })), }; } this._commitPhysicalGeometry(this._t('history.draft_segment_delete'), before); } return; } this._path = this._path.slice(0, -1); } /** Undo the persisted segment command while keeping its surviving draft active. */ private _undoActiveDraftPoint(): void { const activeId = this._activeDraftId; const start = this._path[0] ? [...this._path[0]] : null; const command = this._geometryHistory.undo(); if (!command) { this._undoPoint(); return; } if (!this._applyGeometryState(command.before, true)) { this._geometryHistory.clear(); return; } const draft = activeId ? this._spaceModel()?.room_drafts.find((item) => item.id === activeId) : null; if (draft) { this._activeDraftId = draft.id; this._path = draft.points.map((point) => [...point]); this._draftSegmentCms = this._adoptDraftCms( this._path, draft.segments.map((segment: any) => segment.cm), draft.id, ); this._resumeDraftBySpace[this._space] = draft.id; } else { this._activeDraftId = null; this._path = start ? [start] : []; this._draftSegmentCms = []; if (activeId && this._resumeDraftBySpace[this._space] === activeId) delete this._resumeDraftBySpace[this._space]; } this._clearPlanSnapHover(); this._showToast(this._t('history.undone', { name: command.name })); } public static getConfigElement() { return document.createElement('houseplan-card-editor'); } public static getStubConfig(): Partial { return { type: 'custom:houseplan-card' }; } /** Test hook (smokes): forget the warm re-mount memo — a cold page again. * `ttl` retunes WARM_REVIVE_MS so a smoke can watch the TTL expire without * standing still for ten seconds (AUD-159B1-03); omitted = back to 10 s. */ public static _warmBootReset(ttl?: number): void { for (const list of warmBoot.values()) for (const s of list) clearTimeout(s.evict); warmBoot.clear(); WARM_REVIVE_MS = ttl && ttl > 0 ? ttl : 10000; } /** Test hook (smokes): what the memo is holding — slots per key, and how * many of them still keep a dialog payload alive. */ public static _warmBootStats(): { keys: number; slots: number; dlgs: number; drafts: string[] } { let slots = 0, dlgs = 0; const drafts: string[] = []; for (const list of warmBoot.values()) { for (const s of list) { slots++; if (s.dlg) { dlgs++; drafts.push(s.dlg.kind); } } } return { keys: warmBoot.size, slots, dlgs, drafts }; } public setConfig(config: CardConfig): void { const previousConfig = this._config; const previousFixed = !!previousConfig && Object.prototype.hasOwnProperty.call(previousConfig, 'floor'); this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config }; const fixedChanged = previousFixed !== this._hasFixedFloor || previousConfig?.floor !== this._config.floor; if (fixedChanged) { this._hashApplied = false; this._navApplied = false; this._warmVpArmed = false; } if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle if (!this._hasFixedFloor && config.default_floor) this._commitSpace(config.default_floor, true); try { this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {}; } catch { this._zoomBySpace = {}; } try { const stored = JSON.parse(localStorage.getItem(LS_VIEW) || '{}') || {}; this._viewPreference = Object.fromEntries(Object.entries(stored) .filter((entry): entry is [string, 'flat' | 'iso'] => entry[1] === 'flat' || entry[1] === 'iso')); } catch { this._viewPreference = {}; } this._labs = currentLabs(CARD_VERSION); try { const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null'); this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) }; } catch { /* defaults */ } // instant render from cache (stale-while-revalidate): show the plan and icons // right away, without waiting for the server response — fresh data will load in the background. try { const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null'); if (c && c.config && Array.isArray(c.config.spaces)) { this._serverCfg = c.config; this._cfgEpoch++; this._cfgRev = c.rev || 0; this._cfgContentFingerprint = c.config_fingerprint || contentFingerprint(c.config); this._layout = c.layout || {}; this._layoutRev = c.layout_rev || 0; this._layoutContentFingerprint = c.layout_fingerprint || contentFingerprint(this._layout); this._virtualLights = virtualLightSnapshot(c.virtual_lights, this._cfgRev); this._serverStorage = true; } } catch { /* ignore */ } this._adoptInitialSpace(this._model, this._loadOk); // HP-1551: the saved per-space zoom used to be applied only by // _restoreZoom()'s rAF after the server round-trip, so the cached config // painted its first frames at the default fit and the plan visibly // jumped to the saved zoom. The zoom store is already in hand here — // arm it BEFORE the first view computation, so the very first paint is // already at the user's zoom. if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1; // AUD-159B1-01: the memo is claimed by DOM slot, and Lovelace calls // setConfig BEFORE it inserts the element — so the claim waits for // connectedCallback (still before the first render, so nothing flashes). // A setConfig on a card that is already attached (the editor's live // preview) re-claims right here, because the config is part of the key. if (this.isConnected) { this._syncCycleTimer(); this._warmAdopt(); if (this._warmLongReturn) this._beginResumeSettle(); this._warmLongReturn = false; } } /** * DEV-B703-01/03 + AUD-159B1-01: find the memo of the card that used to sit * in THIS placement and step into it — the settled header height (no veil, * synchronous reveal in the final geometry) and, when the slot is provably * ours, the whole viewport. `_bootSoft` covers residual chrome drift with a * glide instead of a snap. */ private _warmAdopt(): void { if (this._config?.kiosk) return; const key = warmBootKey(this._config); if (this._warmKey === key && this._warmSlot) return; // already sitting in it if (this._warmSlot) this._warmRelease(); // the config (or the window) changed under us const place = this.parentNode; const idx = this._warmIdx(place); const list = warmBoot.get(key); if (!list || !list.length) return; // cold page — the full protective boot // the same element re-attached (Lovelace moves cards around): our own slot // is waiting for us, and our live state is newer than anything in it const mine = list.find((s) => s.owner === this._warmGen); if (mine) { this._warmLongReturn = !!mine.freed && Date.now() - mine.freed >= CONTINUITY_LONG_HIDDEN_MS; clearTimeout(mine.evict); mine.evict = 0; mine.freed = 0; mine.live = true; this._warmSlot = mine; this._warmKey = key; if (mine.frameFingerprint) this._continuity.adoptCompleteFrame(mine.frameFingerprint); if (!this._devices.length && mine.devices?.length) this._devices = [...mine.devices]; return; } const { slot, sure } = warmMatch(list, this._warmGen, place, idx); if (!slot) return; this._warmLongReturn = !!slot.freed && Date.now() - slot.freed >= CONTINUITY_LONG_HIDDEN_MS; this._booting = false; this._bootFading = false; this._hdrH = slot.hdrH; this._bootSoft = true; // timer armed in connectedCallback... if (this.isConnected) { // ...unless we are claiming while already attached clearTimeout(this._bootSoftTimer); this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS); } this._warmKey = key; if (sure) { clearTimeout(slot.evict); // the dialog is ours now, not the TTL's slot.evict = 0; slot.owner = this._warmGen; slot.place = place ? new WeakRef(place) : null; slot.idx = idx; slot.live = true; this._warmSlot = slot; this._warmVp = slot.vp; // adopted below, once the model is in hand if (slot.frameFingerprint) this._continuity.adoptCompleteFrame(slot.frameFingerprint); if (!this._devices.length && slot.devices?.length) this._devices = [...slot.devices]; this._warmAdoptViewport(this._config!); } else { // Two identical cards on one view and no way to tell which slot is ours: // the height is interchangeable (they settle at the same chrome), the // viewport and the dialog belong to somebody and must not be guessed. this._warmSlot = { owner: this._warmGen, path: location.pathname, place: place ? new WeakRef(place) : null, idx, live: true, hdrH: slot.hdrH, stageH: slot.stageH, vp: null, frameFingerprint: '', devices: null, dlg: null, freed: 0, evict: 0, }; list.push(this._warmSlot); this._warmTrim(list); } } /** This element's position among its parent's children (-1 if unmounted). */ private _warmIdx(place: Node | null): number { const kids = (place as Element | null)?.children; if (!kids) return -1; for (let i = 0; i < kids.length; i++) if (kids[i] === this) return i; return -1; } /** Let go of the slot: the placement is still there, this instance is not. */ private _warmRelease(): void { const s = this._warmSlot; const key = this._warmKey; this._warmSlot = null; this._warmKey = null; if (!s || !key) return; s.freed = Date.now(); if (s.owner === this._warmGen) s.live = false; this._warmScheduleEvict(s, key); } /** Drop the oldest slot that nobody is sitting in. */ private _warmTrim(list: WarmEntry[]): void { while (list.length > WARM_MAX_SLOTS) { const i = list.findIndex((s) => !s.live); if (i < 0) break; clearTimeout(list[i].evict); list.splice(i, 1); } } /** * AUD-159B1-03: the TTL used to be a rule checked at revive time only, so a * draft nobody came back for stayed reachable from module scope until the * page reloaded — with the space dialog that means a whole plan file held as * base64 (8 MiB allowed by the backend). Free the payload the moment it * stops being revivable; the height and the viewport are bytes, they stay. */ private _warmScheduleEvict(s: WarmEntry, key: string): void { clearTimeout(s.evict); if (!s.dlg && (!s.vp || s.vp.mode === 'view')) return; const freed = s.freed; const gen = s.owner; s.evict = window.setTimeout(() => { s.evict = 0; if (s.freed !== freed || s.owner !== gen) return; // a successor took over // the dialog is unrevivable now, whoever is sitting in the slot s.dlg = null; // Route events can arrive after Lovelace has detached the old tree. The // TTL is the order-independent backstop: a stale editor viewport is // never revived after the short technical-remount window. s.vp = expiredWarmViewport(s.vp); s.frameFingerprint = ''; // a slot nobody re-claimed is also what makes the NEXT claim ambiguous const list = warmBoot.get(key); if (!s.live && list && list.length > 1) { const i = list.indexOf(s); if (i >= 0) list.splice(i, 1); } }, WARM_REVIVE_MS + 250); } /** * DEV-B703-03: put back the EXACT viewport of the instance Lovelace threw * away — same space, same editor, same zoom and the same pan rect, so the * re-mount is bit-for-bit the view that was on screen. Runs after the LS * snapshot has restored the model (the space must exist) and after the * LS_NAV/default_floor guesses, which it supersedes: the memo is the newer, * finer-grained record of the very same intent. A `#space=` deep link is an * EXPLICIT navigation and still wins. */ private _warmAdoptViewport(config: CardConfig): void { const vp = this._warmVp; if (!vp || this._warmSlot?.path !== location.pathname) { this._warmVp = null; return; } const fixed = this._fixedFloorState(); if (this._hashApplied || !this._model.find((sp) => sp.id === vp.space) || fixed.kind === 'valid' && fixed.id !== vp.space || this._hasFixedFloor && fixed.kind !== 'valid') { this._warmVp = null; // another space is on screen — the memo is not about it return; } this._commitSpace(vp.space, true); this._navApplied = true; // the editor comes back only where an editor is allowed at all this._adoptMode(vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view'); // AUD-159B6-04: the memo is the NEWER and MORE SPECIFIC record than the // global LS nav — a neighbour card writing `mode=devices` into localStorage // must not be replayed over this owner's viewport once can_write answers // (it also broke the draft revival, whose guard compares the mode). So the // pending intent is REPLACED here, never merely added to. this._pendingNavMode = vp.mode !== 'view' && !this._canEdit && !config.kiosk ? vp.mode : null; this._zoom = vp.zoom; const projection = this._effectiveProjection(); const sameProjection = projection === vp.projection && this._labsIso === vp.activeLabsIso; this._view = sameProjection && vp.view ? { ...vp.view } : null; this._viewModeSnap = sameProjection && vp.snap ? { ...vp.snap } : null; if (!sameProjection && vp.logicalCenter) { const center = projection === 'iso' ? projectPlanPoint([vp.logicalCenter.x, vp.logicalCenter.y], 0) : [vp.logicalCenter.x, vp.logicalCenter.y] as ScenePoint; this._applyView(vp.zoom, center[0], center[1]); } this._tool = normalizeMarkupTool(vp.tool); this._decorTool = vp.decorTool; this._showHidden = vp.showHidden; if (this._showFar !== vp.showFar) { this._showFar = vp.showFar; this._frame = null; } this._selId = vp.selId; this._rszSel = vp.rszSel; this._decorSel = vp.decorSel; this._warmVpArmed = true; // _loadFromServer must not re-centre this } /** Merge a patch into this card's warm entry. `create` is false everywhere * but the boot settle: a memo may only be BORN from a settled geometry. */ private _warmPatch(patch: Partial, create = false): void { if (this._config?.kiosk) return; const k = warmBootKey(this._config); // The key carries the window size and the view path: once either changes // under a LIVE card, its slot describes a geometry that is no longer on // screen, and the next mount at the new size must boot cold (documented // in docs/WARM-REMOUNT.md §1) — so write nothing rather than lie. if (this._warmSlot && this._warmKey !== k) return; if (!this._warmSlot) { if (!create) return; const place = this.parentNode; this._warmKey = k; this._warmSlot = { owner: this._warmGen, path: location.pathname, place: place ? new WeakRef(place) : null, idx: this._warmIdx(place), live: true, hdrH: this._hdrH, stageH: 0, vp: null, frameFingerprint: '', devices: null, dlg: null, freed: 0, evict: 0, }; const list = warmBoot.get(k) || []; list.push(this._warmSlot); warmBoot.set(k, list); this._warmTrim(list); while (warmBoot.size > WARM_MAX_KEYS) { const oldest = warmBoot.keys().next().value; // Map keeps insertion order if (oldest === undefined || oldest === k) break; for (const s of warmBoot.get(oldest) || []) clearTimeout(s.evict); warmBoot.delete(oldest); } } Object.assign(this._warmSlot, patch); } private _warmViewportState(): WarmViewport { const projection = this._effectiveProjection(); return { space: this._space, mode: this._mode, projection, activeLabsIso: this._labsIso, logicalCenter: this._logicalViewCenter(projection), zoom: this._zoom, view: this._view ? { ...this._view } : null, snap: this._viewModeSnap ? { ...this._viewModeSnap } : null, tool: this._tool, decorTool: this._decorTool, showHidden: this._showHidden, showFar: this._showFar, selId: this._selId, rszSel: this._rszSel, decorSel: this._decorSel, }; } /** * The open dialog, or null. Deliberately null (docs/WARM-REMOUNT.md §4) for: * • «Выровнять всё по сетке» and the room MERGE confirmation — a modal whose * only content is "press OK to rewrite your plan". Resurrecting a * confirmation next to a user who has just come back to the tab is how a * destructive write gets a blind click; both are one click to reopen. * • a tap confirmation — it carries a closure over the DEAD instance. * • the floor-import wizard — `updated()` reopens it by itself while the * config is still empty; reviving it too would double the queue. * • ANY dialog with a save/upload in flight (`busy`) — the new instance * cannot know whether the write landed, and offering Save again invites a * second one. The config reload shows the truth instead. * Precedence follows the Esc stack: the topmost dialog is the one that is * "open" as far as the user is concerned. */ private _warmDialogState(): WarmDialog | null { const at = (kind: WarmDialogKind, data: any): WarmDialog => ({ kind, space: this._space, mode: this._mode, data }); if (this._tapConfirm || this._alignDialog || this._mergeDialog || this._importDialog || this._backupExportDialog || this._backupImportDialog) return null; if (this._openingInfo) return at('openingInfo', (this._openingInfo as any).id); if (this._infoCard) return at('info', this._infoCard.id); if (this._rulesDialog) return this._rulesDialog.busy ? null : at('rules', this._rulesDialog); if (this._settingsDialog) return this._settingsDialog.busy ? null : at('settings', this._settingsDialog); if (this._markerDialog) return this._markerDialog.busy ? null : at('marker', this._markerDialog); if (this._openingDialog) return at('opening', this._openingDialog); if (this._backdropDialog) return at('backdrop', this._backdropDialog); if (this._decorShapeDialog) return at('decorShape', this._decorShapeDialog); if (this._decorTextDialog) return at('decorText', this._decorTextDialog); if (this._roomDialog) { return at('room', { editId: this._roomEditId, fill: this._roomFill, customFill: this._roomCustomFill, tempSrc: this._roomTempSrc, humSrc: this._roomHumSrc, srcOpen: this._roomSrcOpen, srcFilter: this._roomSrcFilter, nameScale: this._roomNameScale, labelScale: this._roomLabelScale, areaSel: this._areaSel, nameSel: this._nameSel, pendingSplit: this._pendingSplit, wallFaceBatch: this._wallFaceBatch, path: this._path, }); } if (this._spaceDialog) return this._spaceDialog.busy ? null : at('space', this._spaceDialog); return null; } /** Mirror the live viewport (and the open dialog) into the memo. Called from * every `updated()`: whatever the previous instance last PAINTED is what the * next one must open at — and a dialog closed with Esc/Cancel/Save writes * `dlg: null` here on the very next render, so it can never come back. */ private _warmSnapshot(): void { // A candidate may already exist in fields while the DOM still presents the // previous complete frame. Never pair those fields with the old frame // fingerprint in the placement memo; the successful paint barrier writes // the coherent snapshot from its completion callback. if (this._booting || this._config?.kiosk || this._continuity.state !== 'steady') return; const patch: Partial = { vp: this._warmViewportState(), frameFingerprint: this._continuity.frameFingerprint, devices: this._devices, }; // do not overwrite the snapshot we are about to revive FROM if (!this._warmRevivePending) patch.dlg = this._warmDialogState(); // AUD-159B1-01: the placement is re-measured from the live DOM, so a card // that was moved (a sibling added above it) still names its own slot. if (this.isConnected && this._warmSlot?.owner === this._warmGen) { const place = this.parentNode; patch.place = place ? new WeakRef(place) : null; patch.idx = this._warmIdx(place); } this._warmPatch(patch); } /** * Re-open the dialog the dead instance had open. Not done in setConfig: * Lovelace may still be holding the old element there, and a live owner's * dialog must not be stolen. One task later the previous instance has * detached (`freed`) and the snapshot is ours to consume — exactly once. */ private _warmReviveDialog(): void { this._warmRevivePending = false; const e = this._warmSlot; // AUD-159B1-01: OUR slot, never a neighbour's this._warmReviveTimer = undefined; if (!e || !e.dlg) return; const d = e.dlg; const freed = e.freed; e.dlg = null; e.freed = 0; // consume-once: no zombie on the third mount clearTimeout(e.evict); e.evict = 0; if (!freed || Date.now() - freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor switch (d.kind) { case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break; case 'marker': this._markerDialog = { ...d.data, busy: false }; break; case 'settings': this._settingsDialog = { ...d.data, busy: false }; break; case 'rules': this._rulesDialog = { ...d.data, busy: false }; break; case 'opening': this._openingDialog = { ...d.data }; break; case 'backdrop': this._backdropDialog = { ...d.data }; break; case 'decorShape': this._decorShapeDialog = { ...d.data }; break; case 'decorText': { this._decorTextDialog = { ...d.data }; const end = String(this._decorTextDialog?.text ?? '').length; this._decorTextSelection = { start: end, end }; break; } case 'room': { const r = d.data; this._roomEditId = r.editId; this._roomFill = r.fill; this._roomCustomFill = r.customFill || null; this._roomTempSrc = r.tempSrc; this._roomHumSrc = r.humSrc; this._roomSrcOpen = r.srcOpen; this._roomSrcFilter = r.srcFilter; this._roomNameScale = r.nameScale; this._roomLabelScale = r.labelScale; this._areaSel = r.areaSel; this._nameSel = r.nameSel; this._pendingSplit = r.pendingSplit; this._wallFaceBatch = r.wallFaceBatch || null; this._path = r.path; if (this._wallFaceBatch) { this._activeDraftId = this._wallFaceBatch.activeDraftId; this._draftSegmentCms = [...this._wallFaceBatch.activeCms]; } this._roomDialog = true; break; } // info popups are re-resolved by id: the config may have been reloaded // under us, and a card rendered from a stale object is a lie case 'info': { const dev = this._devices.find((x) => x.id === d.data); if (dev) this._infoCard = dev; break; } case 'openingInfo': { const op = (this._curSpaceCfg?.openings || []).find((x: any) => x.id === d.data); if (op) this._openingInfo = op; break; } } this.requestUpdate(); } /** Save a snapshot of the config+layout to localStorage for an instant start. */ private _cacheSnapshot(): void { if (!this._serverCfg) return; try { // Local edits may mutate nested config/layout before the debounced write; // keep the accepted identity paired with exactly what is cached. this._cfgContentFingerprint = contentFingerprint(this._serverCfg); this._layoutContentFingerprint = contentFingerprint(this._layout); this._virtualLights = reconcileVirtualLightSnapshot( this._virtualLights, this._serverCfg, this._cfgRev, ); localStorage.setItem(LS_CFG, JSON.stringify({ config: this._serverCfg, rev: this._cfgRev, config_fingerprint: this._cfgContentFingerprint, layout: this._layout, layout_rev: this._layoutRev, layout_fingerprint: this._layoutContentFingerprint, virtual_lights: virtualLightWire(this._virtualLights), })); } catch { /* ignore */ } } private _beginContinuityCandidate( reason: string, dataReady: boolean, recoveryReason: 'plan' | 'connection' | 'stage-size' | 'asset' = 'plan', ): number { // Cold boot remains under the existing boot veil; #73 governs a frame // that has already been complete at least once. if (this._booting && !this._continuity.hasCompleteFrame) return this._continuity.token; this._continuityDataReady = dataReady; this._continuityPaintToken = -1; this._stagedDeviceSnapshotToken = -1; this._resumeSettling = true; return this._continuity.beginCandidate(reason, recoveryReason); } private _continuityStageValid(): boolean { const stage = this._stageEl; return !!stage && stage.clientWidth > 0 && stage.clientHeight > 0; } private _continuityAssetsReady(): boolean { // An empty/transient model is not a complete plan frame. Returning true // here used to let the previous DOM be blessed while the replacement // config was still being projected. if (!this._model.length) return false; const space = this._model.length ? this._spaceModel() : null; return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href); } private _initialSpaceSelection( models: SpaceModel[], authoritative = this._loadOk, ): InitialSpaceSelection { const fixed = this._fixedFloorState(models, authoritative); if (fixed.kind === 'valid') return { id: fixed.id, source: 'fixed' }; if (this._hasFixedFloor) return { id: null, source: 'none' }; return resolveInitialSpace({ spaceIds: models.map((space) => space.id), hashSpace: this._hashSpace(), acceptHash: !this._hashApplied, currentSpace: this._space, preserveCurrent: this._hashApplied || this._navApplied || this._warmVpArmed, savedSpace: this._savedNav()?.space, defaultSpace: this._config?.default_floor, }); } /** Install one exact raw-space authority before any spatial candidate paints. */ private _adoptInitialSpace( models: SpaceModel[], authoritative = this._loadOk, ): InitialSpaceSelection { const selection = this._initialSpaceSelection(models, authoritative); if (!selection.id) return selection; this._commitSpace(selection.id, true); if (selection.source === 'hash') this._hashApplied = true; if (selection.source === 'saved') this._navApplied = true; return selection; } private _candidateBackdrop(config: ServerConfig | null, spaceId = this._space): string { const models = spaceModels(config); const fixed = this._fixedFloorState(models, true); if (this._hasFixedFloor && fixed.kind !== 'valid') return ''; const preferred = this._initialSpaceSelection(models, true).id || (models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id); return models.find((space) => space.id === preferred)?.bg?.href || ''; } private _visualFrameFingerprint(): string { const stage = this._stageEl; const size = stage ? [stage.clientWidth, stage.clientHeight] : [0, 0]; return visualFrameFingerprint([ this._cfgRev, this._cfgContentFingerprint || contentFingerprint(this._serverCfg), this._layoutRev, this._layoutContentFingerprint || contentFingerprint(this._layout), this._space, this._mode, this._view, size, this._glowScreenBlend ? 'screen' : 'normal', this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '', ]); } /** Called from updated(): one token owns at most one paint barrier. */ private _settleContinuityFrame(): void { if (this._booting || !this._continuityStageValid()) return; if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') { // Cold start belongs to the boot veil. Do not replace it with a recovery // error just because a protected backdrop is still signing/decoding. if (this._continuityAssetsReady()) { this._renderSnapshotAt = Date.now(); this._continuity.markCompleteFrame(this._visualFrameFingerprint()); } return; } if (!this._continuityDataReady) return; if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready'] .includes(this._continuity.state)) return; const token = this._continuity.token; if (this._candidateDeviceSnapshot && this._candidateDeviceSnapshot !== this._visibleDeviceSnapshot && this._stagedDeviceSnapshotToken !== token) { this._stagedDeviceSnapshotToken = token; this.requestUpdate(); return; } if (this._continuityPaintToken === token) return; this._continuityPaintToken = token; if (!this._continuity.candidateReady(token)) return; void this._continuity.commitAfterPaint(token, { updateComplete: () => this.updateComplete, stageValid: () => this.isConnected && this._continuityStageValid(), assetsReady: () => this._continuityAssetsReady(), frameFingerprint: () => this._visualFrameFingerprint(), }).then((committed) => { if (!committed || token !== this._continuity.token) { if (token === this._continuity.token) { this._continuityPaintToken = -1; this._stagedDeviceSnapshotToken = -1; this._candidateDeviceSnapshot = null; this.requestUpdate(); } return; } this._resumeSettling = false; this._renderSnapshotAt = Date.now(); if (this._candidateDeviceSnapshot) this._visibleDeviceSnapshot = this._candidateDeviceSnapshot; this._candidateDeviceSnapshot = null; this._stagedDeviceSnapshotToken = -1; this._warmSnapshot(); }); } private _onBackdropLoaded(raw: string, paintedUrl?: string): void { this._signer.markLoaded(this.hass, raw, paintedUrl); this._continuity.note('asset-ready'); this._continuityPaintToken = -1; if (this._continuity.state !== 'steady') this.requestUpdate(); } private _retryContinuity = (): void => { this._continuityDataReady = false; this._continuityPaintToken = -1; this._continuity.retry(this._continuity.recoveryReason || 'plan'); if (!this._loading) void this._loadFromServer(); }; private _renderRecoveryOverlay(): TemplateResult | typeof nothing { if (!this._continuity.overlayVisible && this._continuity.state !== 'recovery-error') return nothing; const connection = this._continuity.recoveryReason === 'connection'; return html`
event.stopPropagation()} @click=${(event: Event) => event.stopPropagation()} @wheel=${(event: Event) => event.stopPropagation()}> ${this._t(connection ? 'continuity.restore_connection' : 'continuity.restore_plan')} ${this._continuity.state === 'recovery-error' ? html`` : nothing}
`; } /** Redacted lifecycle diagnostics used by the deterministic sampler. */ public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] { return [...this._continuityHistory, ...this._continuity.trace].slice(-80) .map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) })); } public getCardSize(): number { return 12; } // ================= MODEL RESOLUTION (server configuration) ================= /** Whether a server configuration with spaces exists (otherwise — onboarding). */ private get _norm(): boolean { return !!(this._serverCfg && this._serverCfg.spaces.length); } /** Spaces in render units (NORM_W × NORM_W — the canvas is square). */ /** Bumped by every config mutation — the model/geometry cache key (audit L1). */ private _cfgEpoch = 0; private _modelCache: { key: string; model: SpaceModel[] } | null = null; private _emptySpaceStateActive = false; private _decorSnapCache: { epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry; } | null = null; /** Last unsaved marker projection; invalidated by the complete dialog draft. */ private _markerPreviewMemo: { key: string; device: DevItem | null } | null = null; private _markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null = null; /** Cheap structural fingerprint of the config (audit L1 cache key). */ private _cfgFingerprint(): string { const sp = this._serverCfg?.spaces || []; let s = sp.length + ':'; for (const x of sp as any[]) { s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ',' // the backdrop transform is geometry: without it in the key a drag of // the picture would leave the memoized model (and the content frame // built from it) showing the old rectangle (docs/BACKDROP.md §5) + (x.plan_x ?? '') + ',' + (x.plan_y ?? '') + ',' + (x.plan_scale ?? '') + ',' + (x.plan_scale_x ?? '') + ',' + (x.plan_scale_y ?? '') + ',' + (x.plan_angle ?? '') + ',' + (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';'; for (const r of x.rooms || []) { // O(1) geometry roll-up per room: the count alone said nothing about // where the room actually is, so a moved rectangle or a dragged first/ // last vertex looked identical (HP-1454-04). The epoch remains the // primary signal — this is the belt for a mutation that forgot to bump it. const p0 = r.poly?.[0], pn = r.poly?.[r.poly.length - 1]; s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.' + (r.area || '') + '.' + JSON.stringify(r.settings || 0) + '.' + (r.x ?? '') + ',' + (r.y ?? '') + ',' + (r.w ?? '') + ',' + (r.h ?? '') + ',' + (p0 ? p0[0] + '/' + p0[1] : '') + ',' + (pn ? pn[0] + '/' + pn[1] : '') + ';'; } } return s; } private get _model(): SpaceModel[] { if (!this._serverCfg) return []; // same reasoning as _openPairs: mutations in place mean the epoch can lag, // so the key also carries the config's structural fingerprint const key = this._cfgEpoch + '|' + this._cfgFingerprint(); if (this._modelCache && this._modelCache.key === key) return this._modelCache.model; const built = this._buildModel(); this._modelCache = { key, model: built }; return built; } private _buildModel(): SpaceModel[] { if (!this._serverCfg) return []; // HP-1550-01: the model renders the preview overlay, not the raw config // ONE model builder for both cards. This used to be a hand-copied twin of // spaceModels(), and the twin missed the legacy-store fallbacks the shared // one gained (safeViewBox, normRect) — the same broken store rendered fine // in the static card and as viewBox="0 0 0 0" here (HP-1503-01). The only // difference this card needs is the url: raw on purpose, because the model // is memoized on the config fingerprint, so a signed url baked in here // would freeze BEFORE the signature arrives and the plan would never load // (bug found 2026-07-27). _display() is called at render time instead. const cfg = this._renderCfg!; return spaceModels(cfg).map((m, i) => { const raw = (cfg.spaces[i] as any)?.plan_url; return m.bg && raw ? { ...m, bg: { ...m.bg, href: raw } } : m; }); } private _spaceModel(): SpaceModel | undefined { return this._hasFixedFloor ? selectSpaceModelById(this._model, this._space) : selectActiveSpaceModel(this._model, this._space); } private _spaceModelById(id: string | null | undefined): SpaceModel | undefined { return selectSpaceModelById(this._model, id); } /** Abort every space-bound transaction once the authoritative plan is empty. */ private _syncEmptySpaceState(): void { const empty = !!this._serverCfg && this._serverCfg.spaces.length === 0; if (!empty) { this._emptySpaceStateActive = false; return; } if (this._emptySpaceStateActive) return; this._emptySpaceStateActive = true; for (const pointerId of this._pointers.keys()) { for (const node of this.renderRoot.querySelectorAll('*')) { try { if (node.hasPointerCapture?.(pointerId)) node.releasePointerCapture(pointerId); } catch { // A pointer may already have ended between the registry tick and cleanup. } } } this._pointers.clear(); this._panStart = null; this._panLock = null; this._pinchStart = null; this._swipeStart = null; this._drag = null; this._rlResize = null; this._vacFit = null; this._compassDrag = false; this._cancelModeTransition(false); this._mode = 'view'; this._clearGeometryGesture(); this._geometryHistory.clear(); this._resumeDraftBySpace = {}; this._tip = null; this._hoverRoom = null; this._openingInfo = null; this._closeInfoCard(); this._markerDialog = null; this._physicalDialog = null; this._backdropDialog = null; this._decorShapeDialog = null; this._decorTextDialog = null; this._roomDialog = false; if (this._spaceDialog?.mode === 'edit') this._spaceDialog = null; this._editorSecondary.closeForNavigation(); this._saveConfigDebounced.cancel(); this._frame = null; this._planSnapGeometryCache = null; this._hiddenWallDiagnosticCache = null; this._decorSnapCache = null; this._commitSpace('', true); } private get _areaToSpace(): Record { const map: Record = {}; for (const s of this._model) for (const r of s.rooms) if (r.area) map[r.area] = { space: s.id, room: r }; return map; } private get _settings(): ServerConfig['settings'] { return this._serverCfg?.settings || {}; } /** LOCAL editor tool (docs/FILTERING.md): show the hidden devices ghosted. * The old toggle wrote settings.show_all — shared state that flipped the * plan for every wall tablet at once. Legacy configs still honour it * through buildDevices until they are seeded. */ private _showHidden = false; private get _showAll(): boolean { return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all; } private _toggleShowAll(): void { if (!this._serverCfg) return; if (this._settings.filter_seeded) { this._showHidden = !this._showHidden; this.requestUpdate(); return; } // legacy config: the old shared behaviour until an editor materialises it this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } }; this._regSignature = ''; this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); } /** * The seeder (docs/FILTERING.md): materialise the filter into explicit * hidden flags. Runs after every device rebuild on a client that may write; * idempotent — a device with ANY marker is never revisited, so an unticked * checkbox (a marker with hidden: false) is re-seed protection. Also * removes freshly hidden ids from the red-dot list: a dot on an invisible * device would wait forever. */ private _seedHiddenDevices(): void { if (!this._serverCfg || !this._norm || !this._canEdit) return; const cfg = this._serverCfg; const bindings = seedHiddenBindings({ hass: this.hass, registry: this._haRegistry, areaToSpace: Object.fromEntries( Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]), ), markers: this._markers, settings: this._settings, excluded: this._excluded, firstSpaceId: this._model[0]?.id || '', iconRules: this._iconRules, }); if (!bindings.length && cfg.settings?.filter_seeded) return; cfg.markers = cfg.markers || []; const hiddenIds: string[] = []; for (const b of bindings) { const id = 'h' + b.slice(b.indexOf(':') + 1); cfg.markers.push({ id, binding: b, hidden: true }); hiddenIds.push(b.slice(b.indexOf(':') + 1)); } const st = { ...(cfg.settings || {}), filter_seeded: true } as any; delete st.show_all; // the shared toggle retires with the runtime filter if (hiddenIds.length && Array.isArray(st.new_device_ids)) { st.new_device_ids = st.new_device_ids.filter((x: string) => !hiddenIds.includes(x)); } cfg.settings = st; this._regSignature = ''; this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); } /** Compiled icon rules: instance settings override the built-in defaults. */ private get _iconRules(): CompiledIconRule[] | undefined { const custom = this._settings.icon_rules; if (!custom || !Array.isArray(custom) || !custom.length) return undefined; const src = JSON.stringify(custom); if (src !== this._rulesCompiledSrc) { this._rulesCompiledSrc = src; this._rulesCompiled = compileIconRules(custom); } return this._rulesCompiled; } /** Global fill palette (config.settings.fill_colors over the defaults). */ private get _fillColors(): FillColors { return fillColorsOf(this._settings); } private get _excluded(): Set { const list = this._settings.exclude_integrations; return list ? new Set(list) : EXCLUDED_DOMAINS; } protected willUpdate(changed: PropertyValues): void { // `_serverCfg` is the root of every geometry cache. Keep the epoch // invariant local to that reactive assignment so imports, reconnects and // demo harnesses cannot accidentally reuse an older config object's data. if (changed.has('_serverCfg')) this._cfgEpoch++; this._syncEmptySpaceState(); if (changed.has('hass') && this.hass) { this._hassSequence++; this._renderSnapshotAt = Date.now(); this._continuity.note('hass-snapshot'); this._ensureHaRegistryAuthority(); this._planHassMemo = null; this._hookConnection(); if (!this._loadOk && !this._loading && this._loadTries < 8) { this._loadFromServer(); } // Devices must exist before their first state snapshot is classified. // Otherwise the first real off->on edge after mount becomes the baseline. this._maybeRebuildDevices(); this._vacTick(); this._activityTick(); } if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') { this._continuity.refreshCompleteFrame(this._visualFrameFingerprint()); } this._captureRenderDeviceSnapshot(); } protected updated(): void { this._pruneDevicePressFeedback(); this._syncDayCycleClock(); this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen this._dtMeasure(); // the selected label's frame follows the glyphs const stage = this._stageEl; if (stage && !this._roViewport) { this._roViewport = new ResizeObserver(() => this._refitView()); this._roViewport.observe(stage); } if (stage && this._booting && !this._bootTimer) this._bootWatch(); // The stage fills the rest of the viewport. What sits above it inside the // CARD depends on the mode — the editor bars used to be billed against a // hard-coded 118px, so entering an editor pushed the plan down by the // difference. Measure our own chrome (stage top relative to the card) and // allow a BOUNDED amount for what the dashboard puts above us (HA's // toolbar). The first version used the absolute document coordinate here: // put anything tall before the card and the "header budget" swallowed the // whole viewport, leaving a 0px stage (HP-1500-02). Content above the card // is the dashboard's business — it scrolls; it is not header. const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null; if (hdr && stage && !this._roHdr) { const measure = () => { const card = this.renderRoot.querySelector('ha-card'); if (!card) return; const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top; const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120); const t = Math.round(own + above); // `t` is already an integer. Ignoring a one-pixel delta left the View // stage one pixel shorter after an editor collapse and made the fitted // viewport drift; changing stage height cannot feed back into its top. if (t >= 0 && t !== this._hdrH) this._hdrH = t; // DEV-B703-01: chrome that lands after the settle (or a window // resize with a live card) must not poison the next warm mount — // the memo follows the live settled geometry. if (t >= 0 && !this._booting && !this._config?.kiosk && stage.clientHeight > 0) { this._warmPatch({ hdrH: t, stageH: stage.clientHeight }); } }; // a frame later: setting state straight from the observer callback makes // the browser report "ResizeObserver loop completed with undelivered // notifications" — the render it triggers resizes the stage again this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure)); this._roHdr.observe(hdr); this._onWinResize = () => requestAnimationFrame(measure); window.addEventListener('resize', this._onWinResize); measure(); } if (stage && !this._view) this._refitView(); this._editorSecondary.afterRender(); this._settleContinuityFrame(); // onboarding: on an empty server config, open the space dialog right away if ( this._serverStorage && this._loadOk && this._model.length === 0 && !this._spaceDialog && !this._importDialog && !this._onboardingShown ) { this._onboardingShown = true; const floors = floorsOf(this.hass); if (floors.length) { this._importDialog = { floors: floors.map((f) => ({ ...f, checked: true })) }; } else { this._openSpaceDialog('create'); } } } // ================= server: config + layout ================= /** * Adopt one structural WS candidate by revision + actual content identity. * Equal payloads keep their authoritative object references and geometry * epoch; equal revisions never hide changed content (#73 §9.4). */ private _adoptStructuralResponses( cfgResp: any, layResp?: any, layoutOverride?: Record, ): { configChanged: boolean; layoutChanged: boolean } { const rawConfig = cfgResp?.config; const nextConfig = rawConfig && Array.isArray(rawConfig.spaces) ? rawConfig as ServerConfig : null; const nextCfgFingerprint = contentFingerprint(nextConfig); const configChanged = nextCfgFingerprint !== (this._cfgContentFingerprint || contentFingerprint(this._serverCfg)); if (configChanged) { // A genuinely different baseline invalidates local geometry undo. A // reconnect echo with identical content deliberately does not. this._geometryHistory.clear(); this._pendingPhysicalWrites.clear(); if (this._serverCfg) this._clearGeometryGesture(); this._serverCfg = nextConfig; this._cfgContentFingerprint = nextCfgFingerprint; } this._cfgRev = cfgResp?.rev ?? this._cfgRev; if (cfgResp && ('virtual_lights' in cfgResp || 'config' in cfgResp)) { const nextVirtualLights = adoptVirtualLightServerSnapshot( this._virtualLights, cfgResp.virtual_lights, this._cfgRev, 'virtual_lights' in cfgResp, ); if (virtualLightFingerprint(nextVirtualLights) !== virtualLightFingerprint(this._virtualLights)) { this._virtualLights = nextVirtualLights; this._capturedSnapshotVirtual = ''; } } let layoutChanged = false; if (layResp !== undefined || layoutOverride !== undefined) { const nextLayout = layoutOverride ?? layResp?.layout ?? {}; const nextLayoutFingerprint = contentFingerprint(nextLayout); layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint || contentFingerprint(this._layout)); if (layoutChanged) { this._layout = nextLayout; this._layoutContentFingerprint = nextLayoutFingerprint; } this._layoutRev = layResp?.rev ?? this._layoutRev; } this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo); this._undoKind = (cfgResp?.undo_kind || layResp?.undo_kind || null) as any; if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write; if (configChanged) this._continuity.note('config-candidate', { configRev: this._cfgRev }); if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: this._layoutRev }); return { configChanged, layoutChanged }; } /** Resume only a same-route warm editor intent after permissions arrive. * Always enter through _setMode: it owns transition state, contextual tray * cleanup and navigation persistence. Direct assignment leaves those * surfaces in mutually inconsistent modes. */ private _resumePendingNavMode(): boolean { if (!this._pendingNavMode || !this._canEdit || this._config?.kiosk) return false; const pendingMode = this._pendingNavMode; this._pendingNavMode = null; this._setMode(pendingMode, false); return true; } private async _loadFromServer(): Promise { this._loading = true; this._loadTries++; const visibleSpace = this._space; const hadViewport = !!this._view; try { const [cfgResp, layResp] = await Promise.all([ this.hass.callWS({ type: 'houseplan/config/get' }), this.hass.callWS({ type: 'houseplan/layout/get' }), ]); const candidateConfig = cfgResp?.config && Array.isArray(cfgResp.config.spaces) ? cfgResp.config : null; const structuralChanged = contentFingerprint(candidateConfig) !== (this._cfgContentFingerprint || contentFingerprint(this._serverCfg)) || contentFingerprint(layResp?.layout ?? {}) !== (this._layoutContentFingerprint || contentFingerprint(this._layout)); if (structuralChanged) { const assetReady = await this._signer.prepareImage( this.hass, this._candidateBackdrop(candidateConfig), ); if (!assetReady) { this._continuity.note('asset-failed'); this._scheduleLoadRetry(true); return; } } if (structuralChanged && this._continuity.hasCompleteFrame && this._continuity.state === 'steady') { this._beginContinuityCandidate('structural-response', true); } this._connectionWasLost = false; this._serverStorage = true; // absent can_write = older backend / demo stub → keep null (legacy admin fallback) if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write; this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo); this._adoptStructuralResponses(cfgResp, layResp); this._adoptInitialSpace(this._model, true); this._resumePendingNavMode(); this._cacheSnapshot(); // DEV-B703-03: a warm re-mount already holds the exact viewport of the // instance that was thrown away; the centred restore here IS the // reported jerk. Only a genuine navigation (the hash/nav landed us on // another space) still needs it. if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false; else if (!hadViewport || this._space !== visibleSpace) this._restoreZoom(); this._loadOk = true; // Trails and event subscriptions enrich an already complete snapshot. // A read-only HA session may reject these; that must never roll the // accepted config back into the mandatory load catch. void this.hass.callWS({ type: 'houseplan/trail/get' }) .then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); }) .catch(() => undefined); this._ensureLiveSyncSubscriptions(); } catch (e) { if (this._serverCfg) { // DEV-B703-02: this instance already RENDERS a valid config (the LS // snapshot, or an earlier successful load). A failing socket is a // transient condition — nulling _serverCfg here blanked the plan on // every reconnect that took more than 8 hass ticks. Stale-while- // revalidate: the last valid config stays on screen until a // successful reload replaces it, and revalidation keeps running on // our own clock (willUpdate stops driving loads after 8 tries). this._scheduleLoadRetry(true); } else if (this._loadTries >= 8) { // nothing was ever shown — genuine no-backend: local-only fallback this._serverStorage = false; try { this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {}; } catch { this._layout = {}; } } // fewer than 8 tries with nothing shown yet: silently wait for the // next hass update (WS warm-up) } finally { this._loading = false; // Readiness belongs to the candidate attempt, including a bounded asset // failure. Leaving it false on an early return stranded the controller // before its own two-second barrier could ever start. this._continuityDataReady = true; this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); } } /** Best-effort live sync starts only after the initial snapshot is usable. */ private _ensureLiveSyncSubscriptions(): void { const connection = this.hass?.connection; if (!connection) return; if (connection !== this._liveSyncConnection) { this._unsubCfg?.(); this._unsubCfg = null; this._unsubLayout?.(); this._unsubLayout = null; this._unsubTrail?.(); this._unsubTrail = undefined; this._unsubVirtual?.(); this._unsubVirtual = null; this._liveSyncGeneration++; this._liveSyncAttempt = null; this._liveSyncConnection = connection; } if (this._liveSyncAttempt) return; const generation = this._liveSyncGeneration; const attempts: Array<() => Promise> = []; const subscribe = ( current: () => (() => void) | null | undefined, adopt: (unsubscribe: () => void) => void, event: string, callback: (event: any) => void | Promise, ): void => { if (current()) return; attempts.push(async () => { const unsubscribe = await connection.subscribeEvents(callback, event); const valid = generation === this._liveSyncGeneration && this.isConnected && this.hass?.connection === connection && !current(); if (valid) adopt(unsubscribe); else unsubscribe?.(); }); }; subscribe( () => this._unsubCfg, (unsubscribe) => { this._unsubCfg = unsubscribe; }, 'houseplan_config_updated', (ev: any) => { // Flush a pending local edit BEFORE adopting a remote revision: // otherwise the debounced write reads a config that this reload has // already replaced, and the user's edit vanishes (audit L2). const observedRev = Number(ev?.data?.rev ?? -1); if (observedRev !== this._cfgRev) void this._reloadConfigOnly(false, observedRev); }, ); subscribe( () => this._unsubTrail, (unsubscribe) => { this._unsubTrail = unsubscribe; }, 'houseplan_trail_updated', async () => { try { const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' }); this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); } catch { /* transient WS hiccup — the next event retries */ } }, ); subscribe( () => this._unsubLayout, (unsubscribe) => { this._unsubLayout = unsubscribe; }, 'houseplan_layout_updated', (ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)), ); subscribe( () => this._unsubVirtual, (unsubscribe) => { this._unsubVirtual = unsubscribe; }, 'houseplan_virtual_light_updated', (ev: any) => { const next = applyVirtualLightEvent(this._virtualLights, ev?.data); if (next === this._virtualLights) return; this._virtualLights = next; this._capturedSnapshotVirtual = ''; this._cacheSnapshot(); this.requestUpdate(); }, ); if (!attempts.length) return; const task = settleBestEffort(attempts).then(() => undefined); this._liveSyncAttempt = task; void task.finally(() => { if (this._liveSyncAttempt === task) this._liveSyncAttempt = null; }); } /** * Adopt the server config. Any pending local write is flushed first and, if a * write is still in flight, the reload is deferred — adopting a revision on * top of an unsent edit is exactly how edits disappeared (audit L2). * `force` skips the deferral (conflict path: the local edit already lost). */ private async _reloadConfigOnly(force = false, observedRev?: number): Promise { if (!force) { // The event can arrive before the response to our own config/set. By the // time a pending write settles, its returned revision is current and the // queued event is merely an echo. Reloading it would erase the valid // session Undo stack after every local command. Revisions are monotonic, // so an equal or older observation is safe to ignore. if (observedRev !== undefined && observedRev <= this._cfgRev) return; if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush(); if (this._cfgWriting) { // retry once the in-flight write settles clearTimeout(this._reloadRetry); this._reloadRetry = window.setTimeout(() => this._reloadConfigOnly(false, observedRev), 400); return; } } this._beginContinuityCandidate('config-reload', false); try { const resp = await this.hass.callWS({ type: 'houseplan/config/get' }); const candidateConfig = resp?.config && Array.isArray(resp.config.spaces) ? resp.config as ServerConfig : null; const configChanged = contentFingerprint(candidateConfig) !== (this._cfgContentFingerprint || contentFingerprint(this._serverCfg)); if (configChanged && !await this._signer.prepareImage( this.hass, this._candidateBackdrop(candidateConfig), )) { this._continuity.note('asset-failed'); this._scheduleLoadRetry(true); return; } const visibleSpace = this._space; this._adoptStructuralResponses(resp); this._adoptInitialSpace(this._model, true); this._resumePendingNavMode(); this._cacheSnapshot(); if (this._space !== visibleSpace) this._restoreZoom(); this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); } catch (e: any) { // a failed reload leaves the card on its last known config; tell the user // rather than silently diverging from the server (audit L2 note) this._showToast(this._t('toast.cfg_reload_failed', { err: this._errText(e) })); } finally { this._continuityDataReady = true; this.requestUpdate(); } } private _reloadRetry?: number; /** DEV-B703-02: self-driven revalidation once willUpdate's 8-try budget is * spent — without it a card whose socket died at mount would show the * cached plan forever and never revalidate (hass ticks stop driving loads * after 8 tries). Exponential backoff capped at 8 s; single timer; * cleared on disconnect and on a connection 'ready'. */ private _loadRetryTimer?: number; private _scheduleLoadRetry(force = false): void { if (this._loadRetryTimer !== undefined) return; const delay = Math.min(8000, 500 * 2 ** Math.min(4, Math.max(1, this._loadTries - 7))); this._loadRetryTimer = window.setTimeout(() => { this._loadRetryTimer = undefined; if ((force || !this._loadOk) && !this._loading && this.hass) this._loadFromServer(); }, delay); } /** DEV-B703-02: revalidate the moment the socket comes back. The event * subscriptions (houseplan_config_updated / layout / trail) survive a * reconnect on their own — home-assistant-js-websocket keeps the * Connection object alive and replays its subscription commands on * 'ready' — but a LOAD that burned its retry budget while the socket was * down would never run again. 'ready' fires on every (re)connect: reset * the budget and quietly re-read the config. */ private _connHooked: { removeEventListener?: (t: string, cb: () => void) => void } | null = null; private _connectionWasLost = false; /** Shared, page-level full HA registry authority (one fetch/subscription per connection). */ private _haRegistryRelease?: () => void; private _haRegistryConnection: any = null; private _haRegistryRev = -1; private _haBindingCacheKey = ''; private _planHassMemo: { hass: any; sig: string; active: any; full: any } | null = null; private _onHaRegistryUpdate = (): void => { const snapshot = haRegistrySnapshot(this.hass); if (snapshot.revision === this._haRegistryRev && this._devices.length) return; this._haRegistryRev = snapshot.revision; this._planHassMemo = null; this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); }; private _ensureHaRegistryAuthority(): void { const connection = this.hass?.connection || null; if (!connection || connection === this._haRegistryConnection) return; this._haRegistryRelease?.(); this._haRegistryConnection = connection; this._haRegistryRev = -1; this._haBindingCacheKey = ''; this._planHassMemo = null; this._haRegistryRelease = acquireHaRegistries(this.hass, this._onHaRegistryUpdate); this._onHaRegistryUpdate(); } private get _haRegistry(): HaRegistrySnapshot { return haRegistrySnapshot(this.hass); } /** Active-only projection for every plan-level state/data/action consumer. */ private get _planHass(): any { const snapshot = this._haRegistry; const sig = haRegistryBuildSignature(this.hass, snapshot); const memo = this._planHassMemo; if (memo && memo.hass === this.hass && memo.sig === sig) { return memo.active; } const active = activeRegistryHass(this.hass, snapshot); const full = fullRegistryHass(this.hass, snapshot); this._planHassMemo = { hass: this.hass, sig, active, full }; return active; } private _captureRenderDeviceSnapshot(): void { if (!this.hass) return; const now = Date.now(); const activity = [...this._activityRt.entries()] .map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}:` + `${runtime.flashKind && (runtime.expiresAt || runtime.flashTs + ACTIVITY_WINDOW_MS) > now ? 1 : 0}`) .join('|'); const virtualFingerprint = virtualLightFingerprint(this._virtualLights); if (this._capturedSnapshotSequence === this._hassSequence && this._capturedSnapshotDevices === this._devices && this._capturedSnapshotLayout === this._layout && this._capturedSnapshotConfigEpoch === this._cfgEpoch && this._capturedSnapshotVirtual === virtualFingerprint && this._capturedSnapshotActivity === activity) return; const planHass = this._planHass; const presentations = new Map(); const facts = new Map(); const entityIds = new Set(['sun.sun']); if (this._vacFit?.source) entityIds.add(this._vacFit.source); const deviceIds = new Set(); const areaIds = new Set(); const addSource = (source: string | null | undefined): void => { if (!source) return; const separator = source.indexOf(':'); if (separator < 0) { entityIds.add(source); return; } const kind = source.slice(0, separator), reference = source.slice(separator + 1); if (kind === 'device') deviceIds.add(reference); else if (kind === 'entity') entityIds.add(reference); }; for (const space of this._model) for (const room of space.rooms) { if (room.area) areaIds.add(room.area); addSource(room.settings?.temp_source); addSource(room.settings?.hum_source); } for (const rawSpace of this._serverCfg?.spaces || []) for (const opening of rawSpace.openings || []) { for (const entityId of openingEntityReferences(opening)) entityIds.add(entityId); } // Inline HA variables on the decorative layer are part of the painted // frame too. Capture both the current token format and the legacy // one-entity fields so a reconnect cannot update a label ahead of the // rooms, devices and light layers around it. for (const rawSpace of this._serverCfg?.spaces || []) { for (const shape of rawSpace.decor || []) { if (shape.kind !== 'text') continue; for (const match of String(shape.text || '').matchAll(/\{([^{}\r\n]+)\}/g)) { const reference = liveTextReference(match[1]); if (reference?.entity) entityIds.add(reference.entity); } if (shape.entity) entityIds.add(shape.entity); // HA's formatter functions are runtime capabilities and must not cross // the immutable render-snapshot boundary. Freeze the already formatted // caption instead, so locale/precision/unit and every surrounding plan // layer belong to one atomic HA frame. facts.set(`decor:${rawSpace.id}:${shape.id}`, liveText( shape.text, shape, planHass, (entityId) => !!planHass.entities?.[entityId] && !!planHass.states?.[entityId] && !isRemovedPlanEntity(planHass, entityId, removedPlanBindings(this._markers)), )); } } const planLightSources = resolvedLightSources( planHass, this._devices, null, this._virtualLights, ); for (const device of this._devices) { for (const showLqi of [false, true]) { presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation( planHass, device, { liveStates: this._config?.live_states !== false, showTemperature: this._config?.show_temperature !== false, // A per-space show_lqi value is an explicit override of the card // default. Snapshot both projections exactly as requested so the // full card, preview and static card cannot disagree. showSignal: showLqi, activityRuntime: this._activityRt.get(device.id), sourceDetails: false, lightDevices: this._devices, lightSources: planLightSources, registryHass: this._fullRegistryHass, reducedMotion: this._reducedMotion, }, )); } if (this._isVacDev(device)) { const source = this._vacSource(device, planHass); const telemetry = source ? readVacTelemetry(planHass?.states?.[source]?.attributes) : null; const runtime = this._vacRt.get(device.id); facts.set(`vacuum:${device.id}`, { source, telemetry, mapId: telemetry ? this._vacMapId(device, telemetry, planHass) : null, runtime: runtime ? { trail: runtime.trail, lastTs: runtime.lastTs, moving: runtime.moving, jump: runtime.jump, } : null, server: this._vacSrvTrails[device.id] || null, }); } } const snapshot = createRenderDeviceSnapshot({ sourceSequence: this._hassSequence, hass: planHass, devices: this._devices, presentations, positions: renderDeviceSnapshotPositions( this._model.length > 0, this._devices, (device) => this._livePos(device), ), facts, entityIds, deviceIds, areaIds, }); this._capturedSnapshotSequence = this._hassSequence; this._capturedSnapshotDevices = this._devices; this._capturedSnapshotLayout = this._layout; this._capturedSnapshotActivity = activity; this._capturedSnapshotConfigEpoch = this._cfgEpoch; this._capturedSnapshotVirtual = virtualFingerprint; if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') { this._visibleDeviceSnapshot = snapshot; this._candidateDeviceSnapshot = null; } else { this._candidateDeviceSnapshot = snapshot; } } private get _renderDeviceSnapshot(): RenderDeviceSnapshot | null { return this._stagedDeviceSnapshotToken === this._continuity.token ? this._candidateDeviceSnapshot || this._visibleDeviceSnapshot : this._visibleDeviceSnapshot || this._candidateDeviceSnapshot; } private get _renderPlanHass(): any { return this._renderDeviceSnapshot?.hass || this._planHass; } private get _renderDevices(): readonly DevItem[] { return this._renderDeviceSnapshot?.devices || this._devices; } /** * Full registry metadata for diagnostics and action safety checks. Entity * states/services for actions always come from the active `_planHass`; * disabled rows are exposed here only so the resolver can reject/explain. */ private get _fullRegistryHass(): any { void this._planHass; return this._planHassMemo?.full || this.hass; } private _bindingStatus(binding: string): HaBindingStatus { return resolveHaBindingStatus(this.hass, binding, this._haRegistry); } /** Redacted client-side support data; no states, names or marker metadata. */ public houseplanDiagnostics(): { registry: ReturnType & { lastSuccessAgeMs: number | null }; bindings: Record; } { const registry = haRegistryDiagnostics(this.hass); const bindings: Record = { active: 0, ha_disabled: 0, orphaned: 0, unverified: 0, }; for (const marker of this._markers) { if (marker.removed || marker.binding === 'virtual') continue; bindings[this._bindingStatus(marker.binding).kind]++; } return { registry: { ...registry, lastSuccessAgeMs: registry.lastSuccess ? Math.max(0, Date.now() - registry.lastSuccess) : null, }, bindings, }; } private _openBindingInHa(binding: string): void { const [kind, ref] = binding.split(':'); if (!ref) return; if (kind === 'device') { navigate('/config/devices/device/' + encodeURIComponent(ref)); return; } if (kind === 'entity') { const reg = this._fullRegistryHass.entities?.[ref]; if (reg?.device_id) { navigate('/config/devices/device/' + encodeURIComponent(reg.device_id)); } } } /** Only device-backed bindings have a stable HA configuration route. */ private _bindingHasHaPage(binding: string): boolean { const [kind, ref] = binding.split(':'); if (!ref) return false; return kind === 'device' || (kind === 'entity' && !!this._fullRegistryHass.entities?.[ref]?.device_id); } private _toggleMarkerDialogVisibility(): void { const d = this._markerDialog; if (!d) return; const status = d.bindingMode === 'ha' ? this._bindingStatus(d.binding) : null; const effectivelyHidden = d.hideFromPlan || status?.kind === 'ha_disabled'; if (effectivelyHidden && status?.kind === 'ha_disabled') { this._showToast(this._t(status.reason === 'entity' ? 'toast.ha_disabled_show_entity' : 'toast.ha_disabled_show_device')); return; } if (effectivelyHidden && status?.kind === 'unverified') { this._showToast(this._t('toast.ha_binding_unverified')); return; } this._markerDialog = { ...d, hideFromPlan: !effectivelyHidden }; } private _onConnReady = (): void => { this._loadTries = 0; clearTimeout(this._loadRetryTimer); this._loadRetryTimer = undefined; refreshHaRegistries(this.hass); if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush(); if (this._cfgWriting) { clearTimeout(this._reloadRetry); this._reloadRetry = window.setTimeout(this._onConnReady, 400); return; } if (!this._connectionWasLost && this._continuity.hasCompleteFrame) { this._beginContinuityCandidate('connection-ready', false, 'plan'); } else { // A confirmed loss already owns the connection candidate/token. this._continuityDataReady = false; this._continuityPaintToken = -1; } if (this._loading) return; // Re-read config and layout as one mandatory candidate. Optional live-sync // subscriptions are then retried independently for whichever channels are // still missing; their rejection cannot invalidate this snapshot. // `_loadFromServer` adopts each side by revision+fingerprint and preserves // equal references. this._loadFromServer(); }; private _onConnLost = (): void => { if (this._booting && !this._continuity.hasCompleteFrame) return; this._connectionWasLost = true; this._continuityDataReady = false; this._continuityPaintToken = -1; this._continuity.connectionLost(); }; private _hookConnection(): void { const conn = (this.hass as any)?.connection; if (!conn || conn === this._connHooked) return; this._connHooked?.removeEventListener?.('ready', this._onConnReady); this._connHooked?.removeEventListener?.('disconnected', this._onConnLost); this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost); conn.addEventListener?.('ready', this._onConnReady); conn.addEventListener?.('disconnected', this._onConnLost); conn.addEventListener?.('reconnect-error', this._onConnLost); this._connHooked = conn; } /** * Signed urls for the content endpoint (audit follow-up B1 regression). * A browser cannot authenticate an or an : HA takes a * Bearer header or an `authSig` signed path, and an element sends neither. * So the card asks the backend to sign what it is about to display. */ private _signer = new ContentSigner(() => this.requestUpdate()); /** Display url: a signature we hold and still trust, else nothing. */ private _display(url: string | null | undefined): string { return this._signer.display(this.hass, url); } /** Re-sign what the live config still references (wall tablets outlive one). */ private _resign(): void { this._signer.resign(this.hass, referencedContentUrls(this._serverCfg)); } private _layoutSyncTimer?: number; /** * A layout revision appeared. It may well be ours: the event travels over the * same socket as the reply to our own write and can arrive first, so * reacting immediately means re-reading what we just sent — and, worse, * racing a drag that has not been flushed yet. Wait a beat; if our own reply * lands in the meantime, `_layoutRev` catches up and there is nothing to do. */ private _onLayoutEvent(rev: number): void { if (rev <= this._layoutRev) return; clearTimeout(this._layoutSyncTimer); this._layoutSyncTimer = window.setTimeout(() => { if (rev <= this._layoutRev) return; // it was ours after all this._reloadLayoutOnly(); }, 200); } /** * Remember a revision this card produced, so its own `layout_updated` event * is not mistaken for someone else's and does not trigger a pointless * re-read of what we just wrote. */ private _noteLayoutRev(r: any): void { const rev = r?.rev; if (typeof rev === 'number' && rev > this._layoutRev) this._layoutRev = rev; } /** * Adopt positions written elsewhere, without dropping our own (HP-1460-03). * * Only the layout is re-read — the config is untouched, so this cannot * disturb an edit in progress. Positions this card has moved but not yet * sent are flushed first and then kept on top of the server's answer: a fix * for a stale UI must not turn into a lost drag. */ private async _reloadLayoutOnly(): Promise { if (!this._serverStorage || !this.hass?.callWS) return; this._beginContinuityCandidate('layout-reload', false); // Snapshot BEFORE flushing. `flush()` runs the debounced writer // synchronously, and the first thing that does is empty `_dirtyPos` — so // reading the dirty set afterwards found nothing to protect and the server's // older position was painted over the drag the user had just made // (HP-1461-02). Positions are captured by value for the same reason. const mine = new Map(); for (const id of this._dirtyPos) if (this._layout[id]) mine.set(id, this._layout[id]); if (this._persistLayout.pending()) this._persistLayout.flush(); // …and again after the flush: what was dirty is now in flight, and a write // sent before this reload was even scheduled is in there too. Until the // server acknowledges a position, this card is the authority on it. for (const [id, pos] of this._sentPos) mine.set(id, pos); try { const resp = await this.hass.callWS({ type: 'houseplan/layout/get' }); const remote = resp?.layout || {}; const merged: Record = { ...remote }; for (const [id, pos] of mine) merged[id] = pos; const fingerprint = contentFingerprint(merged); if (fingerprint !== (this._layoutContentFingerprint || contentFingerprint(this._layout))) { this._layout = merged; this._layoutContentFingerprint = fingerprint; } this._layoutRev = resp?.rev ?? this._layoutRev; this._canOptimizeUndo = !!resp?.can_optimize_undo; this._undoKind = (resp?.undo_kind || null) as any; this._cacheSnapshot(); this.requestUpdate(); } catch { /* a failed refresh just leaves the positions we already had */ } finally { this._continuityDataReady = true; this.requestUpdate(); } } private _dirtyPos = new Set(); /** Positions sent to the server and not acknowledged yet (HP-1461-02). */ private _sentPos = new Map(); private _persistLayout = debounce(() => { if (this._serverStorage) { // point-wise updates: do not overwrite positions changed in other windows const ids = [...this._dirtyPos]; this._dirtyPos.clear(); for (const id of ids) { const pos = canonicalizePosition(this._layout[id]); if (!pos) continue; this._layout = { ...this._layout, [id]: pos }; // in flight until the server answers: a layout reload triggered in the // meantime must keep this position, not the one the server still has this._sentPos.set(id, pos); this.hass .callWS({ type: 'houseplan/layout/update', device_id: id, pos }) .then((r: any) => this._noteLayoutRev(r)) .catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) }))) .finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); }); } this._cacheSnapshot(); } else { this._layout = canonicalizeLayoutGeometry(this._layout); localStorage.setItem(LS_KEY, JSON.stringify(this._layout)); } }, 600); // ================= devices from the registries ================= private _maybeRebuildDevices(): void { const h = this.hass; if (!h?.devices || !h?.entities || !h?.areas) return; const registry = this._haRegistry; const sig = haRegistryBuildSignature(h, registry) + ':' + Object.keys(h.areas).length + ':' + (this._norm ? 'n' : 'l') + ':' + langOf(h, this._config?.language); if (sig === this._regSignature && this._devices.length) return; this._regSignature = sig; const before = new Map(this._devices.map((d) => [d.id, d.bindingStatus?.kind || 'active'])); this._devices = buildDevices({ hass: h, registry, areaToSpace: Object.fromEntries( Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]), ), markers: this._markers, settings: this._settings, excluded: this._excluded, showAll: this._showAll, firstSpaceId: this._model[0]?.id || '', loc: (k) => this._t(k), iconRules: this._iconRules, }); const savedBindings = this._markers .filter((marker) => !marker.removed && marker.binding !== 'virtual') .map((marker) => marker.binding).sort(); const bindingCacheKey = registry.revision + ':' + savedBindings.join('|'); if (registry.authoritative && bindingCacheKey !== this._haBindingCacheKey) { const statuses = new Map(); for (const binding of savedBindings) { statuses.set(binding, resolveHaBindingStatus(h, binding, registry)); } cacheHaBindingStatuses(statuses); this._haBindingCacheKey = bindingCacheKey; } this._defPos = this._defaultPositions(); this._syncNewDevices(); this._seedHiddenDevices(); // Rebuilds also happen without a hass update (marker save/rebind). Establish // new baselines and clear old flashes synchronously, before the next paint. this._syncActivityRuntime(); const liveVac = new Set(this._devices.filter((d) => !d.hidden).map((d) => d.id)); for (const id of this._vacRt.keys()) if (!liveVac.has(id)) this._vacRt.delete(id); if (this._infoCard) { const current = this._devices.find((d) => d.id === this._infoCard!.id); this._infoCard = current && current.bindingStatus?.kind !== 'ha_disabled' ? current : null; } const disabledNow = this._devices.some( (d) => d.bindingStatus?.kind === 'ha_disabled' && before.get(d.id) !== 'ha_disabled', ); if (disabledNow) { if (this._infoCard?.bindingStatus?.kind === 'ha_disabled' || this._devices.find((d) => d.id === this._infoCard?.id)?.bindingStatus?.kind === 'ha_disabled') { this._closeInfoCard(); } if (this._drag && this._devices.find((d) => d.id === this._drag!.id)?.bindingStatus?.kind === 'ha_disabled') { this._drag = null; } clearTimeout(this._holdTimer); this._holdFired = false; this._tip = null; this._tapConfirm = null; } if (this._nativeMoreInfoEntity && !this._planEntityAvailable(this._nativeMoreInfoEntity)) { fireEvent(this, 'hass-more-info', { entityId: null }); this._nativeMoreInfoEntity = null; } } /** * "New device" flag (server-side, shared by every client): an auto device * that appears after the known baseline was recorded gets a red dot until * someone opens its editor. The baseline is seeded silently on first run, * so an upgrade never floods the plan with dots. */ private _syncNewDevices(): void { if (!this._norm || !this._loadOk || !this._serverCfg) return; // only auto-appearing icons: area devices and light groups; markers are user-made const autoIds = this._devices.filter((d) => !d.marker && !d.virtual).map((d) => d.id).sort(); const key = autoIds.join(','); if (key === this._newSyncKey) return; // same registry picture — nothing to do this._newSyncKey = key; const st: any = this._settings; const { fresh, known } = diffNewDevices(autoIds, st.known_devices); if (!Array.isArray(st.known_devices) || fresh.length) { const newIds = [...new Set([...(st.new_device_ids || []), ...fresh])]; this._serverCfg = { ...this._serverCfg, settings: { ...st, known_devices: known, new_device_ids: newIds }, }; // best-effort persist: non-admins under admin_only just keep the local view this._saveConfig(); } } /** Ids currently flagged as new (drawn with the red dot). */ private get _newIds(): Set { const list = (this._settings as any).new_device_ids; return new Set(Array.isArray(list) ? list : []); } /** First visit to the device's editor acknowledges its "new" flag. */ private _ackNewDevice(id: string): void { if (!this._newIds.has(id) || !this._serverCfg) return; const st: any = this._settings; this._serverCfg = { ...this._serverCfg, settings: { ...st, new_device_ids: (st.new_device_ids || []).filter((x: string) => x !== id) }, }; this._saveConfig(); this.requestUpdate(); } /** Filtering + light groups + overrides + virtual devices. */ private get _markers(): Marker[] { return this._serverCfg?.markers || []; } private _roomLqi(area: string | null): number | null { if (!area) return null; const vals: number[] = []; for (const d of this._renderDevices) { if (d.area !== area || d.virtual) continue; const l = lqiFor(this._renderPlanHass, d.entities); if (l != null) vals.push(l); } return averageLqi(vals); } // ================= positions ================= /** Bounding rectangle of a room (rect or polygon) in render units. */ private _roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } { if (r.poly && r.poly.length) { const xs = r.poly.map((p) => p[0]); const ys = r.poly.map((p) => p[1]); const x = Math.min(...xs); const y = Math.min(...ys); return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; } return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 }; } private _defaultPositions(): Record { const map: Record = {}; const iconPct = this._config?.icon_size ?? 2.5; for (const s of this._model) { // docs/CANVAS.md §6: the icon's RENDER-unit footprint follows the plan's // own size — NORM_W for anything inside the old square (identical to // before), proportionally more for a plan several canvases wide. const minDist = (iconPct / 100) * iconUnit(s) * 1.3; for (const r of s.rooms) { if (!r.area) continue; // HA-disabled saved markers stay in the roster: they render nowhere // outside the service ghost, but reserve their old auto-grid slot so // temporary deactivation cannot shuffle visible neighbours. const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id); if (!ds.length) continue; const b = this._roomBounds(r); const pad = Math.min(b.w, b.h) * 0.1; const iw = b.w - pad * 2; const ih = b.h - pad * 2; const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1)))); const rows = Math.ceil(ds.length / cols); const cw = iw / cols; const ch = ih / Math.max(rows, 1); const pts = ds.map((_, i) => ({ x: b.x + pad + cw * ((i % cols) + 0.5), y: b.y + pad + ch * (Math.floor(i / cols) + 0.5), })); declump(pts, b, minDist, pad * 0.5); // an auto-placed icon lands on a node too (docs/CANVAS.md §9) — the // owner's "everything on the grid" covers what the card places itself ds.forEach((d, i) => (map[d.id] = snapPt(pts[i]))); } } return map; } /** Device position in render units of the current space. */ private _pos(d: DevItem): { x: number; y: number } { const captured = this._renderDeviceSnapshot?.positions.get(d.id); return captured ? { x: captured.x, y: captured.y } : this._livePos(d); } private _livePos(d: DevItem): { x: number; y: number } { const s = this._spaceModelById(d.space); const saved = this._layout[d.id]; if (saved) { if (this._norm) { if (saved.s === d.space) { return { x: saved.x * NORM_W, y: saved.y * NORM_W }; } } else if (saved.s === undefined) { return { x: saved.x, y: saved.y }; } } if (this._defPos[d.id]) return this._defPos[d.id]; if (!s) return { x: NORM_W / 2, y: NORM_W / 2 }; // the middle of what IS drawn, not of a canvas that has no edges any more return snapPt(spaceCenter(s)); } private _savePos(d: DevItem, x: number, y: number): void { if (!this._spaceModelById(d.space)) return; if (this._norm) { // The icon center snaps to the nodes of the same grid as the room markup // (docs/CANVAS.md §9). UX-05 has no free-position escape hatch. const g = this._gridPitch; const gx = Math.round(x / g) * g; const gy = Math.round(y / g) * g; const prevK = (this._layout[d.id] as any)?.k; this._layout = { ...this._layout, [d.id]: { s: d.space, x: clampCanvasN(gx / NORM_W), y: clampCanvasN(gy / NORM_W), ...(prevK ? { k: prevK } : {}) }, }; } else { this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } }; } this._dirtyPos.add(d.id); this._persistLayout(); } // ================= live states ================= /** * Entities that jointly describe the marker. Explicit cover intent wins; * otherwise the same resolved light set used by Glow/fill/controls wins; * a non-light marker uses its resolved device-state role. Critical secondary * entities are appended so an alarm cannot hide behind a less important * functional source. */ private _visualSamples(d: DevItem): EntityVisualSample[] { return resolvePresentationSources( this._renderPlanHass, d, this._renderDevices, undefined, this._fullRegistryHass, ).samples; } /** One semantic projection consumed by the plan, preview and static card. */ private _devicePresentation( d: DevItem, showLqi = this._config?.show_signal !== false, designPreview = false, ): ResolvedDevicePresentation { const captured = !designPreview ? this._renderDeviceSnapshot?.presentations.get(presentationSnapshotKey(d.id, showLqi)) : null; if (captured) return captured; return resolveDevicePresentation(this._renderPlanHass, d, { liveStates: this._config?.live_states !== false, showTemperature: this._config?.show_temperature !== false, showSignal: showLqi, designPreview, activityRuntime: this._activityRt.get(d.id), sourceDetails: false, lightDevices: this._renderDevices, registryHass: this._fullRegistryHass, reducedMotion: this._reducedMotion, }); } /** One semantic result feeds the plate and every non-critical activity effect. */ private _deviceVisual(d: DevItem): DeviceVisualState { return this._devicePresentation(d).visual; } /** CSS classes retained behind the old helper name for smoke/debug callers. */ private _stateClass(d: DevItem, visual = this._deviceVisual(d)): string { const presentation = this._devicePresentation(d); if (presentation.effectiveHidden) return ''; const activity = presentation.display === 'icon_ripple' && this._config?.live_states !== false && visual.status !== 'alarm' ? visual.activity : 'none'; return presentationClasses({ ...presentation, visual, activity }).join(' '); } private _liveTemp(d: DevItem): number | null { if (!this._config?.show_temperature) return null; // Opt-in climate source (marker.use_climate_temp): the AC/thermostat's // current_temperature gets the same badge a thermometer has. No valid // reading (missing attribute, unavailable) -> no badge at all. if (d.marker?.use_climate_temp === true) { const t = climateTempFor(this._renderPlanHass, d.entities); if (t != null) return t; } if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null; return tempFor(this._renderPlanHass, d.entities); } /** Every HA entity owned by a dialog binding. */ private _bindingEntities(binding: string): string[] { if (binding === 'virtual' || !binding) return []; const status = this._bindingStatus(binding); return status.kind === 'active' ? status.enabledEntityIds : status.allEntityIds; } /** Does the dialog's binding carry a climate entity? Gates the opt-in checkbox. */ private _bindingHasClimate(binding: string): boolean { return this._bindingEntities(binding).some((eid) => eid.startsWith('climate.')); } /** Static display can deliberately hide this binding's alarm indication. */ private _bindingHasAlarm(binding: string): boolean { return this._bindingEntities(binding).some((eid) => { const state = this._planHass?.states?.[eid] || this.hass?.states?.[eid]; const registry = this._fullRegistryHass.entities[eid] || this.hass?.entities?.[eid]; return isAlarmCapable( eid.split('.')[0], state?.attributes?.device_class || registry?.device_class || registry?.original_device_class, ); }); } private _liveHum(d: DevItem): number | null { if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature if (!d.primary || !isHumEntity(this.hass, d.primary)) return null; return humFor(this.hass, d.entities); } // ================= interaction ================= private _deviceBindingActive(d: DevItem, notify = true): boolean { if (d.virtual || d.bindingKind === 'virtual') return true; if (!d.bindingKind || !d.bindingRef) return false; const status = this._bindingStatus(`${d.bindingKind}:${d.bindingRef}`); if (status.kind === 'active') return true; if (notify) { this._showToast(this._t(status.kind === 'ha_disabled' ? 'toast.ha_disabled_action' : 'toast.ha_binding_unverified')); } return false; } private _openMoreInfo(entityId?: string): void { if (!entityId) { this._showToast(this._t('toast.no_entity')); return; } if (!this._planEntityAvailable(entityId)) { this._showToast(this._t('toast.ha_disabled_action')); return; } this._nativeMoreInfoEntity = entityId; fireEvent(this, 'hass-more-info', { entityId }); } /** * A modal opened while a view pointer is still down interrupts the stage's * normal pointerup lifecycle. Drop every navigation anchor immediately and * release capture defensively, even though the current view path does not * normally request it. This is intentionally separate from editor geometry. */ private _interruptViewGesture(pointerId?: number, source?: Element | null): void { clearTimeout(this._holdTimer); clearTimeout(this._kioskHoldTimer); if (pointerId !== undefined) { for (const element of [source, this._stageEl]) { try { if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId); } catch { /* Pointer ownership may already have moved to the modal. */ } } } this._pointers.clear(); this._panStart = null; this._panLock = null; this._pinchStart = null; this._swipeStart = null; } /** Close the device card and make stale long-press state non-observable. */ private _closeInfoCard(): void { this._interruptViewGesture(); this._holdFired = false; this._infoCard = null; } /** Right click in VIEW mode always opens HA's more-info (owner's decision). */ private _ctxDevice(ev: MouseEvent, d: DevItem): void { if (this._mode !== 'view') return; // editors keep the native context menu ev.preventDefault(); ev.stopPropagation(); if (!this._deviceBindingActive(d)) return; if (d.primary) this._openMoreInfo(d.primary); else this._infoCard = d; } private _clickDevice(ev: Event, d: DevItem): void { ev.stopPropagation(); if (this._drag?.moved || this._suppressClick || this._holdFired) return; if (this._mode === 'plan') return; if (this._mode === 'devices') { this._openMarkerDialog(d); return; } // The renderer may deliberately keep an older, complete visual snapshot // on screen while a new HA/config frame is being committed (#73). Actions // must never inherit that visual staleness: resolve the current DevItem by // stable marker id before reading tap_action, controls or the binding. const actionDevice = this._devices.find((item) => item.id === d.id); if (!actionDevice) return; // marker disappeared from the live config const action = projectedTapAction( actionDevice.tapAction, actionDevice.primary?.split('.')[0], ); // the accidental-tap guard (owner's spec 2026-07-29): any state-changing // action — toggle or run — may ask first. The dialog is ours, not the // browser confirm(), so it works and looks right on a wall tablet. const guarded = (text: string, exec: () => void): void => { if (actionDevice.marker?.tap_confirm) this._tapConfirm = { kind: 'run', text, exec }; else exec(); }; if (action === 'toggle') { const initial = this._toggleIntent(actionDevice); if (!initial) return; if (!toggleOperation(initial)) { this._showUnavailableToggleTargets(initial); return; // other configured no-target outcomes remain intentional quiet no-ops } const execute = (intent: ResolvedToggleIntent): void => { const operation = toggleOperation(intent); if (!operation) return; if (operation.kind === 'virtual-light') { this._startDevicePressFeedback(actionDevice.id); this.hass.callWS({ type: 'houseplan/virtual_light/toggle', marker_id: operation.markerId, }).then((result: any) => { const next = applyVirtualLightEvent(this._virtualLights, result); if (next === this._virtualLights) return; this._virtualLights = next; this._capturedSnapshotVirtual = ''; this._cacheSnapshot(); this.requestUpdate(); }).catch((e: any) => this._showToast(this._t( 'toast.virtual_light_toggle_failed', { err: this._errText(e) }, ))); return; } const { command } = operation; this._startDevicePressFeedback(actionDevice.id); this.hass.callService(command.domain, command.service, command.data) .catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) }))); }; const name = toggleIntentName(initial) || actionDevice.name; if (actionDevice.marker?.tap_confirm) { const lines = this._toggleConfirmationLines(initial); if (!lines.length) return; this._tapConfirm = { kind: 'toggle', text: this._t('confirm.tap_toggle', { name }), lines, initialIntent: initial, deviceId: actionDevice.id, exec: () => { const currentDevice = this._devices.find((item) => item.id === actionDevice.id); const current = currentDevice ? this._toggleIntent(currentDevice) : null; if (current && !toggleOperation(current) && this._showUnavailableToggleTargets(current)) return; if (!current || !sameToggleOperationTargets(initial, current)) { this._showToast(this._t('toast.tap_target_changed')); return; } execute(current); // current state decides the current direction }, }; } else execute(initial); return; } // The House Plan device card is a local informational surface built from // the DevItem that is already on screen. It does not call an HA service // and must not disappear behind a second, momentary registry-status check // (notably on compound cover/curtain devices). HA-backed actions below // keep the active-binding gate. Closing the stage gesture first also // prevents a pending pan/long-press lifecycle from swallowing the modal. if (action === 'info') { this._interruptViewGesture(); this._infoCard = actionDevice; return; } if (!this._deviceBindingActive(actionDevice)) return; if (action === 'run') { const target = actionDevice.marker?.tap_target || ''; const svc = runServiceFor(target); const st = this.hass.states[target]; if (!svc || !st) { this._showToast(this._t('toast.run_target_missing')); return; } const name = st.attributes?.friendly_name || target; guarded(this._t('confirm.tap_run', { name }), () => { if (!this._deviceBindingActive(actionDevice) || !this._planEntityAvailable(target)) return; this._startDevicePressFeedback(actionDevice.id); this.hass .callService(svc.domain, svc.service, { entity_id: target }) .then(() => { this._stampActivity( actionDevice.id, 'event', this._activitySourceKey(actionDevice), ); this.requestUpdate(); this._showToast(this._t('toast.run_started', { name })); }) .catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) }))); }); return; } if (action === 'more-info' && actionDevice.primary) { this._openMoreInfo(actionDevice.primary); return; } this._infoCard = actionDevice; } /** Explain a safe controls no-op through the card's standard local toast. */ private _showUnavailableToggleTargets(intent: ResolvedToggleIntent): boolean { const names = unavailableToggleTargetNames(intent); if (!names.length) return false; this._showToast(names.length === 1 ? this._t('toast.toggle_target_unavailable', { name: names[0] }) : this._t('toast.toggle_targets_unavailable', { names: names.join(', ') })); return true; } private _keyDevice(ev: KeyboardEvent, d: DevItem): void { if (ev.key !== 'Enter' && ev.key !== ' ') return; if (this._mode !== 'view' && this._mode !== 'devices') return; ev.preventDefault(); this._clickDevice(ev, d); } /** One retargetable visual acknowledgement owned by actual action dispatch. */ private _startDevicePressFeedback(markerId: string): void { const marker = [...this.renderRoot.querySelectorAll('.dev[data-id]')] .find((candidate) => candidate.dataset.id === markerId); const body = marker?.querySelector('.device-shell'); if (!body || typeof body.animate !== 'function') return; const paintedScale = Number.parseFloat( this.ownerDocument.defaultView?.getComputedStyle(body).scale || '', ); const restartScale = Number.isFinite(paintedScale) ? Math.max(0.95, Math.min(1, paintedScale)) : 1; this._devicePressAnimations.get(markerId)?.cancel(); const frames: Keyframe[] = this._reducedMotion ? [ { outlineColor: 'transparent', outlineStyle: 'solid', outlineWidth: '0px' }, { outlineColor: 'var(--hp-accent, #3ea6ff)', outlineStyle: 'solid', outlineWidth: '2px', outlineOffset: '2px', offset: 0.5, }, { outlineColor: 'transparent', outlineStyle: 'solid', outlineWidth: '0px' }, ] : [ { scale: String(restartScale) }, { scale: '0.95', offset: 0.5 }, { scale: '1' }, ]; const animation = body.animate(frames, { duration: 200, easing: 'cubic-bezier(.22,.61,.36,1)', }); this._devicePressAnimations.set(markerId, animation); const clear = (): void => { if (this._devicePressAnimations.get(markerId) === animation) { this._devicePressAnimations.delete(markerId); } }; animation.addEventListener('finish', clear, { once: true }); animation.addEventListener('cancel', clear, { once: true }); } private _cancelDevicePressFeedback(): void { for (const animation of this._devicePressAnimations.values()) animation.cancel(); this._devicePressAnimations.clear(); } private _pruneDevicePressFeedback(): void { if (!this._devicePressAnimations.size) return; const visible = new Set( [...this.renderRoot.querySelectorAll('.dev[data-id]')] .map((marker) => marker.dataset.id || ''), ); for (const [markerId, animation] of this._devicePressAnimations) { if (visible.has(markerId)) continue; animation.cancel(); this._devicePressAnimations.delete(markerId); } } /** Translate a key in the card's current language. */ private _t(key: I18nKey, vars?: Record): string { return t(langOf(this.hass, this._config?.language), key, vars); } /** Per-card copy keeps presentation-only pickers independent across languages. */ private get _colorPickerLabels(): ColorPickerLabels { return { title: this._t('color_picker.title'), hue: this._t('color_picker.hue'), saturation: this._t('color_picker.saturation'), value: this._t('color_picker.value'), hex: this._t('color_picker.hex'), invalidHex: this._t('color_picker.invalid_hex'), }; } /** Localize both parts of a help affordance while hp-help stays presentation-only. */ private _help(key: Extract): TemplateResult | typeof nothing { const ariaKey = `${key}.aria` as I18nKey; const lang = langOf(this.hass, this._config?.language); if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing; return html``; } private get _stageEl(): HTMLElement | null { return this.renderRoot.querySelector('.stage') as HTMLElement | null; } /** * Everything of the current space that counts as CONTENT, one item per * object (docs/CANVAS.md §4): rooms and the backdrop image come from the * model, openings/decor/devices are added here because the model does not * carry them. Devices use their RESOLVED position, so a marker parked in * the far corner of the yard frames with the rest. * * HIDDEN devices are NOT content (DEV-2C947-01): the frame is presentation, * and a device the plan does not draw must not decide what the plan opens * on — hiding a marker that once wandered into the yard used to leave the * house a dot in the corner of an empty frame. They keep counting for room * LQI/climate and they keep their auto-grid cell (docs/FILTERING.md); only * the frame stops seeing them, including the ghosts of the device editor, * whose reach is the pan slack's job, not the opening view's. */ private _contentItems(m: SpaceModel): ContentItem[] { const extra: ContentItem[] = []; for (const d of this._devices) { if (d.space !== m.id || d.hidden) continue; const p = this._pos(d); extra.push({ minX: p.x, minY: p.y, maxX: p.x, maxY: p.y }); } if (m.id === this._space) { for (const o of this._openingsR) { const rad = (Number(o.angle) * Math.PI) / 180; const dx = (Math.cos(rad) * o.rlen) / 2, dy = (Math.sin(rad) * o.rlen) / 2; const it = itemOf([[o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy]]); if (it) extra.push(it); } const H = this._decorH; for (const x of this._decorList) { const pts = x.kind === 'line' ? [[x.x1 * NORM_W, x.y1 * H], [x.x2 * NORM_W, x.y2 * H]] : x.kind === 'text' ? [[x.x * NORM_W, x.y * H]] : boxCorners({ x: x.x * NORM_W, y: x.y * H, w: x.w * NORM_W, h: x.h * H, angle: x.angle }); const it = itemOf(pts); if (it) extra.push(it); } // Independent physical geometry participates in content bounds even // outside room paper. Each body is one bounded object, never a vote made // only by its centre point. for (const body of this._physicalBodiesR(m)) { const it = itemOf(body); if (it) extra.push(it); } } return contentItems(m, extra); } /** * The content frame of the current space, memoised (docs/CANVAS.md §4). * * In VIEW mode it follows the config: adding a device out in the yard * widens what "fit" means. Inside an EDITOR it only ever GROWS — the frame * bounds pan and the meaning of zoom 1, and a frame that shrank the moment * you deleted a room would move the ground under a drag. It never bounds * where you may DRAW: §5 pan slack plus 3x zoom-out is far more room than * the old square ever gave (that is what HP-1490-03 needed). */ private _frame: | { id: string; model: SpaceModel; layout: unknown; devs: unknown; far: boolean; grow: boolean; rect: Rect; all: Rect; outliers: number } | null = null; /** The outlier hint's «Показать» is on: the frame takes in the far strays * too, so zoom, pan and the fit button all agree about what "everything" * is. Reset when the space changes (docs/CANVAS.md §4.1). */ private _showFar = false; private _frameOf(): { rect: Rect; all: Rect; outliers: number } { const m = this._spaceModel(); if (!m) { this._frame = null; const fallback = { x: 0, y: 0, w: NORM_W, h: NORM_W }; return { rect: fallback, all: fallback, outliers: 0 }; } // Memo by IDENTITY, not by an epoch counter: `_model`, `_layout` and // `_devices` are all replaced (never mutated in place) whenever their // content changes, so this catches a marker drag and a server push alike — // an epoch would have to be bumped at every one of those call sites. const f = this._frame; // `grow` is part of the KEY, not just of the computation (DEV-2C947-02): // the union an editor accumulated is an editor's frame, and leaving for // View has to recompute rather than inherit it — otherwise a room moved // far away in the Plan editor kept View framing the empty ground it left // behind, until some unrelated model change happened to invalidate memo. const grow = this._mode !== 'view'; // A LIVE BACKDROP GESTURE FREEZES THE FRAME (docs/BACKDROP.md §2). The // picture is a content item, so dragging it grows the frame — which // rescales the view, which changes how many plan units a screen pixel is // worth, mid-gesture: the picture then runs away from the finger and no // drag lands where it was aimed. The frame catches up on release. if (f && f.id === m.id && this._bdDrag) return f; if (f && f.id === m.id && f.model === m && f.layout === this._layout && f.devs === this._devices && f.far === this._showFar && f.grow === grow) return f; const cf = contentFrame(this._contentItems(m)); let all = cf.all || spaceFrame(m); let rect = this._showFar ? all : (cf.core || spaceFrame(m)); if (f && f.id === m.id && grow && f.grow) { // Inside an editor the frame only GROWS: it bounds pan and defines what // zoom 1 means, and a frame that shrank the instant a room was deleted // would move the ground under the pointer mid-gesture. Only ever unions // with a frame the SAME editor session produced (f.grow). rect = unionRect(f.rect, rect); all = unionRect(f.all, all); } this._frame = { id: m.id, model: m, layout: this._layout, devs: this._devices, far: this._showFar, grow, rect, all, outliers: cf.outliers, }; return this._frame; } /** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */ private _isoSource(): { key: string; build: () => { walls: any; floor: any; openings: readonly IsoOpeningBasis[]; } } | null { const space = this._spaceModel(); if (!space) return null; const walls = this._spaceWalls; const openCuts = this._openPairs().flatMap((pair) => pair.segs); const openings = this._openingsR.map((opening, sourceIndex) => ({ id: String(opening.id || sourceIndex), sourceIndex, type: opening.type, x: opening.rx, y: opening.ry, angle: Number(opening.angle) || 0, length: opening.rlen > 0 ? opening.rlen : 0.9 * NORM_W, flipH: !!opening.flip_h, flipV: !!opening.flip_v, })); const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm); const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm); const key = `${space.id}|${isoGeometryFingerprint({ rooms: space.rooms, walls, openCuts, openings, partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns, cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch, camera: ISO_CAMERA, wallHeight, floorEdgeHeight, algorithm: 3, })}`; return { key, build: () => { const extras = physicalBodyParts( space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002, this._partitionOpeningCuts(space), ).all; const roomOpenings = this._roomWallOpeningInputs(this._openingsR, space); const united = walls.length || extras.length ? wallBodiesGeometry( space.rooms, walls, openCuts, roomOpenings, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras, ) : null; if (united && (united.status === 'failed-core' || united.status === 'not-applicable')) throw new Error('wall boolean geometry failed'); const floor = united?.paperGeom ?? floorFootprintGeometry( space.rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); if (!floor) throw new Error('floor boolean geometry failed'); const openingIndex = united?.openingIndex || buildOpeningWallIndex( space.rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); const openingBases = openings.map((opening) => { const rendered = this._openingsR[opening.sourceIndex]; const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV; const face = rendered?.partitionHost ? partitionOpeningFace(rendered.partitionHost, faceFlipV) : walls.length || opening.type === 'gate' ? openingInnerFaceOffsetFromIndex(openingIndex, { x: opening.x, y: opening.y, angle: opening.angle, length: opening.length, flip_v: faceFlipV, }) : { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 }; return buildIsoOpeningBasis({ ...opening, face }, wallHeight); }); return { walls: united?.components.flatMap((component) => component.geom) || [], floor, openings: Object.freeze(openingBases), }; }, }; } private _isoSceneKey(): string | null { if (!this._labsIso || this._mode !== 'view') return null; try { return this._isoSource()?.key ?? null; } catch { return `${this._space}|invalid`; } } private _isoScene(): { key: string; geometry: IsoWallGeometry; floor: IsoFloorGeometry; openings: readonly IsoOpeningBasis[]; frame: Rect; } | null { const source = this._isoSource(); if (!source) return null; const cached = this._isoGeometryCache.get(source.key); if (cached) return { key: source.key, ...cached }; const flat = this._frameOf().rect; const structural = source.build(); const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm); const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm); const openingFrame = isoOpeningBounds(structural.openings); const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat; const frame = projectedFrame({ rect: structuralFrame, wallHeight, openingHeight: wallHeight, floorDepth: floorEdgeHeight, }); const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight); const floor = buildIsoFloorGeometry(structural.floor, floorEdgeHeight); const value = { geometry, floor, openings: structural.openings, frame }; lruWrite(this._isoGeometryCache, source.key, value, 8); return { key: source.key, ...value }; } private _effectiveProjection(): 'flat' | 'iso' { if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat'; const key = this._isoSceneKey() || `${this._space}|invalid`; if (this._isoFallback.has(key)) return 'flat'; try { const scene = this._isoScene(); if (!scene) return 'flat'; return isoEffectiveView('iso', scene.key, this._isoFallback); } catch (error) { if (!this._isoFallback.has(key)) { this._isoFallback.add(key); const parts = key.split('|'); console.warn(`HOUSEPLAN ISO FALLBACK: #89, space ${this._space}, fingerprint ${parts[parts.length - 1]}, ${error instanceof Error ? error.message : 'renderer error'}`); } return 'flat'; } } private _scenePoint(point: readonly [number, number]): ScenePoint { return this._renderProjection === 'iso' ? projectPlanPoint(point, 0) : point; } private _floorView(view: { x: number; y: number; w: number; h: number }): ViewRect { if (this._renderProjection !== 'iso') return view; const start = unprojectFloorPoint([view.x, view.y]); const end = unprojectFloorPoint([view.x + view.w, view.y + view.h]); return { x: start[0], y: start[1], w: end[0] - start[0], h: end[1] - start[1] }; } private _baseVb(): number[] { if (this._effectiveProjection() === 'iso') { // No-borders is the accepted Stage 1 no-volume scene. Stage 2 structure // is absent there, so its opening/floor-edge bounds must be absent too; // otherwise merely hiding volume reframes every live floor pixel. if (!this._spaceDisplayForRender().showBorders) { const flat = this._frameOf().rect; const frame = projectedFrame({ rect: flat, wallHeight: gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm), }); return [frame.x, frame.y, frame.w, frame.h]; } const frame = this._isoScene()?.frame ?? this._frameOf().rect; return [frame.x, frame.y, frame.w, frame.h]; } const r = this._frameOf().rect; return [r.x, r.y, r.w, r.h]; } /** How many objects the opening view deliberately leaves out (§4.1). */ private get _outliers(): number { return this._showFar ? 0 : this._frameOf().outliers; } /** The outlier hint's action: take the far objects into the frame, then fit. */ private _fitFar(): void { this._showFar = true; this._frame = null; this._resetZoom(); } /** «Вписать всё» (docs/CANVAS.md §8) — the toolbar button and the "home is * that way" arrow share it. It fits whatever the frame currently means: * the main mass, or everything once the far-objects hint has been used. */ private _fitAll(): void { this._showFar = true; this._frame = null; this._resetZoom(); } /** Unobtrusive inline hint: some objects stand an order of magnitude away * from the plan and are deliberately outside the opening view. No modal — * a chip with one action (docs/CANVAS.md §4.1). */ private _renderFarHint(): TemplateResult | typeof nothing { if (this._kiosk || this._mode !== 'view' || this._booting || !this._outliers) return nothing; return html`
${this._t('canvas.far_objects', { n: this._outliers })}
`; } /** "Home is that way" (docs/CANVAS.md §5): the plane has no edges, so it is * possible to pan until nothing is on screen. One small pointer towards the * content, one click back to it. */ private _renderHomeArrow(): TemplateResult | typeof nothing { if (this._booting) return nothing; const v = this._view; if (!v || !v.w || !v.h) return nothing; const base = this._baseVb(); const f = { x: base[0], y: base[1], w: base[2], h: base[3] }; const gone = f.x + f.w <= v.x || f.x >= v.x + v.w || f.y + f.h <= v.y || f.y >= v.y + v.h; if (!gone) return nothing; const ang = Math.atan2((f.y + f.h / 2) - (v.y + v.h / 2), (f.x + f.w / 2) - (v.x + v.w / 2)); const left = 50 + Math.cos(ang) * 38; const top = 50 + Math.sin(ang) * 38; return html``; } /** Aspect ratio of the scene (width/height, px). */ private _stageAspect(): number { const s = this._stageEl; const vb = this._baseVb(); return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3]; } /** Current view with a fallback to the full fit. */ private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } { return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect()); } /** * Room labels must keep their View screen geometry while the Plan toolbar * borrows height from the stage. Devices continue to scale with the active * editor viewport; only the label core uses the hypothetical View viewport * at the same zoom and total card height (#200). */ private _roomLabelReferenceViewWidth(view: { w: number }): number { if (!this._markup) return view.w; const stage = this._stageEl; const chrome = this.renderRoot.querySelector('.editorchrome') as HTMLElement | null; if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return view.w; const totalHeight = stage.clientHeight + (chrome?.getBoundingClientRect().height || 0); if (totalHeight <= 0) return view.w; return this._viewForModeTarget( this._zoom, undefined, undefined, stage.clientWidth, totalHeight, ).w; } /** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */ private _screenToVb(sx: number, sy: number): number[] { const s = this._stageEl; const v = this._viewOr(this._baseVb()); const w = s?.clientWidth || 1, h = s?.clientHeight || 1; return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h]; } /** Zoom limits (docs/CANVAS.md §5): 8x in as before, 3x out — beyond that * the screen is empty plane, which is not information. */ private static readonly ZOOM_MAX = 8; private static readonly ZOOM_MIN = MIN_ZOOM; /** * Keep the view within reach of the content (docs/CANVAS.md §5). * * There is no edge any more, so this is NOT "the content must cover the * scene" — that was the rule that made it impossible to draw or place * anything past the plan. Panning may walk a whole screen off the content * in every direction (PAN_SLACK), which is what an editor needs to extend a * plan outwards; further than that you would only be lost, and the "home is * that way" arrow already covers the walk back. */ private _clampView( v: { x: number; y: number; w: number; h: number }, fit: { x: number; y: number; w: number; h: number }, ): { x: number; y: number; w: number; h: number } { const axis = (vp: number, vs: number, fp: number, fs: number): number => { const slack = Math.max(vs, fs) * PAN_SLACK; const a = fp - slack, b = fp + fs - vs + slack; return Math.max(Math.min(a, b), Math.min(Math.max(a, b), vp)); }; return { w: v.w, h: v.h, x: axis(v.x, v.w, fit.x, fit.w), y: axis(v.y, v.h, fit.y, fit.h) }; } /** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */ private _applyView(zoom: number, cx?: number, cy?: number): boolean { const vb = this._baseVb(); const fit = fitView(vb, this._stageAspect()); const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom)); const w = fit.w / z, h = fit.h / z; const cur = this._viewOr(vb); const ccx = cx ?? cur.x + cur.w / 2; const ccy = cy ?? cur.y + cur.h / 2; const next = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit); const same = this._view && Math.abs(this._zoom - z) < 1e-9 && Math.abs(this._view.x - next.x) < 1e-6 && Math.abs(this._view.y - next.y) < 1e-6 && Math.abs(this._view.w - next.w) < 1e-6 && Math.abs(this._view.h - next.h) < 1e-6; if (same) return false; if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } this._zoom = z; this._view = next; return true; } /** * HP-1552: hold the boot veil until the stage height settles. AUD-1552-02: * the old "two equal reads at a 200 ms cadence" revealed the plan at * ~400 ms, so an HA panel landing right after (450+ ms is realistic on a * slow device) still jumped on a VISIBLE plan. The veil now holds for the * full protective window (BOOT_MIN_MS); the height is sampled every 100 ms * and any change restarts a trailing-quiescence requirement * (BOOT_QUIET_MS), so chrome still settling near the cap extends the wait. * BOOT_MAX_MS lifts the veil unconditionally — it can never get stuck. */ private _bootWatch(): void { clearTimeout(this._bootTimer); // never two concurrent watchers (connect + updated) this._bootStart = Date.now(); this._bootLastH = -1; // the first read only arms the comparison this._bootLastChange = this._bootStart; const tick = () => { if (!this._booting) return; const now = Date.now(); const h = this._stageEl ? this._stageEl.clientHeight : 0; if (h !== this._bootLastH) { this._bootLastH = h; this._bootLastChange = now; } const elapsed = now - this._bootStart; if (elapsed >= BOOT_MAX_MS || (elapsed >= BOOT_MIN_MS && h > 0 && now - this._bootLastChange >= BOOT_QUIET_MS)) { this._bootSettled(); return; } this._bootTimer = window.setTimeout(tick, 100); }; this._bootTimer = window.setTimeout(tick, 100); } private _bootSettled(): void { if (!this._booting || this._bootSettling) return; this._bootSettling = true; this._refitView(); // calculate the final geometry while still covered this._bootSettleRaf = requestAnimationFrame(() => { this._bootSettleRaf = 0; this._finishBootSettled(); }); } private _finishBootSettled(): void { if (!this._booting) return; // `_applyView()` is reactive: wait one paint opportunity, refit once more, // and only then remove the veil. This prevents a default-view reveal. this._refitView(); this._booting = false; this._bootSettling = false; // DEV-B703-01: the page has settled once — the next instance with the // same config at the same viewport opens warm (no veil, no wait). const settledH = this._stageEl?.clientHeight ?? 0; if (!this._config?.kiosk && settledH > 0) { this._warmPatch({ hdrH: this._hdrH, stageH: settledH, vp: this._warmViewportState() }, true); } this._bootFading = true; // one soft opacity-out, then out of the DOM this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220); // AUD-1552-02: chrome that lands after the cap (device slower than // BOOT_MAX_MS) must not snap — for a short grace the stage height // transitions and the viewport ResizeObserver refits the plan each frame. this._bootSoft = true; clearTimeout(this._bootSoftTimer); this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS); } /** The soft grace only covers PASSIVE late chrome. A user action that * changes the stage height (entering/leaving an editor) must apply * instantly — the plan may not drift under the pointer mid-drag. */ private _bootSoftCancel(): void { if (!this._bootSoft) return; clearTimeout(this._bootSoftTimer); this._bootSoft = false; } /** Hold the last complete frame after a long sleep; never hide the scene. */ private _beginResumeSettle(): void { if (this._booting || this._resumeSettling) return; if (this._modeTransitionPreparing) this._modeTransitionForceAtomic = true; else this._cancelModeTransition(true); this._viewportInvalidAt = 0; this._beginContinuityCandidate('warm-resume', false); // A card can be detached for the whole visibility event and reattached as // the same already-loaded instance. In that case `willUpdate()` has no // reason to reload, so the warm tombstone must start its own revalidation // instead of immediately blessing the stale frame as current. if (!this._loading) void this._loadFromServer(); else this.requestUpdate(); } /** Recompute the view for a new scene size, preserving zoom and center. */ private _refitView(): void { if (this._modeTransitionBusy) return; const stage = this._stageEl; // ResizeObserver may deliver a zero/transitional box while a browser tab // is frozen or while Lovelace replaces the card. Mutating `_view` from // that box is the scale jump observed on return. if (!stage || document.visibilityState !== 'visible' || stage.clientWidth <= 0 || stage.clientHeight <= 0) { if (!this._viewportInvalidAt) this._viewportInvalidAt = Date.now(); return; } const size: [number, number] = [stage.clientWidth, stage.clientHeight]; const previous = this._lastValidStageSize; const sameSize = !!previous && Math.abs(previous[0] - size[0]) <= 0.5 && Math.abs(previous[1] - size[1]) <= 0.5; const invalidFor = this._viewportInvalidAt ? Date.now() - this._viewportInvalidAt : 0; this._viewportInvalidAt = 0; if (!previous) { this._lastValidStageSize = size; if (!this._view) this._applyView(this._zoom); return; } // 0x0 -> the same positive size is explicitly a no-op. if (sameSize) { this._pendingRefitSize = null; return; } this._pendingRefitSize = size; if (this._refitRaf) return; this._refitRaf = requestAnimationFrame(() => { this._refitRaf = 0; const target = this._pendingRefitSize; this._pendingRefitSize = null; const live = this._stageEl; if (!target || !live || live.clientWidth <= 0 || live.clientHeight <= 0) return; if (Math.abs(live.clientWidth - target[0]) > 0.5 || Math.abs(live.clientHeight - target[1]) > 0.5) { this._refitView(); return; } const old = this._lastValidStageSize; if (old && Math.abs(old[0] - target[0]) <= 0.5 && Math.abs(old[1] - target[1]) <= 0.5) return; this._lastValidStageSize = target; const cur = this._view; if (invalidFor >= CONTINUITY_LONG_HIDDEN_MS) { this._beginContinuityCandidate('stage-size-restored', true, 'stage-size'); } else if (this._continuity.hasCompleteFrame) { this._beginContinuityCandidate('stage-resize', true, 'stage-size'); } this._applyView( this._zoom, cur ? cur.x + cur.w / 2 : undefined, cur ? cur.y + cur.h / 2 : undefined, ); }); } /** Change the zoom while keeping the point (sx,sy relative to the scene) in place. */ private _zoomAt(sx: number, sy: number, newZoom: number): void { const stage = this._stageEl; if (!stage) return; const vb = this._baseVb(); const fit = fitView(vb, this._stageAspect()); const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom)); if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } const w = stage.clientWidth, h = stage.clientHeight; const pt = this._screenToVb(sx, sy); const nw = fit.w / z, nh = fit.h / z; this._zoom = z; this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit); } private _onWheel(ev: WheelEvent): void { const stage = this._stageEl; if (!stage) return; ev.preventDefault(); const r = stage.getBoundingClientRect(); const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor); this._saveZoom(); } private _stepZoom(delta: number): void { const stage = this._stageEl; if (!stage) return; this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4)); this._saveZoom(); } /** «Вписать всё» (docs/CANVAS.md §8) — the toolbar's middle button and the * old "reset zoom" in one: frame the content at zoom 1, centred. With * `all` it also takes in the far strays the opening view leaves out. */ private _resetZoom(): void { const vb = this._baseVb(); if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } this._zoom = 1; this._view = fitView(vb, this._stageAspect()); this._saveZoom(); } /** Save the current space zoom to localStorage (view mode only). */ private _saveZoom(): void { // Editor zoom is a working tool, never the viewing intent: while an editor // is open the wheel/pinch keep calling _saveZoom, but the per-space VIEW // zoom must not learn about it. The exit-editor restore used to re-save the // view zoom from a rAF — on a slow tablet the floor-tab click lands BEFORE // that rAF (input runs first in the frame), the space guard skipped the // fix-up and the editor 500% stayed in _zoomBySpace/LS_ZOOM for the next // visit to that floor. Editors do not need zoom persistence at all. if (this._mode !== 'view') return; this._zoomBySpace = { ...this._zoomBySpace, [this._space]: this._zoom }; try { localStorage.setItem(LS_ZOOM, JSON.stringify(this._zoomBySpace)); } catch { /* ignore */ } } /** Restore the saved space zoom and center the plan. */ private _restoreZoom(): void { const z = this._zoomBySpace[this._space] || 1; this._zoom = z; const stage = this._stageEl; if (stage && stage.clientHeight) { // HP-1551: the stage is already measured — apply the view NOW, before // the next paint. The unconditional rAF here used to let one frame // (or a whole server round-trip worth of frames, via the updated() // refit running with the stale zoom before this method was called at // all) paint at the default fit — the visible "flash" on opening. const vb = this._baseVb(); this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); this.requestUpdate(); return; } // stage not measurable yet: let updated() fit it with the (already // correct) _zoom on the first layout, then center on the plan this._view = null; requestAnimationFrame(() => { if (!this._stageEl) return; const vb = this._baseVb(); this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); this.requestUpdate(); }); } private _stagePointerDown(ev: PointerEvent): void { if (this._vacFit) return; // no pan/swipe while fitting the robot map // The shared secondary controller owns palette dismissal in window // capture, including the matching synthetic click. Do not duplicate that // lifecycle here: the former fallback left `_suppressClick` stuck when a // pointerup was lost or a pointercancel followed the dismiss press. if (this._kiosk) { this._cyclePausedUntil = Date.now() + 60000; if (this._pointers.size === 0) { this._swipeStart = { x: ev.clientX, y: ev.clientY, id: ev.pointerId }; // long-press on EMPTY stage opens the per-screen size popover if (!(ev.target as HTMLElement).closest?.('.dev, .roomlabel, .oplock')) { clearTimeout(this._kioskHoldTimer); this._kioskHoldTimer = window.setTimeout(() => { this._kioskDialog = true; this._swipeStart = null; }, 3000); } } else { this._swipeStart = null; // second finger = pinch, not a swipe clearTimeout(this._kioskHoldTimer); } } // do not interfere with icon and label dragging if (this._drag) return; if (this._markup) { // Drawing is CLICK-based, so gestures coexist with it: a finger that // MOVES pans, two fingers pinch, and _suppressClick keeps the release // from feeding the active tool. The editor used to opt out of gestures // entirely, which left a phone with no way to zoom or pan the plan // (owner's report). Pointers that begin on interactive children still // stay out — labels and handles run their own drags. if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .rszhandle, .dev, .oplock, .op-hit, button')) return; } if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return; if (this._mode === 'decor' && this._decorPointerDown(ev)) return; this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); const v = this._viewOr(this._baseVb()); if (this._pointers.size === 1) { this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y }; this._panLock = null; // undecided until the finger moves this._suppressClick = false; } else if (this._pointers.size === 2) { // A second pointer turns the gesture into navigation. An unfinished // two-click Boundary edit and an Opening hover must never survive // underneath that pinch. Keep the selected opening preset: lifting the // fingers returns to the same placement session, just without a stale // preview at the pre-gesture coordinate. this._cancelBoundaryAnchor(); if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } else if (this._tool === 'draw') { this._clearPlanSnapHover(); } const pts = [...this._pointers.values()]; const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); this._pinchStart = { dist, zoom: this._zoom }; this._panStart = null; this._panLock = null; } } /** * Is a horizontal drag reserved for the floor swipe right now? Only on a * kiosk screen, only at the zoom `swipeTarget` accepts, and only when there * is more than one space to swipe between — everywhere else nothing * competes with panning. */ private get _swipeZone(): boolean { return !this._hasFixedFloor && this._kiosk && this._zoom <= 1.001 && this._model.length > 1; } private _stagePointerMove(ev: PointerEvent): void { if (this._physicalRotate?.pid === ev.pointerId) { this._physicalRotateMove(ev); return; } if (this._physicalDrag?.pid === ev.pointerId) { this._physicalMove(ev); return; } if (this._dtDrag?.pid === ev.pointerId) { this._dtMove(ev); return; } if (this._bdDrag?.pid === ev.pointerId) { this._bdMove(ev); return; } if (this._decorDraft?.pid === ev.pointerId) { const draft = this._decorDraft; let b = this._decorSnap(this._svgPoint(ev), ev.pointerType); if (ev.shiftKey && (draft.kind === 'rect' || draft.kind === 'ellipse')) { const dx = b[0] - draft.a[0], dy = b[1] - draft.a[1]; const side = Math.max(Math.abs(dx), Math.abs(dy)); b = this._snap([ draft.a[0] + (dx < 0 ? -side : side), draft.a[1] + (dy < 0 ? -side : side), ]); } this._decorDraft = { ...draft, b }; return; } if (this._decorMove?.pid === ev.pointerId) { this._decorMoveUpdate(ev); return; } if (!this._pointers.has(ev.pointerId)) { this._markupMove(ev); return; } this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); // the tool preview (snap dot, measure label) keeps following the finger if (this._markup && this._pointers.size === 1) this._markupMove(ev); if (this._pinchStart && this._pointers.size >= 2) { if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } else if (this._tool === 'draw') { this._clearPlanSnapHover(); } const pts = [...this._pointers.values()]; const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); const scale = dist / (this._pinchStart.dist || 1); const r = this._stageEl!.getBoundingClientRect(); const cx = (pts[0].x + pts[1].x) / 2 - r.left; const cy = (pts[0].y + pts[1].y) / 2 - r.top; this._zoomAt(cx, cy, this._pinchStart.zoom * scale); this._suppressClick = true; this._saveZoom(); } else if (this._panStart) { const ddx = ev.clientX - this._panStart.sx; const ddy = ev.clientY - this._panStart.sy; if (Math.abs(ddx) + Math.abs(ddy) > 4) { this._suppressClick = true; clearTimeout(this._holdTimer); if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } else if (this._tool === 'draw') { this._clearPlanSnapHover(); } } // Which gesture is this? Decided once, on the first movement worth the // name, and kept for the rest of the drag so the plan cannot flip-flop // under the finger. Panning is NOT gated on the zoom any more (owner, // 2026-08-04: «таскать план при любом масштабе»): on an infinite canvas // there is no "the content already covers the scene" state that would // make a drag meaningless — `_clampView` alone decides how far you may // walk, at 400% and at 33% alike. if (this._panLock === null && Math.abs(ddx) + Math.abs(ddy) > 8) { this._panLock = this._swipeZone && Math.abs(ddx) > Math.abs(ddy) * 1.5 ? 'swipe' : 'pan'; } const stage = this._stageEl; if (this._panLock === 'pan' && stage) { const vb = this._baseVb(); const v = this._viewOr(vb); const fit = fitView(vb, this._stageAspect()); this._view = this._clampView( { x: this._panStart.vx - (ddx / (stage.clientWidth || 1)) * v.w, y: this._panStart.vy - (ddy / (stage.clientHeight || 1)) * v.h, w: v.w, h: v.h, }, fit, ); } } } private _stagePointerLeave(_ev: PointerEvent): void { if (!this._markup) return; if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } else if (this._tool === 'draw') { this._clearPlanSnapHover(); } } private _stagePointerUp(ev: PointerEvent): void { if (this._kiosk) { clearTimeout(this._kioskHoldTimer); const ss = this._swipeStart; this._swipeStart = null; if (ss && ss.id === ev.pointerId) { const dx = ev.clientX - ss.x; const dy = ev.clientY - ss.y; // double tap (no movement) resets the zoom to 1:1 if (Math.abs(dx) + Math.abs(dy) < 8) { const now = Date.now(); if (now - this._lastTap < 350) this._resetZoom(); this._lastTap = now; } // The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided // once, on the first movement worth the name, whether this gesture is // a swipe or a pan — and the release may not overturn it. Until this // the release asked `swipeTarget()` again from the raw start→end // vector, so a CURVED gesture (a small vertical lead-in that locks // 'pan', then a long horizontal sweep) dragged the plan under the // finger and still landed on another storey when it lifted. A pan is // a pan to the end: no floor change, whatever the overall vector // happens to look like. A motionless tap never locks anything, so the // double-tap zoom reset above is untouched. const target = this._panLock === 'pan' ? null : swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space); if (target) { // the plan follows the finger: swiping left brings the next one in // from the right, so the current one leaves to the left if (this._slideTo(target, dx < 0 ? 'left' : 'right')) { this._saveNav(); this._suppressClick = true; setTimeout(() => (this._suppressClick = false), 0); this._showKioskDots(); } } } } if (this._physicalDrag?.pid === ev.pointerId) { this._physicalUp(ev); return; } if (this._physicalRotate?.pid === ev.pointerId) { this._physicalRotateUp(ev); return; } if (this._dtDrag?.pid === ev.pointerId) { this._dtUp(); return; } if (this._bdDrag?.pid === ev.pointerId) { this._bdUp(); return; } if (this._decorDraft?.pid === ev.pointerId) { this._decorCommitDraft(); return; } if (this._decorMove?.pid === ev.pointerId) { if (this._decorMove.moved) { this._recordGeometry(this._t('history.decor_move'), this._decorMove.before); this._saveConfig(); } this._decorMove = null; return; } const viewportGestureEnded = !!this._pinchStart || !!this._panStart; this._pointers.delete(ev.pointerId); if (this._pointers.size < 2) this._pinchStart = null; if (this._pointers.size === 0) { this._panStart = null; this._panLock = null; // reset click suppression on the next tick (so that a click right after a pan does not fire) setTimeout(() => (this._suppressClick = false), 0); } if (viewportGestureEnded && this._pointers.size === 0) this.requestUpdate(); } private _clickRoom(r: RoomCfg): void { if (this._suppressClick || !r.area) return; navigate('/config/areas/area/' + r.area); } private _pointerDown(ev: PointerEvent, d: DevItem): void { if (this._mode === 'plan') return; // icons are hidden in plan mode anyway if (this._mode === 'view') { // view: no drag, no capture — panning may start on an icon; only the // long-press timer runs (cancelled by stage movement) this._holdFired = false; clearTimeout(this._holdTimer); const pointerId = ev.pointerId; const source = ev.currentTarget as Element | null; this._holdTimer = window.setTimeout(() => { this._holdFired = true; this._interruptViewGesture(pointerId, source); this._infoCard = d; }, 600); return; } // A disabled ghost is a service entry point, not a movable plan object. // Leave the click intact so the settings dialog still opens. if (d.bindingStatus?.kind === 'ha_disabled') return; ev.preventDefault(); const p = this._pos(d); this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; capturePointer(ev); this._tip = null; } private _pointerMove(ev: PointerEvent, d: DevItem): void { if (!this._drag || this._drag.id !== d.id) return; const stage = this.renderRoot.querySelector('.stage') as HTMLElement; if (!stage) return; const vb = this._baseVb(); const rect = stage.getBoundingClientRect(); const v = this._viewOr(vb); const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w; const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h; if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) { this._drag.moved = true; clearTimeout(this._holdTimer); } // DEV-B58-01. This used to be clamped into `vb` — the CONTENT FRAME — with // a 0.8 % margin, which is exactly the "old canvas border" the owner ran // into: a marker could never be dragged past the outline of whatever was // already drawn, so a plan could not be extended by moving a device out to // where the new room was going to be. The plan has no edges any more // (docs/CANVAS.md §9); the only bound is the garbage limit the backend // enforces, and it is the SAME ±5000 on both sides of the wire. const nx = clampCanvasR(this._drag.ox + dx); const ny = clampCanvasR(this._drag.oy + dy); this._savePos(d, nx, ny); } private _pointerUp(_ev: PointerEvent, d: DevItem): void { clearTimeout(this._holdTimer); if (!this._drag || this._drag.id !== d.id) return; const moved = this._drag.moved; this._drag = moved ? this._drag : null; if (moved) { this._selId = d.id; window.setTimeout(() => (this._drag = null), 0); } } private _showToast(msg: string): void { // A toast supersedes transient explanatory surfaces in this card only. // Dialogs own their portal/overlay registry, so another card stays intact. for (const dialog of this.renderRoot.querySelectorAll('hp-dialog')) { dialog.closeTransientOverlays('toast'); } this._toast = msg; clearTimeout(this._toastTimer); this._toastTimer = window.setTimeout(() => { this._toast = ''; }, 3500); } /** Publish the instance-local input authority to nested shared controls. */ private _syncPointerHoverTargets(): void { const enabled = this._pointerModality.hoverEnabled; for (const target of this.renderRoot.querySelectorAll( POINTER_HOVER_TARGET_SELECTOR, )) { target.toggleAttribute('data-pointer-hover', enabled); } } private _syncPointerHoverSubtree(node: Node): void { if (node.nodeType !== Node.ELEMENT_NODE) return; const element = node as HTMLElement; const enabled = this._pointerModality.hoverEnabled; if (element.matches(POINTER_HOVER_TARGET_SELECTOR)) { element.toggleAttribute('data-pointer-hover', enabled); } for (const target of element.querySelectorAll( POINTER_HOVER_TARGET_SELECTOR, )) { target.toggleAttribute('data-pointer-hover', enabled); } } /** Remove visual state that can only be owned by a live mouse hover. */ private _clearTransientHover(suspend = false): void { if (suspend) this._pointerModality.suspend(); if (this._tip) this._tip = null; if (this._hoverRoom) this._hoverRoom = null; } /** The latest real pointer event, not a page-global first-touch latch, owns hover. */ private _notePointer(ev: PointerEvent): void { this._boundaryPointerType = ev.pointerType || 'mouse'; const previous = this._pointerModality.modality; const modality = this._pointerModality.note(ev); if ((modality === 'touch' || modality === 'pen') && (previous !== modality || !!this._tip || !!this._hoverRoom)) { this._clearTransientHover(); } } /** * Capture-phase guard for browser clicks synthesized after touch gestures. * * `_suppressClick` covers a pan/pinch that the stage itself observed. The * contact set closes the other path: an interactive child is allowed to stop * pointer propagation, but it is not allowed to hide/navigate/toggle from one * finger of a two-finger gesture. The short tail also covers WebKit emitting * `click` after the final pointerup rather than in the same task. */ private _guardTouchGesture(ev: Event): void { if (this._editorSecondary.handleOutsideDismiss(ev)) return; if (ev.type === 'click') { if (!this._suppressClick && !this._touchSequenceMultitouch && Date.now() > this._touchClickBlockUntil) return; ev.preventDefault(); ev.stopImmediatePropagation(); return; } const pointer = ev as PointerEvent; this._notePointer(pointer); if (pointer.pointerType !== 'touch') return; if (ev.type === 'pointerdown') { this._touchContacts.set(pointer.pointerId, { x: pointer.clientX, y: pointer.clientY, inStage: !!(pointer.target as Element | null)?.closest?.('.stage'), }); if (this._touchContacts.size >= 2) { this._clearTransientHover(); this._touchSequenceMultitouch = true; this._touchClickBlockUntil = Number.POSITIVE_INFINITY; clearTimeout(this._holdTimer); clearTimeout(this._kioskHoldTimer); this._swipeStart = null; // Viewing is the guaranteed touch surface. Seed its existing stage // pinch pipeline even when a child swallowed the first pointerdown. const contacts = [...this._touchContacts.values()]; if (this._mode === 'view' && !this._vacFit && contacts.every((contact) => contact.inStage)) { this._pointers = new Map([...this._touchContacts].map(([id, contact]) => [ id, { x: contact.x, y: contact.y }, ])); const [a, b] = contacts; this._pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom: this._zoom }; this._panStart = null; this._panLock = null; } else if (this._vacFit) { this._pointers.clear(); this._pinchStart = null; this._panStart = null; this._panLock = null; } } return; } if (ev.type === 'pointermove') { const contact = this._touchContacts.get(pointer.pointerId); if (!contact) return; const next = { ...contact, x: pointer.clientX, y: pointer.clientY }; this._touchContacts.set(pointer.pointerId, next); if (this._touchSequenceMultitouch && this._mode === 'view' && !this._vacFit && this._pinchStart && [...this._touchContacts.values()].every((item) => item.inStage)) { this._pointers.set(pointer.pointerId, { x: pointer.clientX, y: pointer.clientY }); const contacts = [...this._touchContacts.values()]; if (contacts.length >= 2 && this._stageEl) { const [a, b] = contacts; const scale = Math.hypot(a.x - b.x, a.y - b.y) / (this._pinchStart.dist || 1); const rect = this._stageEl.getBoundingClientRect(); this._zoomAt((a.x + b.x) / 2 - rect.left, (a.y + b.y) / 2 - rect.top, this._pinchStart.zoom * scale); this._saveZoom(); } } return; } if (ev.type !== 'pointerup' && ev.type !== 'pointercancel' && ev.type !== 'lostpointercapture') return; this._clearTransientHover(); this._touchContacts.delete(pointer.pointerId); if (this._touchSequenceMultitouch) { this._touchClickBlockUntil = Date.now() + 500; this._pointers.delete(pointer.pointerId); if (!this._vacFit && this._pointers.size >= 2) { const [a, b] = [...this._pointers.values()]; this._pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom: this._zoom }; } else { this._pinchStart = null; } if (this._pointers.size === 0) { this._panStart = null; this._panLock = null; } } if (this._touchContacts.size === 0) this._touchSequenceMultitouch = false; } private _showTip( ev: PointerEvent, title: string, meta: string, lqi?: number | null, temp?: number | null, hum?: number | null, ): void { this._notePointer(ev); if (!this._pointerModality.hoverEnabled) return; if (this._drag) return; this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum }; } // ================= ROOM MARKUP EDITOR ================= private get _gridPitch(): number { // NORM_W / GRID_N and nothing else. The infinite canvas did NOT touch this // (docs/CANVAS.md §9): the step is the same for every plan, at every zoom, // whatever the content frame happens to be — so no existing plan's nodes // ever moved out from under it. return GRID_PITCH; } /** cm represented by one grid cell for the current space (default 5). */ private get _cellCm(): number { const v = Number(this._curSpaceCfg?.cell_cm); return Number.isFinite(v) && v > 0 ? v : 5; } /** Human-readable length of a segment (render units) using the HA unit system. */ private _fmtLen(a: number[], b: number[]): string { const cm = segmentCm(a, b, this._gridPitch, this._cellCm); return formatLength(cm, this.hass?.config?.unit_system?.length === 'mi'); } private get _curSpaceCfg(): any { // HP-1550-01: while a resize drag is live, every render-path reader sees // the preview overlay; _writeConfig deliberately reads _serverCfg instead, // so a queued write can never pick the preview up. const pv = this._rszPreview; if (pv && pv.space === this._space) return pv.sp; return this._serverCfg?.spaces.find((s: any) => s.id === this._space); } /** The config AS RENDERED: _serverCfg with the live resize preview substituted in. */ private get _renderCfg(): ServerConfig | null { const pv = this._rszPreview; if (!pv || !this._serverCfg) return this._serverCfg; return { ...this._serverCfg, spaces: this._serverCfg.spaces.map((s: any) => (s.id === pv.space ? pv.sp : s)), }; } private get _spaceH(): number { const sp = this._curSpaceCfg; return NORM_W; // square canvas } /** * Walls of the current space in render units — DERIVED from the room outlines. * There is no standalone "line" entity: every wall belongs to a closed room, and a * wall shared with a neighbour survives deleting either room (the other still yields it). */ private get _segments(): number[][] { const sp = this._curSpaceCfg; const H = this._spaceH; return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]); } private _savedNav(): { space?: string } | null { if (this._hasFixedFloor) return null; try { return JSON.parse(localStorage.getItem(LS_NAV) || 'null'); } catch { return null; } } private _saveNav(): void { if (this._hasFixedFloor) return; try { // Writing on every mode/space change also migrates legacy // `{ space, mode }` records by dropping their obsolete editor field. localStorage.setItem(LS_NAV, JSON.stringify({ space: this._space })); } catch { /* private mode etc. */ } } /** * A route change is a real departure from the card, unlike a same-route * Lovelace remount. End the editing session before its old warm slot can be * adopted on return. Space remains persisted; editor mode, selections, * dialogs and editor viewport do not. */ private _leaveCardRoute(): void { if (this._routeDepartureHandled) return; this._routeDepartureHandled = true; // The destination page cannot display this decorative transition and may // disconnect us before its first measured frame. Commit View atomically so // the warm tombstone never records an editor camera under `mode: view`. if (this._mode !== 'view') this._setMode('view', false); this._pendingNavMode = null; this._geometryHistory.clear(); this._activeDraftId = null; this._resumeDraftBySpace = {}; this._draftSegmentCms = []; this._closingWallCm = null; this._drawWallField = null; this._showHidden = false; // A same-element reattach must not render an editor-only modal over View. this._tapConfirm = null; this._vacCalConfirm = null; this._decorEraseConfirm = null; this._openingInfo = null; this._closeInfoCard(); this._rulesDialog = null; this._alignDialog = null; this._backupImportDialog = null; this._backupExportDialog = null; this._settingsDialog = null; this._markerDialog = null; this._openingDialog = null; this._physicalDialog = null; this._wallDialog = null; this._backdropDialog = null; this._decorShapeDialog = null; this._decorTextDialog = null; this._mergeDialog = null; this._roomDialog = false; this._spaceDialog = null; this._importDialog = null; // `_warmPatch()` intentionally refuses to write under a different path, // because warmBootKey already belongs to the route we just left. Seal the // owned slot directly with the safe return state and discard its painted // editor fingerprint; a later instance may still reuse settled dimensions // and device metadata, but never the editor or its draft. const slot = this._warmSlot; if (slot?.owner === this._warmGen) { slot.vp = this._warmViewportState(); slot.dlg = null; slot.frameFingerprint = ''; clearTimeout(slot.evict); slot.evict = 0; } this._saveNav(); } private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void { this._endTabDrag(); // A mode command is newer than the editor remembered by a same-route warm // remount. Clear it before the same-mode early return: while can_write is // pending, Lit may still be presenting the previous editor DOM even though // the new instance already fails closed to `view`. Its close button must // cancel the deferred editor in one press, not let the server response // reopen it and force a second press (#95). this._pendingNavMode = null; const space = this._spaceModel(); if (!space) { this._cancelModeTransition(false); this._mode = 'view'; return; } if (this._kiosk && mode !== 'view') return; // wall devices never edit if (this._mode === mode) { if (!animate && mode === 'view' && this._modeTransitionBusy) { const from = this._currentModeVisual(mode); const targetZoom = this._modeTransitionTargetZoom; const targetCenterX = this._modeTransitionTargetCenterX; const targetCenterY = this._modeTransitionTargetCenterY; this._cancelModeTransition(false); this._commitViewModeAtomic(from, targetZoom, targetCenterX, targetCenterY); } return; } this._bootSoftCancel(); // navigation owns its own short, bounded transition if ((mode === 'plan' || mode === 'decor') && !this._norm) { this._showToast(this._t('toast.markup_needs_server')); return; } if (this._wallFaceBatch) this._roomDialogCancel(); if (this._mode === 'plan' && this._tool === 'draw' && !this._finishWallChain()) return; this._clearTransientHover(true); this._cancelDevicePressFeedback(); const previousMode = this._mode; const previousProjection = this._effectiveProjection(); const capturedVisual = this._currentModeVisual(previousMode); const retargeting = this._modeTransitionBusy; // The target toolbar replaces the old editor markup during the hidden // measurement frame. Give that incoming content its own coordinate on the // shared controller so editor-to-editor switches do not pop abruptly. const fromVisual = capturedVisual && previousMode !== 'view' && mode !== 'view' ? { ...capturedVisual, toolbarContentOpacity: this._reducedMotion ? 1 : 0.35 } : capturedVisual; this._cancelModeTransition(false); this._editorSecondary.closeForNavigation(); // A live decor transform is a transaction. Switching tabs must cancel and // restore it, not merely forget its pointer record after the config has // already been mutated by move/resize. if (this._decorMove || this._dtDrag || this._bdDrag) this._cancelDecorGesture(); const baseChanges = !space.bg && (mode === 'view') !== (previousMode === 'view'); // A running/preparing transition has not committed its target to `_zoom` // yet. Retarget from the painted frame, but preserve the latest camera // intent; otherwise rapid View -> editor A -> editor B resurrects the old // View zoom/center and settles somewhere a direct transition never would. const returningToEditor = retargeting && previousMode === 'view' && mode !== 'view' ? this._modeTransitionEditorCamera : null; let targetZoom = returningToEditor?.zoom ?? (retargeting ? this._modeTransitionTargetZoom : this._zoom); let targetCenterX = returningToEditor ? returningToEditor.centerX : retargeting ? this._modeTransitionTargetCenterX : fromVisual?.viewport.centerX; let targetCenterY = returningToEditor ? returningToEditor.centerY : retargeting ? this._modeTransitionTargetCenterY : fromVisual?.viewport.centerY; if (previousMode === 'view' && mode !== 'view' && !retargeting) { // remember the view-mode viewport: whatever zooming happens inside the // editors is a working tool, not what the user wants to see afterwards const v = this._view; this._viewModeSnap = { space: this._space, zoom: this._zoom, cx: v ? this._logicalViewCenter(previousProjection)?.x : undefined, cy: v ? this._logicalViewCenter(previousProjection)?.y : undefined, }; if (previousProjection === 'iso') { const logical = this._logicalViewCenter('iso'); targetCenterX = logical?.x; targetCenterY = logical?.y; this._view = null; this._mode = mode; this._applyView(this._zoom, logical?.x, logical?.y); } if (baseChanges) { targetZoom = 1; targetCenterX = undefined; targetCenterY = undefined; } } if (previousMode === 'plan' && this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId; this._mode = mode; this._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode; if (mode === 'view') { if (previousMode !== 'view' && !retargeting) { this._modeTransitionEditorCamera = { zoom: this._zoom, centerX: fromVisual?.viewport.centerX, centerY: fromVisual?.viewport.centerY, }; } const snap = this._viewModeSnap; // restore the snapshot only for the space it was taken in if (snap && snap.space === this._space) { targetZoom = snap.zoom; const targetProjection = this._labsIso && this._viewPreference[this._space] === 'iso' ? 'iso' : 'flat'; const center = snap.cx != null && snap.cy != null && targetProjection === 'iso' ? projectPlanPoint([snap.cx, snap.cy], 0) : null; targetCenterX = center?.[0] ?? snap.cx; targetCenterY = center?.[1] ?? snap.cy; } else if (snap) { // HP-1543-01: the floor CHANGED inside the editor — the snapshot // belongs to the floor the editor was entered from, while _zoom still // carries the editor's working zoom for the floor we are exiting on. // Dropping the snapshot alone left that editor zoom (say 500%) on // screen in view mode. The per-space store was never polluted // (_saveZoom is view-only), so the standard centred // _restoreZoom() puts back this floor's saved VIEW viewport. Its rAF // reads _zoomBySpace, not the snapshot, so the same-tick floor-tab // race the view-only guard protects against stays closed. targetZoom = this._zoomBySpace[this._space] || 1; targetCenterX = undefined; targetCenterY = undefined; } } else { this._modeTransitionEditorCamera = { zoom: targetZoom, centerX: targetCenterX, centerY: targetCenterY, }; } this._modeTransitionTargetZoom = targetZoom; this._modeTransitionTargetCenterX = targetCenterX; this._modeTransitionTargetCenterY = targetCenterY; if (previousProjection === 'iso' || (mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso')) { this._modeTransitionForceAtomic = true; } const request = ++this._modeTransitionRequest; if (!animate) { // The only non-animated caller is route departure; keep the guard local // so a future editor caller cannot accidentally use View geometry. if (mode === 'view') { this._commitViewModeAtomic(fromVisual, targetZoom, targetCenterX, targetCenterY); } } else if (fromVisual) { this._modeTransitionPreparing = true; this._modeTransitionVisual = fromVisual; this._prepareModeTransition( request, fromVisual, mode, targetZoom, targetCenterX, targetCenterY, ); } else { // A zero-sized stage cannot animate. Keep the functional fallback // atomic and publish the exact target as soon as it is measurable. this._modeTransitionPreparing = false; this._modeTransitionVisual = null; this._zoom = targetZoom; this._view = null; requestAnimationFrame(() => { if (!this.isConnected || request !== this._modeTransitionRequest || this._mode !== mode) return; this._applyView(targetZoom, targetCenterX, targetCenterY); if (mode === 'view') { this._viewModeSnap = null; this._modeTransitionEditorCamera = null; } this.requestUpdate(); }); } this._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(true); this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._tool = 'draw'; this._mergeSel = null; this._mergeDialog = null; this._splitSel = null; this._pendingSplit = null; this._selId = null; this._physicalSel = null; this._physicalDialog = null; this._physicalDrag = null; this._rszSel = null; this._rszDrag = null; this._rszLive = null; this._rszPreview = null; this._tip = null; this._hoverRoom = null; this._decorDraft = null; this._decorSel = null; this._decorMove = null; this._backdropDialog = null; // Every Background session starts predictably on Select. The image has an // explicit tool of its own; only that tool may claim its body or frame. this._decorTool = 'select'; this._bdDrag = null; this._dtDrag = null; this._dtBox = null; if (mode === 'plan') { this._primeDrawWallField(); this._resumeLastDraft(); } this._saveNav(); } /** Prime the Draw thickness field to 15 cm once per Plan session. */ private _primeDrawWallField(): void { if (this._drawWallField === null) { this._drawWallField = cmToField(DRAW_WALL_DEFAULT_CM, this._imperial); } } private get _drawWallFieldValue(): string { if (this._drawWallField === null) { return cmToField(DRAW_WALL_DEFAULT_CM, this._imperial); } return this._drawWallField; } private get _drawWallCm(): number | null { const raw = strictNumber(this._drawWallFieldValue); if (raw == null || raw <= 0) return null; const cm = this._imperial ? raw * 2.54 : raw; const max = this._tool === 'column' ? COLUMN_MAX_CM : 100; return cm >= 1 && cm <= max ? cm : null; } private get _drawWallMaxCm(): number { return this._tool === 'column' ? COLUMN_MAX_CM : 100; } private _showPhysicalRange(max = this._drawWallMaxCm): void { this._showToast(this._t('toast.physical_range', { max: cmToField(max, this._imperial), unit: this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm'), })); } private _draftSegmentCount(sp = this._curSpaceCfg as any): number { return (sp?.room_drafts || []).reduce( (sum: number, d: any) => sum + (Array.isArray(d.segments) ? d.segments.length : 0), 0, ); } /** * Finish the active Walls chain because the user changed tool/mode/space. * A saved draft is crash safety only; once explicitly finished, its segments * become ordinary selectable partitions and can never auto-resume. */ /** * Collapse collinear partitions of one thickness (issue #229). * * `seedIds` confines the sweep to the connected component containing the * freshly drawn chain; the optimiser calls the pure module without seeds. * Openings ride along: their position is stored as a fraction of the host's * length, so every merge has to rewrite both the fraction and the legacy * `x/y/angle` projection that older readers still consume (#132). */ private _mergeSpacePartitions(sp: any, seedIds?: string[]): number { const partitions = (sp?.partitions || []) as PartitionCfg[]; if (partitions.length < 2) return 0; const result = mergeCollinearPartitions(partitions, { pitch: GRID_STEP_N, seedIds, geometry: spaceMergeGeometry(sp, { excludeDraftId: this._activeDraftId }), }); if (!result.merged) return 0; sp.partitions = result.partitions; applyOpeningMoves(sp.openings, sp.partitions, result.openingMoves, { coordScale: NORM_W, cellCm: this._cellCm, gridPitch: this._gridPitch, }); return result.merged; } private _finishWallChain(): boolean { if (this._tool !== 'draw' || this._wallFaceBatch || this._roomDialog) return true; const sp = this._curSpaceCfg as any; if (!sp || this._path.length < 2) { this._path = []; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._clearPlanSnapHover(); return true; } const segments = wallChainSegments( this._path, chainSegmentCms( this._path.length - 1, this._draftSegmentCms, this._drawWallCm, DRAW_WALL_DEFAULT_CM, ), ); const segmentCount = segments.length; if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) { this._showToast(this._t('toast.physical_limit')); return false; } const before = this._geometrySnapshot(); sp.partitions ||= []; const seed = Date.now().toString(36); const drawnIds: string[] = []; const activeDraft = this._activeDraftId ? (sp.room_drafts || []).find((draft: any) => draft.id === this._activeDraftId) : null; const lineage = this._draftSegmentsForPath( this._path, activeDraft, segments.map((segment) => segment.cm), ); for (let i = 0; i < segmentCount; i++) { const segment = segments[i]; const id = lineage[i]?.id || `partition-${seed}-${i}`; drawnIds.push(id); sp.partitions.push({ id, a: [segment.a[0] / NORM_W, segment.a[1] / NORM_W], b: [segment.b[0] / NORM_W, segment.b[1] / NORM_W], cm: segment.cm, }); } // A straight run drawn in several clicks is one wall, not five (#229). // Only what this chain touches is merged: what has piled up earlier waits // for «Optimise plans», where the sweep is explicit and undoable. this._mergeSpacePartitions(sp, drawnIds); if (this._activeDraftId && Array.isArray(sp.room_drafts)) { sp.room_drafts = sp.room_drafts.filter((draft: any) => draft.id !== this._activeDraftId); if (!sp.room_drafts.length) delete sp.room_drafts; } this._commitPhysicalGeometry(this._t('history.wall_chain_finish'), before); delete this._resumeDraftBySpace[this._space]; this._path = []; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._clearPlanSnapHover(); return true; } /** One transition gate for Plan toolbar tools. */ private _activateMarkupTool(tool: MarkupTool): void { if (tool === this._tool) return; if (this._wallFaceBatch) this._roomDialogCancel(); if (this._tool === 'draw' && !this._finishWallChain()) return; else this._cancelPath(); this._tool = tool; if (tool === 'draw') this._resumeLastDraft(); if (tool === 'resize') this._rszSel = null; if (tool === 'boundary' || tool === 'wallthick') this._wallDialog = null; } private _limitReached(kind: 'draft' | 'partition' | 'column'): boolean { const sp = this._curSpaceCfg as any; if (!sp) return true; const reached = kind === 'draft' ? (sp.room_drafts || []).length >= MAX_ROOM_DRAFTS : kind === 'partition' ? (sp.partitions || []).length >= MAX_PARTITIONS : (sp.wall_columns || []).length >= MAX_WALL_COLUMNS; if (reached) this._showToast(this._t('toast.physical_limit')); return reached; } private _svgPoint(ev: MouseEvent): number[] { const stage = this.renderRoot.querySelector('.stage') as HTMLElement; const r = stage.getBoundingClientRect(); return this._screenToVb(ev.clientX - r.left, ev.clientY - r.top); } /** * THE snap (docs/CANVAS.md §9). Every editor gesture that produces a plan * coordinate goes through here, so "strictly on the grid" is one function * and not a habit. No modifier bypasses it. The canvas clamp rides along: there are no edges * to bump into any more, only the ±5000 the backend refuses to store. */ private _snap(p: number[]): number[] { const g = this._gridPitch; return [clampCanvasR(snapToGrid(p[0], g)), clampCanvasR(snapToGrid(p[1], g))]; } /** Grid snap for the free end of the wall currently being drawn. */ private _snapDrawPoint(p: number[], lock45 = false): number[] { const anchor = this._path[this._path.length - 1]; const candidate = lock45 && anchor ? snapSegment45(anchor, p, this._gridPitch, SANE_LIMIT) : p; return this._snap(candidate); } /** Canonical physical opening slots on room-wall centrelines. */ private _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] { if (!this._openingsR.length) return []; const index = this._openingWallIndexFor(space, openCuts).value; const cuts: number[][] = []; for (const input of this._roomWallOpeningInputs(this._openingsR, space)) { const association = resolveOpeningWallAssociation(index, input); if (!association.negative && !association.positive) continue; const rad = input.angle * Math.PI / 180; const dx = Math.cos(rad) * input.length / 2; const dy = Math.sin(rad) * input.length / 2; cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]); } return cuts; } /** Static presentation axes shared by the overlay and its hit resolver. */ private _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } { const space = this._spaceModel(); if (!space) { return { key: `${this._space}|empty`, value: { segments: [], endpoints: [] } }; } const key = [ this._space, this._cfgEpoch, this._activeDraftId || '', space.rooms.length, space.room_drafts.length, space.partitions.length, ].join('|'); if (this._planSnapGeometryCache?.key === key) return this._planSnapGeometryCache; const openCuts = this._openCuts(); const value = buildPlanSnapGeometry({ space, activeDraftId: this._activeDraftId, roomCuts: [...openCuts, ...this._planSnapOpeningCuts(space, openCuts)], partitionCuts: this._partitionOpeningCuts(space), epsilon: this._gridPitch * 0.0002, }); this._planSnapGeometryCache = { key, value }; return this._planSnapGeometryCache; } /** Independent sources hidden under another wall, without snap deduplication. */ private _hiddenWallDiagnosticSnapshot(): { key: string; value: HiddenWallDiagnosticGeometry; } { const space = this._spaceModel(); if (!space) { return { key: `${this._space}|hidden-empty`, value: { segments: [], endpoints: [] } }; } const key = [ 'hidden', this._space, this._cfgEpoch, this._activeDraftId || '', space.rooms.length, space.room_drafts.length, space.partitions.length, ].join('|'); if (this._hiddenWallDiagnosticCache?.key === key) { return this._hiddenWallDiagnosticCache; } const value = buildHiddenWallDiagnosticGeometry({ space, activeDraftId: this._activeDraftId, epsilon: this._gridPitch * 0.0002, }); this._hiddenWallDiagnosticCache = { key, value }; return this._hiddenWallDiagnosticCache; } /** * Room-face topology deliberately ignores door/window/gate/passage slots: * an opening cuts masonry but does not remove the owning wall from a room * contour (#185). Real open_spans remain structural gaps. Keeping this * snapshot separate preserves the established overlay/snap presentation. */ private _planStructuralGeometrySnapshot(): { key: string; value: PlanSnapGeometry } { const space = this._spaceModel(); if (!space) { return { key: `${this._space}|structural-empty`, value: { segments: [], endpoints: [] } }; } const key = [ 'structural', this._space, this._cfgEpoch, this._activeDraftId || '', space.rooms.length, space.room_drafts.length, space.partitions.length, ].join('|'); if (this._planStructuralGeometryCache?.key === key) { return this._planStructuralGeometryCache; } const value = buildPlanSnapGeometry({ space, activeDraftId: this._activeDraftId, roomCuts: this._openCuts(), epsilon: this._gridPitch * 0.0002, }); this._planStructuralGeometryCache = { key, value }; return this._planStructuralGeometryCache; } private _planSnapContextKey(geometryKey: string): string { const first = this._path[0]; const anchor = this._path[this._path.length - 1]; return [ geometryKey, this._tool, this._path.length, first ? `${first[0]},${first[1]}` : '', anchor ? `${anchor[0]},${anchor[1]}` : '', ].join('|'); } private _resolvePlanDrawPoint( raw: number[], lock45: boolean, ): { point: number[]; candidate: PlanSnapCandidate | null; conflicts: PlanSnapEndpoint[]; ambiguous: boolean; contextKey: string; } { const snapshot = this._planSnapGeometrySnapshot(); const anchor = this._path[this._path.length - 1]; const closure = this._tool === 'draw' && this._path.length >= 3 ? [{ point: this._path[0], key: 'closure:first-point' }] : []; const options = { tolerance: this._cssPxToRender(12), distinguishTolerance: this._cssPxToRender(8), gridStep: this._gridPitch, excludePoints: anchor ? [anchor] : [], extraEndpoints: closure, epsilon: this._gridPitch * 0.0002, }; const resolution = lock45 && anchor ? resolveStrictPlanSnap(snapshot.value, raw, { ...options, anchor }) : resolvePlanSnapResult(snapshot.value, raw, options); const candidate = resolution.kind === 'resolved' ? resolution.candidate : null; const snapped = candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45); const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped; // A nearby topology endpoint loses snap ownership when the drafting rule // moves the actually persisted point away from it. Preview and click must // describe the exact same geometry (#290). const effectiveCandidate = candidate && samePoint(point, candidate.point) ? candidate : null; return { point, candidate: effectiveCandidate, conflicts: resolution.kind === 'ambiguous' ? resolution.conflicts : [], ambiguous: resolution.kind === 'ambiguous', contextKey: this._planSnapContextKey(snapshot.key), }; } private get _activePlanSnapCandidate(): PlanSnapCandidate | null { if (!this._markup || this._tool !== 'draw') return null; const hover = this._planSnapHover; if (!hover) return null; const snapshot = this._planSnapGeometrySnapshot(); return hover.contextKey === this._planSnapContextKey(snapshot.key) ? hover.candidate : null; } private get _activePlanSnapConflicts(): PlanSnapEndpoint[] { if (!this._markup || this._tool !== 'draw') return []; const hover = this._planSnapHover; if (!hover) return []; const snapshot = this._planSnapGeometrySnapshot(); return hover.contextKey === this._planSnapContextKey(snapshot.key) ? hover.conflicts : []; } private _clearPlanSnapHover(clearCursor = true): void { this._planSnapHover = null; this._syncPlanSnapActiveMarker(null); this._syncPlanSnapConflictMarkers([]); if (clearCursor) this._cursorPt = null; } private _samePt(a: readonly number[], b: readonly number[]): boolean { return samePoint(a, b); } /** * Room-boundary walls are derived from rooms, so the legacy per-space * `segments` array is dead weight. Independent walls live in the typed * `partitions` collection and must never be confused with that legacy field. */ private _dropLegacySegments(): void { // «Ripple only» was removed from the UI: keep old configs readable, then // materialise the recognisable icon+activity presentation on any write. for (const marker of this._serverCfg?.markers || []) { if (marker.display === 'ripple') marker.display = 'icon_ripple'; } for (const sp of this._serverCfg?.spaces || []) { delete (sp as any).segments; const physicalIds = new Set(); const validId = (id: any): id is string => typeof id === 'string' && id.length >= 1 && id.length <= 64 && !physicalIds.has(id); const keepId = (id: string): boolean => { physicalIds.add(id); return true; }; const point = (p: any): p is number[] => Array.isArray(p) && p.length === 2 && p.every((v: any) => Number.isFinite(Number(v)) && Math.abs(Number(v)) <= CANVAS_LIMIT); if (Array.isArray((sp as any).partitions)) { (sp as any).partitions = (sp as any).partitions.filter((p: any) => p && validId(p.id) && point(p.a) && point(p.b) && Math.hypot(p.a[0] - p.b[0], p.a[1] - p.b[1]) > 1e-9 && keepId(p.id)) .map((p: any) => ({ ...p, cm: Math.max(1, Math.min(100, Number(p.cm) || 15)) })); if (!(sp as any).partitions.length) delete (sp as any).partitions; } if (Array.isArray((sp as any).wall_columns)) { (sp as any).wall_columns = (sp as any).wall_columns.filter((c: any) => c && validId(c.id) && point(c.center) && keepId(c.id)) .map((c: any) => ({ id: c.id, shape: c.shape === 'circle' ? 'circle' : 'square', center: [Number(c.center[0]), Number(c.center[1])], cm: clampColumnCm(Number(c.cm) || 15), ...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }), })); if (!(sp as any).wall_columns.length) delete (sp as any).wall_columns; } if (Array.isArray((sp as any).room_drafts)) { (sp as any).room_drafts = (sp as any).room_drafts.filter((d: any) => d && validId(d.id) && Array.isArray(d.points) && d.points.length >= 2 && d.points.every(point) && keepId(d.id)) .map((d: any) => { const sanitized = sanitizeRoomDraftPath(d); return { id: d.id, ...sanitized }; }) .filter((d: any) => d.points.length >= 2); if (!(sp as any).room_drafts.length) delete (sp as any).room_drafts; } if (Array.isArray(sp.walls)) { const cuts = sanitizeOpenSpans((sp as any).open_spans) .map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]); sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, cuts); if (!sp.walls.length) delete sp.walls; } } } /** * Config writes are serialized (HP-1454-03). * * The debounce only spaced out the *starts*. If a write took longer than * 500 ms — a busy instance, a slow link — the next edit went out with the * same `expected_rev`, the server accepted the first and rejected the second * as a conflict, and the conflict handler reloaded the server copy over the * local one. The user's second edit was gone, with a toast that blamed * another window when there was none. * * One chain, one write in flight. A write always reads `_serverCfg` at the * moment it runs, so edits made while another write was out are carried by * the next one, with the revision that write returned. */ private _writesPending = 0; private _writeChain: Promise = Promise.resolve(); private _pendingPhysicalWrites = new Map(); /** * A rejected config/set accepted no part of its payload. Restore every * physical space carried by that request to the earliest server-backed * snapshot, including newer edits made on the same unaccepted base while the * request was in flight. Ambiguous persisted walls are never inspected or * cleaned here: this is transaction rollback, not repair (#314). */ private _rollbackRejectedPhysicalWrites( entries: Array<[string, { fingerprint: string; before: SpaceGeometryState }]>, ): boolean { if (!this._serverCfg || !entries.length) return false; let restored = false; for (const [spaceId] of entries) { const pending = this._pendingPhysicalWrites.get(spaceId); if (!pending) continue; restored = this._restoreGeometryStateInConfig(this._serverCfg, pending.before) || restored; this._pendingPhysicalWrites.delete(spaceId); } if (!restored) return false; this._clearGeometryGesture(); this._geometryHistory.clear(); this._cfgEpoch++; this._modelCache = null; this._wallUnionCache = null; this._physicalBodiesCache = null; this._frame = null; this._regSignature = ''; this._cfgContentFingerprint = contentFingerprint(this._serverCfg); this._maybeRebuildDevices(); this.requestUpdate(); return true; } /** Revalidate the synchronous rollback after every write already queued. */ private async _reloadRejectedPhysicalWrite(): Promise { const queued = this._writeChain; await queued.catch(() => undefined); await this._reloadConfigOnly(true); } /** A config write is in flight — the card must not adopt a server revision. */ private get _cfgWriting(): boolean { return this._writesPending > 0; } private _writeConfig(): Promise { this._writesPending++; this._writeChain = enqueueSerializedWrite(this._writeChain, async () => { if (!this._serverCfg) return; this._dropLegacySegments(); const candidate = canonicalizeConfigGeometry(this._serverCfg); const candidateFingerprint = contentFingerprint(candidate); const strictEntries = [...this._pendingPhysicalWrites.entries()]; for (const [spaceId, accepted] of strictEntries) { const candidateSpace = candidate.spaces.find((space: any) => space.id === spaceId); const exactFingerprint = spacePhysicalGeometryFingerprint(candidateSpace); if (exactFingerprint === accepted.fingerprint) continue; let safe = false; try { safe = this._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(accepted.before); this._pendingPhysicalWrites.delete(spaceId); this._geometryHistory.clear(); this._showToast(this._t('toast.geometry_unsafe')); throw Object.assign(new Error('unsafe wall geometry'), { code: 'geometry-unsafe' }); } accepted.fingerprint = exactFingerprint; } // Do not replace the reactive root merely because the pure helper // returned a clone. Besides an unnecessary render, that used to expose // unrelated write-time cleanup as a visual change (#224 review H1). // A real coordinate change still adopts the exact object sent below; // willUpdate owns the corresponding geometry-epoch bump. if (candidateFingerprint !== contentFingerprint(this._serverCfg)) { this._serverCfg = candidate; } this._cfgContentFingerprint = candidateFingerprint; let r: any; try { r = await this.hass.callWS({ type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev, }); } catch (error) { const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries); if (!rolledBack) throw error; // HA normally rejects with an Error-like object, but integrations and // test doubles may reject with a string. Never lose the rollback tag: // the caller still has to schedule an authoritative re-read after the // synchronous, fail-closed restoration (#314). const failure: any = error && typeof error === 'object' ? error : Object.assign(new Error(String(error)), { cause: error }); failure.physicalGeometryRolledBack = true; throw failure; } this._cfgRev = r?.rev ?? this._cfgRev + 1; for (const [spaceId, accepted] of strictEntries) { if (this._pendingPhysicalWrites.get(spaceId)?.fingerprint === accepted.fingerprint) this._pendingPhysicalWrites.delete(spaceId); } }); const mine = this._writeChain.finally(() => { this._writesPending--; }); // keep the chain itself unadorned so the next link waits for the write only return mine; } /** * Every mutation path ends here, so this is the one place that can invalidate * the geometry caches synchronously. The WRITE stays debounced; the epoch bump * must not be (audit L1: a bump inside the debounce arrives 500 ms after the * render that needed it, and the plan renders stale geometry). */ private _saveConfig(): void { this._cfgEpoch++; this._saveConfigDebounced(); } /** Deep, immutable geometry state from the real config (never Resize preview). */ private _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryState | null { const sp = config?.spaces?.find((s: any) => s.id === spaceId); if (!sp) return null; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); const plan_transform: SpaceGeometryState['plan_transform'] = {}; for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) { if (sp[key] !== undefined) plan_transform[key] = sp[key]; } return { spaceId, rooms: copy(sp.rooms || []), ...(Array.isArray(sp.openings) ? { openings: copy(sp.openings) } : {}), ...(Array.isArray(sp.walls) ? { walls: copy(sp.walls) } : {}), ...(Array.isArray(sp.wall_segments) ? { wall_segments: copy(sp.wall_segments) } : {}), ...(Array.isArray((sp as any).open_spans) ? { open_spans: copy((sp as any).open_spans) } : {}), ...(Array.isArray((sp as any).room_drafts) ? { room_drafts: copy((sp as any).room_drafts) } : {}), ...(Array.isArray((sp as any).partitions) ? { partitions: copy((sp as any).partitions) } : {}), ...(Array.isArray((sp as any).wall_columns) ? { wall_columns: copy((sp as any).wall_columns) } : {}), ...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}), plan_transform, }; } private _geometrySnapshot(spaceId = this._space): SpaceGeometryState | null { return this._geometrySnapshotFromConfig(this._serverCfg, spaceId); } /** Finish one geometry transaction and invalidate the redo branch. */ private _recordGeometry(name: string, before: SpaceGeometryState | null): void { if (!before) return; const after = this._geometrySnapshot(before.spaceId); if (!after || JSON.stringify(before) === JSON.stringify(after)) return; this._geometryHistory.push({ name, before, after }); this.requestUpdate(); } /** Replace only persisted geometry in memory; no history entry and no WS. */ private _restoreGeometryStateInConfig( config: any, state: SpaceGeometryState, preserveIdentityHints = false, ): boolean { const sp = config?.spaces?.find((space: any) => space.id === state.spaceId); if (!sp) return false; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); const oldRooms = new Map((sp.rooms || []).map((room: any) => [room.id, room])); sp.rooms = copy(state.rooms); if (preserveIdentityHints) { for (const room of sp.rooms) { const old: any = oldRooms.get(room.id); if (!Array.isArray(room.wall_ids) && Array.isArray(old?.wall_ids) && old.wall_ids.length === room.poly?.length) room.wall_ids = copy(old.wall_ids); } } const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans' | 'room_drafts' | 'partitions' | 'wall_columns' | 'decor', value: unknown): void => { if (value !== undefined) (sp as any)[key] = copy(value); else if (!(preserveIdentityHints && key === 'wall_segments')) delete (sp as any)[key]; }; const oldOpenings = new Map((sp.openings || []).map((opening: any) => [opening.id, opening])); const oldDrafts = new Map((sp.room_drafts || []).map((draft: any) => [draft.id, draft])); assign('openings', state.openings); assign('walls', state.walls); assign('wall_segments', state.wall_segments); assign('open_spans', state.open_spans); assign('room_drafts', state.room_drafts); assign('partitions', state.partitions); assign('wall_columns', state.wall_columns); assign('decor', state.decor); if (preserveIdentityHints) { for (const opening of sp.openings || []) { const old: any = oldOpenings.get(opening.id); if (!opening.host && old?.host?.kind === 'wall') opening.host = copy(old.host); } for (const draft of sp.room_drafts || []) { const old: any = oldDrafts.get(draft.id); for (let index = 0; index < (draft.segments || []).length; index++) { if (!draft.segments[index]?.id && old?.segments?.[index]?.id) draft.segments[index].id = old.segments[index].id; } } } for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) delete (sp as any)[key]; Object.assign(sp, copy(state.plan_transform || {})); return true; } private _restoreGeometryStateLocal(state: SpaceGeometryState): boolean { if (!this._restoreGeometryStateInConfig(this._serverCfg, state)) return false; this._cfgEpoch++; this._modelCache = null; this._wallUnionCache = null; this._physicalBodiesCache = null; this._frame = null; this.requestUpdate(); return true; } /** Shared fail-closed transaction boundary for every physical writer. */ private _wallModelBlockerLabel(error: unknown): string { const reason = error instanceof WallSegmentModelError ? error.reason : 'invalid-room'; return this._t(`wall_model.reason.${reason}`); } private _commitPhysicalGeometry( name: string, before: SpaceGeometryState | null, additionalAuthoredPoints: readonly (readonly number[])[] = [], ): boolean { if (!before || !this._serverCfg) return false; const liveCandidate = this._serverCfg; const editedState = this._geometrySnapshotFromConfig(liveCandidate, before.spaceId); const liveSpace = liveCandidate.spaces.find((space: any) => space.id === before.spaceId); if (!liveSpace || spacePhysicalGeometryFingerprint(before) === spacePhysicalGeometryFingerprint(liveSpace)) return false; // Preserve #278's existing fail-closed guard. Identity materialisation may // repair/atomise a degraded legacy projection, but it must never be used to // make an otherwise rejected user edit look safe. let legacySafe = false; try { legacySafe = this._checkSpacePhysicalGeometry(liveCandidate, before.spaceId).ok; } catch { legacySafe = false; } if (!legacySafe) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); return false; } let committedCandidate: any; let historyBefore = before; try { // ADR 282 Stage 1: every structural writer crosses the same atomic // canonicalisation/identity barrier. The pure candidate is adopted only // after every space migrated successfully, so a blocker leaves the live // config byte-equivalent. if (Number(liveCandidate.model_version || 0) < 8) { // The first edit of a v7 document must derive identity from the // pre-edit carrier, not from its already moved/split coordinates. // Materialise that baseline locally, then replay the edited legacy // projection over it so lineage and Undo share the same stable IDs. const baselineSource = JSON.parse(JSON.stringify(liveCandidate)); if (!this._restoreGeometryStateInConfig(baselineSource, before)) throw new WallSegmentModelError('invalid-room', before.spaceId); const baseline = commitWallSegmentModel(baselineSource).config; historyBefore = this._geometrySnapshotFromConfig(baseline, before.spaceId) || before; const editedWithIdentity = JSON.parse(JSON.stringify(baseline)); if (!editedState || !this._restoreGeometryStateInConfig( editedWithIdentity, editedState, true, )) throw new WallSegmentModelError('invalid-room', before.spaceId); const baselineSpace = baseline.spaces.find((space: any) => space.id === before.spaceId); const editedSpace = editedWithIdentity.spaces.find((space: any) => space.id === before.spaceId); const lineageHints = fixedTopologyWallLineageHints( baselineSpace, before.rooms, editedSpace, ); committedCandidate = commitWallSegmentModel(editedWithIdentity, { lineageHints, lineageSpaceId: before.spaceId, }).config; } else committedCandidate = commitWallSegmentModel(liveCandidate).config; } catch (error) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.wall_model_migration_blocked', { reason: this._wallModelBlockerLabel(error), })); return false; } let safe = false; try { const afterSpace = committedCandidate.spaces.find((space: any) => space.id === before.spaceId); // A completed point of the active, grid-snapped chain is authored input, // even before its first draft record exists in `before`. Include that // transient carrier in the growth baseline; later conversion from draft // to room/partition must not count the same coordinate as newly derived. const authoredPoints = this._path.length >= 2 ? this._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W]) : []; authoredPoints.push(...additionalAuthoredPoints.map((point) => [point[0], point[1]])); safe = wallModelOffGridValueCount(afterSpace) <= wallModelOffGridValueCount(historyBefore, authoredPoints) && this._checkSpacePhysicalGeometry(committedCandidate, before.spaceId).ok; } catch { safe = false; } if (!safe) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); return false; } adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate); this._recordGeometry(name, historyBefore); const afterSpace = liveCandidate.spaces.find((space: any) => space.id === before.spaceId); const pending = this._pendingPhysicalWrites.get(before.spaceId); this._pendingPhysicalWrites.set(before.spaceId, { before: pending?.before || historyBefore, fingerprint: spacePhysicalGeometryFingerprint(afterSpace), }); this._saveConfig(); return true; } /** Drop every transient gesture before replacing committed geometry. */ private _clearGeometryGesture(): void { this._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(false); this._mergeSel = null; this._mergeDialog = null; this._splitSel = null; this._pendingSplit = null; this._wallFaceBatch = null; this._wallRepairDiagnostic = null; this._roomDeleteDialog = null; this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._wallDialog = null; this._physicalDialog = null; this._physicalSel = null; this._physicalDrag = null; this._physicalRotate = null; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._openingDialog = null; this._rszSel = null; this._rszDrag = null; this._rszPreview = null; this._rszLive = null; this._decorDraft = null; this._decorMove = null; this._dtDrag = null; this._bdDrag = null; } /** Cancel only the uncommitted first click of the Boundary tool. */ private _cancelBoundaryAnchor(): boolean { if (this._tool !== 'boundary' || !this._openWallAnchor) return false; this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._clearPlanSnapHover(); this.requestUpdate(); return true; } /** Browser/OS cancellation is an aborted transaction, never a commit. */ private _stagePointerCancel(ev: PointerEvent): void { clearTimeout(this._kioskHoldTimer); if (this._swipeStart?.id === ev.pointerId) this._swipeStart = null; if (this._physicalDrag?.pid === ev.pointerId || this._physicalRotate?.pid === ev.pointerId) { this._cancelPhysicalGesture(); return; } if (this._decorDraft?.pid === ev.pointerId) { this._decorDraft = null; this.requestUpdate(); return; } if (this._decorMove?.pid === ev.pointerId || this._dtDrag?.pid === ev.pointerId || this._bdDrag?.pid === ev.pointerId) { this._cancelDecorGesture(); return; } this._cancelBoundaryAnchor(); if (this._tool === 'opening') { this._cursorPt = null; this._clearOpeningPlacement(false); } else if (this._tool === 'draw') { this._clearPlanSnapHover(); } const viewportGestureEnded = !!this._pinchStart || !!this._panStart; this._pointers.delete(ev.pointerId); if (this._pointers.size < 2) this._pinchStart = null; if (this._pointers.size === 0) { this._panStart = null; this._panLock = null; } if (viewportGestureEnded && this._pointers.size === 0) this.requestUpdate(); } private _applyGeometryState( state: SpaceGeometryState, allowHistoryBoundaryRepair = false, ): boolean { if (!this._canCommitSpace(state.spaceId)) return false; const before = this._geometrySnapshot(state.spaceId); if (!before || !this._restoreGeometryStateLocal(state)) return false; const restoredCandidate = this._serverCfg; const physicalChanged = spacePhysicalGeometryFingerprint(before) !== spacePhysicalGeometryFingerprint(state); if (physicalChanged) { let safe = false; try { const check = restoredCandidate ? this._checkSpacePhysicalGeometry(restoredCandidate, state.spaceId) : null; safe = !!check?.ok || !!(allowHistoryBoundaryRepair && check?.reason === 'wall-degraded-extra'); } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); return false; } } let committedCandidate: any; try { committedCandidate = commitWallSegmentModel(restoredCandidate).config; } catch (error) { this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.wall_model_migration_blocked', { reason: this._wallModelBlockerLabel(error), })); return false; } if (physicalChanged) { let safe = false; try { const check = committedCandidate ? this._checkSpacePhysicalGeometry(committedCandidate, state.spaceId) : null; // A history snapshot can predate the write-time wall degradation that // canonicalized its command. Restore that one repairable baseline so // Undo remains byte-exact immediately; _writeConfig still degrades and // strictly validates the outbound candidate before it can leave the // card. Every other preflight failure stays fail-closed. safe = !!check?.ok || !!(allowHistoryBoundaryRepair && check?.reason === 'wall-degraded-extra'); } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); return false; } adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate); const afterSpace = restoredCandidate?.spaces.find((space: any) => space.id === state.spaceId); const pending = this._pendingPhysicalWrites.get(state.spaceId); this._pendingPhysicalWrites.set(state.spaceId, { before: pending?.before || before, fingerprint: spacePhysicalGeometryFingerprint(afterSpace), }); } else adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate); this._clearGeometryGesture(); if (this._space !== state.spaceId) { this._commitSpace(state.spaceId); this._saveNav(); this._restoreZoom(); } this._modelCache = null; this._frame = null; this._regSignature = ''; this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); return true; } private _undoGeometry = (): void => { if (this._cancelBoundaryAnchor()) return; if (this._physicalDrag || this._physicalRotate) { this._cancelPhysicalGesture(); return; } if (this._decorDraft) { this._decorDraft = null; this.requestUpdate(); return; } if (this._decorMove || this._dtDrag || this._bdDrag) { this._cancelDecorGesture(); return; } if (this._rszDrag) { this._rszCancelDrag(); return; } const command = this._geometryHistory.undo(); if (!command) return; if (!this._applyGeometryState(command.before, true)) { this._geometryHistory.clear(); return; } this._showToast(this._t('history.undone', { name: command.name })); }; private _redoGeometry = (): void => { // Redo and Undo share the same transaction boundary. The first invocation // cancels an in-progress transform; only the next one navigates history. if (this._cancelBoundaryAnchor()) return; if (this._physicalDrag || this._physicalRotate) { this._cancelPhysicalGesture(); return; } if (this._decorDraft) { this._decorDraft = null; this.requestUpdate(); return; } if (this._decorMove || this._dtDrag || this._bdDrag) { this._cancelDecorGesture(); return; } if (this._rszDrag) { this._rszCancelDrag(); return; } const command = this._geometryHistory.redo(); if (!command) return; if (!this._applyGeometryState(command.after, true)) { this._geometryHistory.clear(); return; } this._showToast(this._t('history.redone', { name: command.name })); }; private _saveConfigDebounced = debounce(() => { if (!this._serverCfg) return; this._writeConfig().catch((e: any) => { const physicalRollback = e?.physicalGeometryRolledBack === true; if (e?.code === 'geometry-unsafe') { return; } else if (e?.code === 'wall_model_client_outdated') { this._showToast(this._t('toast.wall_model_client_outdated')); } else if (e?.code === 'conflict') { // a real one now: another window wrote between our read and our write this._showToast(this._t('toast.conflict')); if (!physicalRollback) { this._cancelPath(); void this._reloadConfigOnly(true); } } else { this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) })); } if (physicalRollback) void this._reloadRejectedPhysicalWrite(); }); }, 500); /** * The room that strictly contains p. Being ON a wall does not count: neighbouring * rooms share walls, so new vertices legitimately land on existing outlines. */ private _roomAt(p: number[]): RoomCfg | undefined { return this._spaceModel()?.rooms.find((r) => { const poly = roomPoly(r); return !!poly && pointStrictlyInside(p, poly); }); } /** The first existing room the outline would overlap (rooms must not overlap). */ private _overlapRoom(verts: number[][]): RoomCfg | undefined { return this._spaceModel()?.rooms.find((r) => { const poly = roomPoly(r); return !!poly && roomsOverlap(verts, poly); }); } private _pointInRoom(p: number[], r: RoomCfg): boolean { if (r.poly) return pointInPolygon(p, r.poly); return ( r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h! ); } /** A room ring may meet only at the endpoints of neighbouring edges. */ private _contourSelfIntersects(poly: number[][]): boolean { if (!polyIsSimple(poly)) return true; // transverse crossings const n = poly.length; const eps = 0.001; for (let i = 0; i < n; i++) { const a = poly[i], b = poly[(i + 1) % n]; for (let j = i + 1; j < n; j++) { // Neighbours deliberately share one endpoint, including first ↔ last. if (j === i + 1 || (i === 0 && j === n - 1)) continue; const c = poly[j], d = poly[(j + 1) % n]; const cd = [c[0], c[1], d[0], d[1]]; const ab = [a[0], a[1], b[0], b[1]]; // This also catches a closing edge passing through an older vertex and // collinear overlap, which a proper-crossing test intentionally omits. if (distToSegment(a, cd) <= eps || distToSegment(b, cd) <= eps || distToSegment(c, ab) <= eps || distToSegment(d, ab) <= eps) return true; } } return false; } /** The same append limits guard both an ordinary point and an auto-close terminal point. */ private _canAppendRoomDraftPoint(): boolean { if (this._drawWallCm == null) { this._showPhysicalRange(100); return false; } if (this._path.length >= MAX_DRAFT_POINTS) { this._showToast(this._t('toast.physical_limit')); return false; } const spCfg = this._curSpaceCfg as any; const newDraft = !this._activeDraftId; if ((newDraft && (spCfg?.room_drafts || []).length >= MAX_ROOM_DRAFTS) || this._draftSegmentCount(spCfg) >= MAX_DRAFT_SEGMENTS // Every accepted segment must be finishable later without trapping // the user at the partition limit. || (spCfg?.partitions || []).length + this._path.length > MAX_PARTITIONS) { this._showToast(this._t('toast.physical_limit')); return false; } return true; } private _markupClick(ev: MouseEvent): void { if (this._vacFit) return; // the fit overlay owns all pointer input if (!this._markup) return; const space = this._spaceModel(); if (!space) { this._clearGeometryGesture(); return; } // a pan or pinch just happened — the synthesized click is not a draw if (this._suppressClick) return; // Room cards swallow markup clicks: dragging, resizing or just clicking a // card must not feed the active tool (draw point, delete room, merge/split // pick, opening placement). The drag itself already stops pointer events, // but the synthesized `click` afterwards still bubbles to the stage. if (this._drag || this._rlResize) return; const path = (ev.composedPath?.() || []) as any[]; if (path.some((n) => n?.classList?.contains?.('roomlabel') || n?.classList?.contains?.('rlhandle'))) return; if (path.some((n) => n?.classList?.contains?.('physical-hit'))) { if (this._tool === 'boundary') this._showToast(this._t('toast.boundary_blocked')); return; } const raw = this._svgPoint(ev); if (this._tool === 'select') { this._physicalSel = null; return; } if (this._tool === 'resize') { // a click picks the room for the scale frame; handle drags never get here if (this._rszDrag || path.some((n) => n?.classList?.contains?.('rszhandle'))) return; const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); this._rszSel = room?.id || null; return; } if (this._tool === 'delroom') { this._deleteRoomClick(raw); return; } if (this._tool === 'opening') { this._openingClick(raw); return; } if (this._tool === 'merge') { this._mergeClick(raw); return; } if (this._tool === 'wallthick') { this._wallThickClick(raw); return; } if (this._tool === 'boundary') { this._boundaryClick(raw); return; } if (this._tool === 'split') { this._splitClick(raw); return; } if (this._tool === 'column') { this._columnClick(raw); return; } // Walls: every completed segment is crash-safe in room_drafts until an // explicit finish converts the chain or a confirmed face batch consumes it. this._wallRepairDiagnostic = null; const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey); if (resolved.ambiguous) { this._planSnapHover = { contextKey: resolved.contextKey, candidate: null, conflicts: resolved.conflicts, }; this._syncPlanSnapActiveMarker(null); this._syncPlanSnapConflictMarkers(resolved.conflicts); this._showToast(this._t('toast.plan_snap_ambiguous')); return; } let pt = resolved.point; if (ev.ctrlKey || ev.metaKey) { // The closure shortcut owns the gesture even before the chain has the // two existing edges required to form a room. Falling through here // would turn a refused Ctrl/Cmd+click into an ordinary drawing click. ev.preventDefault(); if (this._path.length < 3) return; // Preserve the established shortcut, but commit its closing wall through // the same graph/draft path as an ordinary click on the first node. pt = snapNearAxisEndpoint( this._path[this._path.length - 1], this._path[0], ); } // Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the // contour may become a nested room (a column, an inner room). Partial // overlaps are still rejected, but only at closing time, when the whole // outline is known (roomsOverlap treats full nesting as legal). if (!this._path.length) { // A free idle click can name a room inside masonry that was completed in // an earlier session. Shift and architectural snap hits retain the Walls // gesture, so this offer never steals an intentional new chain. if (!ev.shiftKey && !resolved.candidate && this._offerExistingWallFace(raw)) return; // After reload a saved open contour is resumed explicitly by clicking // either free end. Mid-segment branching is deliberately unsupported. const endHit = this._draftEndAt(pt); if (endHit) { this._activeDraftId = endHit.draft.id; this._resumeDraftBySpace[this._space] = endHit.draft.id; this._path = endHit.reverse ? [...endHit.draft.points].reverse().map((p) => [...p]) : endHit.draft.points.map((p) => [...p]); this._draftSegmentCms = this._adoptDraftCms( this._path, endHit.reverse ? [...endHit.draft.segments].reverse().map((s) => s.cm) : endHit.draft.segments.map((s) => s.cm), endHit.draft.id, ); return; } this._activeDraftId = null; this._draftSegmentCms = []; this._path = [pt]; return; } const last = this._path[this._path.length - 1]; if (this._samePt(pt, last)) return; // repeated click on the same point // A saved outline may be continued into another saved outline, but only // endpoint-to-endpoint. The two records become one atomic history step; // mid-segment branching remains deliberately unsupported. const join = this._draftEndAt(pt, this._activeDraftId || undefined); if (join) { this._mergeDraftEndpoint(join); return; } if (!this._canAppendRoomDraftPoint()) return; // Точка и толщина её отрезка пишутся вместе (#234). Раньше запись жила в // отдельном методе, который молча выходил при невалидном поле толщины, и // тогда `_draftSegmentCms` становился короче числа отрезков: превью // показывало текущее поле, а запись — 15 см. Инвариант читается прямо // здесь, а не выводится из двух проверок в разных местах. const cm = this._drawWallCm; if (cm == null) { this._showPhysicalRange(100); return; } const beforePath = this._path.map((point) => [...point]); this._path = [...this._path, pt]; this._draftSegmentCms = [...this._draftSegmentCms, cm]; this._persistActiveDraftSegment(); this._offerWallFaces(beforePath); } private _draftSegmentCms: number[] = []; private _closingWallCm: number | null = null; private _draftEndAt( pt: number[], excludeId?: string, ): { draft: RoomDraftCfg; reverse: boolean } | null { const space = this._spaceModel(); if (!space) return null; const view = this._viewOr(this._baseVb()); const eps = Math.max(this._gridPitch * 0.15, this._stageEl?.clientWidth ? (view.w / this._stageEl.clientWidth) * 12 : 0); for (const draft of space.room_drafts || []) { if (draft.id === excludeId) continue; if (draft.points.length < 2) continue; const first = draft.points[0], last = draft.points[draft.points.length - 1]; if (Math.hypot(pt[0] - last[0], pt[1] - last[1]) <= eps) return { draft, reverse: false }; if (Math.hypot(pt[0] - first[0], pt[1] - first[1]) <= eps) return { draft, reverse: true }; } return null; } private _mergeDraftEndpoint( hit: { draft: RoomDraftCfg; reverse: boolean }, ): void { const sp = this._curSpaceCfg as any; if (!sp || !this._path.length) return; const beforePath = this._path.map((point) => [...point]); // Capture topology before the records are merged: the other draft is a // static pre-existing source, not geometry introduced by this click. const beforeGraphSources = this._wallGraphSources(beforePath); const drafts = Array.isArray(sp.room_drafts) ? sp.room_drafts : []; const activeRaw = this._activeDraftId ? drafts.find((d: any) => d.id === this._activeDraftId) : null; const otherRaw = drafts.find((d: any) => d.id === hit.draft.id); if (!otherRaw) return; // `_draftEndAt.reverse` describes how to resume WITH the clicked end at // the tail. A merge needs that end at the head, hence the opposite order. const otherPoints = hit.reverse ? hit.draft.points.map((p) => [...p]) : [...hit.draft.points].reverse().map((p) => [...p]); const otherSegments = hit.reverse ? (otherRaw.segments || []).map((s: any) => ({ ...s })) : [...(otherRaw.segments || [])].reverse().map((s: any) => ({ ...s })); const last = this._path[this._path.length - 1]; const touching = this._samePt(last, otherPoints[0]); const connectorCm = touching ? null : this._drawWallCm; if (!touching && connectorCm == null) { this._showPhysicalRange(100); return; } const activeSegments = this._draftSegmentsForPath( this._path, activeRaw, this._draftSegmentCms, ); const mergedPoints = [ ...this._path.map((p) => [...p]), ...(touching ? otherPoints.slice(1) : otherPoints), ]; const mergedSegments = [ ...activeSegments, ...(connectorCm == null ? [] : [{ cm: connectorCm }]), ...otherSegments, ]; const closed = mergedPoints.length >= 4 && this._samePt(mergedPoints[0], mergedPoints[mergedPoints.length - 1]); const persistedPoints = mergedPoints; const persistedSegments = mergedSegments; if (persistedPoints.length > MAX_DRAFT_POINTS) { this._showToast(this._t('toast.physical_limit')); return; } const oldSegments = (activeRaw?.segments?.length || 0) + (otherRaw.segments?.length || 0); if (this._draftSegmentCount(sp) - oldSegments + persistedSegments.length > MAX_DRAFT_SEGMENTS) { this._showToast(this._t('toast.physical_limit')); return; } if (closed) { const ring = persistedPoints.slice(0, -1); if (this._contourSelfIntersects(ring) || polygonArea(ring) <= 1e-6) { this._showToast(this._t('toast.contour_cannot_close')); return; } const clash = this._overlapRoom(ring); if (clash) { this._showToast(this._t('toast.room_overlap', { name: clash.name || '' })); return; } } const before = this._geometrySnapshot(); const id = this._activeDraftId || hit.draft.id; const base = activeRaw || otherRaw; const saved = { ...base, id, points: persistedPoints.map((p) => [p[0] / NORM_W, p[1] / NORM_W]), segments: persistedSegments, }; sp.room_drafts = drafts.filter((d: any) => d.id !== hit.draft.id && (!this._activeDraftId || d.id !== this._activeDraftId)); sp.room_drafts.push(saved); this._activeDraftId = id; this._resumeDraftBySpace[this._space] = id; this._path = persistedPoints; this._draftSegmentCms = this._adoptDraftCms( persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id, ); this._physicalSel = null; this._commitPhysicalGeometry(this._t('history.draft_merge'), before); if (connectorCm != null) { this._offerWallFaces(beforePath, beforePath.length - 1, beforeGraphSources); } } /** * Thickness array adopted from storage, brought to the length of the path. * * A record written before #234 may be shorter than the path: the resolver * fills the gaps by the same rule the preview and the writers use, so a * resumed draft cannot carry a hidden 15 cm into the next save. Reported to * the console rather than to the user: the person did not cause it and cannot * fix it. */ private _adoptDraftCms(path: readonly (readonly number[])[], recorded: readonly (number | null | undefined)[], id?: string): number[] { const count = Math.max(0, path.length - 1); const resolved = chainSegmentCms(count, recorded, this._drawWallCm, DRAW_WALL_DEFAULT_CM); if (recorded.length !== count) { console.debug( `[houseplan] draft ${id ?? '?'}: восстановлено толщин ${count - recorded.length} (#234)`, ); } return resolved; } /** * Carry persisted draft identity by its physical carrier, not by array index. * Resuming a chain from its opposite end reverses the path, and positional * copying used to silently attach every ID to the neighbouring segment. */ private _draftSegmentsForPath( path: readonly (readonly number[])[], draft: any, cms: readonly (number | null | undefined)[], ): Array<{ id?: string; cm: number; [key: string]: any }> { const points = Array.isArray(draft?.points) ? draft.points.map((p: any) => [Number(p?.[0]) * NORM_W, Number(p?.[1]) * NORM_W]) : []; const stored = Array.isArray(draft?.segments) ? draft.segments : []; const resolved = chainSegmentCms( Math.max(0, path.length - 1), cms, this._drawWallCm, DRAW_WALL_DEFAULT_CM, ); return resolved.map((cm, index) => { const a = path[index], b = path[index + 1]; const oldIndex = points.findIndex((oldA: number[], candidate: number) => { if (candidate + 1 >= points.length) return false; const oldB = points[candidate + 1]; return (this._samePt(a, oldA) && this._samePt(b, oldB)) || (this._samePt(a, oldB) && this._samePt(b, oldA)); }); return { ...(oldIndex >= 0 ? stored[oldIndex] : {}), cm }; }); } /** * Persist every completed draft segment immediately. * * The thickness of the new segment is already recorded by the caller (#234): * this method must not decide whether to record it, or the array and the path * drift apart the moment the toolbar field is mid-edit. */ private _persistActiveDraftSegment(): void { if (this._path.length < 2 || !this._curSpaceCfg) return; const before = this._geometrySnapshot(); const sp = this._curSpaceCfg as any; sp.room_drafts ||= []; if (!this._activeDraftId) this._activeDraftId = 'draft-' + Date.now().toString(36); this._resumeDraftBySpace[this._space] = this._activeDraftId; const i = sp.room_drafts.findIndex((d: any) => d.id === this._activeDraftId); const saved = { ...(i >= 0 ? sp.room_drafts[i] : {}), id: this._activeDraftId, points: this._path.map((p) => [p[0] / NORM_W, p[1] / NORM_W]), segments: this._draftSegmentsForPath( this._path, i >= 0 ? sp.room_drafts[i] : null, this._draftSegmentCms, ), }; if (i >= 0) sp.room_drafts[i] = saved; else sp.room_drafts.push(saved); this._commitPhysicalGeometry(this._t('history.draft_segment'), before); } private _activeWallSourceKey(index: number): string { return `active:${this._activeDraftId || 'session'}:${index}`; } /** Canonical solid axes plus one immutable projection of the active chain. */ private _wallGraphSources(path: readonly (readonly number[])[]): WallGraphSourceSegment[] { const staticGeometry = this._planStructuralGeometrySnapshot().value; const sources: WallGraphSourceSegment[] = staticGeometry.segments.map((segment) => ({ a: segment.a, b: segment.b, key: `static:${segment.key}`, })); for (let i = 0; i + 1 < path.length; i++) { sources.push({ a: path[i], b: path[i + 1], key: this._activeWallSourceKey(i) }); } return sources; } private _wallFaceGraph( sources: readonly WallGraphSourceSegment[], epsilon: number, ): WallFaceGraph { // Exact JSON identity avoids stale topology. Source keys include real // open-span cuts/provenance; presentation opening cuts are intentionally // absent from the structural graph (#185). const key = `${this._space}|${epsilon}|${JSON.stringify(sources)}`; const cachedIndex = this._wallFaceGraphCache.findIndex((entry) => entry.key === key); if (cachedIndex >= 0) { const [cached] = this._wallFaceGraphCache.splice(cachedIndex, 1); this._wallFaceGraphCache.push(cached); return cached.value; } const value = buildWallFaceGraph(sources, epsilon); this._wallFaceGraphCache.push({ key, value }); if (this._wallFaceGraphCache.length > 4) this._wallFaceGraphCache.shift(); return value; } /** Detect click-induced faces and open a mutation-free decision queue. */ private _offerWallFaces( beforePath: number[][], addedSegmentIndex = this._path.length - 2, beforeGraphSources?: WallGraphSourceSegment[], ): void { if (this._path.length < 2 || this._wallFaceBatch || this._roomDialog) return; const space = this._spaceModel(); if (!space) return; const addedSourceKey = this._activeWallSourceKey(addedSegmentIndex); const epsilon = this._gridPitch * 0.0002; let faces: WallGraphFace[]; let after: WallGraphSourceSegment[]; try { const before = beforeGraphSources || this._wallGraphSources(beforePath); after = this._wallGraphSources(this._path); faces = findNewWallFacesInGraphs( this._wallFaceGraph(before, epsilon), this._wallFaceGraph(after, epsilon), addedSourceKey, ); } catch { // Topology is advisory. The just-persisted draft remains the source of // truth and drawing can continue when malformed legacy geometry exists. return; } if (!faces.length) { const repair = planWallFaceRepair(after, { requiredSourceKey: addedSourceKey, maxDistance: wallCmToUnits(2, this._cellCm, this._gridPitch), gridStep: this._gridPitch, epsilon, }); if (repair.kind === 'ambiguous') { this._wallRepairDiagnostic = repair.proposals[0] || null; this._showToast(this._t('toast.wall_repair_ambiguous')); return; } if (repair.kind === 'repair' && !this._overlapRoom(repair.face.ring)) { this._beginWallFaceBatch([{ ...repair.face, repair: repair.proposal }]); } return; } // A clean wall-to-wall cut is one product decision about the smaller part, // even though the planar delta contains both resulting faces. for (const room of space.rooms) { const poly = roomPoly(room); if (!room.id || !poly) continue; const parts = splitRoomPath(poly, this._path, this._gridPitch * 0.02); if (!parts) continue; const [p1, p2] = parts; const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2; const fresh = main === p1 ? p2 : p1; const matched = [...faces].sort((left, right) => Math.abs(left.area - polygonArea(fresh)) - Math.abs(right.area - polygonArea(fresh)) || left.key.localeCompare(right.key))[0]; if (!matched) return; this._beginWallFaceBatch([{ ...matched, ring: fresh.map((point) => [point[0], point[1]] as [number, number]), split: { roomId: room.id, mainPoly: main, newPoly: fresh }, consumeAllActive: true, }]); return; } const eligible = faces.filter((face) => { if (face.ring.length < 3 || this._contourSelfIntersects(face.ring)) return false; // roomsOverlap rejects exact duplicates and partial overlap, while full // nesting in either direction deliberately remains eligible. return !this._overlapRoom(face.ring); }); if (eligible.length) this._beginWallFaceBatch(eligible); } private _beginWallFaceBatch(candidates: WallFaceCandidate[]): void { this._wallRepairDiagnostic = null; this._wallFaceBatch = { candidates: [...candidates].sort((left, right) => left.area - right.area || left.key.localeCompare(right.key)), index: 0, decisions: [], activePath: this._path.map((point) => [...point]), activeCms: [...this._draftSegmentCms], activeDraftId: this._activeDraftId, }; this._clearPlanSnapHover(); this._nameSel = ''; this._areaSel = ''; this._resetRoomDialogFields(); this._roomDialog = true; } /** Offer the smallest unoccupied exact face under an idle Walls click. */ private _offerExistingWallFace(raw: number[]): boolean { if (this._path.length || this._wallFaceBatch || this._roomDialog) return false; const epsilon = this._gridPitch * 0.0002; try { const graph = this._wallFaceGraph(this._wallGraphSources([]), epsilon); let face = findWallFaceAtPoint(graph, raw, epsilon); let repair: WallFaceRepairProposal | undefined; if (!face) { const result = planWallFaceRepair(this._wallGraphSources([]), { point: raw, maxDistance: wallCmToUnits(2, this._cellCm, this._gridPitch), gridStep: this._gridPitch, epsilon, }); if (result.kind === 'ambiguous') { this._wallRepairDiagnostic = result.proposals[0] || null; this._showToast(this._t('toast.wall_repair_ambiguous')); return true; } if (result.kind === 'repair') { face = result.face; repair = result.proposal; } else if (result.kind === 'none') { const diagnostic = planWallFaceRepair(this._wallGraphSources([]), { point: raw, maxDistance: this._cssPxToRender(12), gridStep: this._gridPitch, epsilon, }); if (diagnostic.kind === 'repair') { this._wallRepairDiagnostic = diagnostic.proposal; this._showToast(this._t('toast.wall_repair_too_large')); return true; } } } if (!face || face.ring.length < 3 || this._contourSelfIntersects(face.ring) || this._overlapRoom(face.ring)) return false; this._beginWallFaceBatch([{ ...face, existing: !repair, repair }]); return true; } catch { return false; } } private _columnClick(raw: number[]): void { const center = this._snap(raw); const cm = this._drawWallCm; if (cm == null) { this._showPhysicalRange(COLUMN_MAX_CM); return; } if (!this._curSpaceCfg || this._limitReached('column')) return; const model = this._spaceModel(); if (!model) return; const candidate: WallColumnCfg = { id: 'column-' + Date.now().toString(36), shape: 'square', center, cm: clampColumnCm(cm), angle: 0, }; const duplicate = (model.wall_columns || []).find((c) => sameColumnPlacement(c, candidate, this._gridPitch * 0.02)); if (duplicate) { clearTimeout(this._duplicateColumnTimer); this._duplicateColumnId = duplicate.id; this._duplicateColumnTimer = window.setTimeout(() => { this._duplicateColumnId = null; }, 900); this._showToast(this._t('toast.column_duplicate')); return; } const before = this._geometrySnapshot(); const sp = this._curSpaceCfg as any; sp.wall_columns ||= []; sp.wall_columns.push({ ...candidate, center: [center[0] / NORM_W, center[1] / NORM_W] }); this._commitPhysicalGeometry(this._t('history.column_add'), before); } private _openPhysicalDialog( kind: 'partition' | 'column' | 'draft', id: string, segment?: number, ): void { const model = this._spaceModel(); if (!model) return; if (kind === 'partition') { const p = model.partitions.find((x) => x.id === id); if (p) this._physicalDialog = { kind, id, cm: cmToField(p.cm, this._imperial), length: this._fmtLen(p.a, p.b), }; } else if (kind === 'column') { const c = model.wall_columns.find((x) => x.id === id); if (c) this._physicalDialog = { kind, id, cm: cmToField(c.cm, this._imperial), shape: c.shape, angle: this._angleField(c.shape === 'square' ? canonicalColumnAngle(c.angle) : 0), }; } else { const d = model.room_drafts.find((x) => x.id === id); const i = Math.max(0, Math.min(d?.segments.length ? d.segments.length - 1 : 0, segment || 0)); if (d?.segments[i]) this._physicalDialog = { kind, id, segment: i, cm: cmToField(d.segments[i].cm, this._imperial), length: this._fmtLen(d.points[i], d.points[i + 1]), }; } } private _savePhysicalDialog = (): void => { const d = this._physicalDialog; const sp = this._curSpaceCfg as any; const model = this._spaceModel(); if (!d || !sp || !model) return; const raw = strictNumber(d.cm); if (raw == null) { this._showPhysicalRange(d.kind === 'column' ? COLUMN_MAX_CM : 100); return; } const cmRaw = this._imperial ? raw * 2.54 : raw; const max = d.kind === 'column' ? COLUMN_MAX_CM : 100; if (!Number.isFinite(cmRaw) || cmRaw < 1 || cmRaw > max) { this._showPhysicalRange(max); return; } if (d.kind === 'column') { const current = model.wall_columns.find((x) => x.id === d.id); if (!current) return; const rawAngle = strictNumber(d.angle || '0'); if (d.shape !== 'circle' && (rawAngle == null || rawAngle < 0 || rawAngle >= 90)) { this._showToast(this._t('toast.physical_angle')); return; } const candidate: WallColumnCfg = d.shape === 'circle' ? { id: d.id, shape: 'circle', center: current.center, cm: cmRaw } : { id: d.id, shape: 'square', center: current.center, cm: cmRaw, angle: rawAngle! }; if (model.wall_columns.some((c) => c.id !== d.id && sameColumnPlacement(c, candidate, this._gridPitch * 0.02))) { this._showToast(this._t('toast.column_duplicate')); return; } } const before = this._geometrySnapshot(); if (d.kind === 'partition') { const p = (sp.partitions || []).find((x: any) => x.id === d.id); if (p) p.cm = cmRaw; } else if (d.kind === 'column') { const c = (sp.wall_columns || []).find((x: any) => x.id === d.id); if (c) { c.cm = clampColumnCm(cmRaw); c.shape = d.shape === 'circle' ? 'circle' : 'square'; if (c.shape === 'square') c.angle = strictNumber(d.angle || '0')!; else delete c.angle; } } else { const draft = (sp.room_drafts || []).find((x: any) => x.id === d.id); if (draft?.segments?.[d.segment || 0]) draft.segments[d.segment || 0].cm = cmRaw; } const committed = this._commitPhysicalGeometry(this._t('history.physical_edit'), before); this._physicalDialog = null; if (!committed) this.requestUpdate(); }; private _deletePhysicalSelection = (): void => { const sel = this._physicalSel; const sp = this._curSpaceCfg as any; if (!sel || !sp) return; if (sel.kind === 'draft') { this._deleteDraftWhole(); return; } const before = this._geometrySnapshot(); if (sel.kind === 'partition') { const hosted = (sp.openings || []) .filter((opening: OpeningCfg) => opening.host?.kind === 'partition' && opening.host.id === sel.id) .sort((a: OpeningCfg, b: OpeningCfg) => (a.host?.t || 0) - (b.host?.t || 0)); if (hosted.length) { this._partitionDeleteDialog = { id: sel.id, openings: hosted.map((opening: OpeningCfg) => JSON.parse(JSON.stringify(opening))), }; return; } } const key = sel.kind === 'partition' ? 'partitions' : sel.kind === 'column' ? 'wall_columns' : 'room_drafts'; sp[key] = (sp[key] || []).filter((x: any) => x.id !== sel.id); if (!sp[key].length) delete sp[key]; if (this._activeDraftId === sel.id) this._cancelPath(); this._physicalSel = null; this._physicalDialog = null; this._commitPhysicalGeometry(this._t('history.physical_delete'), before); }; private _confirmPartitionDelete = (): void => { const dialog = this._partitionDeleteDialog; const sp = this._curSpaceCfg as any; if (!dialog || !sp) return; const before = this._geometrySnapshot(); sp.partitions = (sp.partitions || []).filter((partition: PartitionCfg) => partition.id !== dialog.id); if (!sp.partitions.length) delete sp.partitions; sp.openings = (sp.openings || []).filter((opening: OpeningCfg) => opening.host?.kind !== 'partition' || opening.host.id !== dialog.id); if (!sp.openings.length) delete sp.openings; this._partitionDeleteDialog = null; this._physicalSel = null; this._physicalDialog = null; this._commitPhysicalGeometry(this._t('history.physical_delete'), before); }; private _deleteDraftWhole = (): void => { const id = this._physicalDialog?.kind === 'draft' ? this._physicalDialog.id : this._physicalSel?.kind === 'draft' ? this._physicalSel.id : null; const sp = this._curSpaceCfg as any; if (!id || !sp || !confirm(this._t('confirm.delete_draft'))) return; const before = this._geometrySnapshot(); sp.room_drafts = (sp.room_drafts || []).filter((x: any) => x.id !== id); if (!sp.room_drafts.length) delete sp.room_drafts; if (this._activeDraftId === id) this._cancelPath(); this._physicalSel = null; this._physicalDialog = null; this._commitPhysicalGeometry(this._t('history.physical_delete'), before); }; private _deleteDraftSegment = (): void => { const dlg = this._physicalDialog; const sp = this._curSpaceCfg as any; if (!dlg || dlg.kind !== 'draft' || !sp) return; if (!confirm(this._t('confirm.delete_draft_segment'))) return; const index = (sp.room_drafts || []).findIndex((x: any) => x.id === dlg.id); if (index < 0) return; const draft = sp.room_drafts[index]; const cut = Math.max(0, Math.min(draft.segments.length - 1, dlg.segment || 0)); const pieces: any[] = []; const leftPoints = draft.points.slice(0, cut + 1); const rightPoints = draft.points.slice(cut + 1); if (leftPoints.length >= 2) pieces.push({ id: draft.id, points: leftPoints, segments: draft.segments.slice(0, cut), }); if (rightPoints.length >= 2) pieces.push({ id: pieces.length ? `${draft.id}-${Date.now().toString(36)}` : draft.id, points: rightPoints, segments: draft.segments.slice(cut + 1), }); if (pieces.length === 2 && sp.room_drafts.length >= MAX_ROOM_DRAFTS) { this._showToast(this._t('toast.physical_limit')); return; } const before = this._geometrySnapshot(); sp.room_drafts.splice(index, 1, ...pieces); if (!sp.room_drafts.length) delete sp.room_drafts; if (this._activeDraftId === draft.id) this._cancelPath(); this._physicalDialog = null; this._physicalSel = pieces.length ? { kind: 'draft', id: pieces[0].id } : null; this._commitPhysicalGeometry(this._t('history.draft_segment_delete'), before); }; private _physicalDown(ev: PointerEvent, kind: 'partition' | 'column', id: string): void { const model = this._spaceModel(); if (!model) return; ev.stopPropagation(); capturePointer(ev); const point = this._svgPoint(ev); const candidates: Array<{ kind: 'partition' | 'column'; id: string }> = []; for (const c of [...model.wall_columns].reverse()) { if (pointInPhysicalBody(point, columnBody(c, this._cellCm, this._gridPitch))) candidates.push({ kind: 'column', id: c.id }); } for (const p of [...model.partitions].reverse()) { const body = partitionBody(p.a, p.b, p.cm, this._cellCm, this._gridPitch); if (body && pointInPhysicalBody(point, body)) candidates.push({ kind: 'partition', id: p.id }); } if (!candidates.some((x) => x.kind === kind && x.id === id)) candidates.unshift({ kind, id }); const signature = candidates.map((x) => `${x.kind}:${x.id}`).sort().join('|'); const now = performance.now(); const cycle = this._physicalPickCycle; const repeat = candidates.length > 1 && cycle?.signature === signature && now - cycle.at > 380 && now - cycle.at <= 1200 && Math.hypot(ev.clientX - cycle.x, ev.clientY - cycle.y) <= 10; const index = repeat ? (cycle.index + 1) % candidates.length : Math.max(0, candidates.findIndex((x) => x.kind === kind && x.id === id)); this._physicalPickCycle = { signature, index, x: ev.clientX, y: ev.clientY, at: now }; kind = candidates[index].kind; id = candidates[index].id; const base = kind === 'partition' ? model.partitions.find((x) => x.id === id) : model.wall_columns.find((x) => x.id === id); if (!base) return; this._physicalSel = { kind, id }; this._physicalDrag = { pid: ev.pointerId, kind, id, start: this._svgPoint(ev), startClient: [ev.clientX, ev.clientY], before: this._geometrySnapshot(), moved: false, base: JSON.parse(JSON.stringify(base)), delta: [0, 0], }; } private _clampPhysicalDelta( kind: 'partition' | 'column', base: PartitionCfg | WallColumnCfg, delta: number[], ): number[] { const points = kind === 'partition' ? [(base as PartitionCfg).a, (base as PartitionCfg).b] : [(base as WallColumnCfg).center]; const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]); return [ Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...xs), Math.min(CANVAS_LIMIT * NORM_W - Math.max(...xs), delta[0])), Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...ys), Math.min(CANVAS_LIMIT * NORM_W - Math.max(...ys), delta[1])), ]; } private _physicalMove(ev: PointerEvent): void { const drag = this._physicalDrag; if (!drag || drag.pid !== ev.pointerId) return; ev.stopPropagation(); const raw = this._svgPoint(ev); const anchor = drag.kind === 'partition' ? (drag.base as PartitionCfg).a : (drag.base as WallColumnCfg).center; const target = this._snap([ anchor[0] + raw[0] - drag.start[0], anchor[1] + raw[1] - drag.start[1], ]); const delta = this._clampPhysicalDelta( drag.kind, drag.base, [target[0] - anchor[0], target[1] - anchor[1]], ); this._physicalDrag = { ...drag, delta, moved: drag.moved || Math.hypot( ev.clientX - drag.startClient[0], ev.clientY - drag.startClient[1], ) >= 5, }; } private _physicalUp(ev: PointerEvent): void { const drag = this._physicalDrag; if (!drag || drag.pid !== ev.pointerId) return; ev.stopPropagation(); this._physicalDrag = null; if (!drag.moved) { this._registerPhysicalTap(drag.kind, drag.id); return; } const model = this._spaceModel(); if (!this._curSpaceCfg || !model) return; const sp = this._curSpaceCfg as any; if (drag.kind === 'partition') { const p = (sp.partitions || []).find((x: any) => x.id === drag.id); const base = drag.base as PartitionCfg; if (p) { const moved: PartitionCfg = { ...base, a: [base.a[0] + drag.delta[0], base.a[1] + drag.delta[1]], b: [base.b[0] + drag.delta[0], base.b[1] + drag.delta[1]], }; p.a = [moved.a[0] / NORM_W, moved.a[1] / NORM_W]; p.b = [moved.b[0] / NORM_W, moved.b[1] / NORM_W]; for (const opening of sp.openings || []) { if (opening.host?.kind !== 'partition' || opening.host.id !== drag.id) continue; const resolved = resolvePartitionOpeningCompat( opening, [moved], NORM_W, this._cellCm, this._gridPitch, ).resolved; if (resolved) Object.assign( opening, materializePartitionOpening(opening, resolved, NORM_W), ); } } } else { const c = (sp.wall_columns || []).find((x: any) => x.id === drag.id); const base = drag.base as WallColumnCfg; const candidate: WallColumnCfg = { ...base, center: [base.center[0] + drag.delta[0], base.center[1] + drag.delta[1]], } as WallColumnCfg; if (model.wall_columns.some((x) => x.id !== drag.id && sameColumnPlacement(x, candidate, this._gridPitch * 0.02))) { this._showToast(this._t('toast.column_duplicate')); return; } if (c) c.center = [ (base.center[0] + drag.delta[0]) / NORM_W, (base.center[1] + drag.delta[1]) / NORM_W, ]; } this._commitPhysicalGeometry(this._t('history.physical_move'), drag.before); } private _registerPhysicalTap( kind: 'partition' | 'column' | 'draft', id: string, segment?: number, ): void { const now = performance.now(); const twice = this._physicalLastTap?.kind === kind && this._physicalLastTap.id === id && this._physicalLastTap.segment === segment && now - this._physicalLastTap.at <= 360; this._physicalLastTap = { kind, id, segment, at: now }; if (twice) { this._physicalLastTap = null; this._openPhysicalDialog(kind, id, segment); } } private _cancelPhysicalGesture(): void { this._physicalDrag = null; this._physicalRotate = null; this.requestUpdate(); } private _physicalRotateDown(ev: PointerEvent, c: WallColumnCfg): void { if (c.shape !== 'square' || !this._spaceModel()) return; ev.preventDefault(); ev.stopPropagation(); capturePointer(ev); const p = this._svgPoint(ev); this._physicalSel = { kind: 'column', id: c.id }; this._physicalRotate = { pid: ev.pointerId, id: c.id, center: [...c.center], startAngle: Math.atan2(p[1] - c.center[1], p[0] - c.center[0]) * 180 / Math.PI, baseAngle: canonicalColumnAngle(c.angle), angle: canonicalColumnAngle(c.angle), before: this._geometrySnapshot(), moved: false, }; } private _physicalRotateMove(ev: PointerEvent): void { const drag = this._physicalRotate; if (!drag || drag.pid !== ev.pointerId) return; ev.preventDefault(); ev.stopPropagation(); const p = this._svgPoint(ev); const pointerAngle = Math.atan2(p[1] - drag.center[1], p[0] - drag.center[0]) * 180 / Math.PI; const raw = drag.baseAngle + pointerAngle - drag.startAngle; const angle = canonicalColumnAngle(ev.shiftKey ? raw : Math.round(raw / 5) * 5); this._physicalRotate = { ...drag, angle, moved: drag.moved || Math.abs(raw - drag.baseAngle) >= 0.5 }; } private _physicalRotateUp(ev: PointerEvent): void { const drag = this._physicalRotate; if (!drag || drag.pid !== ev.pointerId) return; ev.preventDefault(); ev.stopPropagation(); this._physicalRotate = null; const model = this._spaceModel(); if (!drag.moved || !this._curSpaceCfg || !model) return; const current = model.wall_columns.find((c) => c.id === drag.id); if (!current || current.shape !== 'square') return; const candidate: WallColumnCfg = { ...current, angle: drag.angle }; if (model.wall_columns.some((c) => c.id !== drag.id && sameColumnPlacement(c, candidate, this._gridPitch * 0.02))) { this._showToast(this._t('toast.column_duplicate')); return; } const stored = (this._curSpaceCfg as any).wall_columns?.find((c: any) => c.id === drag.id); if (!stored) return; stored.angle = drag.angle; this._commitPhysicalGeometry(this._t('history.physical_edit'), drag.before); } // ================= room resize tool (docs/RESIZE.md) ================= /** Rooms of the current space as render-unit polygons (legacy rects converted). */ private _rszRooms(): { id: string; poly: number[][]; wall_ids?: string[] }[] { const out: { id: string; poly: number[][]; wall_ids?: string[] }[] = []; const space = this._spaceModel(); if (!space) return out; for (const r of space.rooms) { const poly = r.id ? roomPoly(r) : null; if (poly) out.push({ id: r.id!, poly, wall_ids: Array.isArray(r.wall_ids) ? [...r.wall_ids] : undefined, }); } return coalesceResizeRooms(out, Math.max(1e-12, this._gridPitch * 1e-12)); } private _rszOpenings(): SafeOpeningIn[] { return this._openingsR.map((o) => ({ id: o.id, x: o.rx, y: o.ry, length: o.rlen, hosted: !!o.host, angle: o.angle, type: o.type, })); } private _rszObstacles(): SafeResizeObstacle[] { const space = this._spaceModel(); if (!space) return []; const obstacles: SafeResizeObstacle[] = []; for (const partition of space.partitions || []) { obstacles.push({ kind: 'segment', a: [...partition.a], b: [...partition.b], half: wallCmToUnits(partition.cm, this._cellCm, this._gridPitch) / 2, }); } for (const draft of space.room_drafts || []) { for (let i = 0; i + 1 < draft.points.length; i++) { obstacles.push({ kind: 'segment', a: [...draft.points[i]], b: [...draft.points[i + 1]], half: wallCmToUnits( Number(draft.segments?.[i]?.cm) || DRAW_WALL_DEFAULT_CM, this._cellCm, this._gridPitch, ) / 2, }); } } for (const column of space.wall_columns || []) { const half = wallCmToUnits(column.cm, this._cellCm, this._gridPitch) / 2; obstacles.push({ kind: 'circle', center: [...column.center], radius: column.shape === 'square' ? half * Math.SQRT2 : half, }); } return obstacles; } private _rszOptsFor(a: number[], b: number[]): SafeResizeOptions { const cm = thicknessCmAt(this._spaceWalls, a, b, this._wallKeyPitch, NORM_W); const exact = this._spaceWalls.filter((wall) => Array.isArray(wall.a) && Array.isArray(wall.b) && wall.a.length >= 2 && wall.b.length >= 2).map((wall) => ({ wall, a: [wall.a![0] * NORM_W, wall.a![1] * this._spaceH], b: [wall.b![0] * NORM_W, wall.b![1] * this._spaceH], })); const axis = Math.abs(a[0] - b[0]) <= this._gridPitch * 0.05 ? 'v' : Math.abs(a[1] - b[1]) <= this._gridPitch * 0.05 ? 'h' : null; const overlaps = exact.filter((entry) => { if (!axis) return false; if (axis === 'h') { if (Math.abs(entry.a[1] - a[1]) > this._gridPitch * 0.05 || Math.abs(entry.b[1] - a[1]) > this._gridPitch * 0.05) return false; return Math.min(Math.max(a[0], b[0]), Math.max(entry.a[0], entry.b[0])) - Math.max(Math.min(a[0], b[0]), Math.min(entry.a[0], entry.b[0])) > this._gridPitch * 0.05; } if (Math.abs(entry.a[0] - a[0]) > this._gridPitch * 0.05 || Math.abs(entry.b[0] - a[0]) > this._gridPitch * 0.05) return false; return Math.min(Math.max(a[1], b[1]), Math.max(entry.a[1], entry.b[1])) - Math.max(Math.min(a[1], b[1]), Math.min(entry.a[1], entry.b[1])) > this._gridPitch * 0.05; }); const exactCms = new Set(overlaps.map((entry) => Number(entry.wall.cm)).filter((value) => value > 0)); return { minDim: this._cmToUnits(MIN_ROOM_CM), eps: this._gridPitch * 0.05, step: this._gridPitch, movingHalf: cm > 0 ? wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2 : 0, obstacles: this._rszObstacles(), thicknessConflict: exactCms.size > 1, }; } private _rszResolution( roomId: string, edge: number, renderSnapshot?: string, ): SafeResizeResolution { const snap = this._rszDrag?.snap || renderSnapshot || this._rszSnapshot(); const key = `${this._space}|${this._cellCm}|${this._gridPitch}|${snap}`; if (!this._rszEligibilityCache || this._rszEligibilityCache.key !== key) { this._rszEligibilityCache = { key, values: new Map() }; } const cacheKey = `${roomId}:${edge}`; const cached = this._rszEligibilityCache.values.get(cacheKey); if (cached) return cached; const rooms = this._rszDrag?.rooms || this._rszRooms(); const room = rooms.find((candidate) => candidate.id === roomId); const a = room?.poly?.[edge] || [0, 0]; const b = room?.poly?.[(edge + 1) % (room?.poly?.length || 1)] || [0, 0]; const result = resolveSafeResize( rooms, this._rszDrag?.openings || this._rszOpenings(), roomId, edge, this._rszOptsFor(a, b), ); this._rszEligibilityCache.values.set(cacheKey, result); return result; } private _rszSnapshot(): string { return JSON.stringify(this._geometrySnapshot() || { spaceId: this._space, rooms: [], openings: [], walls: [], open_spans: [], }); } /** Live preview of the candidate geometry, based on the immutable pre-drag snapshot — * walls, fills, labels and openings all follow in the same render. * * HP-1550-01: the preview must NEVER touch _serverCfg. It used to be written * into the shared mutable space config, and the serialized write chain * (HP-1454-03) reads `_serverCfg` AT THE MOMENT a write runs — so a debounced * write still queued from a previous edit carried the mid-drag preview to the * server before pointerup, and an Esc after that left the abandoned geometry * persisted (reload resurrected it). Flushing the queue before the drag would * not close it: the queued write reads the mutable config later anyway. The * live geometry therefore lives in the `_rszPreview` overlay; _curSpaceCfg / * _renderCfg feed it to every render, and only _rszUp moves it into the real * config — the single point where a resize becomes visible to _writeConfig. */ private _rszApplyPreview( polys: Record, ops: Record, ): { ok: true } | { ok: false; reason: 'missing-context' | 'wall-metadata' | 'open-span-metadata' | 'physical-geometry'; } { const g = this._rszDrag; const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space); if (!g || !real || !this._serverCfg) return { ok: false, reason: 'missing-context' }; const s = JSON.parse(g.snap); // fresh deep copies every move — free to mutate const sp: any = { ...real, rooms: s.rooms, openings: s.openings || [], }; for (const key of ['walls', 'open_spans', 'room_drafts', 'partitions', 'wall_columns', 'decor'] as const) { if (s[key] !== undefined) (sp as any)[key] = s[key]; else delete (sp as any)[key]; } for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) { if (s.plan_transform?.[key] !== undefined) (sp as any)[key] = s.plan_transform[key]; else delete (sp as any)[key]; } const H = this._spaceH; for (const [id, poly] of Object.entries(polys)) { const r = sp.rooms.find((x: any) => x.id === id); if (!r) continue; r.poly = poly.map((p) => [p[0] / NORM_W, p[1] / H]); delete r.x; delete r.y; delete r.w; delete r.h; // a resized room is saved as a polygon } for (const [id, c] of Object.entries(ops)) { const o = (sp.openings || []).find((x: any) => x.id === id); if (!o) continue; o.x = c[0] / NORM_W; o.y = c[1] / H; } // Geometry that belongs to a wall must ride in the SAME live overlay as // its room polygons. Map from the immutable snapshot on every move (never // from the previous preview), so partial virtual stretches and the atomic // thickness keys on their solid remainders cannot lag behind or accumulate // rounding error during a long drag. const oldSpans: [number[], number[]][] = []; const newSpans: [number[], number[]][] = []; for (const id of g.changed) { const oldR = g.rooms.find((r) => r.id === id); const nr = sp.rooms.find((x: any) => x.id === id); if (!oldR || !nr?.poly) continue; const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]); if (oldR.poly.length !== newPoly.length) continue; for (let i = 0; i < oldR.poly.length; i++) { oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]); newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]); } } if (oldSpans.length) { const movedOpen = rekeyOpenSpansAfterMove( sanitizeOpenSpans((sp as any).open_spans), oldSpans, newSpans, NORM_W, ); if (movedOpen.length) (sp as any).open_spans = movedOpen; else delete (sp as any).open_spans; if (Array.isArray(sp.walls) && sp.walls.length) { const rekeyed = rekeyWallsAfterMoveChecked( sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W, 'fixed-topology', ); if (rekeyed.rejected) return { ok: false, reason: 'wall-metadata' }; sp.walls = rekeyed.walls; } } // Rekeying may change coordinates/keys, never the number or physical // thicknesses of persisted records. A lossy mapping is a stopped preview. const wallThicknesses = (walls: any[]) => walls .map((wall: any) => Number(wall.cm)) .sort((a: number, b: number) => a - b); const beforeWallCms = wallThicknesses(s.walls || []); const afterWallCms = wallThicknesses(sp.walls || []); if (JSON.stringify(beforeWallCms) !== JSON.stringify(afterWallCms)) { return { ok: false, reason: 'wall-metadata' }; } if ((s.open_spans || []).length !== ((sp as any).open_spans || []).length) { return { ok: false, reason: 'open-span-metadata' }; } const wallCarriers: [number[], number[]][] = []; for (const room of sp.rooms || []) { const poly = roomPoly(room); if (!poly || poly.length < 2) continue; for (let index = 0; index < poly.length; index++) { wallCarriers.push([ [poly[index][0] * NORM_W, poly[index][1] * NORM_W], [poly[(index + 1) % poly.length][0] * NORM_W, poly[(index + 1) % poly.length][1] * NORM_W], ]); } } // Old plans may contain explicit historical debt (for example an authored // off-grid thickness breakpoint). Safe Resize must not silently repair it, // but it may not create a new invalid record either. Remove byte-identical // old records as a multiset and prove only newly written/split records. // That keeps pointermove bounded by the touched thickness profile instead // of comparing every historical record with every carrier twice per frame. const wallSignature = (wall: any): string => JSON.stringify([ wall?.key, wall?.cm, wall?.a, wall?.b, ]); const oldWallCounts = new Map(); for (const wall of s.walls || []) { const key = wallSignature(wall); oldWallCounts.set(key, (oldWallCounts.get(key) || 0) + 1); } const changedWalls: WallEntry[] = []; for (const wall of sp.walls || []) { const key = wallSignature(wall); const remaining = oldWallCounts.get(key) || 0; if (remaining) oldWallCounts.set(key, remaining - 1); else changedWalls.push(wall); } if (wallRecordCarrierViolations( changedWalls, wallCarriers, this._wallKeyPitch, NORM_W, s.walls || [], ).length) return { ok: false, reason: 'wall-metadata' }; if (!this._rszSpaceCandidateRenderable(this._space, sp)) { return { ok: false, reason: 'physical-geometry' }; } this._rszPreview = { space: this._space, sp }; this._cfgEpoch++; return { ok: true }; } /** Fail-closed check for one exact candidate through the common barrier. */ private _rszSpaceCandidateRenderable(spaceId: string, sp: any): boolean { if (!this._serverCfg) return false; try { const candidate = { ...this._serverCfg, spaces: this._serverCfg.spaces.map((space: any) => space.id === spaceId ? sp : space), } as ServerConfig; return this._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { return false; } } /** Final identity guard for the exact overlay already shown to the user. */ private _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean { return !!preview && preview.space === this._space && this._rszPreview === preview && this._rszSpaceCandidateRenderable(preview.space, preview.sp); } private _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void { if (this._tool !== 'resize' || this._rszDrag) return; ev.stopPropagation(); ev.preventDefault(); const rooms = this._rszRooms(); const resolution = this._rszResolution(roomId, edge); if (!resolution.enabled) { this._showToast(this._rszReasonText(resolution.reason)); return; } capturePointer(ev); const plan = resolution.plan; const start = this._svgPoint(ev); this._rszDrag = { pid: ev.pointerId, start: [start[0], start[1]], roomId, plan, opts: this._rszOptsFor(plan.a, plan.b), rooms, openings: this._rszOpenings(), snap: this._rszSnapshot(), moved: false, d: 0, changed: [...plan.roomIds], rejectNotified: false, }; } private _rszReasonText(reason: SafeResizeReason): string { return this._t(`resize.disabled.${reason}` as I18nKey); } private _rszDisabledActivate(ev: Event, reason: SafeResizeReason): void { ev.stopPropagation(); ev.preventDefault(); this._showToast(this._rszReasonText(reason)); } private _rszDisabledKey(ev: KeyboardEvent, reason: SafeResizeReason): void { if (ev.key !== 'Enter' && ev.key !== ' ') return; this._rszDisabledActivate(ev, reason); } private _rszMove(ev: PointerEvent): void { const g = this._rszDrag; if (!g || g.pid !== ev.pointerId) return; ev.stopPropagation(); const p = this._svgPoint(ev); const plan = g.plan; const dRaw = safeResizePointerDisplacement(g.start, p, plan.n); // the moved wall LINE lands on the grid, like every drawn wall const sn = this._snap([plan.a[0] + plan.n[0] * dRaw, plan.a[1] + plan.n[1] * dRaw]); const wanted = (sn[0] - plan.a[0]) * plan.n[0] + (sn[1] - plan.a[1]) * plan.n[1]; let d = clampSafeResize(g.rooms, g.openings, plan, wanted, this._gridPitch, g.opts); if (d === g.d && g.moved) return; const previousPreview = this._rszPreview; const previousLive = this._rszLive; const previousD = g.d; let res = applySafeResize(g.rooms, g.openings, plan, d); const previewResult = this._rszApplyPreview(res.polys, res.openings); if (!previewResult.ok) { // Persistence metadata is part of the geometry transaction. If wall or // virtual-span rekeying would be lossy, keep the last complete preview: // the pointer visibly stops there and pointerup can commit only that // already-rendered safe candidate. this._rszPreview = previousPreview; this._rszLive = previousLive; g.d = previousD; if (!g.rejectNotified) { g.rejectNotified = true; this._showToast(this._t('resize.preview_failed')); } this.requestUpdate(); return; } g.moved = true; g.changed = [...plan.roomIds]; g.d = d; this._rszLive = this._rszEdgeLabels(res, plan); this.requestUpdate(); } private _rszUp(ev: PointerEvent): void { const g = this._rszDrag; if (!g || g.pid !== ev.pointerId) return; ev.stopPropagation(); const preview = this._rszPreview; const wantedCommit = g.moved && Math.abs(g.d) > 1e-9; const snapshotStillCurrent = this._rszSnapshot() === g.snap; const topologyValid = validateSafeResize(g.rooms, g.openings, g.plan, g.d, g.opts); const candidateValid = this._rszCandidateRenderable(preview); this._rszDrag = null; this._rszLive = null; this._rszPreview = null; // the overlay is gone either way; renders read the real config again if (!wantedCommit || !preview) { // HP-1550-01: nothing to restore — the preview never touched the config this._cfgEpoch++; this.requestUpdate(); return; } if (!snapshotStillCurrent || !topologyValid || !candidateValid) { this._cfgEpoch++; this._showToast(this._t('resize.commit_failed')); this.requestUpdate(); return; } const before = JSON.parse(g.snap) as SpaceGeometryState; // Commit the exact preview in one step. No simplify/rebuild pass is allowed: // preview and persistence share the same fixed-topology candidate. const sp = this._curSpaceCfg; if (sp) { sp.rooms = preview.sp.rooms; sp.openings = preview.sp.openings; if (Array.isArray(preview.sp.walls)) { if (preview.sp.walls.length) sp.walls = preview.sp.walls; else delete sp.walls; } if (Array.isArray(preview.sp.open_spans) && preview.sp.open_spans.length) { (sp as any).open_spans = preview.sp.open_spans; } else { delete (sp as any).open_spans; } } // the click synthesized after the drag must not re-pick the selection this._suppressClick = true; setTimeout(() => (this._suppressClick = false), 0); this._commitPhysicalGeometry(this._t('history.resize_room'), before); this._rszEligibilityCache = null; this.requestUpdate(); } private _rszCancelDrag(): void { const g = this._rszDrag; if (!g) return; this._rszDrag = null; this._rszLive = null; // HP-1550-01/-03: a cancel just drops the overlay — the real config was // never touched, so there is nothing to restore, no undo step and no write this._rszPreview = null; this._cfgEpoch++; this.requestUpdate(); } /** HP-1550-03: pointercancel / lostpointercapture is an ABORT, not a release — * the system interrupted the stream (app switch, palm rejection), so the drag * takes the cancel path: snapshot geometry, no undo step, no write. The pid * guard also absorbs the lostpointercapture that follows a normal pointerup * or a pointercancel (the drag is already gone — no double cancel/commit). */ private _rszPointerCancel(ev: PointerEvent): void { const g = this._rszDrag; if (!g || g.pid !== ev.pointerId) return; ev.stopPropagation(); this._rszCancelDrag(); } private _rszEdgeLabels( res: { polys: Record }, plan: SafeResizePlan, ): ResizeLiveLabel[] { const g = this._rszDrag!; const labels: ResizeLiveLabel[] = []; const own = res.polys[plan.roomId] || g.rooms.find((r) => r.id === plan.roomId)!.poly; const n = own.length; // Длины — между внутренними гранями, как и площадь ниже (#233). Раньше // здесь считалась осевая длина, и одно облачко подписей несло две разные // конвенции: «3.00 × 4.00» по центрам стен рядом с площадью по полу. const spanCms = this._rszInnerSpanCms(plan.roomId, own, res.polys); // The moving wall is obvious under the pointer. Only its two side walls // remain useful measurements, and each one owns the matching highlight. for (const edge of resizeMeasuredEdges(own, plan.edge)) { const a = own[edge], b = own[(edge + 1) % n]; const cm = spanCms?.[edge]; labels.push({ kind: 'length', x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, text: cm == null ? this._fmtLen(a, b) : formatLength(cm, this.hass?.config?.unit_system?.length === 'mi'), edge: { a: [a[0], a[1]], b: [b[0], b[1]] }, }); } // Live areas sit beside the moving wall, one on each owner side. Placement // is pure screen-space math fed by the stage size cached by ResizeObserver; // no pointermove DOM measurement is allowed. const imperial = this.hass?.config?.unit_system?.length === 'mi'; const ids = plan.roomIds; const walls = this._spaceWalls; const openCuts = this._openPairs().flatMap((p) => p.segs); const physical = this._physicalBodiesR(); const base = this._baseVb(); const currentView = this._view && this._view.w > 0 && this._view.h > 0 ? this._view : { x: base[0], y: base[1], w: base[2], h: base[3] }; const [cachedW, cachedH] = this._lastValidStageSize || [currentView.w, currentView.h]; const view = { ...currentView, stageWidth: Math.max(1, cachedW), stageHeight: Math.max(1, cachedH), }; const space = this._spaceModel(); const cfgSize = this._config?.icon_size ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const iconPx = space ? iconCqw(iconPct, space, currentView.w, this._kiosk ? this._kioskScale.icon : 1) * view.stageWidth / 100 : 24; const gearHeightPx = Math.max(10, iconPx * 0.77); const gearText = this._t('room.settings_short'); // Mirrors the CSS proportions conservatively: icon + gap + two paddings + // localized text at the button's 0.42*height font size. const gearWidthPx = gearHeightPx * ( 0.55 + 0.35 * 0.42 + 0.76 + Math.max(1, gearText.length) * 0.66 * 0.42 ); for (const id of ids) { const poly = res.polys[id] || g.rooms.find((r) => r.id === id)!.poly; const floor = walls.length ? (innerContourForRoom( Object.entries(res.polys).map(([rid, p]) => ({ id: rid, poly: p })), id, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ) || poly) : poly; const m2 = physical.length ? geometryArea(floorMinusBodies(floor, physical)) * Math.pow(this._cellCm / this._gridPitch, 2) / 1e4 : areaM2(floor, this._gridPitch, this._cellCm); const text = formatArea(m2, imperial); const placement = placeResizeAreaLabel({ poly, edge: plan.edgeByRoom[id], text, view, gearCenter: poleOfInaccessibility(poly), gearWidthPx, gearHeightPx, }); labels.push({ kind: 'area', roomId: id, x: placement.anchor[0], y: placement.anchor[1], text, placement, }); } return labels; } /** * Внутренние длины рёбер комнаты в сантиметрах, по одному числу на ребро. * * Толщины берутся из атомарного профиля (#233): `thicknessCmAt` по целому * ребру возвращает 0 на ребре со сплит-толщиной, и подписи молча перестали бы * сокращаться. `null` означает «профиля нет» — тогда вызывающий показывает * осевую длину, как до правки. */ private _rszInnerSpanCms( roomId: string, own: number[][], polys: Record, ): number[] | null { const rooms = Object.keys(polys).length ? Object.entries(polys).map(([id, poly]) => ({ id, poly })) : this._spaceModel()?.rooms; if (!rooms?.length) return null; const walls = this._spaceWalls; if (!walls.length) return null; const openCuts = this._openPairs().flatMap((pair) => pair.segs); const offsets = ownEdgeOffsets( rooms, roomId, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); if (!offsets || offsets.length !== own.length) return null; const perUnitCm = this._cellCm / this._gridPitch; return own.map((_, edge) => innerEdgeSpan(own, edge, offsets) * perUnitCm); } /** Pointer-inert measurement ink above masonry and below openings/handles. */ private _renderResizeMeasurements(): TemplateResult | typeof nothing { if (!this._rszLive?.length) return nothing; const lengths = this._rszLive.filter((label) => label.kind === 'length'); const areas = this._rszLive.filter((label) => label.kind === 'area'); return svg``; } /** Fixed-topology wall handles. Disabled geometry remains visible and * explains why it cannot start a gesture; the old corner scale is gone. */ private _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult { const hr = Math.max(view.w * 0.013, 5); // finger-sized HIT radius on touch, like .vacfithandle // Wall-handle glyph: half the old circle — a wall segment with two arrows // pointing perpendicular to it (the directions the wall can be dragged). // Drawn in local coords (wall along X), rotated per edge at render time. // The invisible circle above keeps the full finger-sized hit area and the // HP-1550-04 hit-test priority over openings. const s = hr / 2, f = (v: number) => v.toFixed(1); const iconD = `M ${f(-0.7 * s)} 0 H ${f(0.7 * s)}` + ` M 0 ${f(-0.22 * s)} V ${f(-s)} M ${f(-0.32 * s)} ${f(-0.6 * s)} L 0 ${f(-s)} L ${f(0.32 * s)} ${f(-0.6 * s)}` + ` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`; const parts: TemplateResult[] = []; const rooms = this._rszRooms(); // A geometry snapshot is a deep clone/serialization. It is the cache key // for every handle in this frame, so compute it once per layer render — // never once per room edge. During a drag the immutable gesture snapshot // remains authoritative inside _rszResolution. const renderSnapshot = this._rszDrag?.snap || this._rszSnapshot(); for (const r of rooms) { for (let i = 0; i < r.poly.length; i++) { const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this._gridPitch) continue; const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2); const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI); const resolution = this._rszResolution(r.id, i, renderSnapshot); const disabled = !resolution.enabled; const reason = disabled ? this._rszReasonText(resolution.reason) : this._t('title.markup_resize'); parts.push(svg` this._rszEdgeDown(e, r.id, i)} @pointermove=${(e: PointerEvent) => this._rszMove(e)} @pointerup=${(e: PointerEvent) => this._rszUp(e)} @pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)} @lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)} @click=${disabled ? (e: Event) => this._rszDisabledActivate(e, resolution.reason) : null} @keydown=${disabled ? (e: KeyboardEvent) => this._rszDisabledKey(e, resolution.reason) : null}> ${reason} `); parts.push(svg``); } } return svg`${parts}`; } /** Openings of the current space in render units. */ private get _openingsR(): RenderOpening[] { const sp = this._curSpaceCfg; const H = this._spaceH; const space = this._spaceModel(); if (!space) return []; return (sp?.openings || []).flatMap((o: OpeningCfg) => { const fallback: RenderOpening = { ...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W, }; // Contour-wall hosts are identity metadata; their materialised x/y/angle // stay the legacy render input until the Stage 3 graph renderer. if (!o.host || o.host.kind === 'wall') return [fallback]; const resolution = resolvePartitionOpeningCompat( o, space.partitions, NORM_W, this._cellCm, this._gridPitch, ); if (!resolution.resolved) { return this._mode === 'plan' ? [{ ...fallback, orphanReason: resolution.reason || 'invalid-host' }] : []; } let [rx, ry] = resolution.resolved.center; const drag = this._physicalDrag; if (drag?.moved && drag.kind === 'partition' && drag.id === o.host.id) { rx += drag.delta[0]; ry += drag.delta[1]; } return [{ ...o, rx, ry, rlen: resolution.resolved.length, angle: resolution.resolved.angle, partitionHost: resolution.resolved, }]; }); } private _partitionOpeningCuts( space: SpaceModel | undefined = this._spaceModel(), accept: (opening: OpeningCfg) => boolean = () => true, ): PartitionOpeningCut[] { if (!space) return []; const config = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg : null; const openings = geometryOpenings( config, space, this._cellCm, this._gridPitch, NORM_W, ); return geometryPartitionOpeningCuts(openings, accept); } /** * Room masonry may be cut by a hosted opening only for an exact collinear * composite. Nearby/crossing walls never inherit the partition's cut. */ private _roomWallOpeningInputs( openings: readonly RenderOpening[] = this._openingsR, space: SpaceModel | undefined = this._spaceModel(), ): Array<{ x: number; y: number; angle: number; length: number }> { if (!space) return []; const openCuts = this._openPairs().flatMap((pair) => pair.segs); return geometryRoomOpeningInputs( openings as readonly GeometryOpeningProjection[], space, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); } private _openingFace( opening: RenderOpening, index: OpeningWallIndex, flipV: boolean, ): OpeningFaceOffset { return opening.partitionHost ? partitionOpeningFace(opening.partitionHost, flipV) : openingInnerFaceOffsetFromIndex(index, { x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen, flip_v: flipV, }); } /** cm → render units via the space scale (cm per grid cell). */ private _cmToUnits(cm: number): number { return (cm / this._cellCm) * this._gridPitch; } // ================= decor (background) layer ================= private get _decorList(): DecorShape[] { const sp = this._curSpaceCfg; return (Array.isArray(sp?.decor) ? sp.decor : []) as DecorShape[]; } private get _decorH(): number { return NORM_W; } private _decorResolvedStyle(shape?: DecorShape | null): DecorStyle { return decorStyleOf(shape, this._cellCm, this._gridPitch, DEFAULT_DECOR_STYLE); } private _decorWidthUnits(shape?: DecorShape | null): number { return decorStrokeUnits(shape, this._cellCm, this._gridPitch, DEFAULT_DECOR_STYLE.widthCm); } /** Canonical text size is physical; old size/scale values stay pixel-exact. */ private _decorTextSizeCm(shape?: DecorShape | null): number { if (shape?.kind === 'text') { const canonical = Number(shape.size_cm); if (Number.isFinite(canonical) && canonical > 0) return canonical; return decorUnitsToCm( DECOR_TEXT_BASE * decorTextScale(shape), this._cellCm, this._gridPitch, ); } return decorUnitsToCm(DECOR_TEXT_BASE, this._cellCm, this._gridPitch); } private _decorTextUnits(shape: DecorShape): number { if (shape.kind !== 'text') return DECOR_TEXT_BASE; const canonical = Number(shape.size_cm); return Number.isFinite(canonical) && canonical > 0 ? decorCmToUnits(canonical, this._cellCm, this._gridPitch) : DECOR_TEXT_BASE * decorTextScale(shape); } /** Small physical fields are centimetres in metric plans and inches in imperial plans. */ private _decorSmallField(cm: number): number { return Math.round((this._imperial ? cm / 2.54 : cm) * 100) / 100; } private _decorSmallCm(value: number): number { const cm = this._imperial ? value * 2.54 : value; return Number.isFinite(cm) ? Math.max(0.1, Math.min(100, cm)) : 0.1; } private _decorTextCm(value: number): number { const cm = this._imperial ? value * 2.54 : value; return Number.isFinite(cm) ? Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, cm)) : 0.1; } private _decorLargeField(cm: number): number { return Math.round((this._imperial ? cm / 30.48 : cm / 100) * 100) / 100; } private _decorLargeCm(value: number): number { const cm = this._imperial ? value * 30.48 : value * 100; return Number.isFinite(cm) ? Math.max(0.1, Math.min(CANVAS_LIMIT * this._cellCm, cm)) : 0.1; } /** Keep full geometry precision while presenting human-sized angle fields. */ private _angleField(value: unknown): string { const angle = Number(value); return Number.isFinite(angle) ? String(Number(angle.toFixed(3))) : '0'; } private _decorBoxOf(shape: DecorShape): DecorBox | null { if (shape.kind !== 'rect' && shape.kind !== 'ellipse' && shape.kind !== 'furniture') return null; return { x: shape.x * NORM_W, y: shape.y * this._decorH, w: shape.w * NORM_W, h: shape.h * this._decorH, angle: normalizeAngle(shape.angle) || undefined, }; } /** Magnet candidates are intentionally limited to decor and room contours. */ private _decorSnapGeometry(excludeId?: string): SnapGeometry { const space = this._spaceModel(); if (!space) return { points: [], segments: [] }; const cacheKey = excludeId || ''; const cached = this._decorSnapCache; if (cached && cached.epoch === this._cfgEpoch && cached.space === this._space && cached.height === this._decorH && cached.exclude === cacheKey) { return cached.geometry; } const parts: SnapGeometry[] = []; for (const shape of this._decorList) { if (shape.id === excludeId) continue; if (shape.kind === 'line') { const a = [shape.x1 * NORM_W, shape.y1 * this._decorH]; const b = [shape.x2 * NORM_W, shape.y2 * this._decorH]; parts.push({ points: [a, b, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]], segments: [{ a, b }], }); } else if (shape.kind === 'text') { parts.push({ points: [[shape.x * NORM_W, shape.y * this._decorH]], segments: [] }); } else { const box = this._decorBoxOf(shape); if (box) parts.push(boxAnchors(box)); } } for (const room of space.rooms) { const poly = roomPoly(room); if (!poly?.length) continue; parts.push({ points: poly.flatMap((p, i) => { const q = poly[(i + 1) % poly.length]; return [p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]]; }), segments: poly.map((p, i) => ({ a: p, b: poly[(i + 1) % poly.length] })), }); } const geometry = mergeSnapGeometry(parts); this._decorSnapCache = { epoch: this._cfgEpoch, space: this._space, height: this._decorH, exclude: cacheKey, geometry, }; return geometry; } private _decorSnap(raw: number[], pointerType = 'mouse', excludeId?: string): number[] { const stage = this._stageEl; const view = this._viewOr(this._baseVb()); const px = pointerType === 'touch' || pointerType === 'pen' ? 14 : 8; const tolerance = stage ? (view.w / Math.max(1, stage.clientWidth)) * px : this._gridPitch; return snapDecorPoint( raw, this._decorSnapGeometry(excludeId), tolerance, (point) => this._snap(point), ).point; } private _replaceDecor(id: string, patch: Partial): void { const sp = this._curSpaceCfg; if (!sp) return; sp.decor = this._decorList.map((shape) => shape.id === id ? { ...shape, ...patch } : shape); // The edited shape is excluded from the gesture's magnet candidates, so // keep the cached geometry of every *other* object across pointermoves. // A different exclude id/cache key rebuilds it when the next gesture starts. this.requestUpdate(); } /** Esc/Ctrl+Z during a live gesture restores its transaction start without creating history. */ private _cancelDecorGesture(): void { const before = this._decorMove?.before || this._dtDrag?.before || this._bdDrag?.before; const sp = before && this._serverCfg?.spaces.find((space: any) => space.id === before.spaceId); if (before && sp) { const copy = (value: T): T => JSON.parse(JSON.stringify(value)); if (before.decor !== undefined) sp.decor = copy(before.decor); else delete sp.decor; for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) delete sp[key]; Object.assign(sp, copy(before.plan_transform || {})); this._cfgEpoch++; } this._decorMove = null; this._dtDrag = null; this._bdDrag = null; this.requestUpdate(); } /** Begin a decor gesture. Returns true when the event is consumed (no pan). */ private _decorPointerDown(ev: PointerEvent): boolean { const t = this._decorTool; // A DRAWING tool owns the whole canvas. Pressing on top of an existing // shape must start a NEW figure at that very point — otherwise a line can // never begin at the end of another line, because the old line grabs the // press first (owner, 2026-08-04). Only select/erase talk to shapes, and // only for them does the shape's own handler get to deal with the event. const onShape = (t === 'select' || t === 'erase') ? ((ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null) : null; if (onShape) return true; // the shape's own handler deals with it if (t === 'line' || t === 'rect' || t === 'ellipse') { ev.preventDefault(); const p = this._decorSnap(this._svgPoint(ev), ev.pointerType); this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId }; capturePointer(ev); return true; } if (t === 'text') { // …and the press did NOT land on an existing label: those are the one // exception to the inertness above (see _decorShapeDown). const p = this._decorSnap(this._svgPoint(ev), ev.pointerType); this._decorTextDialog = { x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH), text: '', color: this._decorStyle.color, opacity: this._decorStyle.opacity, angle: '0', sizeCm: decorUnitsToCm(DECOR_TEXT_BASE, this._cellCm, this._gridPitch), }; this._decorTextSelection = { start: 0, end: 0 }; return true; } if (t === 'furniture') { // The furniture tool is a STAMP: the palette arms a symbol, the press // puts it down at its real size and the editor goes back to `select` // with the new piece selected (owner: «сразу выделен»). Without an armed // symbol the press does nothing but keep the pan — pressing the canvas // must not silently place whatever was chosen last week. if (!this._furnPalette) return false; ev.preventDefault(); this._furnPlace(this._svgPoint(ev), ev.shiftKey); return true; } // Empty-space selection belongs only to Select. Erase is an object // command: a miss must be a true no-op. With SVG text its painted glyphs // do not cover the whole logical label box, so clearing here made a click // between letters appear to erase only the selection outline. if (t === 'select') this._decorSel = null; // …and under its own tool the picture is grabbable by its body // (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the // picture and the plane still pans with one finger. if (this._bdMovable) { const r = this._bdRect!; const p = this._svgPoint(ev); const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const a = (-normalizeAngle(r.angle) * Math.PI) / 180; const dx = p[0] - cx, dy = p[1] - cy; const local = [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)]; if (local[0] >= r.x && local[0] <= r.x + r.w && local[1] >= r.y && local[1] <= r.y + r.h) { ev.preventDefault(); return this._bdStart(ev); } } return false; // pan is allowed } /** Commit the dragged shape (ignore degenerate ones) and persist. */ private _decorCommitDraft(): void { const d = this._decorDraft; this._decorDraft = null; if (!d) return; const min = this._gridPitch * 0.5; if (!validDecorDraft(d.kind, d.a, d.b, min)) return; const W = NORM_W, H = this._decorH; const st = this._decorStyle; const before = this._geometrySnapshot(); const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); // creation had no canvas guard at all while the MOVE did — a draft could be // born outside the range the mover then refused to leave. One limit, both // ends of the gesture (docs/CANVAS.md §9). const cn = clampCanvasN; let shape: DecorShape; if (d.kind === 'line') { shape = { id, kind: 'line', x1: cn(d.a[0] / W), y1: cn(d.a[1] / H), x2: cn(d.b[0] / W), y2: cn(d.b[1] / H), ...decorStylePatch(st, false) } as DecorShape; } else { const x = cn(Math.min(d.a[0], d.b[0]) / W), y = cn(Math.min(d.a[1], d.b[1]) / H); const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H; shape = { id, kind: d.kind, x, y, w, h, ...decorStylePatch(st, true) } as DecorShape; } const sp = this._curSpaceCfg; sp.decor = [...this._decorList, shape]; this._decorSel = id; this._recordGeometry(this._t('history.decor_add'), before); this._saveConfig(); this.requestUpdate(); } /** Select tool: pointerdown on a shape starts moving it. */ private _decorShapeDown(ev: PointerEvent, shape: DecorShape): void { if (this._mode !== 'decor') return; // Under any other tool the shape is not a target at all: the press has to // reach the stage, where the drawing tool starts a new figure (or the // backdrop tool grabs the picture). Swallowing it here was the bug — the // click on a line end did nothing but keep the old selection alive. const t = this._decorTool; // ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL // is a target again, and pressing it opens its editor instead of starting // a new label on top of it. Everything else stays inert — a press on a // line or a rectangle under the text tool still reaches the stage and // creates a new label there (the CSS keeps them pointer-inert, this is // the belt to that pair of braces). if (t === 'text') { if (shape.kind !== 'text') return; ev.stopPropagation(); ev.preventDefault(); this._decorOpenText(shape); return; } if (t !== 'select' && t !== 'erase') return; ev.stopPropagation(); ev.preventDefault(); if (t === 'erase') { this._decorEraseConfirm = { id: shape.id, kind: shape.kind }; return; } this._decorSel = shape.id; this._decorMove = { id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)), pid: ev.pointerId, moved: false, before: this._geometrySnapshot(), }; capturePointer(ev); } private _decorMoveUpdate(ev: PointerEvent): void { const m = this._decorMove!; // Furniture is dragged by its CENTRE and has a magnet of its own // (docs/FURNITURE.md §5) — a different rule, not a different gesture. if (m.orig?.kind === 'furniture') { this._furnMoveUpdate(ev); return; } const p = this._svgPoint(ev); const o0: any = m.orig; // The delta used to be what got snapped, which preserves whatever off-grid // offset the shape already had: a legacy shape at 0.3013 stayed at 0.3013 // for ever, one step at a time. Snap the RESULTING ANCHOR instead, so one // drag is enough to put any shape on the grid (docs/CANVAS.md §9). const ax0 = (o0.kind === 'line' ? o0.x1 : o0.x) * NORM_W; const ay0 = (o0.kind === 'line' ? o0.y1 : o0.y) * this._decorH; const anchor = this._decorSnap( [ax0 + (p[0] - m.start[0]), ay0 + (p[1] - m.start[1])], ev.pointerType, m.id, ); let dx = (anchor[0] - ax0) / NORM_W; let dy = (anchor[1] - ay0) / this._decorH; // audit follow-up L4 gave decor a bounds clamp of -0.25..1.25 — the plan // was a sheet with edges then. It is not any more (docs/CANVAS.md): the // clamp is now the same garbage limit the backend enforces, so decor can // follow a plan that lives at 2.7 and still cannot be flung to 1e100. const o: any = m.orig; const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x; const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y; const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0); const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0); const lim = CANVAS_LIMIT; dx = Math.max(-lim - curX, Math.min(lim - curX - w, dx)); dy = Math.max(-lim - curY, Math.min(lim - curY - h, dy)); if (dx || dy) m.moved = true; const sp = this._curSpaceCfg; sp.decor = this._decorList.map((x) => { if (x.id !== m.id) return x; const o: any = m.orig; if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy }; return { ...x, x: o.x + dx, y: o.y + dy }; }); this.requestUpdate(); } /** Select tool: every decor object has a double-click properties dialog. */ private _decorShapeDbl(ev: MouseEvent, shape: DecorShape): void { if (this._mode !== 'decor' || this._decorTool !== 'select') return; ev.preventDefault(); ev.stopPropagation(); this._decorMove = null; this._decorSel = shape.id; this._openDecorProperties(shape); } /** One properties entry point shared by double click and the context tray. */ private _openDecorProperties(shape: DecorShape): void { if (this._mode !== 'decor' || this._decorTool !== 'select') return; if (shape.kind === 'text') { this._decorOpenText(shape); return; } if (!['line', 'rect', 'ellipse', 'furniture'].includes(shape.kind)) return; const style = this._decorResolvedStyle(shape); const line = shape.kind === 'line' ? shape : null; const box = this._decorBoxOf(shape); this._decorShapeDialog = { id: shape.id, kind: shape.kind as 'line' | 'rect' | 'ellipse' | 'furniture', color: style.color, opacity: style.opacity, widthCm: style.widthCm, angle: this._angleField(line ? normalizeAngle(segmentAngle( [line.x1 * NORM_W, line.y1 * this._decorH], [line.x2 * NORM_W, line.y2 * this._decorH], )) : normalizeAngle((shape as any).angle)), ...(line ? { lengthCm: decorUnitsToCm( Math.hypot((line.x2 - line.x1) * NORM_W, (line.y2 - line.y1) * this._decorH), this._cellCm, this._gridPitch, ), lineStyle: line.line_style === 'dashed' ? 'dashed' : 'solid', } : {}), ...(box ? { sizeWCm: decorUnitsToCm(box.w, this._cellCm, this._gridPitch), sizeHCm: decorUnitsToCm(box.h, this._cellCm, this._gridPitch), } : {}), ...(shape.kind === 'furniture' ? { symbol: shape.symbol } : {}), ...(shape.kind === 'rect' || shape.kind === 'ellipse' ? { fill: style.fill, fillColor: style.fillColor, fillOpacity: style.fillOpacity, } : {}), }; } /** Open the editor of an existing label (double click, or the text tool). */ private _decorOpenText(shape: DecorShape): void { if (shape.kind !== 'text') return; let text = String(shape.text ?? ''); const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)] .some((m) => !!liveTextReference(m[1])); const explicitUnit = String(shape.unit ?? '').trim(); // Old dialogs represented the entity state as attr="state"; inline text // represents that same value with a bare `{entity}` token. const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state' ? null : shape.attr; const legacyToken = !hasInline && !explicitUnit ? liveTextToken(shape.entity, legacyAttr) : ''; const preserveLegacy = !!String(shape.entity ?? '').trim() && !hasInline && (!legacyToken || !!explicitUnit); if (legacyToken && !preserveLegacy) { const slot = text.indexOf('{}'); text = slot >= 0 ? text.slice(0, slot) + legacyToken + text.slice(slot + 2) : `${text}${text ? ' ' : ''}${legacyToken}`; } this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y, text, color: safeStoredColor(shape.color, this._decorStyle.color), opacity: clamp01(shape.opacity, this._decorStyle.opacity), angle: this._angleField(shape.angle), sizeCm: this._decorTextSizeCm(shape), pickerEntity: shape.entity || '', preserveLegacy: preserveLegacy || undefined, }; this._decorTextSelection = { start: text.length, end: text.length }; } private _decorRememberTextSelection(el: HTMLTextAreaElement): void { this._decorTextSelection = { start: el.selectionStart ?? el.value.length, end: el.selectionEnd ?? el.value.length, }; } /** Insert a complete reference without ever truncating it into broken text. */ private _decorInsertLiveVariable(attr: string | null): void { const d = this._decorTextDialog; if (!d) return; const token = liveTextToken(d.pickerEntity, attr); if (!token) return; const old = d.text; const start = Math.max(0, Math.min(old.length, this._decorTextSelection.start)); const end = Math.max(start, Math.min(old.length, this._decorTextSelection.end)); if (old.length - (end - start) + token.length > 200) return; const text = old.slice(0, start) + token + old.slice(end); const caret = start + token.length; this._decorTextSelection = { start: caret, end: caret }; // Inserting a new inline reference is the explicit replacement of any // unrepresentable legacy link; the stale side fields can now be dropped. this._decorTextDialog = { ...d, text, preserveLegacy: undefined }; this.updateComplete.then(() => { const el = this.renderRoot.querySelector('textarea.dtarea'); if (!el) return; el.focus(); el.setSelectionRange(caret, caret); }); } private _decorSaveText(): void { const d = this._decorTextDialog; // The user's own line breaks are kept; the surrounding whitespace is not. const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim(); if (!d || !text) { this._decorTextDialog = null; return; } const before = this._geometrySnapshot(); const sp = this._curSpaceCfg; const textStyle = { color: d.color, opacity: clamp01(d.opacity), size_cm: Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.sizeCm)).toFixed(4)), ...(normalizeAngle(d.angle) ? { angle: normalizeAngle(d.angle) } : {}), }; if (d.id) { sp.decor = this._decorList.map((x) => { if (x.id !== d.id) return x; if (x.kind !== 'text') return x; // A legacy unit or non-canonical attribute cannot be represented by // the inline grammar. Keep those fields until the user explicitly // replaces the binding; ordinary representable links still migrate. if (d.preserveLegacy) { const { angle: _angle, size: _size, scale: _scale, ...straight } = x; return { ...straight, text, ...textStyle }; } // beta.9 and earlier stored one live link beside the text. Saving in // the new editor migrates it to inline tokens and drops the old fields. const { entity, attr, unit, ...rest } = x; const { angle: _angle, size: _size, scale: _scale, ...straight } = rest; return { ...straight, text, ...textStyle }; }); } else { const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y, text, ...textStyle }]; this._decorSel = id; } this._decorTextDialog = null; this._recordGeometry(this._t(d.id ? 'history.decor_edit' : 'history.decor_add'), before); this._saveConfig(); this.requestUpdate(); } private _decorSaveShape(): void { const d = this._decorShapeDialog; if (!d) return; const before = this._geometrySnapshot(); const style: DecorStyle = { color: d.color, opacity: clamp01(d.opacity), widthCm: Math.max(0.1, Math.min(100, Number(d.widthCm) || 0.1)), fill: !!d.fill, fillColor: d.fillColor || d.color, fillOpacity: clamp01(d.fillOpacity, 0.25), }; const sp = this._curSpaceCfg; sp.decor = this._decorList.map((shape) => { if (shape.id !== d.id) return shape; const fillable = d.kind === 'rect' || d.kind === 'ellipse'; const visual = decorStylePatch(style, fillable); if (shape.kind === 'line') { const cx = ((shape.x1 + shape.x2) / 2) * NORM_W; const cy = ((shape.y1 + shape.y2) / 2) * this._decorH; const length = decorCmToUnits(Math.max(0.1, Number(d.lengthCm) || 0.1), this._cellCm, this._gridPitch); const rad = (normalizeAngle(d.angle) * Math.PI) / 180; const dx = Math.cos(rad) * length / 2, dy = Math.sin(rad) * length / 2; const a = this._snap([cx - dx, cy - dy]); const b = this._snap([cx + dx, cy + dy]); const { width: _legacyWidth, line_style: _oldLineStyle, ...rest } = shape; return { ...rest, ...visual, x1: clampCanvasN(a[0] / NORM_W), y1: clampCanvasN(a[1] / this._decorH), x2: clampCanvasN(b[0] / NORM_W), y2: clampCanvasN(b[1] / this._decorH), ...(d.lineStyle === 'dashed' ? { line_style: 'dashed' as const } : {}), } as DecorShape; } if (shape.kind === 'rect' || shape.kind === 'ellipse' || shape.kind === 'furniture') { const oldW = shape.w * NORM_W, oldH = shape.h * this._decorH; const w = Math.max(this._gridPitch, snapToGrid( decorCmToUnits(Number(d.sizeWCm), this._cellCm, this._gridPitch), this._gridPitch, )); const h = Math.max(this._gridPitch, snapToGrid( decorCmToUnits(Number(d.sizeHCm), this._cellCm, this._gridPitch), this._gridPitch, )); const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this._decorH + oldH / 2; const angle = normalizeAngle(d.angle); const topLeft = resizedBoxTopLeft( { x: cx - w / 2, y: cy - h / 2 }, angle, (point) => this._snap(point), ); const { width: _legacyWidth, angle: _oldAngle, ...rest } = shape; return { ...rest, ...visual, x: clampCanvasN(topLeft[0] / NORM_W), y: clampCanvasN(topLeft[1] / this._decorH), w: w / NORM_W, h: h / this._decorH, ...(shape.kind === 'furniture' && d.symbol ? { symbol: d.symbol } : {}), ...(angle ? { angle } : {}), } as DecorShape; } return shape; }); this._decorStyle = { ...style, fill: d.kind === 'rect' || d.kind === 'ellipse' ? style.fill : this._decorStyle.fill, fillColor: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillColor : this._decorStyle.fillColor, fillOpacity: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillOpacity : this._decorStyle.fillOpacity, }; this._decorShapeDialog = null; this._recordGeometry(this._t('history.decor_edit'), before); this._saveConfig(); this.requestUpdate(); } // ---- common decor transform controller ---- // The mechanics are the backdrop frame's (docs/BACKDROP.md), reused // rather than reinvented: chrome that never takes a pointer, finger-sized // handles that always do, the gesture written live into the config and // PERSISTED only if something actually moved. What differs is the pivot — // a label has an anchor (its x/y), not a box, so both gestures are about // that anchor and the text never walks away from the point it was placed at. /** The selected shape under Select. Every decor kind uses this controller. */ private get _dtSel(): DecorShape | null { if (this._mode !== 'decor' || this._decorTool !== 'select' || !this._decorSel) return null; return this._decorList.find((x) => x.id === this._decorSel) || null; } /** * What the object turns about. A label has an ANCHOR (its x/y is the point it * was placed at, and it must never walk away from it); boxes and lines use * their geometric centres. */ private _dtPivot(sh: DecorShape): number[] { if (sh.kind === 'line') return [ ((sh.x1 + sh.x2) / 2) * NORM_W, ((sh.y1 + sh.y2) / 2) * this._decorH, ]; if (sh.kind === 'furniture' || sh.kind === 'rect' || sh.kind === 'ellipse') return [(sh.x + sh.w / 2) * NORM_W, (sh.y + sh.h / 2) * this._decorH]; return [sh.x * NORM_W, sh.y * this._decorH]; } /** Write physical text size/angle into the shape — live, without saving. */ private _dtApply(id: string, patch: { textSizeCm?: number; angle?: number }): void { const sp = this._curSpaceCfg; if (!sp) return; sp.decor = this._decorList.map((x) => { if (x.id !== id) return x; const rest: any = { ...x }; if (x.kind === 'text' && patch.textSizeCm !== undefined) { delete rest.size; delete rest.scale; } const out: any = { ...rest }; if (patch.textSizeCm !== undefined) out.size_cm = Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, patch.textSizeCm)).toFixed(4)); if (patch.angle !== undefined) { if (patch.angle) out.angle = Number(patch.angle.toFixed(2)); else delete out.angle; // straight again = the field goes away } return out; }); this._cfgEpoch++; this.requestUpdate(); } private _dtStart( ev: PointerEvent, kind: 'scale' | 'rotate', corner?: number[], lineEnd?: 0 | 1, ): void { const sh = this._dtSel; if (!sh) return; ev.stopPropagation(); ev.preventDefault(); const [ax, ay] = this._dtPivot(sh); const p = this._svgPoint(ev); const box = this._decorBoxOf(sh); this._dtDrag = { id: sh.id, kind, pid: ev.pointerId, ax, ay, r0: Math.hypot(p[0] - ax, p[1] - ay), a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI, textSizeCm0: sh.kind === 'text' ? this._decorTextSizeCm(sh) : 1, angle0: sh.kind === 'line' ? 0 : Number(sh.angle) || 0, // which corner is being pulled, and the box it started from: a piece of // furniture is resized about the OPPOSITE corner, so both have to be // remembered — a label, scaled about its anchor, needs neither sgx: corner?.[0], sgy: corner?.[1], orig: box || undefined, origShape: JSON.parse(JSON.stringify(sh)), before: this._geometrySnapshot(), lineEnd, moved: false, }; capturePointer(ev); } private _dtMove(ev: PointerEvent): void { const d = this._dtDrag; if (!d) return; const p = this._svgPoint(ev); if (d.lineEnd !== undefined && d.origShape.kind === 'line') { const at = this._decorSnap(p, ev.pointerType, d.id); const nx = clampCanvasN(at[0] / NORM_W), ny = clampCanvasN(at[1] / this._decorH); const old = d.origShape; const ox = d.lineEnd === 0 ? old.x1 : old.x2; const oy = d.lineEnd === 0 ? old.y1 : old.y2; if (Math.abs(nx - ox) > 1e-9 || Math.abs(ny - oy) > 1e-9) d.moved = true; this._replaceDecor(d.id, d.lineEnd === 0 ? { x1: nx, y1: ny } : { x2: nx, y2: ny }); return; } if (d.kind === 'scale' && d.orig) { // Box geometry preserves ratio by default and separates axes with Shift. // Each dimension lands on a whole cell. const box = resizeDecorBox( d.orig, d.sgx ?? 1, d.sgy ?? 1, p[0], p[1], !ev.shiftKey, this._gridPitch, this._gridPitch, ); const changed = Math.abs(box.x - d.orig.x) > 1e-6 || Math.abs(box.y - d.orig.y) > 1e-6 || Math.abs(box.w - d.orig.w) > 1e-6 || Math.abs(box.h - d.orig.h) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._decorApplyBox(d.id, box); return; } if (d.kind === 'scale') { // uniform, about the anchor: the distance from the anchor is invariant // under the block's own rotation, so a rotated block scales the same way const r = Math.hypot(p[0] - d.ax, p[1] - d.ay); if (d.r0 < 1e-6) return; const sizeCm = Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.textSizeCm0 * (r / d.r0))); const changed = Math.abs(sizeCm - d.textSizeCm0) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._dtApply(d.id, { textSizeCm: sizeCm }); return; } const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI; let ang = d.angle0 + (a - d.a0); // 5° rotation steps; Shift remains an angle-only precision modifier. if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP; ang = ((ang % 360) + 360) % 360; if (ang > 180) ang -= 360; const changed = Math.abs(ang - d.angle0) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._dtApply(d.id, { angle: ang }); } private _dtUp(): void { const d = this._dtDrag; this._dtDrag = null; if (d?.moved) { this._recordGeometry(this._t('history.decor_transform'), d.before); this._saveConfig(); } this.requestUpdate(); } /** * Measure the selected label's own box. SVG can only tell us how big a text * actually came out once it is in the DOM, so the frame is one render * behind — and it is re-measured whenever the text, its scale or the * selection changes. Guarded against the render→measure→render loop by * comparing the numbers before asking for another update. */ private _dtMeasure(): void { const sh = this._dtSel; if (!sh) { if (this._dtBox) { this._dtBox = null; this.requestUpdate(); } return; } let box: { id: string; x: number; y: number; w: number; h: number }; if (sh.kind === 'line') { const x1 = sh.x1 * NORM_W, y1 = sh.y1 * this._decorH; const x2 = sh.x2 * NORM_W, y2 = sh.y2 * this._decorH; box = { id: sh.id, x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) }; } else if (sh.kind === 'furniture' || sh.kind === 'rect' || sh.kind === 'ellipse') { // …and furniture needs no measuring at all: its box IS the config. The // frame therefore appears in the SAME frame as the selection, not one // after it, and a resize can never be a render behind the shape. box = { id: sh.id, x: sh.x * NORM_W, y: sh.y * this._decorH, w: sh.w * NORM_W, h: sh.h * this._decorH }; } else { const el = this.renderRoot.querySelector(`text.dtext[data-id="${sh.id}"]`) as SVGGraphicsElement | null; if (!el || typeof (el as any).getBBox !== 'function') return; let b: DOMRect; try { b = el.getBBox(); } catch { return; } // not rendered yet (hidden card) if (!b || (!b.width && !b.height)) return; box = { id: sh.id, x: b.x, y: b.y, w: b.width, h: b.height }; } const cur = this._dtBox; const same = cur && cur.id === box.id && Math.abs(cur.x - box.x) < 0.01 && Math.abs(cur.y - box.y) < 0.01 && Math.abs(cur.w - box.w) < 0.01 && Math.abs(cur.h - box.h) < 0.01; if (same) return; this._dtBox = box; this.requestUpdate(); } private _deleteDecor(id: string): void { if (!this._decorList.some((shape) => shape.id === id)) return; const before = this._geometrySnapshot(); const sp = this._curSpaceCfg; sp.decor = this._decorList.filter((shape) => shape.id !== id); if (this._decorSel === id) this._decorSel = null; this._recordGeometry(this._t('history.decor_delete'), before); this._saveConfig(); this.requestUpdate(); } private _decorDeleteSel(): void { if (this._decorSel) this._deleteDecor(this._decorSel); } private _confirmDecorErase(): void { const pending = this._decorEraseConfirm; this._decorEraseConfirm = null; if (pending) this._deleteDecor(pending.id); } // ================= the furniture library (docs/FURNITURE.md) ================= /** Furniture sees room centrelines plus the physical faces of independent * partitions/drafts/columns. Openings intentionally still use room walls * only; furniture is allowed to lean against every real obstacle. */ private get _furnWalls(): number[][] { const faces = this._rawPhysicalBodiesR().flatMap((body) => body.map((a, i) => { const b = body[(i + 1) % body.length]; return [a[0], a[1], b[0], b[1]]; })); return [...this._segments, ...faces]; } /** How far the wall magnet reaches, render units. */ private get _furnWallReach(): number { return this._gridPitch * FURN_WALL_CELLS; } /** cm → the number the size fields SHOW (metres, or decimal feet). The * `_imperial` it asks is the card's one answer to "feet or metres?", the * same the glow radius reads. */ private _furnFieldValue(cm: number): number { return Math.round((this._imperial ? cm / 30.48 : cm / 100) * 100) / 100; } /** …and back. The config never stores feet: a unit system is how a user * reads a plan, not what the plan is (docs/STYLING-HOOKS.md §6). */ private _furnFieldToCm(v: number): number { return clampFurnCm(this._imperial ? v * 30.48 : v * 100); } /** Arm a symbol: the palette remembers it with ITS default real size, which * the two fields then let the user overrule before the click. */ private _furnPick(symbol: string): void { const d = furnitureDefaultCm(symbol); this._furnPalette = { symbol, w: d.w, h: d.h }; } /** * Put the armed symbol down. * * The press is the CENTRE of the piece, not its corner: a user points at the * place the sofa goes. The size comes from the palette in CENTIMETRES and is * turned into the canvas's normalised units through the space's `cell_cm` — * the one scale this card has — so a 2.2 m sofa is 2.2 m of THIS plan, and a * plan drawn at 10 cm per cell gets a sofa half the cells of one drawn at 5. * * A wall within reach claims it immediately, with its angle, so the common * case (put the bed against that wall) is one click and no dragging. */ private _furnPlace(raw: number[], free = false): void { const pal = this._furnPalette; const sp = this._curSpaceCfg; if (!pal || !sp) return; const W = NORM_W, H = this._decorH; const wN = clampFurnSize(cmToNorm(pal.w, this._cellCm, this._gridPitch, W)); const hN = clampFurnSize(cmToNorm(pal.h, this._cellCm, this._gridPitch, W)); const before = this._geometrySnapshot(); const c = this._decorSnap(raw); let cx = c[0], cy = c[1]; let angle = 0; const snap = free ? null : snapFurnitureToWall(cx, cy, hN * H, this._furnWalls, this._furnWallReach, this._gridPitch); if (snap) { cx = snap.cx; cy = snap.cy; angle = snap.angle; } const id = 'df' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); const shape: any = { id, kind: 'furniture', symbol: pal.symbol, x: clampCanvasN(cx / W - wN / 2), y: clampCanvasN(cy / H - hN / 2), w: wN, h: hN, ...decorStylePatch(this._decorStyle, false), }; // a straight piece stores no angle at all, exactly as a straight label // stores none (docs/LIVE-TEXT.md §3) if (angle) shape.angle = Number(angle.toFixed(2)); sp.decor = [...this._decorList, shape]; this._decorSel = id; // …and the editor goes back to the tool that can move what was just placed this._decorTool = 'select'; this._furnPalette = null; this._recordGeometry(this._t('history.decor_add'), before); this._saveConfig(); this.requestUpdate(); } /** * Drag a placed piece. The magnet wins over the grid while a wall is within * reach — it decides the position AND the rotation, which is the whole point * of it: a sofa pushed at a wall is parallel to that wall or it is wrong. * Out of reach it is the ordinary grid snap on the piece's own anchor, and * the angle it already had is kept. */ private _furnMoveUpdate(ev: PointerEvent): void { const m = this._decorMove!; if (m.orig.kind !== 'furniture') return; const o: any = m.orig; const sp = this._curSpaceCfg; if (!sp) return; const W = NORM_W, H = this._decorH; const p = this._svgPoint(ev); const rawCx = (o.x + o.w / 2) * W + (p[0] - m.start[0]); const rawCy = (o.y + o.h / 2) * H + (p[1] - m.start[1]); let x: number, y: number; let angle = Number(o.angle) || 0; const snap = ev.shiftKey ? null : snapFurnitureToWall( rawCx, rawCy, o.h * H, this._furnWalls, this._furnWallReach, this._gridPitch); if (snap) { x = snap.cx / W - o.w / 2; y = snap.cy / H - o.h / 2; angle = snap.angle; } else { const a = this._decorSnap( [rawCx - (o.w / 2) * W, rawCy - (o.h / 2) * H], ev.pointerType, m.id, ); x = a[0] / W; y = a[1] / H; } x = clampCanvasN(x); y = clampCanvasN(y); if (Math.abs(x - o.x) > 1e-9 || Math.abs(y - o.y) > 1e-9 || Math.abs(angle - (Number(o.angle) || 0)) > 1e-9) m.moved = true; sp.decor = this._decorList.map((s) => { if (s.id !== m.id) return s; const out: any = { ...s, x, y }; if (angle) out.angle = Number(angle.toFixed(2)); else delete out.angle; return out; }); this.requestUpdate(); } /** Write a resized box into the shape — live, without saving. */ private _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: number }): void { const sp = this._curSpaceCfg; if (!sp) return; const W = NORM_W, H = this._decorH; sp.decor = this._decorList.map((s) => { if (s.id !== id) return s; const topLeft = resizedBoxTopLeft( box, (s as any).angle, (point) => this._snap(point), ); return { ...s, x: clampCanvasN(topLeft[0] / W), y: clampCanvasN(topLeft[1] / H), w: Math.max(this._gridPitch / W, Math.min(CANVAS_LIMIT * 2, box.w / W)), h: Math.max(this._gridPitch / H, Math.min(CANVAS_LIMIT * 2, box.h / H)), } as DecorShape; }); this._cfgEpoch++; this.requestUpdate(); } /** * The two live badges of a corner drag: the piece's real WIDTH along its own * top edge and its real DEPTH along its left one — in the HA unit system, * through the same `_fmtLen` (`segmentCm` over `cell_cm`) and the same * `.measurelabel` the wall ruler, the room resize and the backdrop use. * There is one way this card ever states a length. */ private get _furnLive(): { x: number; y: number; text: string }[] | null { const d = this._dtDrag; if (!d || d.kind !== 'scale' || !d.orig) return null; const sh = this._decorList.find((s) => s.id === d.id); if (!sh || sh.kind !== 'furniture') return null; const W = NORM_W, H = this._decorH; const w = sh.w * W, h = sh.h * H; const c = furnitureCorners(sh.x * W, sh.y * H, w, h, Number(sh.angle) || 0); const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; const top = mid(c[0], c[1]); const left = mid(c[0], c[3]); return [ { x: top[0], y: top[1], text: this._fmtLen([0, 0], [w, 0]) }, { x: left[0], y: left[1], text: this._fmtLen([0, 0], [0, h]) }, ]; } /** * The palette: every symbol of the library, grouped, each drawn with the * very same `furniturePathD` the plan uses — so what the user picks is what * the user gets, at any size, and there is no second set of preview assets * to keep in step. Under it the two size fields, prefilled with the chosen * symbol's real size and shown in metres or feet by the HA unit system. */ private _renderFurnPalette(): TemplateResult { const pal = this._furnPalette; // the unit the fields are read in — the same two words the glow radius // already uses, because a plan has one unit system, not one per control const unit = this._t(this._imperial ? 'gs.unit_ft' : 'gs.unit_m'); const preview = (id: string): TemplateResult => { const s = furnitureSymbol(id)!; // fit the symbol into a 40×40 box keeping its real proportions, so a // sofa reads as a sofa and a toilet does not become a square const k = 36 / Math.max(s.w, s.h); const w = s.w * k, h = s.h * k; return svg`` as unknown as TemplateResult; }; return html`
e.stopPropagation()}>
${this._t('furn.title')}
${FURNITURE_GROUPS.map((g) => html`
${this._t(`furn.group_${g}` as any)}
${furnitureOfGroup(g as FurnitureGroup).map((s) => html``)}
`)}
${pal ? html`
(this._furnPalette = { ...pal, w: this._furnFieldToCm(Number((e.target as HTMLInputElement).value)) })} /> (this._furnPalette = { ...pal, h: this._furnFieldToCm(Number((e.target as HTMLInputElement).value)) })} /> ${this._t('furn.place_hint')}
` : html`
${this._t('furn.pick_hint')}
`}
`; } // ============ backdrop transform frame (docs/BACKDROP.md) ============ /** The centred, UNTRANSFORMED rectangle of the current backdrop image. */ private get _bdBase(): Rect | null { const sp = this._curSpaceCfg; return sp?.plan_url ? { ...fitInSquare(sp.plan_aspect, NORM_W) } : null; } /** Where the backdrop image sits right now, render units (null: no image). */ private get _bdRect(): (Rect & { angle?: number }) | null { const sp = this._curSpaceCfg; return sp?.plan_url ? planRect(sp, NORM_W) : null; } /** The stored transform of the current space (defaults = the old behaviour). */ private get _bdParams(): { dx: number; dy: number; sx: number; sy: number; angle: number } { const sp = this._curSpaceCfg; const dx = Number(sp?.plan_x), dy = Number(sp?.plan_y); const legacy = Number(sp?.plan_scale); const sxRaw = Number(sp?.plan_scale_x), syRaw = Number(sp?.plan_scale_y); const baseScale = Number.isFinite(legacy) && legacy > 0 ? legacy : 1; return { dx: Number.isFinite(dx) ? dx : 0, dy: Number.isFinite(dy) ? dy : 0, sx: Number.isFinite(sxRaw) && sxRaw > 0 ? sxRaw : baseScale, sy: Number.isFinite(syRaw) && syRaw > 0 ? syRaw : baseScale, angle: normalizeAngle(sp?.plan_angle), }; } private _openBackdropDialog(ev?: Event): void { if (!this._bdMovable || !this._bdRect) return; ev?.preventDefault(); ev?.stopPropagation(); this._bdDrag = null; const r = this._bdRect; this._backdropDialog = { widthCm: decorUnitsToCm(r.w, this._cellCm, this._gridPitch), heightCm: decorUnitsToCm(r.h, this._cellCm, this._gridPitch), angle: this._angleField(r.angle), }; } private _saveBackdropDialog(): void { const d = this._backdropDialog; const base = this._bdBase, current = this._bdRect; if (!d || !base || !current) return; const angle = normalizeAngle(d.angle); const before = this._geometrySnapshot(); const w = Math.min(base.w * PLAN_SCALE_MAX, Math.max( base.w * PLAN_SCALE_MIN, snapToGrid( decorCmToUnits(d.widthCm, this._cellCm, this._gridPitch), this._gridPitch, ), )); const h = Math.min(base.h * PLAN_SCALE_MAX, Math.max( base.h * PLAN_SCALE_MIN, snapToGrid( decorCmToUnits(d.heightCm, this._cellCm, this._gridPitch), this._gridPitch, ), )); const cx = current.x + current.w / 2, cy = current.y + current.h / 2; const topLeft = resizedBoxTopLeft( { x: cx - w / 2, y: cy - h / 2 }, angle, (point) => this._snap(point), ); this._bdApply((topLeft[0] - base.x) / NORM_W, (topLeft[1] - base.y) / NORM_W, w / base.w, h / base.h, angle); this._backdropDialog = null; this._recordGeometry(this._t('history.backdrop_transform'), before); this._saveConfig(); } /** * The transform frame belongs exclusively to the backdrop tool. It is never * active in Select and can never swallow a decor gesture. */ private get _bdActive(): boolean { return this._mode === 'decor' && !!this._bdRect && this._decorTool === 'backdrop'; } /** * …and may the picture be dragged by its BODY? Only under its own tool. * * The corner handles are precise targets and are live as soon as the frame * is, but the body is the whole picture, i.e. most of the screen — claiming * it under the select tool would take away the one-finger pan the owner * asked for on 2026-08-04 («таскать план при любом масштабе»), which * smoke_pan_any_zoom guards. So moving the picture is a tool, exactly like * drawing a line is. */ private get _bdMovable(): boolean { return this._mode === 'decor' && this._decorTool === 'backdrop' && !!this._bdRect; } /** Write a transform into the space config — live, without saving. */ private _bdApply(dx: number, dy: number, sx: number, sy: number, angle: number): void { const sp = this._curSpaceCfg; if (!sp) return; // 6 decimals: the same precision the rest of the normalised config keeps, // and enough for a 1000-unit canvas to be exact to a thousandth of a pixel sp.plan_x = Number(clampCanvasN(dx).toFixed(6)); sp.plan_y = Number(clampCanvasN(dy).toFixed(6)); delete sp.plan_scale; sp.plan_scale_x = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sx)).toFixed(6)); sp.plan_scale_y = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sy)).toFixed(6)); const a = normalizeAngle(angle); if (a) sp.plan_angle = Number(a.toFixed(2)); else delete sp.plan_angle; this._cfgEpoch++; this.requestUpdate(); } /** * Begin a backdrop gesture. `corner` is the DRAGGED corner as a pair of * signs (-1 = the low side of the axis, +1 = the high one); absent = the * body, i.e. a move. Returns false when there is nothing to grab. */ private _bdStart(ev: PointerEvent, corner?: number[], rotate = false): boolean { const base = this._bdBase, r = this._bdRect; if (!base || !r) return false; const p = this._svgPoint(ev); const sgx = corner ? corner[0] : 0; const sgy = corner ? corner[1] : 0; // the corner that stays put is the OPPOSITE one const fx = sgx > 0 ? r.x : r.x + r.w; const fy = sgy > 0 ? r.y : r.y + r.h; this._bdDrag = { kind: rotate ? 'rotate' : corner ? 'scale' : 'move', pid: ev.pointerId, sx: p[0], sy: p[1], base, p0: this._bdParams, fx, fy, sgx, sgy, rect0: { x: r.x, y: r.y, w: r.w, h: r.h, angle: r.angle }, before: this._geometrySnapshot(), moved: false, }; capturePointer(ev); return true; } /** * One step of the gesture. * * MOVE — the resulting TOP-LEFT CORNER is snapped, not the delta, so one * drag is enough to put a legacy off-grid picture on the lattice * (docs/CANVAS.md §9.3, the same rule decor already follows). * * SCALE — proportional about the fixed corner by default; Shift allows the * two axes to diverge. ROTATE — 5° by default, free with Shift. * * Positional snapping is mandatory (UX-05). */ private _bdMove(ev: PointerEvent): void { const d = this._bdDrag; if (!d) return; const p = this._svgPoint(ev); const b = d.base; if (d.kind === 'move') { const x0 = d.rect0.x, y0 = d.rect0.y; const at = this._snap([x0 + (p[0] - d.sx), y0 + (p[1] - d.sy)]); const changed = Math.abs(at[0] - x0) > 1e-9 || Math.abs(at[1] - y0) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; this._bdApply((at[0] - b.x) / NORM_W, (at[1] - b.y) / NORM_W, d.p0.sx, d.p0.sy, d.p0.angle); return; } if (d.kind === 'rotate') { const cx = d.rect0.x + d.rect0.w / 2, cy = d.rect0.y + d.rect0.h / 2; const a0 = Math.atan2(d.sy - cy, d.sx - cx) * 180 / Math.PI; let angle = d.p0.angle + (Math.atan2(p[1] - cy, p[0] - cx) * 180 / Math.PI - a0); if (!ev.shiftKey) angle = Math.round(angle / DT_ANGLE_STEP) * DT_ANGLE_STEP; angle = normalizeAngle(angle); const changed = Math.abs(angle - d.p0.angle) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; this._bdApply(d.p0.dx, d.p0.dy, d.p0.sx, d.p0.sy, angle); return; } const box = resizeDecorBox( d.rect0, d.sgx, d.sgy, p[0], p[1], !ev.shiftKey, this._gridPitch, Math.min(b.w, b.h) * PLAN_SCALE_MIN, ); const sx = box.w / Math.max(1e-9, b.w), sy = box.h / Math.max(1e-9, b.h); const changed = Math.abs(sx - d.p0.sx) > 1e-9 || Math.abs(sy - d.p0.sy) > 1e-9 || Math.abs(box.x - d.rect0.x) > 1e-9 || Math.abs(box.y - d.rect0.y) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; const topLeft = resizedBoxTopLeft( box, d.p0.angle, (point) => this._snap(point), ); this._bdApply((topLeft[0] - b.x) / NORM_W, (topLeft[1] - b.y) / NORM_W, sx, sy, d.p0.angle); } /** Has this space's picture been moved or scaled at all? */ private get _bdMoved(): boolean { if (this._mode !== 'decor' || !this._bdRect) return false; const p = this._bdParams; return p.dx !== 0 || p.dy !== 0 || p.sx !== 1 || p.sy !== 1 || p.angle !== 0; } /** Put the picture back where an untouched plan has it: centred, own size. */ private _bdReset(): void { const sp = this._curSpaceCfg; if (!sp) return; const before = this._geometrySnapshot(); delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale; delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle; this._bdDrag = null; this._recordGeometry(this._t('history.backdrop_transform'), before); this._saveConfig(); this._showToast(this._t('decor.backdrop_reset_done')); this.requestUpdate(); } /** Release: persist only when something actually moved. */ private _bdUp(): void { const d = this._bdDrag; this._bdDrag = null; if (d?.moved) { this._recordGeometry(this._t('history.backdrop_transform'), d.before); this._saveConfig(); } this.requestUpdate(); } /** * Live size badge while the picture is dragged or scaled (owner 2026-08-04): * its REAL width × height through `cell_cm`, in the HA unit system — the * same `_fmtLen` (`segmentCm`/`formatLength`) and the same `.measurelabel` * the wall ruler and the room resize use, so there is one way the card ever * states a length. */ private get _bdLive(): { x: number; y: number; text: string } | null { if (!this._bdDrag) return null; const r = this._bdRect; if (!r) return null; return { x: r.x + r.w / 2, y: r.y + r.h / 2, text: `${this._fmtLen([0, 0], [r.w, 0])} × ${this._fmtLen([0, 0], [0, r.h])}`, }; } /** The transform frame itself: a dashed outline and four corner handles. */ private _renderBackdropFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const r = this._bdRect; if (!this._bdActive || !r) return nothing; // Two radii, one gesture — the split the text frame uses (docs/LIVE-TEXT.md // §3) and, since 2026-08-05, every corner handle in the card. `hr` is the // HIT radius: a fraction of the visible view, so the target stays // finger-sized at any zoom. `kr` is what you SEE — a quarter of it, because // a blob the size of a room is not a handle, it is an occlusion (owner: // «уменьшить в 4 раза… они постоянно гигантские»). The clickable area is // unchanged; only the ink shrank. const hr = Math.max(view.w, view.h) * 0.02; const kr = hr / 4; const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const angle = normalizeAngle(r.angle); const arm = hr * 2.2; const corners: [number, number, string][] = [ [-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'], ]; return svg` { e.stopPropagation(); e.preventDefault(); this._bdStart(e, undefined, true); }}> ${corners.map(([sx, sy, cur]) => { const cx = sx < 0 ? r.x : r.x + r.w; const cy = sy < 0 ? r.y : r.y + r.h; return svg` { e.stopPropagation(); e.preventDefault(); this._bdStart(e, [sx, sy]); }}>`; })} ` as unknown as TemplateResult; } /** * The common selected-object frame: line endpoints, or a dashed outline, * four corner handles and one rotation handle. Same * mechanics and same handle size as the backdrop frame (docs/BACKDROP.md * §2) — finger-sized in SCREEN terms, so it stays grabbable at any zoom — * and it rides the block's own rotation, so the corners stay at the corners. */ private _renderTextFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const sh = this._dtSel; const b = this._dtBox; if (!sh || !b || b.id !== sh.id) return nothing; // Two radii, one gesture (owner 2026-08-05: «уменьшить в 4 раза»). `hr` // is the HIT radius — unchanged, still 1.8 % of the visible view, still // finger-sized at any zoom — carried by an invisible circle. `kr` is what // you SEE: a quarter of it, a bead instead of a button, so the frame stops // covering the very words it is framing. Same split the wall-resize // handles use (.rszhandle + .rszicon, docs/RESIZE.md). const hr = Math.max(view.w, view.h) * 0.018; const kr = hr / 4; if (sh.kind === 'line') { const a = [sh.x1 * NORM_W, sh.y1 * this._decorH]; const c = [sh.x2 * NORM_W, sh.y2 * this._decorH]; return svg` ${[a, c].map((p, i) => svg` this._dtStart(e, 'scale', undefined, i as 0 | 1)}> `)} ` as unknown as TemplateResult; } const [ax, ay] = this._dtPivot(sh); const ang = Number(sh.angle) || 0; const corners: [number, number, string][] = [ [-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'], ]; const arm = hr * 2.2; return svg` this._dtStart(e, 'rotate')}> ${corners.map(([sx, sy, cur]) => svg` this._dtStart(e, 'scale', [sx, sy])}>`)} ` as unknown as TemplateResult; } private _renderDecorLayer(): TemplateResult { const W = NORM_W, H = this._decorH; const editing = this._mode === 'decor'; const erasing = editing && this._decorTool === 'erase'; const shapes = this._decorList.map((sh) => { const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : ''); const style = this._decorResolvedStyle(sh); const strokeWidth = this._decorWidthUnits(sh); const down = (e: PointerEvent) => this._decorShapeDown(e, sh); const dbl = (e: MouseEvent) => this._decorShapeDbl(e, sh); // docs/STYLING-HOOKS.md §3: every shape carries its kind and its id if (sh.kind === 'line') // round caps: line ends read as circles of the stroke width, so two // lines meeting at an angle join without the notch (owner's screenshot) return svg` ${editing && this._decorTool === 'select' ? svg`` : nothing} ${erasing ? svg`` : nothing}`; if (sh.kind === 'rect') { const cx = (sh.x + sh.w / 2) * W, cy = (sh.y + sh.h / 2) * H; const ang = normalizeAngle(sh.angle); return svg` ${erasing ? svg`` : nothing}`; } if (sh.kind === 'ellipse') { const cx = (sh.x + sh.w / 2) * W, cy = (sh.y + sh.h / 2) * H; const ang = normalizeAngle(sh.angle); return svg` ${erasing ? svg`` : nothing}`; } if (sh.kind === 'furniture') { // One path per piece, generated at the shape's REAL size, so the // stroke is an ordinary stroke-width in render units like every other // decor shape — no non-uniform scale to fight (docs/FURNITURE.md §2). // An unknown symbol renders as nothing: a plan from a newer card must // open in an older one, not break it. const W2 = sh.w * W, H2 = sh.h * H; const d = furniturePathD(sh.symbol, W2, H2); if (!d) return nothing; const ang = Number(sh.angle) || 0; const cx = sh.x * W + W2 / 2, cy = sh.y * H + H2 / 2; const tr = `${ang ? `rotate(${ang} ${cx} ${cy}) ` : ''}translate(${sh.x * W} ${sh.y * H})`; return svg` ${erasing ? svg`` : nothing}`; } if (sh.kind === 'text') { // The label is painted from the LIVE value on every render — the same // `hass` the rest of the card reads, no polling of its own. Without an // entity `liveText` gives the stored text back byte-for-byte, so a // plain label is the plain label it always was (docs/LIVE-TEXT.md). const fs = this._decorTextUnits(sh); const frozenText = this._renderDeviceSnapshot?.facts.get(`decor:${this._space}:${sh.id}`); const lines = decorTextLines(typeof frozenText === 'string' ? frozenText : liveText( sh.text, sh, this._renderPlanHass, (eid) => this._renderEntityAvailable(eid), )); const ax = sh.x * W, ay = sh.y * H; const ang = Number(sh.angle) || 0; // the block is centred on its anchor, horizontally (the layer's // text-anchor) and vertically — so adding a second line grows the // label in both directions instead of pushing the first one up const y0 = ay - ((lines.length - 1) * fs * DT_LINE) / 2; return svg`${lines.map( (ln, i) => svg`${ln}`, )}`; } return nothing; }); // живое превью рисуемой фигуры let draft: unknown = nothing; const d = this._decorDraft; if (d) { const st = this._decorStyle; const sw = decorCmToUnits(st.widthCm, this._cellCm, this._gridPitch); if (d.kind === 'line') draft = svg``; else { const x = Math.min(d.a[0], d.b[0]), y = Math.min(d.a[1], d.b[1]); const w = Math.abs(d.b[0] - d.a[0]), h = Math.abs(d.b[1] - d.a[1]); draft = d.kind === 'rect' ? svg`` : svg``; } } return svg`${shapes}${draft}` as unknown as TemplateResult; } // ================= shared editor secondary surface ================= private get _editorToolbarGroups(): readonly EditorToolbarGroup[] { if (this._mode !== 'plan') return []; return [{ id: 'opening', label: this._t('markup.opening'), icon: 'mdi:door', activeItemId: this._tool === 'opening' ? this._openingPreset?.type : undefined, items: [ { id: 'window', label: this._t('opening.window'), icon: 'mdi:window-closed-variant', role: 'tool', invoke: () => this._activateOpeningPlacement('window'), }, { id: 'door', label: this._t('opening.door'), icon: 'mdi:door-open', role: 'tool', invoke: () => this._activateOpeningPlacement('door'), }, { id: 'passage', label: this._t('opening.passage'), icon: 'mdi:arch', role: 'tool', invoke: () => this._activateOpeningPlacement('passage'), }, { id: 'gate', label: this._t('opening.gate'), icon: 'mdi:gate', role: 'tool', invoke: () => this._activateOpeningPlacement('gate'), }, ], }]; } private _renderEditorGroupLauncher(group: EditorToolbarGroup): TemplateResult { return this._editorSecondary.renderGroupLauncher( group, this._editorToolbarGroups, this._editorSecondaryCopy, ); } /** Stable target identity + current config epoch + operation revision. */ private get _editorSecondaryContextId(): string { const base = `editor:${this._mode}:${this._space}:${this._cfgEpoch}`; const group = this._editorSecondary.activeGroup(this._editorToolbarGroups); if (group) return `${base}:group:${group.id}:${this._editorSecondary.groupGeneration}`; if (this._mode === 'plan') { const sel = this._physicalSel; if (sel) return `${base}:selection:${sel.kind}:${sel.id}:${sel.segment ?? ''}`; return `${base}:tool:${this._tool}:${this._path.length}:${this._openWallAnchor ? 1 : 0}`; } if (this._mode === 'decor') { if (this._decorTool === 'furniture') return `${base}:palette:furniture:${this._furnPalette?.symbol || 'none'}`; if (this._decorTool === 'select' && this._decorSel) return `${base}:selection:decor:${this._decorSel}`; return `${base}:tool:${this._decorTool}:${this._bdMoved ? 1 : 0}:${this._bdDrag ? 1 : 0}`; } return `${base}:none`; } private _runEditorContext(contextId: string, action: () => void): void { this._editorSecondary.runContext(contextId, this._editorSecondaryContextId, action); } private _renderEditorGroupModel(group: EditorToolbarGroup): EditorSecondaryModel { return this._editorSecondary.renderGroupModel( group, this._editorSecondaryContextId, this._editorSecondaryCopy, ); } private _renderDrawWallControl(): TemplateResult { return html``; } private _renderPlanSecondary(): EditorSecondaryModel | null { const contextId = this._editorSecondaryContextId; const sel = this._physicalSel; if (sel) { const exists = sel.kind === 'partition' ? !!this._curSpaceCfg.partitions?.some((item: PartitionCfg) => item.id === sel.id) : sel.kind === 'column' ? !!this._curSpaceCfg.wall_columns?.some((item: WallColumnCfg) => item.id === sel.id) : !!this._curSpaceCfg.room_drafts?.some((item: RoomDraftCfg) => item.id === sel.id); if (!exists) return null; const label = sel.kind === 'partition' ? this._t('markup.partition') : sel.kind === 'column' ? this._t('markup.column') : this._t('markup.add'); return { contextId, kind: 'selection', ariaLabel: this._t('editor.context_actions', { object: label }), visibleLabel: label, content: html` `, }; } const hasThickness = this._tool === 'draw' || this._tool === 'column'; const hintKey = this._tool === 'column' ? 'markup.hint_column' : this._tool === 'resize' ? 'markup.hint_resize' : this._tool === 'wallthick' ? 'markup.hint_wallthick' : this._tool === 'boundary' ? this._boundaryHintKey : null; const drawHint = this._tool === 'draw' ? this._t(this._path.length ? 'markup.hint_points' : 'markup.hint_start', this._path.length ? { n: this._path.length } : undefined) : ''; if (!hasThickness && !hintKey && !drawHint) return null; const operation = (this._tool === 'draw' && this._path.length > 0) || (this._tool === 'boundary' && !!this._openWallAnchor); return { contextId, kind: operation ? 'operation' : 'tool', ariaLabel: this._t('editor.tool_options', { tool: this._t((this._tool === 'draw' ? 'markup.add' : this._tool === 'column' ? 'markup.column' : this._tool === 'resize' ? 'markup.resize' : this._tool === 'wallthick' ? 'markup.wallthick' : 'markup.boundary') as any), }), content: html` ${hasThickness ? this._renderDrawWallControl() : nothing} ${drawHint ? html`${drawHint}` : nothing} ${hintKey ? html`${this._t(hintKey as any)}` : nothing} ${this._tool === 'draw' && this._path.length ? html`` : nothing}`, }; } private _renderDecorSecondary(): EditorSecondaryModel | null { const contextId = this._editorSecondaryContextId; if (this._decorTool === 'furniture') { return { contextId, kind: 'palette', ariaLabel: this._t('editor.palette', { tool: this._t('decor.furniture') }), launcherId: 'furniture', dismissPolicy: this._furnPalette ? 'stay-open-on-canvas' : 'outside', dismiss: () => { if (this._decorTool !== 'furniture') return; this._furnPalette = null; this._decorTool = 'select'; this.requestUpdate(); }, content: this._renderFurnPalette(), }; } const selected = this._dtSel; if (selected) { const label = this._t((`decor.${selected.kind}`) as any); return { contextId, kind: 'selection', ariaLabel: this._t('editor.context_actions', { object: label }), visibleLabel: label, content: html` `, }; } const draws = this._decorTool === 'line' || this._decorTool === 'rect' || this._decorTool === 'ellipse'; const canFill = this._decorTool === 'rect' || this._decorTool === 'ellipse'; const backdrop = this._decorTool === 'backdrop' && !!this._bdRect; if (!draws && !backdrop) return null; return { contextId, kind: 'tool', ariaLabel: this._t('editor.tool_options', { tool: this._t((draws ? `decor.${this._decorTool}` : 'decor.backdrop') as any), }), content: html` ${draws ? html` ) => (this._decorStyle = { ...this._decorStyle, ...e.detail })}> ${canFill ? html` ) => (this._decorStyle = { ...this._decorStyle, fillColor: e.detail.color, fillOpacity: e.detail.opacity })}>` : nothing} ` : nothing} ${backdrop ? html`${this._t('decor.backdrop_hint')}` : nothing}`, }; } /** Backdrop reset is a space-level maintenance action, not a tool option. * Keep it reachable from every ordinary decor context as it was before the * contextual tray extraction; an explicit palette keeps priority. */ private _withBackdropReset(model: EditorSecondaryModel | null): EditorSecondaryModel | null { if (!this._bdMoved || this._bdDrag || model?.kind === 'palette') return model; const contextId = this._editorSecondaryContextId; const reset = html``; if (!model) { return { contextId, kind: 'tool', ariaLabel: this._t('editor.tool_options', { tool: this._t('decor.backdrop') }), content: reset, }; } return { ...model, kind: model.kind === 'selection' ? 'mixed' : model.kind, content: html`${model.content}${reset}`, }; } private get _editorSecondaryDialogBlocked(): boolean { return !!(this._tapConfirm || this._vacCalConfirm || this._roomDialog || this._mergeDialog || this._openingDialog || this._physicalDialog || this._openingInfo || this._decorTextDialog || this._decorShapeDialog || this._backdropDialog || this._decorEraseConfirm || this._spaceDialog || this._markerDialog || this._infoCard || this._rulesDialog || this._settingsDialog || this._alignDialog || this._importDialog || this._kioskDialog || this._backupExportDialog || this._backupImportDialog || this._wallDialog); } private _renderEditorSecondary(): TemplateResult | typeof nothing { if (!this._editing) return nothing; const group = this._editorSecondary.activeGroup(this._editorToolbarGroups); const model = group ? this._renderEditorGroupModel(group) : this._mode === 'plan' ? this._renderPlanSecondary() : this._mode === 'decor' ? this._withBackdropReset(this._renderDecorSecondary()) : null; return this._editorSecondary.render(model, this._editorSecondaryDialogBlocked); } private _renderDecorBar(): TemplateResult { const tools = [ ['select', 'mdi:cursor-default-outline', 'decor.select'], // moving the picture is a TOOL (docs/BACKDROP.md §2) — offered only when // there IS a picture, so a hand-drawn space's bar is unchanged ...(this._bdRect ? [['backdrop', 'mdi:image-move', 'decor.backdrop'] as const] : []), ['line', 'mdi:vector-line', 'decor.line'], ['rect', 'mdi:rectangle-outline', 'decor.rect'], ['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'], ['text', 'mdi:format-text', 'decor.text'], // the library sits next to the shapes it belongs with (docs/FURNITURE.md) ['furniture', 'mdi:sofa-outline', 'decor.furniture'], ['erase', 'mdi:eraser', 'decor.erase'], ] as const; const undoName = this._geometryHistory.undoName; const redoName = this._geometryHistory.redoName; return html`
${tools.map( ([t, ic, k]) => html``, )} ${this._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
`; } private _renderDecorEraseConfirm(): TemplateResult { const pending = this._decorEraseConfirm!; const kind = this._t(`decor.${pending.kind}` as any); return html` (this._decorEraseConfirm = null)}>

${this._t('confirm.erase_decor', { kind })}

`; } private _renderDecorTextDialog(): TemplateResult { const d = this._decorTextDialog!; const ent = (d.pickerEntity || '').trim(); const st = ent ? this.hass?.states?.[ent] : null; return html` (this._decorTextDialog = null)}>
${''/* a textarea, not an input: the user's own line breaks are kept and rendered (centred). Enter is a NEW LINE here, so saving moved to Ctrl/Cmd+Enter and the button. */} ) => (this._decorTextDialog = { ...d, ...e.detail })}>
(this._decorTextDialog = { ...d, sizeCm: this._decorTextCm(Number((e.target as HTMLInputElement).value)) })} /> ${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
(this._decorTextDialog = { ...d, angle: (e.target as HTMLInputElement).value })} /> (this._decorTextDialog = { ...d, pickerEntity: (e.target as HTMLInputElement).value, })} /> ${Object.keys(this.hass?.states || {}).map((id) => html``)} ${ent ? html` ` : nothing}
`; } private _renderDecorShapeDialog(): TemplateResult { const d = this._decorShapeDialog!; const canFill = d.kind === 'rect' || d.kind === 'ellipse'; const kindLabel = this._t(('decor.' + d.kind) as any); const unit = this._t(this._imperial ? 'gs.unit_ft' : 'gs.unit_m'); return html` (this._decorShapeDialog = null)}>
${d.kind === 'furniture' ? html` ` : nothing} ) => (this._decorShapeDialog = { ...d, ...e.detail })}>
(this._decorShapeDialog = { ...d, widthCm: this._decorSmallCm(Number((e.target as HTMLInputElement).value)), })} />${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
${d.kind === 'line' ? html`
(this._decorShapeDialog = { ...d, lengthCm: this._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> ${unit}
` : html`
(this._decorShapeDialog = { ...d, sizeWCm: this._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> × (this._decorShapeDialog = { ...d, sizeHCm: this._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> ${unit}
`} (this._decorShapeDialog = { ...d, angle: (e.target as HTMLInputElement).value })} /> ${canFill ? html` ) => (this._decorShapeDialog = { ...d, fillColor: e.detail.color, fillOpacity: e.detail.opacity })}>` : nothing}
`; } private _renderBackdropDialog(): TemplateResult { const d = this._backdropDialog!; const unit = this._t(this._imperial ? 'gs.unit_ft' : 'gs.unit_m'); return html` (this._backdropDialog = null)}>
(this._backdropDialog = { ...d, widthCm: this._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> × (this._backdropDialog = { ...d, heightCm: this._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> ${unit}
(this._backdropDialog = { ...d, angle: (e.target as HTMLInputElement).value })} />
`; } /** CSS-pixel interaction constants stay stable at every plan zoom. */ private _cssPxToRender(px: number): number { const stage = this._stageEl; const view = this._viewOr(this._baseVb()); if (!stage?.clientWidth || !stage.clientHeight) return (this._gridPitch / 8) * px; return Math.max(view.w / stage.clientWidth, view.h / stage.clientHeight) * px; } private get _boundaryCoarse(): boolean { return this._boundaryPointerType === 'touch' || this._boundaryPointerType === 'pen'; } private _boundaryTolerances(): { hit: number; cap: number; ambiguity: number } { return { hit: this._cssPxToRender(this._boundaryCoarse ? 22 : 12), cap: this._cssPxToRender(this._boundaryCoarse ? 10 : 6), ambiguity: this._cssPxToRender(6), }; } /** Independent masonry owns its hit zone and blocks a room boundary below. */ private _boundaryBlocked(raw: number[], hit: number): boolean { const space = this._spaceModel(); if (!space) return false; const nearBody = (body: number[][]): boolean => pointInPhysicalBody(raw, body) || body.some((a, i) => { const b = body[(i + 1) % body.length]; return distToSegment(raw, [a[0], a[1], b[0], b[1]]) <= hit; }); for (const c of space.wall_columns || []) { if (nearBody(columnBody(c, this._cellCm, this._gridPitch))) return true; } for (const p of space.partitions || []) { const half = wallCmToUnits(p.cm, this._cellCm, this._gridPitch) / 2; if (distToSegment(raw, [p.a[0], p.a[1], p.b[0], p.b[1]]) <= Math.max(hit, half)) return true; } for (const draft of space.room_drafts || []) { for (let i = 0; i + 1 < draft.points.length; i++) { const a = draft.points[i], b = draft.points[i + 1]; const half = wallCmToUnits(draft.segments[i]?.cm || 15, this._cellCm, this._gridPitch) / 2; if (distToSegment(raw, [a[0], a[1], b[0], b[1]]) <= Math.max(hit, half)) return true; } } return false; } private _solidBoundaryPull(seg: number[], cuts = this._openCuts()): number { const tol = this._boundaryTolerances(); const space = this._spaceModel(); if (!space) return tol.hit; const cm = intervalCmAt( space.rooms, this._spaceWalls, cuts, seg, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); return Math.max( tol.hit, cm > 0 ? wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2 : 0, ); } /** One resolver drives cursor, preview, hint and click semantics. */ private _boundaryTargetAt(raw: number[]): BoundaryUiTarget { const view = this._viewOr(this._baseVb()); const stage = this._stageEl; const key = [ this._space, this._cfgEpoch, raw[0], raw[1], this._boundaryPointerType, view.x, view.y, view.w, view.h, stage?.clientWidth || 0, stage?.clientHeight || 0, ].join('|'); if (this._boundaryTargetMemo?.key === key) return this._boundaryTargetMemo.value; const tol = this._boundaryTolerances(); const cuts = this._openCuts(); const space = this._spaceModel(); if (!space) return { kind: 'none' }; let value: BoundaryUiTarget = resolveBoundaryTarget(raw, space.rooms, cuts, { openPull: tol.hit, openEndCap: tol.cap, ambiguity: tol.ambiguity, eps: this._gridPitch * 0.02, solidPull: (seg) => this._solidBoundaryPull(seg, cuts), }); // Independent masonry blocks only a real room boundary underneath it. A // bare click on a partition/column/draft elsewhere remains a neutral miss. if ((value.kind === 'shared' || value.kind === 'open') && this._boundaryBlocked(raw, tol.hit)) { value = { kind: 'blocked' }; } this._boundaryTargetMemo = { key, value }; return value; } private get _boundaryTarget(): BoundaryUiTarget | null { if (!this._markup || this._tool !== 'boundary' || !this._cursorPt) return null; return this._boundaryTargetAt(this._cursorPt); } /** Exact predicted action under the pointer, including restored wall body. */ private get _boundaryPreview(): BoundaryPreview | null { const view = this._viewOr(this._baseVb()); const stage = this._stageEl; const raw = this._cursorPt; const anchor = this._openWallAnchor; const key = [ this._markup, this._tool, this._space, this._cfgEpoch, this._boundaryPointerType, raw?.[0] ?? 'none', raw?.[1] ?? 'none', anchor?.p?.[0] ?? 'none', anchor?.p?.[1] ?? 'none', anchor?.edge?.join(',') ?? 'none', anchor?.aId ?? 'none', anchor?.bId ?? 'none', view.x, view.y, view.w, view.h, stage?.clientWidth || 0, stage?.clientHeight || 0, ].join('|'); if (this._boundaryPreviewMemo?.key === key) return this._boundaryPreviewMemo.value; const remember = (value: BoundaryPreview | null): BoundaryPreview | null => { this._boundaryPreviewMemo = { key, value }; return value; }; if (!this._markup || this._tool !== 'boundary') return remember(null); // A touch tap need not emit pointermove. The committed P1 marker therefore // comes from the anchor itself and never depends on a hover-only cursor. if (anchor && !raw) return remember({ kind: 'anchor', point: [...anchor.p] }); if (!raw) return remember(null); const cuts = this._openCuts(); const eps = this._gridPitch * 0.02; if (anchor) { const { p, edge } = anchor; const joints = jointsOnEdge(edge, cuts, eps); const p2 = snapOpenPoint(raw, edge, joints, this._gridPitch, this._gridPitch * 1.5); const q = clampToEdgeEnds(p2, edge); const invalid = this._boundaryBlocked(raw, this._boundaryTolerances().hit) || distToSegment(raw, edge) > this._solidBoundaryPull(edge, cuts); return remember({ kind: 'range', seg: [p[0], p[1], q[0], q[1]], invalid }); } const target = this._boundaryTargetAt(raw); if (target.kind === 'shared') { const point = snapOpenPoint( raw, target.edge, jointsOnEdge(target.edge, cuts, eps), this._gridPitch, this._gridPitch * 1.5, ); return remember({ kind: 'anchor', point }); } if (target.kind === 'open') { const plan = this._planClosedOpenSpan(target.seg); const body = plan ? partitionBody( [target.seg[0], target.seg[1]], [target.seg[2], target.seg[3]], plan.cm, this._cellCm, this._gridPitch, ) : null; return remember({ kind: 'restore', seg: target.seg, body }); } if (target.kind === 'blocked' || target.kind === 'ambiguous' || target.kind === 'outer') { return remember({ kind: 'invalid', point: raw }); } return remember(null); } private get _boundaryHintKey(): I18nKey { if (this._openWallAnchor) { const preview = this._boundaryPreview; return preview?.kind === 'range' && preview.invalid ? 'markup.boundary_hint_retry' : 'markup.boundary_hint_second'; } const target = this._boundaryTarget; if (!target || target.kind === 'none') return 'markup.boundary_hint'; if (target.kind === 'open') return 'markup.boundary_hint_restore'; if (target.kind === 'shared') return 'markup.boundary_hint_open'; if (target.kind === 'outer') return 'markup.boundary_hint_outer'; if (target.kind === 'blocked') return 'markup.boundary_hint_blocked'; return 'markup.boundary_hint_ambiguous'; } private get _boundaryStageClass(): string { if (!this._markup || this._tool !== 'boundary') return ''; if (this._openWallAnchor) { const preview = this._boundaryPreview; return preview?.kind === 'range' && preview.invalid ? ' boundary-invalid' : ' boundary-solid'; } const target = this._boundaryTarget; if (target?.kind === 'open') return ' boundary-open'; if (target?.kind === 'shared') return ' boundary-solid'; if (target && target.kind !== 'none') return ' boundary-invalid'; return ''; } /** Dashed virtual boundaries plus the unified tool's local action preview. */ private _renderOpenWalls(disp?: SpaceDisplay): TemplateResult { if (disp && !disp.showBorders && !this._editing) return svg`` as unknown as TemplateResult; const cuts = this._openCuts(); const preview = this._boundaryPreview; if (!cuts.length && !preview) return svg`` as unknown as TemplateResult; const stroke = disp?.color || 'var(--hp-muted)'; const bodyPath = (body: number[][]) => `M ${body.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; const marker = (point: number[], invalid = false) => invalid ? svg` ` : svg``; return svg` ${cuts.map((sg) => svg``)} ${preview?.kind === 'range' ? svg` ${marker([preview.seg[0], preview.seg[1]])} ${marker([preview.seg[2], preview.seg[3]], preview.invalid)}` : nothing} ${preview?.kind === 'restore' && preview.body ? svg`` : nothing} ${preview?.kind === 'anchor' ? marker(preview.point) : preview?.kind === 'invalid' ? marker(preview.point, true) : nothing} ` as unknown as TemplateResult; } /** Open cuts in render units (from open_spans or legacy open_to). */ private _openCuts(): number[][] { const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!space) return []; return geometryOpenCuts(sp, space, this._gridPitch, NORM_W); } /** Open cuts grouped by room pair (for per-room outline trimming). */ private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] { const cuts = this._openCuts(); return geometryOpenPairs(this._spaceModel()?.rooms || [], cuts, this._gridPitch); } /** * One geometry transaction for open spans (AUD-159B6-02). Called by EVERY * operation that rewrites the room set — resize/scale commit, Undo-adjacent * commits, Split, Merge, Delete: rekey explicit spans onto the moved edges, * drop/clip them against the NEW rooms, then derive `open_to` from what is * left. The order matters: the legacy `open_to` index is never read in the * middle, or a removed explicit span resurrects a different stretch. */ private _commitOpenSpans( rekey?: { old: [number[], number[]][]; next: [number[], number[]][] }, ): void { const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!sp || !space) return; const eps = this._gridPitch * 0.02; this._cfgEpoch++; // the room set changed under the model memo const rooms = space.rooms; let spans = sanitizeOpenSpans((sp as any).open_spans); if (spans.length && rekey?.old.length) { spans = rekeyOpenSpansAfterMove(spans, rekey.old, rekey.next, NORM_W); } else if (!spans.length) { // A legacy `open_to`-only space: its truth is connectivity, so the // stretches are re-derived on the new geometry and persisted here (the // first-save migration the spec asks for). spans = cutsToSpanEntries(resolveOpenCuts(rooms, null, NORM_W, eps), NORM_W); } spans = clipOpenSpansToShared(spans, rooms, NORM_W, eps); this._persistOpenCuts(spans.map((e) => entryToSeg(e, NORM_W))); } /** Write cuts into space.open_spans and sync open_to. */ private _persistOpenCuts(cuts: number[][]): void { const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!sp || !space) return; const eps = this._gridPitch * 0.02; const entries = clipOpenSpansToShared( cutsToSpanEntries(cuts, NORM_W), space.rooms, NORM_W, eps, ); const canonicalCuts = entries.map((e) => entryToSeg(e, NORM_W)); if (entries.length) (sp as any).open_spans = entries; else delete (sp as any).open_spans; syncOpenToFromCuts(sp.rooms || [], space.rooms, canonicalCuts, eps); } /** Pure close plan shared by the body preview and the actual mutation. */ private _planClosedOpenSpan(sg: number[]): { cuts: number[][]; walls: WallEntry[]; cm: number } | null { const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!sp || !space) return null; const eps = this._gridPitch * 0.02; const oldCuts = this._openCuts(); // Materialise exact endpoints of every real remainder before removing the // cut. Older entries carry only midpoint+angle; without this one cut can be // the sole record of the boundary between (for example) 20 and 30 cm. let seededWalls = Array.isArray(sp.walls) ? sp.walls.slice() : []; for (const iv of wallIntervals( space.rooms, seededWalls, oldCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, )) { if (iv.open || !(iv.cm > 0)) continue; seededWalls = setWallThickness( seededWalls, iv.a, iv.b, iv.cm, this._wallKeyPitch, NORM_W, ); } const cuts = removeCut(oldCuts, sg, eps); // The stretch is solid again: rekey thickness onto the merged intervals // first — a partially opened wall keeps the cm of the part that stayed // closed. Only a wall that was open end to end has nothing to inherit and // takes the default (owner 2026-08-05). let walls = this._normalizeWalls(seededWalls, cuts); let cm = intervalCmAt( space.rooms, walls, cuts, sg, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); if (!(cm > 0)) { const solid: number[][] = []; for (const iv of wallIntervals( space.rooms, walls, cuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, )) { if (iv.open) continue; solid.push([iv.a[0], iv.a[1], iv.b[0], iv.b[1]]); } walls = applyThicknessOnClose( walls, sg, solid, this._wallKeyPitch, NORM_W, DRAW_WALL_DEFAULT_CM, ); walls = this._normalizeWalls(walls, cuts); cm = intervalCmAt( space.rooms, walls, cuts, sg, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); } return { cuts, walls, cm: cm > 0 ? cm : DRAW_WALL_DEFAULT_CM }; } private _closeOpenSpan(sg: number[]): void { const sp = this._curSpaceCfg; if (!sp) return; const before = this._geometrySnapshot(); const plan = this._planClosedOpenSpan(sg); if (!plan) return; if (plan.walls.length) sp.walls = plan.walls; else delete sp.walls; this._persistOpenCuts(plan.cuts); if (this._commitPhysicalGeometry(this._t('history.close_boundary'), before)) this._showToast(this._t('toast.boundary_restored')); this.requestUpdate(); } /** Unified Boundary tool: restore a dash, or choose two points on one shared wall. */ private _boundaryClick(raw: number[]): void { // Ignore a same-place follow-up after restoring a span. Some touch browsers // report both taps with detail=0/1, so time and position are the stable guard. const guard = this._boundaryRestoreGuard; if (guard && Date.now() > guard.until) this._boundaryRestoreGuard = null; if (guard && Date.now() <= guard.until && Math.hypot(raw[0] - guard.point[0], raw[1] - guard.point[1]) <= this._boundaryTolerances().hit) return; const eps = this._gridPitch * 0.02; const cuts = this._openCuts(); if (this._openWallAnchor) { const { p, edge } = this._openWallAnchor; if (distToSegment(raw, edge) > this._solidBoundaryPull(edge, cuts)) { this._showToast(this._t('toast.boundary_same_edge')); return; // keep P1 for a retry } if (this._boundaryBlocked(raw, this._boundaryTolerances().hit)) { this._showToast(this._t('toast.boundary_blocked')); return; } const joints = jointsOnEdge(edge, cuts, eps); const p2 = snapOpenPoint(raw, edge, joints, this._gridPitch, this._gridPitch * 1.5); const clamped = clampToEdgeEnds(p2, edge); const len = Math.hypot(clamped[0] - p[0], clamped[1] - p[1]); this._openWallAnchor = null; this._cursorPt = null; if (len < this._gridPitch * 0.5) { this._showToast(this._t('toast.openwall_short')); return; } const sg = [p[0], p[1], clamped[0], clamped[1]]; const next = [...cuts, sg]; const sp = this._curSpaceCfg; if (!sp) return; const before = this._geometrySnapshot(); // A virtual stretch carries no thickness: the key of the wall it cuts is // SPLIT here — the covered piece is dropped, the solid remainder keeps // the cm under its own atomic key (spec invariant, AUD-159B6-01). Simply // deleting every key that touches the span, as this used to do, threw the // remainder away with it. const walls = this._normalizeWalls(sp.walls, next); if (walls.length) sp.walls = walls; else delete sp.walls; const beforeOp = (sp.openings || []).length; sp.openings = purgeOpeningsOnSpan(sp.openings, sg, NORM_W, this._gridPitch * 6); const removedOpenings = (sp.openings || []).length < beforeOp; this._persistOpenCuts(next); if (this._commitPhysicalGeometry(this._t('history.open_boundary'), before, [ [sg[0] / NORM_W, sg[1] / NORM_W], [sg[2] / NORM_W, sg[3] / NORM_W], ])) { if (removedOpenings) this._showToast(this._t('toast.openwall_openings_removed')); this._showToast(this._t('toast.boundary_opened')); } this.requestUpdate(); return; } const target = this._boundaryTargetAt(raw); if (target.kind === 'open') { this._boundaryRestoreGuard = { until: Date.now() + 450, point: [...raw] }; this._cursorPt = null; this._closeOpenSpan(target.seg); return; } if (target.kind === 'ambiguous') { this._showToast(this._t('toast.boundary_ambiguous')); return; } if (target.kind === 'blocked') { this._showToast(this._t('toast.boundary_blocked')); return; } if (target.kind === 'outer') { this._showToast(this._t('toast.openwall_shared_only')); return; } if (target.kind !== 'shared') { this._showToast(this._t('toast.openwall_pick')); return; } const joints = jointsOnEdge(target.edge, cuts, eps); const p1 = snapOpenPoint(raw, target.edge, joints, this._gridPitch, this._gridPitch * 1.5); this._openWallAnchor = { p: p1, edge: target.edge, aId: target.a.id!, bId: target.b.id!, }; this.requestUpdate(); } /** Delete tool: defer all wall consequences to one explicit accessible choice. */ private _deleteRoomClick(raw: number[]): void { const space = this._spaceModel(); if (!space) return; const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!room) { this._showToast(this._t('toast.delete_room_pick')); return; } if (!room.id) return; this._roomDeleteDialog = { roomId: room.id, name: room.name }; } private _confirmRoomDelete = (keepWalls: boolean): void => { const dialog = this._roomDeleteDialog; const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!dialog || !sp || !space) return; const room = space.rooms.find((candidate) => candidate.id === dialog.roomId); if (!room) { this._roomDeleteDialog = null; return; } const openCuts = this._openCuts(); const allIntervals = wallIntervals( space.rooms, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); const roomIntervals = allIntervals.filter((interval) => interval.roomId === room.id); const plan = planRoomDeletion( roomIntervals, space.partitions, this._openingsR.map((opening) => ({ ...opening, x: opening.rx, y: opening.ry, })), this._gridPitch * 0.02, ); const newCount = new Set(plan.materialize .filter((item) => !item.reusePartitionId) .map((item) => item.interval.key)).size; if (keepWalls && (sp.partitions || []).length + newCount > MAX_PARTITIONS) { this._showToast(this._t('toast.physical_limit')); return; } const before = this._geometrySnapshot(); const materializedWalls = materializeWallIntervals( space.rooms, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); if (keepWalls) { sp.partitions ||= []; const targetByInterval = new Map(); const createdByKey = new Map(); const sourceWallId = (interval: { a: readonly number[]; b: readonly number[] }): string | null => { const match = (sp.wall_segments || []).find((segment: any) => { const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W]; const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W]; return (this._samePt(interval.a, a) && this._samePt(interval.b, b)) || (this._samePt(interval.a, b) && this._samePt(interval.b, a)); }); return typeof match?.id === 'string' && match.id ? match.id : null; }; const seed = Date.now().toString(36); for (const item of plan.materialize) { let target = item.reusePartitionId ? sp.partitions.find((partition: PartitionCfg) => partition.id === item.reusePartitionId) : createdByKey.get(item.interval.key); if (!target) { target = { id: sourceWallId(item.interval) || `partition-room-${seed}-${createdByKey.size}`, a: [item.interval.a[0] / NORM_W, item.interval.a[1] / NORM_W], b: [item.interval.b[0] / NORM_W, item.interval.b[1] / NORM_W], cm: item.interval.cm, }; sp.partitions.push(target); createdByKey.set(item.interval.key, target); } targetByInterval.set(item.interval.key, target); } for (const opening of sp.openings || []) { const intervalKey = plan.openingIntervals.get(opening.id); const target = intervalKey ? targetByInterval.get(intervalKey) : null; const rendered = this._openingsR.find((candidate) => candidate.id === opening.id); if (!target || !rendered) continue; const renderTarget = { a: [target.a[0] * NORM_W, target.a[1] * NORM_W], b: [target.b[0] * NORM_W, target.b[1] * NORM_W], }; opening.host = { kind: 'partition', id: target.id, t: parameterOnPartition([rendered.rx, rendered.ry], renderTarget), }; } } else if (plan.removeOpeningIds.length) { const remove = new Set(plan.removeOpeningIds); sp.openings = (sp.openings || []).filter((opening: OpeningCfg) => !remove.has(opening.id)); if (!sp.openings.length) delete sp.openings; } sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id); this._cfgEpoch++; this._commitOpenSpans(); const normalized = this._normalizeWalls(materializedWalls, this._openCuts()); if (normalized.length) sp.walls = normalized; else delete sp.walls; this._roomDeleteDialog = null; this._commitPhysicalGeometry(this._t( keepWalls ? 'history.delete_room_keep_walls' : 'history.delete_room_with_walls', ), before); this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); }; // ================= WALL THICKNESS (docs/WALL-THICKNESS.md) ================= /** Keys are always stored in normalised space (GRID_STEP_N). */ private get _wallKeyPitch(): number { return GRID_STEP_N; } private get _spaceWalls(): WallEntry[] { const w = this._curSpaceCfg?.walls; return Array.isArray(w) ? (w as WallEntry[]) : []; } /** * Open cuts in CONFIG space (normalised), for key work that runs on * `sp.rooms` rather than on the render model. The canvas is square, so both * axes divide by NORM_W and a stored span IS a config-space segment. * `degradeWalls` needs them: a wall split by an open span keeps its solid * remainder under an atomic key that is only "live" when the cut is known. */ private _cfgOpenCuts(): number[][] { return sanitizeOpenSpans((this._curSpaceCfg as any)?.open_spans) .map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]); } /** Thickness of the atomic stretch under a segment (docs/WALL-THICKNESS.md). */ private _intervalCm(seg: number[]): number { const space = this._spaceModel(); if (!space) return 0; return intervalCmAt( space.rooms, this._spaceWalls, this._openCuts(), seg, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); } /** Rewrite thickness keys onto the current atomic intervals and drop dead ones. */ private _normalizeWalls(walls: WallEntry[] | null | undefined, cuts: number[][]): WallEntry[] { const space = this._spaceModel(); if (!space) return []; const next = normalizeWallIntervals( space.rooms, walls, cuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); return degradeWalls(next, this._curSpaceCfg?.rooms || [], GRID_STEP_N, 1, cuts.map((c) => [c[0] / NORM_W, c[1] / NORM_W, c[2] / NORM_W, c[3] / NORM_W])); } /** Paper under rooms, grown by shared-wall half-thickness when set. */ private _paperShapes(rooms: any[]): Array< | { path: string } | { poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } } > { const walls = this._spaceWalls; if (!walls.length) return paperRoomShapes(rooms); const united = this._wallUnionGeometry(); return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms); } /** Canonical paper + masonry geometry, cached by structural config epoch. */ private _wallUnionGeometry(): ReturnType { const space = this._spaceModel(); if (!space) return null; const walls = this._spaceWalls; const extras = this._physicalBodiesR(); if (!walls.length && !extras.length) return null; const openCuts = this._openPairs().flatMap((p) => p.segs); const openings = this._roomWallOpeningInputs(); const unionKey = `${this._space}|${this._cfgEpoch}|${space.rooms.length}`; if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) { this._wallUnionCache = { key: unionKey, value: wallBodiesUnionPath( space.rooms, walls, openCuts, openings, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras, ), }; } return this._wallUnionCache.value; } /** Thick-wall spans in render units — suppress centreline stroke under bodies. */ private _thickWallCuts(): number[][] { const space = this._spaceModel(); if (!space) return []; const walls = this._spaceWalls; if (!walls.length) return []; const openCuts = this._openPairs().flatMap((p) => p.segs); return wallEdgeBodies( space.rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ).map((b) => [b.a[0], b.a[1], b.b[0], b.b[1]]); } /** * The ATOMIC wall stretch under the cursor for the wall-thickness tool. * * AUD-159B6-01: it used to return the whole polygon edge (only preferring a * shared overlap), so a click on the outer remainder of a partially shared * wall wrote a key that the renderer then spread over the shared part as * well. The unit of the tool is now the same interval the renderer draws. */ private _wallThickHit(raw: number[]): WallThickHit | null { const space = this._spaceModel(); if (!space) return null; const pull = this._gridPitch * 6; const cuts = this._openCuts(); type Hit = WallThickHit; // #313: the tool serves every wall — room intervals AND independent // masonry. On an exact overlap (the #308 duplicate) the independent wall // wins: it owns the hit zone, exactly like the select tool, and it is // the body the eye actually sees. let best: { hit: Hit; d: number; independent: boolean } | null = null; const offer = (hit: Hit, d: number, independent: boolean): void => { if (d > pull) return; if (!best || d < best.d - 1e-9 || (independent && !best.independent && d <= best.d + 1e-9)) { best = { hit, d, independent }; } }; for (const iv of wallIntervals( space.rooms, this._spaceWalls, cuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, )) { offer({ a: iv.a, b: iv.b, roomId: iv.roomId, segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]], open: iv.open, cm: iv.cm, source: { kind: 'room' }, }, distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]), false); } for (const partition of space.partitions || []) { const a = [partition.a[0], partition.a[1]]; const b = [partition.b[0], partition.b[1]]; offer({ a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]], open: false, cm: Number(partition.cm) || 0, source: { kind: 'partition', id: partition.id }, }, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true); } for (const draft of space.room_drafts || []) { // The active chain belongs to the draw tool; like the snap geometry, // Thickness only sees saved drafts. if (draft.id === this._activeDraftId) continue; for (let i = 0; i + 1 < draft.points.length; i++) { const a = [draft.points[i][0], draft.points[i][1]]; const b = [draft.points[i + 1][0], draft.points[i + 1][1]]; // Zero is a legitimate stored value («Empty / 0 leaves the new wall // thin», docs/WALL-THICKNESS.md §6) — only a MISSING record falls // back to the drawing default. `|| 15` would silently turn a stored // 0 into 15 on Apply (CODE-REVIEW-313-r1 High). const rawCm = Number(draft.segments[i]?.cm); offer({ a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]], open: false, cm: Number.isFinite(rawCm) ? rawCm : 15, source: { kind: 'draft', id: draft.id, segment: i }, }, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true); } } return best ? (best as { hit: Hit }).hit : null; } private get _wallThickHover(): { segs: number[][]; open: boolean; d: string } | null { if (!this._markup || this._tool !== 'wallthick' || !this._cursorPt || this._wallDialog) return null; const hit = this._wallThickHit(this._cursorPt); if (!hit) return null; // The visible strip follows the physical wall body. Only a zero-thickness // centreline receives a scale-independent visual minimum; hit testing keeps // its deliberately generous and separate radius in _wallThickHit(). const half = wallThickHoverHalfUnits(hit.cm, this._cellCm, this._gridPitch); let d = ''; for (const sg of hit.segs) { d += (d ? ' ' : '') + drawWallPreviewD( [[sg[0], sg[1]], [sg[2], sg[3]]], half, false, ); } return { segs: hit.segs, open: hit.open, d }; } private _wallThickClick(raw: number[]): void { const hit = this._wallThickHit(raw); if (!hit) { this._showToast(this._t('toast.wallthick_pick')); return; } if (hit.open) { this._showToast(this._t('toast.wallthick_open')); return; } const cm = hit.cm; const view = this._viewOr(this._baseVb()); const mx = (hit.a[0] + hit.b[0]) / 2, my = (hit.a[1] + hit.b[1]) / 2; this._wallDialog = { a: hit.a, b: hit.b, value: cmToField(cm, this._imperial), roomId: hit.roomId, source: hit.source, sx: ((mx - view.x) / view.w) * 100, sy: ((my - view.y) / view.h) * 100, }; } private _wallThickApply(allRoom: boolean): void { const d = this._wallDialog; if (!d) return; const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!sp || !space) return; const text = d.value.trim(); const raw = text ? strictNumber(text) : 0; if (raw == null) { this._showPhysicalRange(100); return; } const cmRaw = this._imperial ? raw * 2.54 : raw; if (text && (!Number.isFinite(cmRaw) || cmRaw < 0 || (cmRaw > 0 && cmRaw < 1) || cmRaw > 100)) { this._showPhysicalRange(100); return; } // #313: independent masonry writes its own record. This switch is the // single seam for #306: the future «zero turns the wall virtual» branch // belongs here, next to the range refusal below. if (d.source.kind !== 'room') { // A partition or draft segment without thickness does not exist in the // model (owner decision 2026-08-26): zero and empty are refused with // the ordinary range toast until #306 gives zero a meaning. if (!text || !(cmRaw > 0)) { this._showPhysicalRange(100); return; } const before = this._geometrySnapshot(); if (d.source.kind === 'partition') { const partition = (sp.partitions || []) .find((item: PartitionCfg) => item.id === (d.source as { id: string }).id); if (!partition) return; partition.cm = cmRaw; } else { const draft = (sp.room_drafts || []) .find((item: any) => item.id === (d.source as { id: string }).id); const segment = draft?.segments?.[(d.source as { segment: number }).segment]; if (!segment) return; segment.cm = cmRaw; } this._wallDialog = null; const committed = this._commitPhysicalGeometry( this._t('history.wall_thickness'), before, ); if (committed) this._showToast(this._t('toast.wallthick_set')); this.requestUpdate(); return; } const before = this._geometrySnapshot(); const cm = text && cmRaw > 0 ? cmRaw : null; const openCuts = this._openCuts(); let next: WallEntry[]; if (allRoom && d.roomId) { next = setWallThicknessForRoom( sp.walls, space.rooms, d.roomId, cm, this._wallKeyPitch, openCuts, NORM_W, ); } else { next = setWallThickness(sp.walls, d.a, d.b, cm, this._wallKeyPitch, NORM_W); } next = this._normalizeWalls(next, openCuts); // ADR 282 Stage 1: this writer knows that absence from `next` is an // explicit zero chosen by the user, not a missing compatibility record. // Mirror that intent into the canonical catalogue before the common // barrier; otherwise its lineage fallback would resurrect the old cm. for (const segment of sp.wall_segments || []) { segment.cm = thicknessCmAt( next, segment.a, segment.b, GRID_STEP_N, 1, ); } if (next.length) sp.walls = next; else delete sp.walls; this._wallDialog = null; if (this._commitPhysicalGeometry(this._t('history.wall_thickness'), before)) this._showToast(this._t(cm == null ? 'toast.wallthick_cleared' : 'toast.wallthick_set')); this.requestUpdate(); } private _wallHatchDefs(color: string): TemplateResult { if (!this._spaceWalls.length && !this._physicalBodiesR().length && !this._markup) return svg`` as unknown as TemplateResult; // The step is a physical distance now — 9.6 cm of plan, whatever the grid // scale — so it must NOT be compensated for zoom: a wall that changes its // hatching as you zoom is exactly what #230 is about (spec §4.2). const step = wallHatchStepUnits(this._cellCm); const stripe = 2 * (step / HATCH_BASE_STEP_UNITS); // Bake the wall colour into the pattern — CSS vars on content // do not inherit from the filled path, so var(--room-stroke) fell back // to grey while the wall outline used the real border colour. const stroke = color || '#607d8b'; return svg` ` as unknown as TemplateResult; } /** * One authoritative room-fill projection per render frame. Room polygons and * thick-wall opening tunnels consume the same object, so a live HA tick can * never update one without the other or drift in palette semantics. */ private _resolvedRoomFills(space: SpaceModel, disp: SpaceDisplay): RoomFillFrame { const byRoom = new Map(); const byId = new Map(); for (const room of space.rooms) { // The dimmed plan behind the room dialog is a real preview, not a second // renderer. Feed its pending selection through this same frame resolver. const mode = this._roomDialog && room.id === this._roomEditId ? (this._roomFill || disp.fill) : roomFillModeOf(disp.fill, room); const customFill = this._roomDialog && room.id === this._roomEditId ? (this._roomCustomFill || disp.customFill) : roomCustomFillOf(disp.customFill, room); const resolved = resolveEffectiveRoomFill( mode, mode === 'lqi' && room.area ? this._roomLqi(room.area) : null, mode === 'light' ? resolvedLightState(resolvedLightSources( this._renderPlanHass, this._renderDevices, room, this._virtualLights, )) : 'none', mode === 'temp' ? this._roomTemp(room) : null, disp.tempMin, disp.tempMax, this._fillColors, customFill, ); byRoom.set(room, resolved); if (room.id) byId.set(room.id, resolved); } return { byRoom, byId }; } /** Pending space-dialog values projected through the production resolver. */ private _spaceDisplayForRender(): SpaceDisplay { const current = spaceDisplayOf(this._curSpaceCfg); const dialog = this._spaceDialog; if (!dialog || dialog.mode !== 'edit' || dialog.spaceId !== this._space) return current; return { ...current, showBorders: dialog.showBorders, showNames: dialog.showNames, hideDecor: dialog.hideDecor, hideOpenings: dialog.hideOpenings, color: dialog.roomColor, opacity: dialog.roomOpacity, fill: dialog.fillMode, customFill: dialog.customFill ? customFillOf(dialog.customFill) : DEFAULT_CUSTOM_FILL, glow: dialog.glowEnabled, tempMin: dialog.tempMin, tempMax: dialog.tempMax, showLqi: dialog.showLqi, cardFontScale: dialog.cardFontScale, labelTemp: dialog.labelTemp, labelHum: dialog.labelHum, labelLqi: dialog.labelLqi, labelLight: dialog.labelLight, }; } /** Atomic wall association shared by symbols, wall cuts and tunnel geometry. */ private _openingWallIndexFor(space: SpaceModel, openCuts: number[][]): { key: string; value: OpeningWallIndex; } { const roomFingerprint = space.rooms.map((room) => ( `${room.id}:${room.poly?.map((point) => point.join(',')).join('/') || `${room.x},${room.y},${room.w},${room.h}`}` )).join(';'); const wallFingerprint = this._spaceWalls.map((wall) => ( `${wall.key}:${wall.a?.join(',') || ''}:${wall.b?.join(',') || ''}:${wall.cm}` )).join(';'); const cutFingerprint = openCuts.map((cut) => cut.join(',')).join(';'); const key = [ space.id, this._cfgEpoch, this._wallKeyPitch, this._cellCm, this._gridPitch, roomFingerprint, wallFingerprint, cutFingerprint, ].join('|'); let value = this._openingWallIndexCache.get(key); if (value) { // Refresh recency on hit; the pool is intentionally tiny because each // entry retains derived wall/tunnel geometry. lruWrite(this._openingWallIndexCache, key, value, 4); } else { value = buildOpeningWallIndex( space.rooms, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); lruWrite(this._openingWallIndexCache, key, value, 4); } return { key, value }; } /** * Room-coloured base beneath Glow/sun and beneath the architectural symbol. * The pure helper returns exact atomic wall strips in opening-local coords; * a hard-stop gradient changes side precisely at local y=0 (the wall axis), * never at an assumed 50% of an asymmetric bounding box. */ private _renderOpeningTunnelFills( space: SpaceModel, roomFills: RoomFillFrame, layer: 'data' | 'glow-base' = 'data', ): TemplateResult { // Plan mode replaces live fills with its blue editing wash. A coloured // tunnel there would no longer repeat the room and could obstruct handles. if (this._markup || !this._spaceWalls.length || !this._openingsR.length) return svg`` as unknown as TemplateResult; // Do not leave an empty presentation layer behind when Glow is disabled // for every room. Besides keeping the DOM contract explicit, this avoids // needless opening-index work on plans that do not use the overlay. if (layer === 'glow-base' && ![...roomFills.byRoom.values()].some(Boolean)) return svg`` as unknown as TemplateResult; const openCuts = this._openPairs().flatMap((p) => p.segs); const geometryInputs = this._openingsR.map((opening) => ({ x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen, })); const geometryFingerprint = geometryInputs .map((opening) => `${opening.x},${opening.y},${opening.angle},${opening.length}`).join(';'); const wallIndex = this._openingWallIndexFor(space, openCuts); const cacheKey = `${wallIndex.key}|${geometryFingerprint}`; if (!this._openingTunnelCache || this._openingTunnelCache.key !== cacheKey) { this._openingTunnelCache = { key: cacheKey, value: openingTunnelGeometriesFromIndex(wallIndex.value, geometryInputs), }; } return renderOpeningTunnelFills({ openings: this._openingsR, geometries: this._openingTunnelCache.value, fillsByRoomId: roomFills.byId, idPrefix: `${space.id}-${layer}`, groupClass: layer === 'data' ? 'opening-tunnels' : 'opening-tunnels glow-base-tunnels', dataLayer: layer, }); } /** Frame-local Glow-base projection. Data/static fills keep their exact * colors; a room receives darkness only when its selected data mode resolves * to no visible data object (for example Temperature without a sensor). */ private _resolvedGlowBase( space: SpaceModel, disp: SpaceDisplay, dataFills: RoomFillFrame, ): RoomFillFrame { const byRoom = new Map(); const byId = new Map(); const base = this._fillColors.glow_base; for (const room of space.rooms) { const dataFill = dataFills.byRoom.get(room); const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room) && (!dataFill || dataFill.opacity <= 0) ? { color: base.c, opacity: base.a, mode: 'glow' } : null; byRoom.set(room, resolved); if (room.id) byId.set(room.id, resolved); } return { byRoom, byId }; } /** * Paint the same clean-floor geometry as the interactive room layer, but as * a pointer-transparent source-over overlay above the resolved data fill. */ private _renderGlowBaseRooms(space: SpaceModel, frame: RoomFillFrame): TemplateResult { if (this._markup || ![...frame.byRoom.values()].some(Boolean)) return svg`` as unknown as TemplateResult; const polys = new Map( space.rooms.map((room) => [room, roomPoly(room)]), ); const openCuts = this._openPairs().flatMap((pair) => pair.segs); const roomWalls = this._wallUnionGeometry()?.roomGeom; const pathD = (points: number[][]) => 'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z'; const shapes = space.rooms.map((room) => { const fill = frame.byRoom.get(room) || null; const ownPoly = polys.get(room) || null; if (!fill || !ownPoly) return nothing; const floor = this._spaceWalls.length && room.id ? (innerContourForRoom( space.rooms, room.id, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, roomWalls, ) || ownPoly) : ownPoly; const otherPolys = space.rooms .filter((other) => other !== room) .map((other) => polys.get(other)) .filter((poly): poly is number[][] => !!poly); const holes = islandsOf(floor, otherPolys); const cleanPath = this._cleanFloor(room, floor, space).path; if (cleanPath || holes.length) { return svg``; } if (room.poly || floor !== ownPoly) { return svg``; } return svg``; }); return svg`` as unknown as TemplateResult; } private _renderWallBodies(disp: SpaceDisplay): TemplateResult { if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult; if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices')) return svg`` as unknown as TemplateResult; const united = this._wallUnionGeometry(); if (!united) return svg`` as unknown as TemplateResult; const stage = this._stageEl; const v = this._viewOr(this._baseVb()); const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1; const stroke = disp?.color || '#607d8b'; // Two independent ways for hatching to become noise: a body too thin to // hold stripes, and stripes too close to tell apart (#230 §8.4). const solid = wallBodyNeedsSolid(united.depthUnits, px) || wallHatchNeedsSolid(wallHatchStepUnits(this._cellCm), px); const wf = this._fillColors.wall_fill; // Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead // of the other. When the body is thinner than ~3px on screen the hatch // collapses into noise — keep the solid fill alone. return svg` ${united.paths.map((component) => svg` `)} ` as unknown as TemplateResult; } private _isoDecorationLayers(disp: SpaceDisplay): IsoDecorationLayers { const filtersSupported = typeof CSS === 'undefined' || typeof CSS.supports !== 'function' || CSS.supports('filter', 'blur(1px)'); const forcedColors = typeof matchMedia === 'function' && matchMedia('(forced-colors: active)').matches; return resolveIsoDecoration({ showBorders: disp.showBorders, hideOpenings: disp.hideOpenings, filtersSupported, forcedColors, }); } private _isoOpeningPanels(layers: IsoDecorationLayers): IsoOpeningPanel[] { if (!layers.panels) return []; const scene = this._isoScene(); if (!scene) return []; const panels = scene.openings.flatMap((basis) => { const opening = this._openingsR[basis.sourceIndex]; return opening ? projectIsoOpening(basis, this._openingAmt(opening)) : []; }); return panels.sort((a, b) => a.depth - b.depth || a.sourceIndex - b.sourceIndex || a.leaf - b.leaf); } private _renderIsoDefs( layers: IsoDecorationLayers, root: 'underlay' | 'shadows' | 'walls', ): TemplateResult { const visualScale = gridVisualScale(this._cellCm); return svg` ${root === 'walls' && layers.materialNuance ? svg` ` : nothing} ${root === 'underlay' && layers.shadows ? svg` ` : nothing} ${root === 'shadows' && layers.shadows ? svg` ` : nothing} ` as unknown as TemplateResult; } private _renderIsoUnderlay(layers: IsoDecorationLayers): TemplateResult { if (!layers.structural) return svg`` as unknown as TemplateResult; const floor = this._isoScene()?.floor; if (!floor) return svg`` as unknown as TemplateResult; return svg`` as unknown as TemplateResult; } private _renderIsoShadows( layers: IsoDecorationLayers, panels: readonly IsoOpeningPanel[], ): TemplateResult { if (!layers.shadows) return svg`` as unknown as TemplateResult; const geometry = this._isoScene()?.geometry; if (!geometry) return svg`` as unknown as TemplateResult; return svg`` as unknown as TemplateResult; } private _renderIsoWalls( layers: IsoDecorationLayers, panels: readonly IsoOpeningPanel[], ): TemplateResult { if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult; if (!layers.structural) return svg`` as unknown as TemplateResult; const scene = this._isoScene(); if (!scene) return svg`` as unknown as TemplateResult; return svg` ${this._renderIsoDefs(layers, 'walls')} ${scene.geometry.sides.map((face) => svg``)} ${layers.panels ? svg`` : nothing} ` as unknown as TemplateResult; } /** * View-only clean-floor perimeter above real wall bodies. * * The hover follows the same inner face that defines the displayed room * area. A nested room is a hole in the hovered room, so its boundary uses the * opposite (outward) wall face. Open spans stay dashed in their own layer; * doors, windows and gates remain gaps instead of being bridged by a solid accent. */ private _roomHoverPaths(space: SpaceModel): { fillD: string; outlineD: string } | null { const hover = this._hoverRoom; if (this._mode !== 'view' || !hover || hover.space !== space.id) { return null; } const room = space.rooms.find((r) => r === hover.room || (!!r.id && r.id === hover.room.id)); if (!room) return null; const poly = roomPoly(room); if (!poly) return null; // A parent room also owns the floor-facing side of walls around rooms // nested inside it. Keep the room together with its cached polygon so a // hole can use the OUTER face of that nested room's wall. const others = space.rooms .filter((r) => r !== room) .map((r) => ({ room: r, poly: roomPoly(r) })) .filter((v): v is { room: RoomCfg; poly: number[][] } => !!v.poly); const islandPolys = islandsOf(poly, others.map((v) => v.poly)); const pairs = this._openPairs(); const allOpenCuts = pairs.flatMap((p) => p.segs); const openCuts = room.id ? pairs.filter((p) => p.a.id === room.id || p.b.id === room.id).flatMap((p) => p.segs) : pairs.flatMap((p) => p.segs); const walls = this._spaceWalls; const roomWalls = this._wallUnionGeometry()?.roomGeom; const floor = walls.length && room.id ? (innerContourForRoom( space.rooms, room.id, walls, allOpenCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, roomWalls, ) || poly) : poly; const contours: { axis: number[][]; face: number[][] }[] = [{ axis: poly, face: floor }]; for (const island of islandPolys) { const owner = others.find((v) => v.poly === island)?.room; let face = island; if (walls.length && owner?.id) { const profile = roomWallProfile( space.rooms, owner.id, walls, allOpenCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); if (profile) face = outsetContour(profile.poly, profile.offsets) || island; } contours.push({ axis: island, face }); } // A hidden opening symbol is still a physical opening: never bridge a // doorway/window with the hover stroke merely because its symbol is off. const openingCuts = this._roomWallOpeningInputs(this._openingsR, space).map((o) => { const rad = (o.angle * Math.PI) / 180; const dx = (Math.cos(rad) * o.length) / 2; const dy = (Math.sin(rad) * o.length) / 2; return [o.x - dx, o.y - dy, o.x + dx, o.y + dy]; }); const eps = this._gridPitch * 0.02; // Cuts are stored on wall centrelines. Project each relevant cut onto the // clean-floor face before trimming; otherwise a door on a thick wall would // no longer intersect the inset hover contour. const cutOnFace = (cut: number[], axis: number[][], face: number[][]): number[] | null => { const cx = cut[2] - cut[0], cy = cut[3] - cut[1]; const cLen = Math.hypot(cx, cy); if (cLen < eps) return null; const cux = cx / cLen, cuy = cy / cLen; const mx = (cut[0] + cut[2]) / 2, my = (cut[1] + cut[3]) / 2; let belongs = false; for (let i = 0; i < axis.length; i++) { const a = axis[i], b = axis[(i + 1) % axis.length]; const ex = b[0] - a[0], ey = b[1] - a[1]; const eLen = Math.hypot(ex, ey); if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue; if (distToSegment([mx, my], [a[0], a[1], b[0], b[1]]) <= eps * 4) { belongs = true; break; } } if (!belongs) return null; let best: { a: number[]; b: number[]; d: number } | null = null; for (let i = 0; i < face.length; i++) { const a = face[i], b = face[(i + 1) % face.length]; const ex = b[0] - a[0], ey = b[1] - a[1]; const eLen = Math.hypot(ex, ey); if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue; const d = distToSegment([mx, my], [a[0], a[1], b[0], b[1]]); if (!best || d < best.d) best = { a, b, d }; } if (!best) return null; const fx = best.b[0] - best.a[0], fy = best.b[1] - best.a[1]; const fLen = Math.hypot(fx, fy) || 1; const nx = -fy / fLen, ny = fx / fLen; const shift = (best.a[0] - mx) * nx + (best.a[1] - my) * ny; return [ cut[0] + nx * shift, cut[1] + ny * shift, cut[2] + nx * shift, cut[3] + ny * shift, ]; }; const rawCuts = openCuts.concat(openingCuts); const d = contours.map(({ axis, face }) => { const cuts = rawCuts .map((cut) => cutOnFace(cut, axis, face)) .filter((cut): cut is number[] => !!cut); if (!cuts.length) { return `M ${face.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; } return outlineWithout(face, cuts, eps) .map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`) .join(' '); }).filter(Boolean).join(' '); if (!d) return null; const pathOf = (points: number[][]) => `M ${points.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; const cleanFloor = this._cleanFloor(room, floor, space).path || pathOf(floor); const fillD = [cleanFloor, ...islandPolys.map(pathOf)].join(' '); return { fillD, outlineD: d }; } private _renderRoomHoverFill( paths: { fillD: string; outlineD: string } | null, ): TemplateResult { if (!paths) return svg`` as unknown as TemplateResult; return svg` ` as unknown as TemplateResult; } private _renderRoomHoverOutline( paths: { fillD: string; outlineD: string } | null, ): TemplateResult { if (!paths) return svg`` as unknown as TemplateResult; return svg` ` as unknown as TemplateResult; } /** Hover highlight for the wall-thickness tool (SVG). */ private _renderWallThickUi(): TemplateResult { const hover = this._wallThickHover; if (!hover || !hover.d) return svg`` as unknown as TemplateResult; return svg`` as unknown as TemplateResult; } /** Thickness input popover, anchored in stage % like measure labels. */ private _renderWallThickDialog(): TemplateResult { const d = this._wallDialog; if (!d) return html``; return html`
e.stopPropagation()}>
{ this._wallDialog = { ...d, value: (e.target as HTMLInputElement).value }; }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); this._wallThickApply(false); } }} /> ${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
${d.source.kind === 'room' ? html`` : nothing}
`; } private _openingAt(raw: readonly number[]): RenderOpening | null { if (!this._openingsR.length) return null; const roughHits = this._openingsR.flatMap((o) => { const rad = o.angle * Math.PI / 180; const dx = raw[0] - o.rx, dy = raw[1] - o.ry; const localX = dx * Math.cos(rad) + dy * Math.sin(rad); if (Math.abs(localX) > o.rlen / 2 + gridVisualUnits(12, this._cellCm)) return []; return [{ o, localY: -dx * Math.sin(rad) + dy * Math.cos(rad) }]; }); if (!roughHits.length) return null; const space = this._spaceModel(); if (!space) return null; const openCuts = this._openPairs().flatMap((pair) => pair.segs); const wallIndex = this._openingWallIndexFor(space, openCuts).value; return roughHits.find(({ o, localY }) => { const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v; const face = o.partitionHost || this._spaceWalls.length || o.type === 'gate' ? this._openingFace(o, wallIndex, !!faceFlipV) : { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 }; const metrics = openingVisibleMetrics({ type: o.type, length: o.rlen, angle: o.angle, amount: this._openingAmt(o), flipH: !!o.flip_h, flipV: !!o.flip_v, base: '', tone: '', cellCm: this._cellCm, gridPitch: this._gridPitch, face, }); // Match the committed `.op-hit` rectangle exactly. The old radial test // made a 3 m gate suppress placement in a huge circular area far away // from its visible/hit geometry. return Math.abs(localY) <= metrics.hitHalf; })?.o || null; } private _resolveOpeningPlacement(raw: readonly number[]): OpeningPlacementCandidate | null { const preset = this._openingPreset; if (this._tool !== 'opening' || !preset) return null; const space = this._spaceModel(); if (!space) return null; const openCuts = this._openCuts(); const wallIndex = this._openingWallIndexFor(space, openCuts); const placementKey = `${wallIndex.key}|partitions:${this._cfgEpoch}`; if (!this._openingPlacementIntervalsCache || this._openingPlacementIntervalsCache.key !== placementKey) { this._openingPlacementIntervalsCache = { key: placementKey, value: [ ...wallIntervals( space.rooms, this._spaceWalls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ), ...partitionPlacementIntervals(space.partitions, this._cellCm, this._gridPitch), ], }; } if (!this._openingDimensionContextCache || this._openingDimensionContextCache.key !== placementKey) { this._openingDimensionContextCache = { key: placementKey, value: buildOpeningDimensionContext({ rooms: space.rooms, walls: this._spaceWalls, openCuts, partitions: space.partitions, roomOpenings: this._roomWallOpeningInputs(), partitionCuts: this._partitionOpeningCuts(space), pitch: this._wallKeyPitch, cellCm: this._cellCm, gridPitch: this._gridPitch, coordScale: NORM_W, epsilon: this._gridPitch * 0.0002, }), }; } const resolution = resolveOpeningPlacementResult({ pointer: [raw[0], raw[1]], preset, geometryRevision: this._cfgEpoch, renderedLength: this._cmToUnits(preset.lengthCm), intervals: this._openingPlacementIntervalsCache.value, baseTolerance: this._gridPitch * 1.5, bodyPointerPadding: this._cssPxToRender(this._boundaryCoarse ? 10 : 6), gridStep: this._gridPitch, }); this._openingJambBlockCm = resolution.jambBlockedTarget ? (resolution.jambBlockedTarget.physicalHalfWidth / this._gridPitch) * this._cellCm : null; const core = resolution.candidate; if (!core) return null; const faceFlipV = core.type === 'gate' ? !core.flipV : core.flipV; let face: OpeningFaceOffset; if (core.host) { const hosted = resolvePartitionOpeningCompat({ id: 'preview', type: core.type, x: core.x / NORM_W, y: core.y / NORM_W, angle: core.angle, length: core.renderedLength / NORM_W, host: core.host, }, space.partitions, NORM_W, this._cellCm, this._gridPitch).resolved; face = hosted ? partitionOpeningFace(hosted, faceFlipV) : { ox: 0, oy: 0, cm: 0, side: -1 }; } else { face = openingInnerFaceOffsetFromIndex( wallIndex.value, { x: core.x, y: core.y, angle: core.angle, length: core.renderedLength, flip_v: faceFlipV }, ); } const imperial = this.hass?.config?.unit_system?.length === 'mi'; // #238: placement labels are physical dimensions. They consume the ONE // resolved candidate above and never re-run pointer snap; existing-opening // drag deliberately keeps `_opRuler`/`openingShoulders` below. const dimensions = resolveOpeningDimensions( core, this._openingDimensionContextCache.value, ); const labels = dimensions.map((dimension) => ({ x: dimension.label[0], y: dimension.label[1], text: formatLength((dimension.distance / this._gridPitch) * this._cellCm, imperial), dimension, })); return { ...core, face, measure: { labels, guide: core.measure.guide } }; } private _activateOpeningPlacement(type: OpeningPlacementType): void { this._activateMarkupTool('opening'); if (this._tool !== 'opening') return; this._openingPreset = openingPlacementPreset(type, ++this._openingPresetRevision); this._openingHoverCandidate = null; this._cursorPt = null; } private _clearOpeningPlacement(clearPreset: boolean): void { this._openingHoverCandidate = null; this._openingJambBlockCm = null; if (clearPreset) { this._openingPreset = null; this._openingRebindId = null; } } /** Opening tool: click an existing opening to edit it, or a wall to place one. */ private _openingClick(raw: number[]): void { const space = this._spaceModel(); if (!space) return; // Rebind is an explicit one-shot placement: the orphan's stale fallback // position must not win hit testing when the replacement wall crosses it. const hit = this._openingRebindId ? null : this._openingAt(raw); if (hit) { this._editOpening(hit); return; } const preset = this._openingPreset; if (!preset) return; const cached = this._openingHoverCandidate; const place = cached && sameOpeningPlacementInput( cached, [raw[0], raw[1]], preset.revision, this._cfgEpoch, ) ? cached : this._resolveOpeningPlacement(raw); if (!place) { if (this._openingJambBlockCm != null) { this._showToast(this._t('opening.partition_jamb_margin', { distance: formatLength(this._openingJambBlockCm, this._imperial), })); return; } const eps = this._gridPitch * 1.5; const snap = snapToWall(raw, space.rooms, eps); if (snap && pointOnOpenCut(snap.x, snap.y, snap.angle, this._openCuts(), eps)) { this._showToast(this._t('toast.opening_on_virtual')); return; } this._showToast(this._t('toast.opening_no_wall')); return; } const rebound = this._openingRebindId ? this._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === this._openingRebindId) : null; this._openingDialog = { ...(rebound ? { id: rebound.id, type: rebound.type, lengthCm: Math.round((rebound.length * NORM_W / this._gridPitch) * this._cellCm), lengthTouched: false, contact: rebound.contact || '', lock: rebound.lock || '', invert: !!rebound.invert, flipH: !!rebound.flip_h, flipV: !!rebound.flip_v, } : { type: place.type, lengthCm: place.lengthCm, contact: '', lock: '', invert: false, flipH: place.flipH, flipV: place.flipV, }), ...(place.host ? { host: place.host } : {}), x: place.x, y: place.y, angle: place.angle, }; this._openingRebindId = null; // rulers, tick and ghost live only through the placement gesture this._openingHoverCandidate = null; this._cursorPt = null; } /** Open the properties dialog for an existing opening. */ private _editOpening(o: RenderOpening): void { this._openingDialog = { id: o.id, type: o.type, lengthCm: Math.round((o.rlen / this._gridPitch) * this._cellCm), lengthTouched: false, contact: o.contact || '', lock: o.lock || '', invert: !!o.invert, flipH: !!o.flip_h, flipV: !!o.flip_v, ...(o.host?.kind === 'partition' ? { host: { ...o.host } } : {}), x: o.rx, y: o.ry, angle: o.angle, }; } /** Drag an opening along the walls (view mode): it re-snaps continuously. */ private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void { if (this._mode !== 'plan' || !this._spaceModel()) return; // HP-1550-04: in the resize tool the wall handles own the geometry — a door // in the middle of a wall must neither swallow the handle nor start its own // drag (it travels with the wall through the resize pipeline instead) if (this._tool === 'resize') return; ev.preventDefault(); ev.stopPropagation(); try { capturePointer(ev); } catch { /* an inactive pointerId (synthetic events, some browsers) must not kill the drag */ } this._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false, before: this._geometrySnapshot(), }; } private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void { if (!this._opDrag || this._opDrag.id !== o.id) return; // audit L4: the other drag pipelines require 3 px before calling it a drag. // Without it every tap counted as a drag: the properties dialog never // opened and an unchanged config was written (which then broadcast the // event behind the L2 data loss). if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return; const space = this._spaceModel(); if (!space) return; const raw = this._svgPoint(ev); const sp = this._curSpaceCfg; const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id); if (!cfg) return; if (cfg.host?.kind === 'partition') { const partition = space.partitions.find((item) => item.id === cfg.host!.id); if (!partition) return; const dx = partition.b[0] - partition.a[0], dy = partition.b[1] - partition.a[1]; const length = Math.hypot(dx, dy); if (!(length > 1e-9)) return; const ux = dx / length, uy = dy / length; const perpendicular = Math.abs( (raw[0] - partition.a[0]) * uy - (raw[1] - partition.a[1]) * ux, ); if (perpendicular > this._gridPitch * 4) return; const half = cfg.length * NORM_W / 2; const jamb = partitionOpeningJambMargin(partition, this._cellCm, this._gridPitch); const shoulder = half + jamb; let along = (raw[0] - partition.a[0]) * ux + (raw[1] - partition.a[1]) * uy; along = Math.round(along / this._gridPitch) * this._gridPitch; along = Math.max(shoulder, Math.min(length - shoulder, along)); if (length < shoulder * 2 - 1e-9) return; const t = along / length; const nx = (partition.a[0] + ux * along) / NORM_W; const ny = (partition.a[1] + uy * along) / this._spaceH; let angle = Math.atan2(dy, dx) * 180 / Math.PI; if (angle >= 90) angle -= 180; else if (angle < -90) angle += 180; const changed = cfg.host.t !== t || cfg.x !== nx || cfg.y !== ny || cfg.angle !== angle; this._opDrag.moved = true; if (changed) this._opDrag.dirty = true; cfg.host = { ...cfg.host, t }; cfg.x = nx; cfg.y = ny; cfg.angle = angle; this._opMeasure = null; if (changed) this._cfgEpoch++; this.requestUpdate(); return; } const snap = snapToWall(raw, space.rooms, this._gridPitch * 4); if (!snap) return; // too far from any wall: the opening stays where it was this._opDrag.moved = true; // ruler badges on both shoulders + soft magnet to the wall's center // (owner 2026-08-03) — the very same helper the PLACEMENT preview uses const r = this._opRuler(snap, cfg.length * NORM_W); this._opMeasure = r.measure; const nx = r.x / NORM_W; const ny = r.y / this._spaceH; const geometryChanged = cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle; if (geometryChanged) this._opDrag.dirty = true; cfg.x = nx; cfg.y = ny; cfg.angle = snap.angle; // The wall cut and room-coloured tunnel are geometry caches too. Advance // the preview epoch so both follow the live opening instead of remaining at // its pre-drag location until pointerup/save. if (geometryChanged) this._cfgEpoch++; this.requestUpdate(); } /** * Shoulder rulers + the soft centre magnet for an opening of `rlen` sitting * at a wall snap. ONE implementation for both gestures the owner asked to * behave alike (2026-08-03): dragging an existing opening and placing a new * one. `tol` is half a grid step; the centre magnet and along-wall grid step * are mandatory. The returned * x/y are ALREADY magnetised, so the caller just writes them. */ private _opRuler( snap: { x: number; y: number; angle: number }, rlen: number, ): { x: number; y: number; angle: number; measure: OpMeasure | null } { const rooms = this._spaceModel()?.rooms || []; const tol = this._gridPitch / 2; let cx = snap.x, cy = snap.y; let sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol); if (sh && sh.centered && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) { [cx, cy] = sh.wallCenter; sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol); } else if (sh) { // Not centred: quantise the offset ALONG the wall to the grid step // (docs/CANVAS.md §9.3). Grid-BOUND would lift the opening off a diagonal // wall, so the wall stays the master and the grid only says WHERE on it. // The magnet is consulted first on purpose — a wall whose middle is not a // node must still be able to hold a centred window. const [ax, ay] = sh.wallA, [bx, by] = sh.wallB; const wx = bx - ax, wy = by - ay; const len = Math.hypot(wx, wy); if (len > 0) { const g = this._gridPitch; const half = Math.min(rlen / 2, len / 2); let along = Math.round((((cx - ax) * wx + (cy - ay) * wy) / len) / g) * g; along = Math.max(half, Math.min(len - half, along)); cx = ax + (along / len) * wx; cy = ay + (along / len) * wy; sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol) || sh; } } if (!sh) return { x: cx, y: cy, angle: snap.angle, measure: null }; const imperial = this.hass?.config?.unit_system?.length === 'mi'; const lbl = (d: number, m: number[]) => ({ x: m[0], y: m[1], text: formatLength((d / this._gridPitch) * this._cellCm, imperial) }); return { x: cx, y: cy, angle: snap.angle, measure: { labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)], guide: sh.centered ? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle } : null, }, }; } private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void { if (!this._opDrag || this._opDrag.id !== o.id) return; const drag = this._opDrag; const moved = drag.moved; this._opMeasure = null; // badges and the center tick live only through the drag // only write when the geometry actually changed (audit L4) if (moved && drag.dirty) { this._commitPhysicalGeometry(this._t('history.move_opening'), drag.before); } // keep the flag until the click event that follows pointerup, then let it go if (moved) window.setTimeout(() => (this._opDrag = null), 0); else this._opDrag = null; } /** Click: the status card (delayed so a double click can cancel it). */ private _opClick(ev: MouseEvent, o: RenderOpening): void { // HP-1550-04: in the resize tool a click over an opening falls through to // the stage (room picking) instead of opening the editor dialog if (this._mode === 'plan' && this._tool === 'resize') return; ev.stopPropagation(); if (this._opDrag?.moved) return; // that click was the tail of a drag // openings are inert outside Plan mode (owner's decision: View must not // interact with them at all); in Plan any click on an opening edits it if (this._mode === 'plan') this._editOpening(o); } private _saveOpening(): void { const d = this._openingDialog; const sp = this._curSpaceCfg; const model = this._spaceModel(); if (!d || !sp || !model) return; const before = this._geometrySnapshot(); const H = this._spaceH; const previous = (sp.openings || []).find((item: OpeningCfg) => item.id === d.id); const o: OpeningCfg = { ...(previous || {}), id: d.id || 'o' + Date.now().toString(36), type: d.type, x: d.x / NORM_W, y: d.y / H, angle: d.angle, length: previous && !d.lengthTouched ? previous.length : this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W, ...(d.host ? { host: { ...d.host } } : {}), }; if (o.host?.kind === 'partition') { const strict = partitionOpeningNeedsStrictValidation(previous, o); const resolution = strict ? resolvePartitionOpeningStrict( o, model.partitions, NORM_W, this._cellCm, this._gridPitch, ) : resolvePartitionOpeningCompat( o, model.partitions, NORM_W, this._cellCm, this._gridPitch, ); if (!resolution.resolved) { const partition = model.partitions.find((item) => item.id === o.host!.id); if (resolution.reason === 'does-not-fit-jamb' && partition) { const margin = partitionOpeningJambMargin( partition, this._cellCm, this._gridPitch, ); this._showToast(this._t('opening.partition_jamb_margin', { distance: formatLength( (margin / this._gridPitch) * this._cellCm, this._imperial, ), })); } else { this._showToast(this._t('opening.partition_orphan')); } return; } const siblings = (sp.openings || []).flatMap((item: OpeningCfg) => { if (!item.host || item.id === o.id) return []; const sibling = resolvePartitionOpeningCompat( item, model.partitions, NORM_W, this._cellCm, this._gridPitch, ).resolved; return sibling ? [sibling] : []; }); if (hostedOpeningIntervalsOverlap(resolution.resolved, siblings)) { this._showToast(this._t('toast.opening_no_wall')); return; } Object.assign(o, materializePartitionOpening(o, resolution.resolved, NORM_W)); } else { delete o.host; } if (d.type === 'passage') { // The canonical passage record contains geometry only. Delete keys // instead of writing null/false so imports and old broken records have // one unambiguous representation after an explicit edit. delete o.contact; delete o.lock; delete o.invert; delete o.flip_h; delete o.flip_v; } else { o.contact = d.contact || null; o.lock = d.type === 'door' || d.type === 'gate' ? d.lock || null : null; o.invert = d.invert || undefined; o.flip_h = d.type !== 'gate' && d.flipH || undefined; o.flip_v = d.flipV || undefined; } sp.openings = sp.openings || []; const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id); if (i >= 0) sp.openings[i] = o; else sp.openings.push(o); this._commitPhysicalGeometry( this._t(d.id ? 'history.edit_opening' : 'history.add_opening'), before, ); this._openingDialog = null; this.requestUpdate(); } private _deleteOpening(): void { const d = this._openingDialog; const sp = this._curSpaceCfg; if (!d?.id || !sp?.openings) return; const before = this._geometrySnapshot(); sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id); this._commitPhysicalGeometry(this._t('history.delete_opening'), before); this._openingDialog = null; this.requestUpdate(); } private _rebindPartitionOpening = (): void => { const d = this._openingDialog; if (!d?.id) return; const openingId = d.id; this._activateMarkupTool('opening'); if (this._tool !== 'opening') return; // Tool activation clears every stale placement session. Publish the // rebind identity only after that gate so the next click cannot degrade // into creating a second opening. this._openingRebindId = openingId; this._openingPreset = { type: d.type, lengthCm: d.lengthCm, flipH: d.flipH, flipV: d.flipV, revision: ++this._openingPresetRevision, }; this._openingDialog = null; this._openingHoverCandidate = null; }; /** Contact-sensor candidates: door/window/gate-like classes first, then the rest. */ private _contactCandidates(): { value: string; label: string }[] { const out: [string, string, number][] = []; for (const eid of Object.keys(this.hass.states)) { if (!this._openingEntityAvailable(eid)) continue; const dom = eid.split('.')[0]; if (dom !== 'binary_sensor' && dom !== 'cover') continue; const st = this.hass.states[eid]; const dc = st?.attributes?.device_class || ''; const doorish = ['door', 'window', 'opening', 'garage_door', 'garage'].includes(dc); if (dom === 'cover' && !doorish) continue; out.push([eid, st?.attributes?.friendly_name || eid, doorish ? 0 : 1]); } return out .sort((a, b) => a[2] - b[2] || a[1].localeCompare(b[1])) .map(([value, label]) => ({ value, label })); } private _lockCandidates(): { value: string; label: string }[] { return Object.keys(this.hass.states) .filter((eid) => eid.startsWith('lock.') && this._openingEntityAvailable(eid)) .map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid })) .sort((a, b) => a.label.localeCompare(b.label)); } private _toggleOpeningEntityPicker(kind: 'contact' | 'lock'): void { const d = this._openingDialog; if (!d) return; const nextOpen = kind === 'contact' ? !d.contactOpen : !d.lockOpen; this._openingDialog = { ...d, contactOpen: kind === 'contact' ? nextOpen : false, lockOpen: kind === 'lock' ? nextOpen : false, }; } private _filterOpeningEntities(kind: 'contact' | 'lock', value: string): void { const d = this._openingDialog; if (!d) return; this._openingDialog = kind === 'contact' ? { ...d, contactFilter: value } : { ...d, lockFilter: value }; } private _selectOpeningEntity(kind: 'contact' | 'lock', value: string): void { const d = this._openingDialog; if (!d) return; this._openingDialog = kind === 'contact' ? { ...d, contact: value, contactOpen: false, contactFilter: '' } : { ...d, lock: value, lockOpen: false, lockFilter: '' }; } /** Merge: first click picks a room, second picks the room to merge it with. */ private _mergeClick(raw: number[]): void { const space = this._spaceModel(); if (!space) return; const rooms = space.rooms; const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!hit?.id) return; const hitId = hit.id; if (!this._mergeSel || this._mergeSel === hitId) { this._mergeSel = this._mergeSel === hitId ? null : hitId; // click again = deselect return; } const a = rooms.find((r) => r.id === this._mergeSel); const pa = a ? roomPoly(a) : null; const pb = roomPoly(hit); const merged = pa && pb ? mergeRooms(pa, pb) : null; if (!merged) { // only rooms sharing a wall collapse into one outline (see mergeRooms) this._showToast(this._t('toast.merge_not_adjacent')); this._mergeSel = null; return; } this._mergeDialog = { aId: this._mergeSel, bId: hitId, poly: merged, pick: 'a' }; this._mergeSel = null; } private _commitMerge(): void { const d = this._mergeDialog; const sp = this._curSpaceCfg; if (!d || !sp || !this._spaceModel()) return; const before = this._geometrySnapshot(); const H = this._spaceH; const keepId = d.pick === 'a' ? d.aId : d.bId; const dropId = d.pick === 'a' ? d.bId : d.aId; const keep = sp.rooms.find((r: any) => r.id === keepId); if (!keep) { this._mergeDialog = null; return; } // the kept room keeps its id, so its label position and devices stay put keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]); delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId); this._commitOpenSpans(); const committed = this._commitPhysicalGeometry(this._t('history.merge_rooms'), before); this._mergeDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); if (committed) this._showToast(this._t('toast.rooms_merged', { name: keep.name || '' })); } /** Split: click the room, then two points on its walls. */ private _splitClick(raw: number[]): void { const space = this._spaceModel(); if (!space) return; const rooms = space.rooms; if (!this._splitSel) { const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!hit?.id) return; this._splitSel = { roomId: hit.id, pts: [] }; return; } const room = rooms.find((r) => r.id === this._splitSel!.roomId); const poly = room ? roomPoly(room) : null; if (!room || !poly) { this._splitSel = null; return; } // A split point lands on the room's nearest wall — the user aims at a wall, // and rooms need not be grid-aligned (imported/legacy polygons), so snapping // to the grid would miss the outline. The pull is capped: a click far from // any wall (e.g. an accidental one in the middle of the room) is a miss and // gets the toast, not a wall the user never meant. splitRoom() still rejects // any cut that is not a clean wall-to-wall chord. // …and it is still QUANTISED: the offset ALONG the wall moves in whole grid // steps (docs/CANVAS.md §9), which on the axis-aligned, grid-drawn walls the // editor itself makes IS a grid node. There is no free-position bypass. const eps = this._gridPitch * 0.02; const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional const raw0 = closestPointOnBoundary(raw, poly); const near = raw0 ? (snapPointAlongPoly(raw0, poly, this._gridPitch) || raw0) : raw0; const wallPt = raw0 && near && Math.hypot(raw0[0] - raw[0], raw0[1] - raw[1]) <= pull ? near : null; const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps); const cur = this._splitSel.pts; if (!cur.length) { // the cut starts on a wall if (!onWall) { this._showToast(this._t('toast.split_pick_wall')); return; } this._splitSel = { ...this._splitSel, pts: [wallPt!] }; return; } if (!onWall) { // an interior click adds an intermediate vertex of the cut path const mid = this._snap(raw); if (!ptInside(mid, poly, eps)) { this._showToast(this._t('toast.split_pick_inside')); return; } this._splitSel = { ...this._splitSel, pts: [...cur, mid] }; return; } // a wall point finishes the cut const parts = splitRoomPath(poly, [...cur, wallPt!], eps); if (!parts) { this._showToast(this._t('toast.split_bad_cut')); return; } this._resetRoomDialogFields(); // the bigger part stays the room it was — name, area and devices go with it const [p1, p2] = parts; const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2; const fresh = main === p1 ? p2 : p1; this._pendingSplit = { roomId: room.id!, mainPoly: main, newPoly: fresh }; this._cursorPt = null; this._nameSel = ''; this._areaSel = ''; this._roomDialog = true; } private get _contourClosed(): boolean { // Legacy/split dialog closure appends a render-only edge and records its // thickness separately. #173 graph closure persists the terminal segment // in the draft, so it must stay editable after Cancel. return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]) && (this._closingWallCm != null || !this._activeDraftId); } private _markupMove(ev: MouseEvent): void { if (!this._markup) return; const pointerType = (ev as PointerEvent).pointerType; if (pointerType) this._boundaryPointerType = pointerType; if (this._tool === 'column') { this._cursorPt = this._snap(this._svgPoint(ev)); return; } if (this._tool === 'opening' || this._tool === 'boundary' || this._tool === 'wallthick') { // hover preview: raw cursor point; snapping happens in the preview getters this._cursorPt = this._svgPoint(ev); return; } const architectural = this._tool === 'draw' && !this._contourClosed; const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length; if (!architectural && !cutting) return; const raw = this._svgPoint(ev); if (architectural) { const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey); this._planSnapHover = { contextKey: resolved.contextKey, candidate: resolved.candidate, conflicts: resolved.conflicts, }; this._syncPlanSnapConflictMarkers(resolved.conflicts); // Before the first click there is no rubber-band to repaint. Updating the // dedicated marker avoids walking the complete large-house Lit tree for // every mouse move while click still resolves from the event coordinate. if (!this._path.length) { this._syncPlanSnapActiveMarker(resolved.candidate); return; } this._cursorPt = resolved.point; return; } this._cursorPt = this._snap(raw); } /** One resolved architectural candidate shared by hover and click. */ private get _openingPreview(): OpeningPlacementCandidate | null { const preset = this._openingPreset; if (this._tool !== 'opening' || !preset || !this._cursorPt) return null; const raw = this._cursorPt; const cached = this._openingHoverCandidate; if (cached && sameOpeningPlacementInput( cached, [raw[0], raw[1]], preset.revision, this._cfgEpoch, )) return cached; // an existing opening under the cursor will be edited, not added — no preview if (this._openingAt(raw)) { this._openingHoverCandidate = null; return null; } const resolved = this._resolveOpeningPlacement(raw); this._openingHoverCandidate = resolved; return resolved; } /** The rulers to draw right now: from the DRAG of an existing opening, or * from the PLACEMENT preview of a new one — identical badges either way. */ private get _opMeasureView(): OpMeasure | null { return this._opMeasure || this._openingPreview?.measure || null; } /** Save a room with an optional HA-area binding. * An area supplies the fallback name; a room without one needs a name. */ private _saveRoom(): void { if (!this._areaSel && !this._nameSel.trim()) return; if (this._wallFaceBatch) { this._decideWallFace(true); return; } this._commitRoom(); } private _decideWallFace(create: boolean): void { const batch = this._wallFaceBatch; if (!batch) return; const candidate = batch.candidates[batch.index]; if (!candidate) return; if (!create && (candidate.existing || candidate.repair) && batch.candidates.length === 1) { this._wallFaceBatch = null; this._roomDialog = false; this._nameSel = ''; this._areaSel = ''; this.requestUpdate(); return; } const decision: WallFaceDecision = create ? { candidate, create: true, name: this._nameSel.trim(), area: this._areaSel || null, settings: this._roomSettingsFromDialog() ? JSON.parse(JSON.stringify(this._roomSettingsFromDialog())) : null, } : { candidate, create: false }; const decisions = [...batch.decisions, decision]; const nextIndex = batch.index + 1; if (nextIndex < batch.candidates.length) { this._wallFaceBatch = { ...batch, decisions, index: nextIndex }; this._nameSel = ''; this._areaSel = ''; this._resetRoomDialogFields(); this.requestUpdate(); return; } this._wallFaceBatch = { ...batch, decisions }; this._applyWallFaceBatch(); } private _wallSourceCmAt( point: number[], activePath: number[][], activeCms: number[], ): number { const validCm = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; const epsilon = this._gridPitch * 0.02; const model = this._spaceModel(); if (!model) return DRAW_WALL_DEFAULT_CM; // Already-saved independent masonry is authoritative when the new chain // traces it. Room-owned walls are preserved by the canonical wall helpers. for (const segment of this._planSnapGeometrySnapshot().value.segments) { if (segment.sourceKind === 'room' || distToSegment(point, [segment.a[0], segment.a[1], segment.b[0], segment.b[1]]) > epsilon) continue; if (segment.sourceKind === 'partition') { return validCm(model.partitions.find((item) => item.id === segment.sourceId)?.cm) ?? DRAW_WALL_DEFAULT_CM; } const separator = segment.sourceId.lastIndexOf(':'); const draftId = separator >= 0 ? segment.sourceId.slice(0, separator) : segment.sourceId; const index = separator >= 0 ? Number(segment.sourceId.slice(separator + 1)) : -1; return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm) ?? DRAW_WALL_DEFAULT_CM; } // Толщина отрезка активной цепочки решается тем же резолвером (#234): // именно это значение подсвечивает инструмент «Толщина», и расхождение с // записью здесь было тем способом, которым дефект и обнаружился. const resolved = chainSegmentCms( Math.max(0, activePath.length - 1), activeCms, this._drawWallCm, DRAW_WALL_DEFAULT_CM, ); for (let i = 0; i + 1 < activePath.length; i++) { const a = activePath[i], b = activePath[i + 1]; if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) { return resolved[i] ?? DRAW_WALL_DEFAULT_CM; } } return DRAW_WALL_DEFAULT_CM; } private _activePathWithRepair( path: number[][], proposal: WallFaceRepairProposal | undefined, ): number[][] { const result = path.map((point) => [...point]); if (!proposal?.sourceKey.startsWith('active:')) return result; const match = /:(\d+)$/.exec(proposal.sourceKey); const index = match ? Number(match[1]) + (proposal.endpoint === 'b' ? 1 : 0) : -1; if (index >= 0 && index < result.length) result[index] = [...proposal.to]; return result; } private _validateWallRepair( proposal: WallFaceRepairProposal, activePath: number[][], ): boolean { const epsilon = this._gridPitch * 0.0002; const sources = this._wallGraphSources(activePath); const source = sources.find((item) => item.key === proposal.sourceKey); const target = sources.find((item) => item.key === proposal.targetSourceKey); if (!source || !target) return false; const current = proposal.endpoint === 'a' ? source.a : source.b; if (Math.hypot(current[0] - proposal.from[0], current[1] - proposal.from[1]) > epsilon) return false; if (distToSegment(proposal.to, [target.a[0], target.a[1], target.b[0], target.b[1]]) > epsilon) { return false; } if (repairMovesHostedPartition( proposal, (this._curSpaceCfg as any)?.openings || [], )) return false; return true; } /** Apply one revalidated endpoint move inside the surrounding room transaction. */ private _applyWallRepair( proposal: WallFaceRepairProposal, batch: WallFaceBatch, ): boolean { const sp = this._curSpaceCfg as any; if (!sp || !this._validateWallRepair(proposal, batch.activePath)) return false; const write = (point: number[]): void => { point[0] = proposal.to[0] / NORM_W; point[1] = proposal.to[1] / NORM_W; }; if (proposal.sourceKey.startsWith('active:')) { const match = /:(\d+)$/.exec(proposal.sourceKey); const index = match ? Number(match[1]) + (proposal.endpoint === 'b' ? 1 : 0) : -1; if (index < 0 || index >= batch.activePath.length) return false; batch.activePath[index] = [...proposal.to]; const draft = (sp.room_drafts || []).find((item: any) => item.id === batch.activeDraftId); if (draft?.points?.[index]) write(draft.points[index]); return true; } if (!proposal.sourceKey.startsWith('static:')) return false; const [kind, sourceId] = proposal.sourceKey.slice('static:'.length).split('|'); if (kind === 'partition') { const partition = (sp.partitions || []).find((item: PartitionCfg) => item.id === sourceId); if (!partition) return false; const renderA = [partition.a[0] * NORM_W, partition.a[1] * NORM_W]; const renderB = [partition.b[0] * NORM_W, partition.b[1] * NORM_W]; const point = Math.hypot(renderA[0] - proposal.from[0], renderA[1] - proposal.from[1]) <= this._gridPitch * 0.0002 ? partition.a : Math.hypot(renderB[0] - proposal.from[0], renderB[1] - proposal.from[1]) <= this._gridPitch * 0.0002 ? partition.b : null; if (!point) return false; write(point); return true; } if (kind === 'draft') { const separator = sourceId.lastIndexOf(':'); const draftId = separator >= 0 ? sourceId.slice(0, separator) : ''; const segment = separator >= 0 ? Number(sourceId.slice(separator + 1)) : -1; const draft = (sp.room_drafts || []).find((item: any) => item.id === draftId); if (!draft?.points?.[segment] || !draft?.points?.[segment + 1]) return false; const candidates = [segment, segment + 1]; const index = candidates.find((candidate) => Math.hypot( draft.points[candidate][0] * NORM_W - proposal.from[0], draft.points[candidate][1] * NORM_W - proposal.from[1], ) <= this._gridPitch * 0.0002); if (index == null) return false; write(draft.points[index]); return true; } return false; } /** Revalidate and apply every queued answer as one geometry transaction. */ private _applyWallFaceBatch(): void { const batch = this._wallFaceBatch; const sp = this._curSpaceCfg as any; const model = this._spaceModel(); if (!batch || !sp || !model) return; const abort = (message: string, vars?: Record): void => { this._showToast(this._t(message as any, vars)); this._roomDialogCancel(); }; const accepted = batch.decisions.filter((decision) => decision.create); const acceptedRings = accepted.map((decision) => decision.candidate.ring); const existingRooms = model.rooms; for (let i = 0; i < acceptedRings.length; i++) { if (this._contourSelfIntersects(acceptedRings[i]) || polygonArea(acceptedRings[i]) <= 1e-6) { abort('toast.contour_cannot_close'); return; } for (let j = i + 1; j < acceptedRings.length; j++) { if (roomsOverlap(acceptedRings[i], acceptedRings[j])) { abort('toast.contour_cannot_close'); return; } } const ownSplit = accepted[i].candidate.split?.roomId; const clash = existingRooms.find((room) => room.id !== ownSplit && !!roomPoly(room) && roomsOverlap(acceptedRings[i], roomPoly(room)!)); if (clash) { abort('toast.room_overlap', { name: clash.name || '' }); return; } } if ((sp.rooms || []).length + accepted.length > MAX_ROOMS) { abort('toast.physical_limit'); return; } if (accepted.some((decision) => decision.candidate.split && !sp.rooms.some((room: any) => room.id === decision.candidate.split!.roomId))) { abort('toast.contour_cannot_close'); return; } const repairs = accepted .map((decision) => decision.candidate.repair) .filter((repair): repair is WallFaceRepairProposal => !!repair); if (repairs.length > 1 || (repairs[0] && !this._validateWallRepair(repairs[0], batch.activePath))) { abort('toast.wall_repair_changed'); return; } const effectiveActivePath = this._activePathWithRepair(batch.activePath, repairs[0]); const epsilon = this._gridPitch * 0.0002; const activeSourceCms = new Map(); for (let i = 0; i < batch.activeCms.length; i++) activeSourceCms.set(this._activeWallSourceKey(i), batch.activeCms[i]); const partitions: Array<{ id?: string; a: number[]; b: number[]; cm: number }> = []; if (!accepted.length) { partitions.push(...wallChainSegments( effectiveActivePath, chainSegmentCms( effectiveActivePath.length - 1, batch.activeCms, this._drawWallCm, DRAW_WALL_DEFAULT_CM, ), )); } else { const consumed = new Set(accepted.flatMap((decision) => decision.candidate.atomKeys)); const consumeAll = accepted.some((decision) => decision.candidate.consumeAllActive); for (const atom of atomizeWallSegments(this._wallGraphSources(effectiveActivePath), epsilon)) { const activeKey = atom.sourceKeys.find((key) => activeSourceCms.has(key)); if (!activeKey || consumeAll || consumed.has(atom.key)) continue; partitions.push({ a: atom.a, b: atom.b, cm: activeSourceCms.get(activeKey)! }); } } if ((sp.partitions || []).length + partitions.length > MAX_PARTITIONS) { abort('toast.physical_limit'); return; } // A face decision may split one persisted draft segment between a room // contour and leftover partitions. Pick the single physical child that // inherits the old ID by the same midpoint/old-a rule as the catalogue. const roomLineage = accepted.map((decision) => decision.candidate.ring.map(() => '')); const activeDraft = batch.activeDraftId ? (sp.room_drafts || []).find((draft: any) => draft.id === batch.activeDraftId) : null; const draftLineage = this._draftSegmentsForPath( batch.activePath, activeDraft, batch.activeCms, ); const geometryKey = (a: readonly number[], b: readonly number[]): string => { const ka = `${a[0].toFixed(9)},${a[1].toFixed(9)}`; const kb = `${b[0].toFixed(9)},${b[1].toFixed(9)}`; return ka <= kb ? `${ka}|${kb}` : `${kb}|${ka}`; }; for (let sourceIndex = 0; sourceIndex < draftLineage.length; sourceIndex++) { const id = draftLineage[sourceIndex]?.id; if (!id) continue; const sourceA = batch.activePath[sourceIndex]; const sourceB = batch.activePath[sourceIndex + 1]; const sourceSpan = [sourceA[0], sourceA[1], sourceB[0], sourceB[1]]; const candidates: Array<{ kind: 'room' | 'partition'; owner: number; edge: number; a: number[]; b: number[]; }> = []; accepted.forEach((decision, owner) => decision.candidate.ring.forEach((a, edge) => { const b = decision.candidate.ring[(edge + 1) % decision.candidate.ring.length]; if (distToSegment(a, sourceSpan) <= epsilon && distToSegment(b, sourceSpan) <= epsilon) candidates.push({ kind: 'room', owner, edge, a, b }); })); partitions.forEach((partition, owner) => { if (distToSegment(partition.a, sourceSpan) <= epsilon && distToSegment(partition.b, sourceSpan) <= epsilon) candidates.push({ kind: 'partition', owner, edge: 0, a: partition.a, b: partition.b }); }); const midpoint = [(sourceA[0] + sourceB[0]) / 2, (sourceA[1] + sourceB[1]) / 2]; candidates.sort((left, right) => { const leftSpan = [left.a[0], left.a[1], left.b[0], left.b[1]]; const rightSpan = [right.a[0], right.a[1], right.b[0], right.b[1]]; return (distToSegment(midpoint, leftSpan) <= epsilon ? 0 : 1) - (distToSegment(midpoint, rightSpan) <= epsilon ? 0 : 1) || (distToSegment(sourceA, leftSpan) <= epsilon ? 0 : 1) - (distToSegment(sourceA, rightSpan) <= epsilon ? 0 : 1) || geometryKey(left.a, left.b).localeCompare(geometryKey(right.a, right.b)); }); const winner = candidates[0]; if (!winner) continue; const winnerKey = geometryKey(winner.a, winner.b); for (const candidate of candidates) { if (geometryKey(candidate.a, candidate.b) !== winnerKey) continue; if (candidate.kind === 'room') roomLineage[candidate.owner][candidate.edge] = id; else partitions[candidate.owner].id = id; } } const before = this._geometrySnapshot(); if (repairs[0] && !this._applyWallRepair(repairs[0], batch)) { abort('toast.wall_repair_changed'); return; } const hasSplit = accepted.some((decision) => !!decision.candidate.split); const splitWalls = hasSplit ? materializeWallIntervals( model.rooms, sp.walls, this._openCuts(), this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ) : null; const wallsBeforeSplit = hasSplit && Array.isArray(sp.walls) ? sp.walls : null; for (const decision of accepted) { const split = decision.candidate.split; if (!split) continue; const main = sp.rooms.find((room: any) => room.id === split.roomId); if (!main) { abort('toast.contour_cannot_close'); return; } main.poly = split.mainPoly.map((point) => [point[0] / NORM_W, point[1] / this._spaceH]); delete main.x; delete main.y; delete main.w; delete main.h; } const seed = Date.now().toString(36); const newRooms: Array<{ room: any; decision: WallFaceDecision }> = []; accepted.forEach((decision, index) => { const areaName = decision.area ? this.hass.areas[decision.area]?.name : ''; const room = { id: `r${seed}-${index}`, name: decision.name || areaName || this._t('room.default_name'), area: decision.area || null, poly: decision.candidate.ring.map((point) => [point[0] / NORM_W, point[1] / this._spaceH]), ...(roomLineage[index].some(Boolean) ? { wall_ids: roomLineage[index] } : {}), ...(decision.settings ? { settings: JSON.parse(JSON.stringify(decision.settings)) } : {}), }; sp.rooms.push(room); newRooms.push({ room, decision }); }); if (batch.activeDraftId && Array.isArray(sp.room_drafts)) { sp.room_drafts = sp.room_drafts.filter((draft: any) => draft.id !== batch.activeDraftId); if (!sp.room_drafts.length) delete sp.room_drafts; } if (partitions.length) { sp.partitions ||= []; partitions.forEach((partition, index) => sp.partitions.push({ id: partition.id || `partition-${seed}-${index}`, a: [partition.a[0] / NORM_W, partition.a[1] / NORM_W], b: [partition.b[0] / NORM_W, partition.b[1] / NORM_W], cm: partition.cm, })); } if (hasSplit) { this._commitOpenSpans(); const next = this._normalizeWalls(splitWalls, this._openCuts()); if (next.length) sp.walls = next; else if (!wallsBeforeSplit?.length) delete sp.walls; else sp.walls = wallsBeforeSplit; } if (newRooms.length) { // Make the new model visible to the canonical wall helpers before they // key intervals. This is still inside the one history/save transaction. this._cfgEpoch++; const openCuts = this._openCuts(); const updatedModel = this._spaceModel(); if (!updatedModel) return; let walls = sp.walls; for (const { room, decision } of newRooms) { const ring = decision.candidate.ring; const fallback = this._wallSourceCmAt( ring[0], batch.activePath, batch.activeCms, ); walls = applyWallThicknessToNewRoom( walls, updatedModel.rooms, room.id, fallback, this._wallKeyPitch, openCuts, NORM_W, ); for (const interval of wallIntervals( updatedModel.rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, )) { if (interval.roomId !== room.id || interval.kind !== 'outer') continue; const midpoint = [ (interval.a[0] + interval.b[0]) / 2, (interval.a[1] + interval.b[1]) / 2, ]; walls = setWallThickness( walls, interval.a, interval.b, this._wallSourceCmAt(midpoint, batch.activePath, batch.activeCms), this._wallKeyPitch, NORM_W, ); } } const normalized = this._normalizeWalls(walls, openCuts); if (normalized.length) sp.walls = normalized; else delete sp.walls; } if (!this._commitPhysicalGeometry(this._t('history.wall_face_batch'), before)) return; delete this._resumeDraftBySpace[this._space]; this._path = []; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._wallFaceBatch = null; this._roomDialog = false; this._nameSel = ''; this._areaSel = ''; this._regSignature = ''; this._maybeRebuildDevices(); // Match the ordinary room-save contract: newly area-bound devices are // auto-added and their initial positions are pinned immediately, while a // clean split leaves the surviving parent's existing layout untouched. const boundAreas = new Set(accepted .map((decision) => decision.area) .filter((area): area is string => !!area)); if (boundAreas.size) { const next = { ...this._layout }; for (const device of this._devices) { if (!boundAreas.has(device.area || '') || device.space !== this._space) continue; if (this._layout[device.id]) continue; const position = this._defPos[device.id]; if (!position) continue; next[device.id] = { s: this._space, x: position.x / NORM_W, y: position.y / NORM_W, }; this._dirtyPos.add(device.id); } this._layout = next; this._persistLayout(); } this._showToast(this._t(accepted.length ? 'toast.wall_rooms_saved' : 'toast.wall_chain_saved', { n: accepted.length, })); } private _commitRoom(): void { const sp = this._curSpaceCfg; const space = this._spaceModel(); if (!sp || !space) return; const before = this._geometrySnapshot(); const H = this._spaceH; const wasSplit = !!this._pendingSplit; // Split changes the two source-wall edges into shorter children. Preserve // the effective old profile while the original outline still proves the // full extent of legacy midpoint-only wall keys; after the mutation that // information is irrecoverable and one child would normalise to 0 cm. const splitWalls = wasSplit ? materializeWallIntervals( space.rooms, sp.walls, this._openCuts(), this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ) : null; const wallsBeforeSplit = wasSplit && Array.isArray(sp.walls) ? sp.walls : null; let verts: number[][]; if (this._pendingSplit) { // apply the cut now: the bigger part keeps the original room, this dialog names the rest const main = sp.rooms.find((r: any) => r.id === this._pendingSplit!.roomId); if (!main) { this._pendingSplit = null; this._splitSel = null; this._roomDialog = false; return; } main.poly = this._pendingSplit.mainPoly.map((p) => [p[0] / NORM_W, p[1] / H]); delete main.x; delete main.y; delete main.w; delete main.h; verts = this._pendingSplit.newPoly; } else { if (!this._contourClosed) return; verts = this._path.slice(0, -1); // without the duplicated closing vertex } const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : ''; const newRoom: any = { id: 'r' + Date.now().toString(36), name: this._nameSel || areaName || this._t('room.default_name'), area: this._areaSel || null, poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]), ...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}), }; if (!wasSplit && this._activeDraftId) { const activeDraft = Array.isArray((sp as any).room_drafts) ? (sp as any).room_drafts.find((draft: any) => draft.id === this._activeDraftId) : null; const cms = chainSegmentCms( verts.length, [...this._draftSegmentCms, this._closingWallCm ?? undefined], this._drawWallCm, DRAW_WALL_DEFAULT_CM, ); const lineage = this._draftSegmentsForPath(this._path, activeDraft, cms); if (lineage.length === verts.length && lineage.every((segment) => !!segment.id)) { newRoom.wall_ids = lineage.map((segment) => segment.id!); } } sp.rooms.push(newRoom); // Closing a saved draft promotes it into a room in the same transaction. if (!wasSplit && this._activeDraftId && Array.isArray((sp as any).room_drafts)) { (sp as any).room_drafts = (sp as any).room_drafts.filter((d: any) => d.id !== this._activeDraftId); if (!(sp as any).room_drafts.length) delete (sp as any).room_drafts; } // A Split rewrites the parent outline and adds a child, so a span that used // to sit between the parent and a neighbour may now belong to the child. // Geometry first, then spans and the derived open_to — otherwise border // trimming reads the new geometry while glow reads the old connectivity // (AUD-159B6-02). if (wasSplit) { this._commitOpenSpans(); const next = this._normalizeWalls(splitWalls, this._openCuts()); if (next.length) sp.walls = next; else if (!wallsBeforeSplit?.length) delete sp.walls; else sp.walls = wallsBeforeSplit; } // Draw-session wall thickness: apply to new edges only; keep neighbour cm // on shared stretches. Split naming does not use the Draw field. if (!wasSplit) { const edgeCms = chainSegmentCms( verts.length, [...this._draftSegmentCms, this._closingWallCm ?? undefined], this._drawWallCm, DRAW_WALL_DEFAULT_CM, ); const cm = edgeCms[0]; if (cm != null) { this._cfgEpoch++; // the new room must be in the model before keying const openCuts = this._openCuts(); const updatedSpace = this._spaceModel(); if (!updatedSpace) return; let next = applyWallThicknessToNewRoom( sp.walls, updatedSpace.rooms, newRoom.id, cm, this._wallKeyPitch, openCuts, NORM_W, ); // The room may have been drawn while the toolbar thickness changed. // Shared stretches keep the already-existing physical wall; every // outer atomic stretch receives the value of its source draft edge. for (const iv of wallIntervals( updatedSpace.rooms, next, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, )) { if (iv.roomId !== newRoom.id || iv.kind !== 'outer') continue; const mid = [(iv.a[0] + iv.b[0]) / 2, (iv.a[1] + iv.b[1]) / 2]; const source = verts.findIndex((a, i) => { const b = verts[(i + 1) % verts.length]; return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this._gridPitch * 0.02; }); if (source >= 0) next = setWallThickness( next, iv.a, iv.b, edgeCms[source], this._wallKeyPitch, NORM_W, ); } next = this._normalizeWalls(next, openCuts); if (next.length) sp.walls = next; else delete sp.walls; } } const committed = this._commitPhysicalGeometry( this._t(wasSplit ? 'history.split_room' : 'history.add_room'), before, ); this._path = []; delete this._resumeDraftBySpace[this._space]; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._pendingSplit = null; this._splitSel = null; const boundArea = this._areaSel; this._areaSel = ''; this._nameSel = ''; this._roomDialog = false; this._regSignature = ''; this._maybeRebuildDevices(); if (!committed) return; // auto-add the area's device icons + PIN their positions in the layout, // so that icons do not get reshuffled when the order in the HA registry changes. let added = 0; if (boundArea) { const H2 = NORM_W; const next = { ...this._layout }; for (const d of this._devices) { if (d.area !== boundArea || d.space !== this._space) continue; added++; if (this._layout[d.id]) continue; // placed manually — leave it alone const dp = this._defPos[d.id]; if (!dp) continue; next[d.id] = { s: this._space, x: dp.x / NORM_W, y: dp.y / H2 }; this._dirtyPos.add(d.id); } this._layout = next; this._persistLayout(); } const roomsN = this._model.find((s) => s.id === this._space)?.rooms.length || 0; this._showToast( boundArea ? this._t('toast.room_saved', { n: roomsN, added }) : this._t('toast.room_saved_no_area', { n: roomsN }), ); } private _cancelPath(): void { this._wallRepairDiagnostic = null; if (this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId; this._path = []; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; this._clearPlanSnapHover(); this._roomDialog = false; this._pendingSplit = null; this._wallFaceBatch = null; this._splitSel = null; this._mergeSel = null; this._mergeDialog = null; this._openWallAnchor = null; this._boundaryRestoreGuard = null; this._physicalSel = null; this._physicalDrag = null; this._physicalRotate = null; this._clearOpeningPlacement(true); } private _resumeLastDraft(): void { const id = this._resumeDraftBySpace[this._space]; if (!id) return; const draft = this._spaceModel()?.room_drafts.find((d) => d.id === id); if (!draft) { delete this._resumeDraftBySpace[this._space]; return; } this._activeDraftId = id; this._path = draft.points.map((p) => [...p]); this._draftSegmentCms = this._adoptDraftCms( this._path, draft.segments.map((s: any) => s.cm), draft.id, ); this._clearPlanSnapHover(); } /** Cancel a room flow without applying geometry; graph batches keep their terminal draft. */ private _roomDialogCancel(): void { this._roomDialog = false; if (this._roomEditId) { this._roomEditId = null; this._nameSel = ''; this._areaSel = ''; return; } if (this._wallFaceBatch) { const batch = this._wallFaceBatch; this._wallFaceBatch = null; this._path = batch.activePath.map((point) => [...point]); this._draftSegmentCms = [...batch.activeCms]; this._activeDraftId = batch.activeDraftId; this._nameSel = ''; this._areaSel = ''; this.requestUpdate(); return; } if (this._pendingSplit) { // nothing was applied yet — drop the cut entirely, the room stays whole this._pendingSplit = null; this._splitSel = null; return; } this._undoPoint(); } /** HA areas not yet assigned to any room in the config. */ private get _freeAreas(): any[] { const used = new Set(); for (const sp of this._serverCfg?.spaces || []) for (const r of sp.rooms || []) if (r.area) used.add(r.area); for (const decision of this._wallFaceBatch?.decisions || []) if (decision.create && decision.area) used.add(decision.area); return Object.values(this.hass?.areas || {}) .filter((a) => !used.has(a.area_id)) .sort((a, b) => (a.name || '').localeCompare(b.name || '')); } // ================= DEVICE EDITOR (markers) ================= private _openMarkerDialog(d?: DevItem): void { // A global-camera snapshot belongs to one explicit expansion only; never // carry it across device-dialog sessions. this._vacAllCamerasFor = null; this._vacAllCameraCache = null; if (d) this._ackNewDevice(d.id); if (!this._norm) { this._showToast(this._t('toast.marker_needs_server')); return; } if (d) { const marker = d.marker; const hasIsLight = Object.prototype.hasOwnProperty.call(marker || {}, 'is_light'); const hasLightEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'light_entity'); const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color'); const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge'); const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action'); const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity'); const glowOverride = normalizeGlowColorOverride(marker?.glow_color); const currentBadge = this._devicePresentation(d, true).valueBadge; const badgeCandidates = valueBadgeCandidates(this._planHass, d, this._devices); const recommendedBadge = recommendedValueBadgeSource(this._planHass, d, badgeCandidates); this._markerDialog = { devId: d.id, name: d.name, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, bindingMode: d.bindingKind === 'virtual' ? 'virtual' : 'ha', bindingOpen: false, // a marker bound to an ENTITY of a device only shows up with the box on showEntities: d.bindingKind === 'entity' && !!this._fullRegistryHass.entities[d.bindingRef || '']?.device_id, bindingFilter: '', icon: d.marker?.icon || '', autoIcon: d.icon || '', display: normalizeDeviceDisplay(d.marker?.display), rippleColor: safeStoredColor(d.marker?.ripple_color, ''), rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 1.5, size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1, angle: Number(d.marker?.angle) || 0, tapAction: projectedTapAction(d.marker?.tap_action, d.primary?.split('.')[0]), tapActionTouched: false, originalHasTapAction: hasTapAction, originalTapAction: d.marker?.tap_action, tapHintAnnouncement: '', toggleEntity: marker?.toggle_entity || '', toggleEntityTouched: false, originalHasToggleEntity: hasToggleEntity, originalToggleEntity: marker?.toggle_entity, tapTarget: d.marker?.tap_target || '', tapConfirm: d.marker?.tap_confirm === true, runFilter: '', // Keep unknown, temporarily inactive and duplicate external targets // byte-for-byte across Open → Save. Runtime uses the filtered // effective projection; a legacy self-reference is not a light source. controls: persistedExternalControls(d.marker?.binding, d.marker?.controls, d.entities), controlsFilter: '', lightRole: marker?.is_light === true ? 'always' : marker?.is_light === false ? 'never' : 'auto', lightRoleTouched: false, originalHasIsLight: hasIsLight, originalIsLight: marker?.is_light, lightEntity: marker?.light_entity || '', lightEntityTouched: false, originalHasLightEntity: hasLightEntity, originalLightEntity: marker?.light_entity, glowMode: glowOverride?.bri != null ? 'fixed' : glowOverride ? 'color' : 'auto', glowColor: glowOverride?.c || this._fillColors.glow_light.c, glowBrightness: Math.max(1, Math.round((glowOverride?.bri ?? 1) * 100)), glowColorDrafted: !!glowOverride, glowBrightnessDrafted: glowOverride?.bri != null, glowTouched: false, originalHasGlowColor: hasGlowColor && (!!glowOverride || marker?.glow_color === null), originalGlowColor: glowOverride || (marker?.glow_color === null ? null : undefined), valueBadgeEnabled: hasValueBadge ? marker?.value_badge?.enabled === true : !!currentBadge, valueBadgeSource: marker?.value_badge?.source || currentBadge?.source || recommendedBadge, valueBadgePosition: marker?.value_badge?.position || currentBadge?.position || 'right', valueBadgeTouched: false, originalHasValueBadge: hasValueBadge, originalValueBadge: marker?.value_badge, useClimateTemp: d.marker?.use_climate_temp === true, glowRadius: Number(d.marker?.glow_radius_cm) > 0 ? String(this._imperial ? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10 : Math.round(Number(d.marker!.glow_radius_cm)) / 100) : '', model: d.model || '', link: d.link || '', description: d.description || '', pdfs: [...(d.pdfs || [])], room: d.marker?.room_id ? d.space + '#@' + d.marker.room_id : d.space && d.area ? d.space + '#' + d.area : '', hideFromPlan: d.marker?.hidden === true, busy: false, }; } else { this._markerDialog = { name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false, showEntities: false, bindingFilter: '', icon: '', autoIcon: '', display: 'badge', rippleColor: '', rippleSize: 1.5, size: 1, angle: 0, tapAction: 'info', tapActionTouched: false, originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '', toggleEntity: '', toggleEntityTouched: false, originalHasToggleEntity: false, originalToggleEntity: undefined, tapTarget: '', tapConfirm: false, runFilter: '', controls: [], controlsFilter: '', lightRole: 'auto', lightRoleTouched: false, originalHasIsLight: false, originalIsLight: undefined, lightEntity: '', lightEntityTouched: false, originalHasLightEntity: false, originalLightEntity: undefined, glowMode: 'auto', glowColor: this._fillColors.glow_light.c, glowBrightness: 100, glowColorDrafted: false, glowBrightnessDrafted: false, glowTouched: false, originalHasGlowColor: false, originalGlowColor: undefined, valueBadgeEnabled: false, valueBadgeSource: null, valueBadgePosition: 'right', valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined, useClimateTemp: false, glowRadius: '', model: '', link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false, uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), }; } } /** Runnable targets for the 'run' tap action: automations, scripts, scenes. */ private _runCandidates(): { value: string; label: string; sub: string }[] { const out: { value: string; label: string; sub: string }[] = []; for (const dom of RUN_TARGET_DOMAINS) { for (const [eid, st] of Object.entries(this.hass.states)) { if (!eid.startsWith(dom + '.')) continue; if (!this._planEntityAvailable(eid)) continue; out.push({ value: eid, label: st?.attributes?.friendly_name || eid, sub: this._t(('run.' + dom) as any), }); } } return out.sort((a, b) => a.sub.localeCompare(b.sub) || a.label.localeCompare(b.label)); } /** Binding candidates: HA devices + group/helper entities, minus the ones already placed. */ private _bindingCandidates(): { value: string; label: string; sub: string }[] { const h = this._planHass; const removed = removedPlanBindings(this._markers); const removedBindings = new Set( this._markers.filter((m) => m.removed).map((m) => m.binding), ); const taken = new Set(); for (const dev of this._devices) { if (dev.id === this._markerDialog?.devId) continue; if (dev.bindingKind === 'device' && dev.bindingRef) taken.add('device:' + dev.bindingRef); if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add('entity:' + dev.bindingRef); } // dedup as on the plan: hide devices with the same “name|area” as already shown ones (Tuya duplicates) const shownKeys = new Set(); for (const dev of this._devices) { if (dev.bindingKind === 'device' && dev.name) shownKeys.add(dev.name.trim() + '|' + (dev.area || '')); } const list: { value: string; label: string; sub: string }[] = []; // devices (incl. Z2M groups with model=Group) for (const dev of Object.values(h.devices)) { if (dev.entry_type === 'service') continue; const v = 'device:' + dev.id; if (taken.has(v)) continue; const name = (dev.name_by_user || dev.name || dev.id).trim(); if (v !== this._markerDialog?.binding && !removedBindings.has(v) && shownKeys.has(name + '|' + (dev.area_id || ''))) continue; list.push({ value: v, label: name, sub: (dev.model || this._t('marker.sub_device')) + (dev.model === 'Group' ? this._t('marker.sub_z2m_group') : '') }); } // group/helper entities without a physical device of their own const helperPlatforms = new Set([ 'group', 'template', 'derivative', 'min_max', 'threshold', 'integration', 'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule', ]); for (const [eid, reg] of Object.entries(h.entities)) { const v = 'entity:' + eid; if (taken.has(v)) continue; if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(v)) continue; const isHelper = helperPlatforms.has(reg.platform); const isGroupEntity = reg.platform === 'group'; if (!isHelper && !isGroupEntity) continue; if (reg.hidden && !removedBindings.has(v)) continue; const st = h.states[eid]; list.push({ value: v, label: reg.name || st?.attributes?.friendly_name || eid, sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')), }); } // Individual entities of devices — behind the "show entities" checkbox // (groups/helpers above are ALWAYS listed: they are standalone objects). if (this._markerDialog?.showEntities) { const seen = new Set(list.map((o) => o.value)); for (const [eid, reg] of Object.entries(h.entities)) { const v = 'entity:' + eid; if (taken.has(v) || seen.has(v) || (reg.hidden && !removedBindings.has(v))) continue; // A deleted device remains offered as a whole, but the person may also // restore just one of its children. The runtime override begins only // after that exact entity marker is saved; this picker-only exception // is what makes the intentional transition possible (#262). const childOfRemovedDevice = !!reg.device_id && removed.devices.has(reg.device_id); if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(v) && !childOfRemovedDevice) continue; const stt = h.states[eid]; const label = reg.name || stt?.attributes?.friendly_name || eid; const dev = reg.device_id ? h.devices[reg.device_id] : null; const devName = dev ? (dev.name_by_user || dev.name || '') : ''; list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') }); } } const f = (this._markerDialog?.bindingFilter || '').toLowerCase().trim(); const filtered = f ? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f)) : list; filtered.sort((a, b) => a.label.localeCompare(b.label)); return filtered.slice(0, 200); } /** A closed outline may intentionally remain a set of independent walls. */ private _keepClosedAsPartitions = (): void => { if (this._wallFaceBatch) { this._decideWallFace(false); return; } if (!this._contourClosed || this._pendingSplit || !this._curSpaceCfg) return; const sp = this._curSpaceCfg as any; const verts = this._path.slice(0, -1); if ((sp.partitions || []).length + verts.length > MAX_PARTITIONS) { this._showToast(this._t('toast.physical_limit')); return; } const before = this._geometrySnapshot(); // Замкнутый контур: отрезков столько же, сколько вершин, и последний — // закрывающий. Его известное значение подаётся резолверу как запись, всё // остальное решает единое правило (#234). const cms = chainSegmentCms( verts.length, [...this._draftSegmentCms, this._closingWallCm ?? undefined], this._drawWallCm, DRAW_WALL_DEFAULT_CM, ); sp.partitions ||= []; const seed = Date.now().toString(36); const activeDraft = this._activeDraftId ? (sp.room_drafts || []).find((draft: any) => draft.id === this._activeDraftId) : null; const lineage = this._draftSegmentsForPath(this._path, activeDraft, cms); for (let i = 0; i < verts.length; i++) { const a = verts[i], b = verts[(i + 1) % verts.length]; sp.partitions.push({ id: lineage[i]?.id || `partition-${seed}-${i}`, a: [a[0] / NORM_W, a[1] / NORM_W], b: [b[0] / NORM_W, b[1] / NORM_W], cm: cms[i], }); } if (this._activeDraftId && Array.isArray(sp.room_drafts)) { sp.room_drafts = sp.room_drafts.filter((d: any) => d.id !== this._activeDraftId); if (!sp.room_drafts.length) delete sp.room_drafts; } this._commitPhysicalGeometry(this._t('history.contour_to_partitions'), before); this._roomDialog = false; this._path = []; delete this._resumeDraftBySpace[this._space]; this._activeDraftId = null; this._draftSegmentCms = []; this._closingWallCm = null; }; /** All independent physical bodies in render units. Physics intentionally * does not depend on show_borders. */ private _physicalBodiesR(space: SpaceModel | undefined = this._spaceModel()): number[][][] { if (!space) return []; const key = `${space.id}|${this._cfgEpoch}|${this._cellCm}|${this._gridPitch}`; if (this._physicalBodiesCache?.key === key) return this._physicalBodiesCache.all; const frame = physicalBodyParts( space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002, this._partitionOpeningCuts(space), ); this._physicalBodiesCache = { key, ...frame }; return frame.all; } /** Per-record bodies remain the editor/furniture identity surface. */ private _rawPhysicalBodiesR(space: SpaceModel | undefined = this._spaceModel()): number[][][] { if (!space) return []; this._physicalBodiesR(space); const frame = this._physicalBodiesCache; return frame ? [...frame.drafts, ...frame.partitions, ...frame.columns] : []; } /** Cached clean floor. A cheap bbox pass is the spatial index needed for * rooms which touch only a small subset of independent bodies. */ private _cleanFloor( room: RoomCfg, floor: number[][], space: SpaceModel | undefined = this._spaceModel(), ): { floor: number[][]; geom: any; path: string; area: number } { if (!space) { return { floor, geom: null, path: '', area: geometryArea([[[...floor, floor[0]]]]), }; } const roomKey = room.id || `#${space.rooms.indexOf(room)}`; const key = `${space.id}|${this._cfgEpoch}|${roomKey}`; if (!this._rszPreview) { const cached = lruRead(this._cleanFloorCache, key); if (cached.hit) return cached.value; } const xs = floor.map((p) => p[0]), ys = floor.map((p) => p[1]); const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; const candidates = this._physicalBodiesR(space).filter((body) => { const bx = body.map((p) => p[0]), by = body.map((p) => p[1]); return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2] && Math.max(...by) >= box[1] && Math.min(...by) <= box[3]; }); const geom = candidates.length ? floorMinusBodies(floor, candidates) : null; const result = { floor, geom, path: geom ? polyclipPathD(geom) : '', area: geom ? geometryArea(geom) : geometryArea([[[...floor, floor[0]]]]), }; if (!this._rszPreview) { lruWrite(this._cleanFloorCache, key, result, 600); } return result; } /** Effective auto icon for a binding selected but not saved yet. */ private _autoIconForBinding(binding: string): string { if (binding === 'virtual') return 'mdi:map-marker'; const [kind, ref] = binding.split(':'); if (!ref) return ''; const h = this._fullRegistryHass; const status = this._bindingStatus(binding); const activeEntities = status.kind === 'active' ? status.enabledEntityIds : status.allEntityIds; if (kind === 'device') { const dev = h.devices?.[ref]; if (!dev) return 'mdi:help-circle'; const entities = activeEntities; if (entities.some((eid) => eid.startsWith('lock.'))) return 'mdi:lock'; return resolveIcon( h, dev.name_by_user || dev.name || '', dev.model, entities, this._iconRules, ); } if (kind === 'entity') { const reg = h.entities?.[ref]; const state = this.hass.states?.[ref]; const name = reg?.name || state?.attributes?.friendly_name || ref; return ref.startsWith('lock.') ? 'mdi:lock' : resolveIcon(h, name, '', [ref], this._iconRules); } return ''; } /** List of rooms across all spaces for a virtual device. */ private _allRoomsFlat(): { value: string; label: string }[] { const res: { value: string; label: string }[] = []; for (const sp of this._serverCfg?.spaces || []) { for (const r of sp.rooms || []) { if (r.area) { res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name }); } else if (r.id) { // sub-area room (no HA area): manual placement by room id — issue #3 res.push({ value: sp.id + '#@' + r.id, label: (sp.title || sp.id) + ' · ' + r.name + ' · ' + this._t('marker.subarea'), }); } } } return res; } /** Readable error text (never “[object Object]”). */ private _errText(e: any): string { if (!e) return this._t('err.unknown'); if (typeof e === 'string') return e; if (e.code === 'invalid_passage_fields') { const match = String(e.message || e.error || '').match( /space=([^;]*);\s*opening=([^;]*);\s*fields=([^;]*)/, ); if (match) { const space = this._serverCfg?.spaces?.find((item: any) => String(item.id) === match[1]); const labels: Record = { contact: 'opening.contact_label', lock: 'opening.lock_label', invert: 'opening.invert', flip_h: 'opening.flip_h', flip_v: 'opening.flip_v', }; const fields = match[3].split(',').filter(Boolean) .map((field) => labels[field] ? this._t(labels[field]) : field).join(', '); return this._t('opening.invalid_passage_fields', { room: space?.title || match[1], fields, }); } } if (e.code === 'invalid_partition_opening_jamb_margin') { const match = String(e.message || e.error || '').match(/margin_cm=([^;]*)/); const marginCm = match ? Number(match[1]) : NaN; if (Number.isFinite(marginCm)) { return this._t('opening.partition_jamb_margin', { distance: formatLength(marginCm, this._imperial), }); } } if (e.message) return e.message; if (e.error) return e.error; if (e.code != null) return this._t('err.code', { code: e.code }); try { return JSON.stringify(e); } catch { return String(e); } } private _backupErrorText(e: any): string { const code = e?.code ?? e?.error; if (typeof code === 'string') { const key = `backup.error.${code}`; const translated = this._t(key as I18nKey); if (translated !== key) return translated; } return this._errText(e); } /** * Manual files are uploaded via HTTP (multipart) — not via WebSocket, whose message size * limit breaks the connection on large PDFs. */ private async _pickMarkerFiles(ev: Event): Promise { const input = ev.target as HTMLInputElement; const files = input.files ? [...input.files] : []; input.value = ''; if (!files.length || !this._markerDialog) return; const mid = this._markerDialog.uploadId || this._markerDialog.devId || 'new'; const uploaded: PdfRef[] = []; for (const file of files) { try { const fd = new FormData(); fd.append('marker_id', mid); fd.append('file', file, file.name); // fetchWithAuth refreshes a stale access_token itself; the fallback is the raw token const resp: Response = this.hass?.fetchWithAuth ? await this.hass.fetchWithAuth('/api/houseplan/upload', { method: 'POST', body: fd }) : await fetch('/api/houseplan/upload', { method: 'POST', body: fd, headers: this.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.hass.auth.data.access_token}` } : {}, }); const json = await resp.json().catch(() => ({})); if (!resp.ok || json.error) { const map: Record = { too_large: this._t('err.too_large', { mb: json.max_mb || 50 }), bad_ext: this._t('err.bad_ext'), unauthorized: this._t('err.unauthorized'), }; throw new Error(map[json.error] || json.error || 'HTTP ' + resp.status); } uploaded.push({ name: json.name || file.name, url: json.url }); } catch (e: any) { this._showToast(this._t('toast.file_failed', { name: file.name, err: this._errText(e) })); } } // the dialog might have closed during the upload — add only if it is still open if (uploaded.length && this._markerDialog) { this._markerDialog = { ...this._markerDialog, pdfs: [...this._markerDialog.pdfs, ...uploaded] }; this._showToast(this._t('toast.files_attached', { n: uploaded.length })); } } private _removeMarkerPdf(url: string): void { if (!this._markerDialog) return; this._markerDialog = { ...this._markerDialog, pdfs: this._markerDialog.pdfs.filter((p) => p.url !== url), }; } /** Persist only deliberate role/appearance edits; untouched legacy absence stays absent. */ private _markerLightFields(d: NonNullable): Partial { const fields: Partial = {}; if (!d.lightRoleTouched) { if (d.originalHasIsLight) fields.is_light = d.originalIsLight ?? null; } else if (d.lightRole === 'always') fields.is_light = true; else if (d.lightRole === 'never') fields.is_light = false; if (!d.lightEntityTouched) { if (d.originalHasLightEntity) fields.light_entity = d.originalLightEntity ?? null; } else if (d.lightEntity) { fields.light_entity = d.lightEntity; } if (!d.glowTouched) { if (d.originalHasGlowColor) fields.glow_color = d.originalGlowColor ?? null; } else if (d.glowMode !== 'auto') { const glow: NonNullable = { c: safeStoredColor(d.glowColor, this._fillColors.glow_light.c), }; if (d.glowMode === 'fixed') { glow.bri = Math.max(0.01, Math.min(1, Math.round(d.glowBrightness) / 100)); } fields.glow_color = glow; } return fields; } /** Preserve absent/default and legacy cover until the user edits the select. */ private _markerTapActionFields( d: NonNullable, ): Pick | Record { if (!d.tapActionTouched) { return d.originalHasTapAction ? { tap_action: d.originalTapAction ?? null } : {}; } return { tap_action: d.tapAction || null }; } /** Preserve absence and stale/future literals until this exact selector is edited. */ private _markerToggleEntityFields( d: NonNullable, ): Pick | Record { return toggleEntityWriteFields({ touched: d.toggleEntityTouched, originalHas: d.originalHasToggleEntity, original: d.originalToggleEntity, value: d.toggleEntity, }); } private async _saveMarker(): Promise { const dlg = this._markerDialog; if (!dlg || dlg.busy) return; const effectiveTapAction = this._effectiveMarkerTapAction(dlg); if (dlg.bindingMode === 'ha' && (!dlg.binding || dlg.binding === 'virtual')) return; if (dlg.binding === 'virtual' && !dlg.name.trim()) { this._showToast(this._t('toast.virtual_name_required')); return; } if (effectiveTapAction === 'run' && !dlg.tapTarget) { this._showToast(this._t('toast.run_target_required')); return; } if (dlg.valueBadgeTouched && dlg.valueBadgeEnabled && !dlg.valueBadgeSource) { this._showToast(this._t('toast.value_badge_source_required')); return; } if (dlg.bindingMode === 'ha') { const status = this._bindingStatus(dlg.binding); const previous = dlg.devId ? this._markers.find((marker) => marker.id === dlg.devId) : null; const replacingWithInactive = !previous || previous.binding !== dlg.binding; if (status.kind !== 'active' && replacingWithInactive) { this._showToast(this._t(status.kind === 'ha_disabled' ? 'toast.ha_disabled_add' : 'toast.ha_binding_unverified')); return; } // Saving metadata for an already-disabled binding is allowed, but a // blocked Show attempt may never erase an older explicit user hidden. if (status.kind === 'ha_disabled' && previous?.hidden === true && !dlg.hideFromPlan) { this._markerDialog = { ...dlg, hideFromPlan: true }; this._showToast(this._t(status.reason === 'entity' ? 'toast.ha_disabled_show_entity' : 'toast.ha_disabled_show_device')); return; } } const cfg = this._serverCfg; if (!cfg) return; const markers = cfg.markers || []; const roomRef = parseRoomRef(dlg.room); let space: string | null = roomRef?.space || null; const area: string | null = roomRef?.area || null; const roomId: string | null = roomRef?.roomId || null; const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36)); const oldId = dlg.devId; const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null; const explicitSpaceId = roomRef?.space || prevDev?.space || null; const targetSpaceModel = explicitSpaceId ? this._spaceModelById(explicitSpaceId) : this._spaceModel(); if (!targetSpaceModel) return; const targetSpaceId = targetSpaceModel.id; if (dlg.binding === 'virtual' && !space) space = targetSpaceId; this._markerDialog = { ...dlg, busy: true }; try { const replacedRemovedIds = dlg.binding === 'virtual' ? [] : markers .filter((m) => m.removed && m.binding === dlg.binding) .map((m) => m.id); const replacingRemoved = replacedRemovedIds.length > 0; const controls = persistedExternalControls( dlg.binding, dlg.controls, this._bindingEntities(dlg.binding), ); // the vacuum block is edited live outside the dialog transaction — // the rebuild below must carry it over, not erase it const prevVac = markers.find((m0: Marker) => m0.id === id || m0.id === oldId)?.vacuum || null; const marker: Marker = { id, vacuum: prevVac, binding: dlg.binding, name: dlg.name.trim() || null, icon: dlg.icon || null, display: dlg.display !== 'badge' ? dlg.display : null, ripple_color: dlg.display === 'icon_ripple' && dlg.rippleColor ? dlg.rippleColor : null, ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 1.5 ? dlg.rippleSize : null, size: dlg.size !== 1 ? dlg.size : null, angle: dlg.angle ? dlg.angle : null, ...this._markerTapActionFields(dlg), ...this._markerToggleEntityFields(dlg), tap_target: effectiveTapAction === 'run' ? dlg.tapTarget || null : null, tap_confirm: dlg.tapConfirm ? true : null, controls: controls.length ? controls : null, // pdfs may be rewritten below when rebinding changes the marker id ...this._markerLightFields(dlg), ...this._markerValueBadgeFields(dlg), use_climate_temp: dlg.useClimateTemp ? true : null, glow_radius_cm: (() => { const v = strictNumber(dlg.glowRadius); if (v == null || v <= 0) return null; return Math.round(this._imperial ? v * 30.48 : v * 100); })(), model: dlg.model.trim() || null, link: dlg.link.trim() || null, description: dlg.description.trim() || null, pdfs: dlg.pdfs, // Explicit false, not absence: a marker of any kind is what tells the // seeder "the user decided" — unticking must not invite a re-hide // (docs/FILTERING.md). hidden: dlg.hideFromPlan ? true : false, }; // save the room choice (always for virtual ones; for bound ones — if chosen) if (dlg.binding === 'virtual' || dlg.room) { marker.space = space; marker.area = area; marker.room_id = roomId; } // the room changed → move the icon to its center const prevRoomId = prevDev?.marker?.room_id ?? null; const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId); // Rebinding changes the marker id, so the uploaded files must follow. // Order matters (review CR-2): COPY first, save the config, and only then // delete the old folder. If the save is rejected, the old urls in the // stored config still resolve — the files never left. A failed copy // leaves the urls untouched and tells the user (review CR-3). let cleanupOldFiles = false; // a new icon uploaded into its own staging folder; an edited one into its // own id. Either way the files move to the final id here. const fileSrc = dlg.uploadId || oldId; if (fileSrc && fileSrc !== id && marker.pdfs?.length) { try { const res: any = await this.hass.callWS({ type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id, }); const mapping = res?.mapping || {}; marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping); cleanupOldFiles = Object.keys(mapping).length > 0; } catch (e: any) { this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) })); } } // Rebinding changes source identity. Rewrite every marker:* edge in the // same config transaction before replacing the marker itself. cfg.markers = markers; if (oldId && oldId !== id) cfg.markers = rewriteMarkerControlReferences(cfg.markers, oldId, id); if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state' && marker.value_badge.source.ref === `marker:${oldId}`) { marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` }; } // remove the previous marker (by the old id and by the new id) cfg.markers = cfg.markers.filter( (m) => m.id !== id && m.id !== oldId && (marker.binding === 'virtual' || m.binding !== marker.binding), ); cfg.markers.push(marker); // Position rule (owner's decision, v1.33.4): editing an existing icon — // rebinding it to another HA device/entity or to another room — must NOT // move it. Its current position (saved or the ephemeral auto one) is // migrated to the new marker id. Only two cases still center the icon: // a truly NEW icon, and a move to a room in a DIFFERENT space (keeping // the old coordinates there would be meaningless). // Write POINT-WISE (layout/update), not the whole layout — a full layout/set // overwrites positions changed in other windows (the v1.4.4 incident). let newPos: { s: string; x: number; y: number } | null = null; const prevRec = oldId ? this._layout[oldId] : null; const prevPos = prevRec ? { s: prevRec.s || prevDev?.space || this._space, x: prevRec.x, y: prevRec.y } : oldId && prevDev && this._defPos[oldId] ? this._normPos(prevDev.space, this._defPos[oldId].x, this._defPos[oldId].y) : null; if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId) { // stays in place; pin it under the (possibly new) id if (id !== oldId || !this._layout[id] || roomChanged) { newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y }; this._layout = { ...this._layout, [id]: newPos }; } } else if (replacingRemoved || !this._layout[id] || roomChanged) { let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2; let cy = targetSpaceModel.vb[1] + targetSpaceModel.vb[3] / 2; const room = roomId ? targetSpaceModel.rooms.find((r) => r.id === roomId) : area ? targetSpaceModel.rooms.find((r) => r.area === area) : undefined; if (room) [cx, cy] = this._roomCenter(room); newPos = this._normPos(targetSpaceId, cx, cy); this._layout = { ...this._layout, [id]: newPos }; } await this._saveConfigNow(); if (newPos) { const pos = canonicalizePosition(newPos); this._layout = { ...this._layout, [id]: pos }; this._noteLayoutRev(await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos, })); } const obsoleteIds = new Set(replacedRemovedIds); if (oldId && oldId !== id) obsoleteIds.add(oldId); obsoleteIds.delete(id); for (const obsoleteId of obsoleteIds) { // Rebinding or replacing a legacy tombstone changed the icon id. delete this._layout[obsoleteId]; await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: obsoleteId }) .then((r: any) => this._noteLayoutRev(r)).catch(() => undefined); } // the config is committed — now it is safe to drop the old folder if (cleanupOldFiles && fileSrc) { await this.hass .callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc }) .catch(() => undefined); // leftovers are harmless; broken links are not } this._markerDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.marker_saved')); } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this._markerDialog) this._markerDialog = { ...this._markerDialog, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private async _deleteMarker(): Promise { const dlg = this._markerDialog; if (!dlg || dlg.busy || !dlg.devId) return; const d = dlg.devId ? this._devices.find((x) => x.id === dlg.devId) : null; if (!d) return; const label = dlg.name || this._t('device.fallback'); if (!confirm(this._t('confirm.remove_marker', { name: label }))) return; const cfg = this._serverCfg!; cfg.markers = cfg.markers || []; const previousMarkers = cfg.markers; const binding = d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : ''; if (!binding) return; const deletion = deletePlanMarkerRecords( cfg.markers, d.id, binding, d.bindingKind === 'virtual', ); cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds); const cleanupIds = deletion.cleanupIds; this._markerDialog = { ...dlg, busy: true }; try { await this._saveConfigNow(); // Housekeeping follows the durable config write. Every call is // idempotent and best-effort: the device is already deleted even if an // old integration cannot yet serve one of the cleanup commands. for (const id of cleanupIds) { delete this._layout[id]; delete this._defPos[id]; this._dirtyPos.delete(id); this._sentPos.delete(id); const activity = this._activityRt.get(id); if (activity) clearTimeout(activity.timer); this._activityRt.delete(id); this._vacRt.delete(id); delete this._vacSrvTrails[id]; await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: id }) .then((r: any) => this._noteLayoutRev(r)).catch(() => undefined); await this.hass.callWS({ type: 'houseplan/files/cleanup', marker_id: id }) .catch(() => undefined); await this.hass.callWS({ type: 'houseplan/trail/delete', marker_id: id }) .catch(() => undefined); } this._markerDialog = null; if (this._infoCard?.id === d.id) this._closeInfoCard(); if (this._selId === d.id) this._selId = null; if (this._drag?.id === d.id) this._drag = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.marker_removed')); } catch (e: any) { if (this._serverCfg === cfg) cfg.markers = previousMarkers; if (this._markerDialog) this._markerDialog = { ...this._markerDialog, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } { return { s: space, x: x / NORM_W, y: y / NORM_W }; } // ================= SPACE MANAGEMENT ================= private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void { if (!this._serverStorage || !this._serverCfg) { this._showToast(this._t('toast.integration_missing')); return; } if (mode === 'edit') { const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId); if (!sp) return; const disp = spaceDisplayOf(sp); const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object' ? customFillOf(sp.settings.custom_fill) : null; // Space-level None is no longer a separate UI choice. Project it to the // same custom colour at zero opacity, so merely opening/saving settings // cannot change either the visible floor or the Glow darkness. const dialogCustom = disp.fill === 'none' ? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 } : storedCustom; this._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, source: sp.plan_url ? 'file' : 'draw', showBorders: disp.showBorders, showNames: disp.showNames, displayTouched: true, hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings, roomColor: disp.color, roomOpacity: disp.opacity, // `none` is a legacy space token. The current editor represents its // exact appearance as a transparent user colour. fillMode: disp.fill === 'none' ? 'custom' : disp.fill, customFill: dialogCustom, glowEnabled: disp.glow, bgColor: disp.bgColor, bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null, northDeg: northDegOf({}, sp.settings), sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null, tempMin: disp.tempMin, tempMax: disp.tempMax, showLqi: disp.showLqi ?? this._config?.show_signal ?? true, cardFontScale: disp.cardFontScale, labelTemp: disp.labelTemp, labelHum: disp.labelHum, labelLqi: disp.labelLqi, labelLight: disp.labelLight, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, cellCmInput: gridCellFieldValue( Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this._imperial, ), cellCmTouched: false, busy: false, }; } else { this._spaceDialog = { mode, title: '', planUrl: null, planFile: null, ...initialSpaceDisplayDraft(), hideDecor: false, hideOpenings: false, roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom', // `custom` replaces the old `none` choice in the editor, but creating // a space must preserve the old no-floor visual by default. The user // can make this colour visible explicitly with the opacity control. customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 }, glowEnabled: true, bgColor: null, bgMode: 'daynight', northDeg: null, sunRays: null, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, showLqi: this._config?.show_signal ?? true, cardFontScale: 1, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, cellCm: newSpaceCellCm(this._imperial), cellCmInput: gridCellFieldValue(newSpaceCellCm(this._imperial), this._imperial), cellCmTouched: false, busy: false, }; } } /** Background file selection: read base64 and determine the aspect ratio. */ private async _pickPlanFile(ev: Event): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; if (!file || !this._spaceDialog) return; const extMap: Record = { 'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', }; const ext = extMap[file.type] || (file.name.toLowerCase().endsWith('.svg') ? 'svg' : ''); if (!ext) { this._showToast(this._t('toast.plan_formats')); return; } const buf = new Uint8Array(await file.arrayBuffer()); let bin = ''; for (let i = 0; i < buf.length; i += 32768) bin += String.fromCharCode(...buf.subarray(i, i + 32768)); const b64 = btoa(bin); // aspect ratio: render into an Image const url = URL.createObjectURL(file); const aspect = await new Promise((res) => { const img = new Image(); img.onload = () => res(img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414); img.onerror = () => res(1.414); img.src = url; }); URL.revokeObjectURL(url); this._spaceDialog = { ...this._spaceDialog, planFile: { ext, b64, aspect, name: file.name } }; } /** * Plans that are on the server but not attached anywhere are not garbage — * the component never deletes them (docs/SCOPE.md), which only makes sense if * they can be found again. This is that: detach a plan, come back later, pick * it out of the list. It is also the only way one is ever deleted. */ private _toggleServerPlans = async (): Promise => { const d = this._spaceDialog; if (!d) return; if (d.pickSaved) { this._spaceDialog = { ...d, pickSaved: false }; return; } this._spaceDialog = { ...d, pickSaved: true, savedBusy: true }; try { const r: any = await this.hass.callWS({ type: 'houseplan/plans/list' }); const cur = this._spaceDialog; if (cur) this._spaceDialog = { ...cur, saved: r?.plans || [], savedBusy: false }; } catch (e: any) { const cur = this._spaceDialog; if (cur) this._spaceDialog = { ...cur, saved: [], savedBusy: false }; this._showToast(this._t('toast.plans_list_failed', { err: this._errText(e) })); } }; /** The in-flight proportions read for the last picked saved plan. Save * awaits it rather than shipping whatever was there before (HP-1490-04). */ private _aspectJob: Promise | null = null; private _useServerPlan(url: string): void { const d = this._spaceDialog; if (!d) return; // Attach immediately — the click should not wait for anything. The OLD // file's proportions go right away: they describe the previous image, and // a Save racing the read must get "unknown", never "the wrong shape". this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined }; this._aspectJob = this._readPlanAspect(url); } /** * Read a stored plan's proportions from the image itself. * * The content endpoint needs a signature, and `_display()` deliberately * returns nothing until one arrives. Loading too early therefore failed and * an earlier version treated that as "unknown ratio" and saved a fallback of * 1.414 — a square plan came out stretched (HP-1470-03). So wait for the * signature, and bind the result to THIS dialog and THIS url, or a late * answer would reshape whatever the user opened next. */ private async _readPlanAspect(url: string): Promise { for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly const src = this._display(url); if (src) { const ratio = await new Promise((res) => { const img = new Image(); img.onload = () => res(img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0); img.onerror = () => res(0); img.src = src; }); const cur = this._spaceDialog; if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) { this._spaceDialog = { ...cur, savedAspect: ratio }; return ratio; } return 0; } await new Promise((r) => setTimeout(r, 150)); if (this._spaceDialog?.planUrl !== url) return 0; // the user moved on } return 0; } private async _deleteServerPlan(name: string): Promise { if (!confirm(this._t('confirm.delete_plan', { name }))) return; try { await this.hass.callWS({ type: 'houseplan/plans/delete', name }); const d = this._spaceDialog; if (d?.saved) this._spaceDialog = { ...d, saved: d.saved.filter((p) => p.name !== name) }; } catch (e: any) { this._showToast(this._t('toast.plan_delete_failed', { err: this._errText(e) })); } } private _renderServerPlans(d: NonNullable): TemplateResult { if (d.savedBusy) return html`
${this._t('space.loading')}
`; const list = d.saved || []; if (!list.length) return html`
${this._t('space.no_saved')}
`; const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB'); return html`
${list.map((p) => html`
${p.name} ${kb(p.size)}${p.used_by.length ? ' · ' + this._t('space.used_by', { list: p.used_by.join(', ') }) : ''}
`)}
`; } private async _saveSpaceDialog(): Promise { const d = this._spaceDialog; if (!d || d.busy || !d.title.trim()) return; if (d.source === 'file' && !d.planFile && !d.planUrl) { this._showToast(this._t('toast.plan_required')); return; } const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0; this._spaceDialog = { ...d, busy: true }; try { const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!; /* Upload BEFORE touching the config, and never hold a reference to a config object across an await. `houseplan/config/get` runs on every `houseplan_config_updated` event and REPLACES `_serverCfg`; a space object captured before the upload is then detached, so plan_url, aspect and settings were written into an orphan and the save shipped the untouched config. Symptom: the file lands on disk, the plan never appears, and re-saving does not help (owner's install, 2026-07-27). */ let uploaded: { url: string; aspect: number } | null = null; if (d.source === 'file' && d.planFile) { const resp = await this.hass.callWS({ type: 'houseplan/plan/set', space_id: spaceId, ext: d.planFile.ext, data: d.planFile.b64, }); uploaded = { url: resp.url, aspect: d.planFile.aspect }; } // A plan picked from the server list reads its proportions from the // image, and Save used to outrun that read: the snapshot still carried // the PREVIOUS file's ratio and a wide plan came out at the old shape // for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and // the dialog is already busy. Unknown stays unknown, never the old // value: a square fallback is honest, an inherited ratio is not. let pickedAspect: number | null = d.savedAspect || null; if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this._aspectJob) { pickedAspect = (await this._aspectJob) || null; } // from here on: no awaits until the save, so `sp` cannot be orphaned const cfg = this._serverCfg!; let sp: any; if (d.mode === 'create') { sp = { id: spaceId, title: d.title.trim(), plan_url: null, view_box: [0, 0, 1, 1], rooms: [], }; cfg.spaces.push(sp); } else { sp = cfg.spaces.find((x: any) => x.id === spaceId); if (!sp) throw new Error('space ' + spaceId + ' is gone from the config'); sp.title = d.title.trim(); } if (uploaded) { sp.plan_url = uploaded.url; // the image's own proportions, so it can be centred before it loads sp.plan_aspect = uploaded.aspect; } else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) { // picked from the server list: no upload, just a reference — and the // previous image's proportions never survive the switch sp.plan_url = d.planUrl; sp.plan_aspect = pickedAspect; } // switching an existing space to "draw" detaches its background image // (the uploaded file stays on disk; only the reference is cleared). // Its transform goes with it — there is nothing left for plan_x/plan_y/ // plan_scale to describe, and a stale one would silently apply to the // NEXT picture uploaded here (docs/BACKDROP.md §1). if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale; delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle; } // Persist exactly what the dialog showed. Source defaults are projected // visibly while editing the create state, never hidden here at Save. sp.settings = { ...(sp.settings || {}), show_borders: d.showBorders, show_names: d.showNames, // written only when ON: a plan that never hid anything stores nothing hide_decor: d.hideDecor || undefined, hide_openings: d.hideOpenings || undefined, room_color: d.roomColor, room_opacity: d.roomOpacity, bg_color: d.bgColor || undefined, // empty = inherit the general setting bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit north_deg: d.northDeg ?? undefined, sun_rays: d.sunRays ?? undefined, fill_mode: d.fillMode, custom_fill: d.customFill || undefined, // Always materialize the independent value. In particular, editing a // legacy fill_mode:'glow' space preserves its appearance atomically // while replacing the old token with the selected data fill. glow_enabled: d.glowEnabled, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, show_lqi: d.showLqi, card_font_scale: d.cardFontScale !== 1 ? d.cardFontScale : undefined, label_temp: d.labelTemp, label_hum: d.labelHum, label_lqi: d.labelLqi, label_light: d.labelLight, }; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5; // Nothing to clean up from here: the backend collects the superseded // file inside the same locked transaction that accepted this config // (review R3-1). A cleanup driven from the client could not be ordered // against another client's commit and deleted its freshly saved plan. await this._saveConfigNow(); this._spaceDialog = null; if (d.mode === 'create') this._commitSpace(sp.id); this._regSignature = ''; this._maybeRebuildDevices(); if (this._importQueue.length) { // floors-import wizard: proceed to the next floor this._openNextImport(); } else if (wasFirst || this._importTotal > 0) { // guide the user onward: straight into room markup mode this._importTotal = 0; this._commitSpace(this._serverCfg!.spaces[0]?.id || this._space); this._setMode('plan'); this._tool = 'draw'; this._path = []; this._cursorPt = null; this._primeDrawWallField(); this._showToast(this._t(wasFirst && !this._importTotal ? 'toast.space_added_onboard' : 'import.done')); } else { this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved')); // A freshly created space is empty — View has nothing to show. Open // the plan editor (draw tool) so the user can mark rooms immediately, // same as the first-space onboard path above. if (d.mode === 'create') { if (this._mode !== 'plan') this._setMode('plan'); else { this._tool = 'draw'; this._path = []; this._cursorPt = null; this._primeDrawWallField(); this._saveNav(); } } } } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this._spaceDialog) this._spaceDialog = { ...this._spaceDialog, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private async _deleteSpace(): Promise { const d = this._spaceDialog; if (!d || d.mode !== 'edit') return; const serverCfg = this._serverCfg; if (!serverCfg) return; const sp = serverCfg.spaces.find((x: any) => x.id === d.spaceId); const dependencies = collectSpaceMarkerDependencies( serverCfg, this._layout || {}, d.spaceId || '', ); const deletingLastSpace = serverCfg.spaces.length === 1 && serverCfg.spaces[0]?.id === d.spaceId; if (dependencies.count && !deletingLastSpace) { this._spaceDialog = { ...d, deleteBlockers: dependencies.count }; return; } if (!confirm(this._t('confirm.delete_space', { title: sp.title }))) return; this._spaceDialog = { ...d, deleteBlockers: 0, busy: true }; try { if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush(); if (this._persistLayout.pending()) this._persistLayout.flush(); await this._writeChain; const response: any = await this.hass.callWS({ type: 'houseplan/space/delete', space_id: d.spaceId, expected_config_rev: this._cfgRev, expected_layout_rev: this._layoutRev, }); const [configResponse, layoutResponse] = await Promise.all([ this.hass.callWS({ type: 'houseplan/config/get' }), this.hass.callWS({ type: 'houseplan/layout/get' }), ]); this._adoptStructuralResponses(configResponse, layoutResponse); this._cfgRev = response?.config_rev ?? this._cfgRev; this._layoutRev = response?.layout_rev ?? this._layoutRev; this._spaceDialog = null; if (this._space === d.spaceId) this._commitSpace(this._serverCfg!.spaces[0]?.id || ''); this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.space_deleted')); } catch (e: any) { if (e?.code === 'conflict' || e?.code === 'space_in_use') { await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]); } const refreshedConfig = this._serverCfg; if (this._spaceDialog && refreshedConfig) { const refreshed = collectSpaceMarkerDependencies( refreshedConfig, this._layout || {}, d.spaceId || '', ); const stillLastSpace = refreshedConfig.spaces.length === 1 && refreshedConfig.spaces[0]?.id === d.spaceId; this._spaceDialog = { ...this._spaceDialog, busy: false, deleteBlockers: stillLastSpace ? 0 : refreshed.count, }; } this._showToast(this._t('toast.delete_failed', { err: this._errText(e) })); } } /** Immediate config save with a revision bump (no debounce). On a rev conflict the local copy is refreshed before rethrowing, so the user's retry starts from the fresh config instead of hitting the same conflict again. */ private async _saveConfigNow(): Promise { this._cfgEpoch++; try { // same queue as the debounced writer: a dialog saving while a background // write is still out must not race it into a self-inflicted conflict await this._writeConfig(); } catch (e: any) { if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite(); else if (e?.code === 'conflict') await this._reloadConfigOnly(); throw e; } } // ================= FLOORS IMPORT WIZARD ================= private _startImport(): void { const dlg = this._importDialog; if (!dlg) return; const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name); this._importDialog = null; if (!titles.length) { this._openSpaceDialog('create'); return; } this._importQueue = titles; this._importTotal = titles.length; this._openNextImport(); } /** Open the space dialog for the next queued floor (title prefilled, plan required). */ private _openNextImport(): void { const title = this._importQueue.shift(); if (title === undefined) return; this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, ...initialSpaceDisplayDraft(), hideDecor: false, hideOpenings: false, roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom', customFill: null, glowEnabled: true, bgColor: null, bgMode: 'daynight', northDeg: null, sunRays: null, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, showLqi: this._config?.show_signal ?? true, cardFontScale: 1, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, cellCm: newSpaceCellCm(this._imperial), cellCmInput: gridCellFieldValue(newSpaceCellCm(this._imperial), this._imperial), cellCmTouched: false, busy: false, }; } /** Skip the current floor of the wizard without creating a space. */ private _skipImport(): void { this._spaceDialog = null; if (this._importQueue.length) this._openNextImport(); else if (this._importTotal > 0 && this._model.length) { this._importTotal = 0; this._commitSpace(this._serverCfg!.spaces[0]?.id || this._space); this._setMode('plan'); this._showToast(this._t('import.done')); } } private _renderImportDialog(): TemplateResult { const d = this._importDialog!; const n = d.floors.filter((f) => f.checked).length; return html` (this._importDialog = null)}>
${this._t('import.hint')}
${d.floors.map( (f, i) => html``, )}
`; } // ================= SUN ON THE PLAN (docs/SUN.md) ================= /** Global settings with the general-settings dialog's pending values. */ private _sunGlobal(): any { const gd = this._settingsDialog; if (!gd) return this._settings; return { ...this._settings, north_deg: gd.northDeg ?? undefined, bg_mode: gd.bgMode, sun_rays: gd.sunRays, }; } /** Current space settings with the space dialog's pending values. */ private _sunSpace(): any { const sd = this._spaceDialog; const saved = this._curSpaceCfg?.settings || {}; if (!sd || sd.mode !== 'edit' || sd.spaceId !== this._space) return saved; return { ...saved, north_deg: sd.northDeg ?? undefined, bg_mode: sd.bgMode ?? undefined, sun_rays: sd.sunRays ?? undefined, }; } /** Effective compass; null = the whole sun feature is inert (docs/SUN.md). */ private _effNorth(): number | null { return northDegOf(this._sunGlobal(), this._sunSpace()); } private _effBgMode(): 'static' | 'daynight' { return bgModeOf(this._sunGlobal(), this._sunSpace()); } /** Current four-phase environment. Editors stay static; #101 supplies viewWeight. */ private _dayCycleState(now: Date | number = new Date()): DayCycleState | null { const viewWeight = this._modeTransitionVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0); if (viewWeight <= 0 || this._effBgMode() !== 'daynight') return null; return resolveDayCycle(this._renderPlanHass, now); } private _dayCycleTick = (): void => { if (!this.isConnected || this.ownerDocument.visibilityState === 'hidden') return; const state = this._dayCycleState(); if (!state) { if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; } this._dayCycleClockKey = ''; return; } const key = dayCycleFingerprint(state); if (key === this._dayCycleClockKey) return; this._dayCycleClockKey = key; this.requestUpdate(); }; /** Arm a 30 s timer only for the browser-clock fallback while visible. */ private _syncDayCycleClock(): void { const state = this._dayCycleState(); this._dayCycleClockKey = state ? dayCycleFingerprint(state) : ''; const needsTimer = state?.source === 'clock' && this.ownerDocument.visibilityState !== 'hidden' && this.isConnected; if (needsTimer && !this._dayCycleTimer) { this._dayCycleTimer = window.setInterval(this._dayCycleTick, 30_000); } else if (!needsTimer && this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; } } private _dayCycleVisibility(signal: PageVisibilitySignal): void { if (signal.kind === 'hidden') { if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; } return; } this._dayCycleTick(); this._syncDayCycleClock(); } private _effSunRays(): boolean { return sunRaysOn(this._sunGlobal(), this._sunSpace()); } /** sun.sun, but only when the feature is armed (north_deg set somewhere). */ private _sunNow(): { azimuth: number; elevation: number } | null { return this._effNorth() !== null ? sunStateOf(this._renderPlanHass) : null; } /** * Window light wedges (docs/SUN.md): view/kiosk only. Geometry recomputes * ONLY when the memo key changes — sun attributes tick every ~30-120 s and * everything else in `hass` must not trigger the polygon clipping. */ private _renderSunRays(space: SpaceModel): TemplateResult { const empty = svg`` as unknown as TemplateResult; // HARD gates — the feature is simply not on: leaving an editor, a space // with rays off, or night. Those never fade, they just are not there. // Logical mode changes at transition start. Keep the outgoing view-only // layer alive until #101's `viewWeight` reaches zero instead of dropping // the rays in frame zero. const transitionViewWeight = this._modeTransitionVisual?.viewWeight ?? 0; if ((this._editing && transitionViewWeight <= 0) || !this._effSunRays()) { this._sunFadeReset(); return empty; } const north = this._effNorth(); const sun = north !== null ? sunStateOf(this._renderPlanHass) : null; if (!sun || sun.elevation <= 0) { this._sunFadeReset(); return empty; } const alpha = rayPeakAlpha(); // The ONE thing that fades (owner 2026-08-03): the 3° threshold. Above it // the layer is there at full strength, below it gone — with a 2 s CSS // fade either way. The keep-alive below is what lets the fade-OUT play at // all: without it the layer would leave the DOM in the same frame. if (raysVisible(sun.elevation)) { if (this._sunOutTimer) { clearTimeout(this._sunOutTimer); this._sunOutTimer = 0; } this._sunOut = false; this._sunShown = true; } else { if (!this._sunShown) return empty; // never lit: nothing to dissolve if (!this._sunOut) { this._sunOut = true; this._sunOutTimer = window.setTimeout(() => { this._sunOutTimer = 0; this._sunShown = false; this._sunOut = false; this.requestUpdate(); }, RAY_FADE_MS); } } // DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev. // Every local mutation ends in _saveConfig(), which bumps the epoch // SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is // acked, so a rev-keyed memo served wedges for the OLD window position // during the whole write window (and forever if the write failed). const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`; if (!this._sunRaysCache || this._sunRaysCache.key !== key) { const rooms = space.rooms .map((r) => ({ id: r.id || '', poly: roomPoly(r) })) .filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly); const windows = this._openingsR // A contour-wall host is stable identity metadata, not a different // physical carrier. Only an independent partition window is excluded // from exterior sunlight (#132, ADR 282 Stage 1). .filter((o) => o.type === 'window' && o.host?.kind !== 'partition') .map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen })); const walls = this._spaceWalls; const openCuts = this._openPairs().flatMap((p) => p.segs); const openingWallIndex = this._openingWallIndexFor(space, openCuts).value; const innerByRoom: Record = {}; const wallDepthByOpening: Record = {}; const roomWalls = this._wallUnionGeometry()?.roomGeom; if (walls.length) { for (const r of rooms) { const inn = innerContourForRoom( space.rooms, r.id, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, roomWalls, ); if (inn) innerByRoom[r.id] = inn; } for (const o of windows) { const face = openingInnerFaceOffsetFromIndex( openingWallIndex, { x: o.x, y: o.y, angle: o.angle, length: o.length }, ); if (face.cm > 0) { wallDepthByOpening[o.id] = wallCmToUnits(face.cm, this._cellCm, this._gridPitch); } } } let rays = computeSunRays( rooms, windows, sun.azimuth, sun.elevation, north!, walls.length ? innerByRoom : undefined, walls.length ? wallDepthByOpening : undefined, ); const physical = this._physicalBodiesR(space); if (physical.length) { rays = rays.map((ray) => { const shadows = directionalOccluders(physical, ray.dir, ray.len); const clipped = ray.polys.map((poly) => floorMinusBodies(poly, shadows)); return { ...ray, paths: clipped.map(polyclipPathD).filter(Boolean), polys: clipped.flatMap(geometryOuterRings), }; }).filter((ray) => ray.paths?.length || ray.polys.length); } // the rim is pure geometry off the same wedges — memoised on the same key this._sunRaysCache = { key, rays, rims: rays.map((r) => rayRimEdges(r)) }; } const rays = this._sunRaysCache.rays; const rims = this._sunRaysCache.rims; if (!rays.length) return empty; const color = rayColor(dayPhase(sun.elevation).warmth); const stops = rayStops(); const rimAlpha = rimPeakAlpha(); const rimStopList = rimStops(); // NO filter here, and none in . Owner 2026-08-04: «не надо размывать // их боковые грани» — the shaft keeps the crisp sides real light has, and // the only falloff is the gradient. The tip needs no blur either: // `rayStops()` is already at zero from RAY_FADE_END on, and the wedge's far // edge IS the gradient's last iso-alpha line, so it has nothing left to // draw. The polygons come out of `computeSunRays()` already intersected // with the room, so no clip-path is needed to keep the light off the far // side of a wall. // // DEV-EB173-01: the axis runs along the wall's INWARD NORMAL, from the // room-side opening face inward, and is `r.depth` = `len·cos` long — NOT // along the ray. For parallel rays, distance from the source span is an // affine function of the point, so its iso-alpha lines are parallel to the // wall; with this axis every point `source + dir·u` lands at offset // `u/len`. Whole inner opening at peak alpha, identical fade distance along every // ray, and the parallelogram's far edge exactly on the gradient's end. // // THE RIM (owner 2026-08-04, docs/SUN.md «The rim»): a 1 px black hairline // along the two SIDE edges only, so the shaft stays legible on white paper // where added luminance cannot read. It gets a gradient of its own — // `hp-sunrim-i` — deliberately built on the SAME x1/y1/x2/y2 and the same // `rayStops()` curve as `hp-sun-i`, only black and with its own peak: the // line must die on the very stop the fill dies on, never outlive it. // `non-scaling-stroke` keeps it one screen pixel at any zoom, and the // segments come out of the ALREADY clipped polygons, so a wall still stops // the light by geometry alone and no clip-path enters this layer. return svg` ${rays.map((r, i) => { const mx = (r.a[0] + r.b[0]) / 2; const my = (r.a[1] + r.b[1]) / 2; const ax = mx + r.normal[0] * r.depth; const ay = my + r.normal[1] * r.depth; return svg` ${stops.map(([off, k]) => svg``)} ${rimStopList.map(([off, k]) => svg``)} `; })} ${rays.map((r, i) => r.paths?.length ? r.paths.map((d) => svg``) : r.polys.map((p) => svg``))} ${rays.map((r, i) => (rims[i] || []).map((e) => svg``))} ` as unknown as TemplateResult; } /** Sun-ray layer keep-alive: `shown` = in the DOM, `out` = playing the * 2 s dissolve before it leaves (plain fields — the timer requests the * update, render just reads them). */ private _sunShown = false; private _sunOut = false; private _sunOutTimer = 0; /** Drop the layer at once: used by every gate that is NOT the 3° threshold. */ private _sunFadeReset(): void { if (this._sunOutTimer) { clearTimeout(this._sunOutTimer); this._sunOutTimer = 0; } this._sunShown = false; this._sunOut = false; } /** One drag/click sample on the compass dial → dialog north_deg. */ private _compassPoint(ev: PointerEvent): void { const el = ev.currentTarget as SVGSVGElement; const r = el.getBoundingClientRect(); const dx = ev.clientX - (r.left + r.width / 2); const dy = ev.clientY - (r.top + r.height / 2); if (Math.hypot(dx, dy) < 5) return; // the dead centre says nothing about angle let a = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI); if (ev.shiftKey) a = Math.round(a / 15) * 15; // coarse 15° steps with Shift a = ((a % 360) + 360) % 360; this._settingsDialog = { ...this._settingsDialog!, northDeg: a }; } /** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */ private _renderCompass(): TemplateResult { const d = this._settingsDialog!; const deg = d.northDeg; return html` { (e.currentTarget as Element).setPointerCapture(e.pointerId); this._compassDrag = true; this._compassPoint(e); }} @pointermove=${(e: PointerEvent) => { if (this._compassDrag) this._compassPoint(e); }} @pointerup=${() => (this._compassDrag = false)} @pointercancel=${() => (this._compassDrag = false)}> ${[0, 45, 90, 135, 180, 225, 270, 315].map( (t) => svg``, )} ${this._t('gs.north_letter')} ${deg === null ? '—' : deg + '°'} `; } /** * Effective stage background: per-space override → global setting → '' (the * theme default from the stylesheet). An open dialog previews its pending * value, so the color can be picked against the live plan. The four-phase * environment is a separate layer behind the plan (#146). */ private _stageBg(disp: SpaceDisplay): string { const gd = this._settingsDialog; const sd = this._spaceDialog; const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null }); const spaceBg = sd && sd.mode === 'edit' && sd.spaceId === this._space ? sd.bgColor || '' : disp.bgColor || ''; return spaceBg || globalBg; } /** Current computed stage background as #rrggbb — the color input's default. */ private _stageBgHex(): string { const st = this._stageEl; if (st) { const m = getComputedStyle(st).backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (m) return '#' + m.slice(1, 4).map((n) => (+n).toString(16).padStart(2, '0')).join(''); } return '#111111'; } // ================= GENERAL SETTINGS ================= private _openSettingsDialog = (): void => { if (!this._norm) return; // deep copy so the dialog edits do not leak into the live palette const cm = this._glowRadiusCm; const glowRadius = this._imperial ? Math.round((cm / 30.48) * 10) / 10 : Math.round(cm) / 100; this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), glowRadius, bgColor: stageBgOf(this._settings, { bgColor: null }) || null, northDeg: northDegOf(this._settings, {}), bgMode: bgModeOf(this._settings, {}), sunRays: sunRaysOn(this._settings, {}), busy: false, }; }; /** * Preview whole-plan maintenance. Nothing is written here: the pure run * produces both the report and the exact config/layout pair to commit. */ private _checkOptimizeGeometry(config: ServerConfig): OptimizeGeometryPreflightResult { return checkOptimizeGeometry(config, { fallbackSpaceName: (index) => this._t('gs.align_preflight_space', { n: String(index), }), }); } /** One strict production source shared by editor commits and stale rechecks. */ private _checkSpacePhysicalGeometry(config: ServerConfig, spaceId: string) { return checkSpacePhysicalGeometry(config, spaceId, { fallbackSpaceName: (index) => this._t('gs.align_preflight_space', { n: String(index) }), }); } private _optimizeReferenceContext( removeLiveMissingPositions: boolean, ): SpaceReferenceRepairContext { const registry = this._haRegistry; const full = this._fullRegistryHass; const names: Record = {}; const humanName = (...values: unknown[]): string => { for (const value of values) { const name = String(value || '').trim(); if (name) return name; } return ''; }; for (const [deviceId, device] of Object.entries(registry.devices || {})) { names[deviceId] = humanName(device?.name_by_user, device?.name, device?.model); } for (const [entityId, entity] of Object.entries(registry.entities || {})) { names[`lg_${entityId}`] = humanName( this.hass?.states?.[entityId]?.attributes?.friendly_name, entity?.name, entity?.original_name, ); } for (const device of this._devices) names[device.id] = humanName(device.name, names[device.id]); for (const marker of this._serverCfg?.markers || []) { const separator = String(marker.binding || '').indexOf(':'); const kind = separator > 0 ? marker.binding.slice(0, separator) : ''; const ref = separator > 0 ? marker.binding.slice(separator + 1) : ''; const bound = kind === 'device' ? full?.devices?.[ref] : kind === 'entity' ? full?.entities?.[ref] : null; names[marker.id] = humanName( marker.name, kind === 'device' ? bound?.name_by_user : null, bound?.name, kind === 'entity' ? this.hass?.states?.[ref]?.attributes?.friendly_name : null, names[marker.id], ); } return { effectiveAreaByMarker: Object.fromEntries( this._devices .filter((device) => !device.virtual && !!device.area) .map((device) => [device.id, device.area]), ), ownerRoster: { authoritative: registry.authoritative, deviceIds: Object.keys(registry.devices || {}), // State-only YAML entities are positive existence evidence even though // an authoritative Entity Registry cannot list them. entityIds: [...new Set([ ...Object.keys(registry.entities || {}), ...Object.keys(this.hass?.states || {}), ])], names, }, removeLiveMissingPositions, }; } private _previewAlignDialog(removeLiveMissingPositions: boolean): void { if (!this._norm || !this._serverCfg) return; const spaces = this._serverCfg.spaces || []; let r; try { r = optimizePlans( this._serverCfg, this._layout || {}, this._optimizeReferenceContext(removeLiveMissingPositions), ); } catch (error) { this._showToast(this._t('toast.wall_model_migration_blocked', { reason: this._wallModelBlockerLabel(error), })); return; } const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null; // The maximum geometry shift is an UPPER BOUND, not a sample. The run // measured every element in the centimetres of ITS OWN space — converting // one normalised maximum through the first space's `cell_cm` understated // a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is // rounded UP, so the promise can never be smaller than the deed. const cm = Math.ceil(r.report.maxShiftCm * 10) / 10; const sp = spaces.find((x: any) => x?.id != null && String(x.id) === r.report.maxSpace); const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : ''; this._alignDialog = { report: r.report, config: r.config, layout: r.layout, cm, where, preflight, changed: r.changed, busy: false, removeLiveMissingPositions, }; } private _openAlignDialog = (): void => this._previewAlignDialog(false); private _toggleOptimizeLivePositions = (): void => { const dialog = this._alignDialog; if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return; this._previewAlignDialog(!dialog.removeLiveMissingPositions); }; /** * The backend persists an intent before either store changes, then keeps a * one-deep snapshot that remains undoable until the next plan edit. */ private async _runAlignToGrid(): Promise { let d = this._alignDialog; if (!d || d.busy || !this._serverCfg || !d.changed || !d.preflight?.ok) return; const fingerprint = contentFingerprint(d.config); if (d.preflight.fingerprint !== fingerprint) { const preflight = this._checkOptimizeGeometry(d.config); d = { ...d, preflight }; this._alignDialog = d; if (!preflight.ok) return; } this._clearGeometryGesture(); this._alignDialog = { ...d, busy: true }; try { if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush(); await this._writeChain; const resp = await this.hass.callWS({ type: 'houseplan/plan/optimize', config: d.config, layout: d.layout, expected_config_rev: this._cfgRev, expected_layout_rev: this._layoutRev, }); this._serverCfg = d.config; this._layout = d.layout; this._geometryHistory.clear(); this._cfgRev = resp?.config_rev ?? this._cfgRev + 1; this._layoutRev = resp?.layout_rev ?? this._layoutRev + 1; this._canOptimizeUndo = !!resp?.can_undo; this._undoKind = resp?.can_undo ? 'optimize' : null; this._dirtyPos.clear(); this._sentPos.clear(); this._cfgEpoch++; this._modelCache = null; this._frame = null; this._regSignature = ''; this._maybeRebuildDevices(); this._cacheSnapshot(); this._alignDialog = null; this.requestUpdate(); this._showToast(this._t('gs.align_done', { n: String(d.report.moved), m: String(d.report.migrated + d.report.canonicalized + d.report.coordsCanonicalized + d.report.latticeCoordinatesCanonicalized + d.report.wallsMerged + d.report.spansMerged + d.report.partitionsMerged + d.report.partitionsReconciled + d.report.openingsRehosted + d.report.wallsStraightened), r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped + d.report.positionsRemapped + d.report.markersDetached + d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved + d.report.orphanGroupPositionsRemoved), })); } catch (e: any) { if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false }; if (e?.code === 'wall_model_client_outdated') { this._showToast(this._t('toast.wall_model_client_outdated')); return; } if (e?.code === 'conflict') { await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]); } this._showToast(this._t('toast.error', { err: this._errText(e) })); } } /** Prevent a double click from sending two restores of the same snapshot. */ private _optimizeUndoBusy = false; /** Restore the one-deep snapshot, provided no later plan edit exists. */ private async _undoPlanOptimization(): Promise { if (!this._canOptimizeUndo || this._optimizeUndoBusy) return; const undoKind = this._undoKind; this._clearGeometryGesture(); this._optimizeUndoBusy = true; this.requestUpdate(); try { await this.hass.callWS({ type: 'houseplan/plan/optimize_undo', expected_config_rev: this._cfgRev, expected_layout_rev: this._layoutRev, }); const [cfgResp, layResp] = await Promise.all([ this.hass.callWS({ type: 'houseplan/config/get' }), this.hass.callWS({ type: 'houseplan/layout/get' }), ]); this._serverCfg = cfgResp?.config || this._serverCfg; this._cfgRev = cfgResp?.rev ?? this._cfgRev; this._layout = layResp?.layout || this._layout; this._geometryHistory.clear(); this._layoutRev = layResp?.rev ?? this._layoutRev; this._canOptimizeUndo = false; this._undoKind = null; this._cfgEpoch++; this._modelCache = null; this._frame = null; this._regSignature = ''; this._maybeRebuildDevices(); this._cacheSnapshot(); this.requestUpdate(); this._showToast(this._t(undoKind === 'import' ? 'backup.import_undone' : 'gs.optimize_undone')); } catch (e: any) { this._canOptimizeUndo = false; this._undoKind = null; this._showToast(this._t('toast.error', { err: this._errText(e) })); } finally { this._optimizeUndoBusy = false; this.requestUpdate(); } } private _openBackupExport = (): void => { this._settingsDialog = null; this._backupExportDialog = { kind: 'full', planOnly: false, busy: false, error: '' }; }; private async _runBackupExport(): Promise { const d = this._backupExportDialog; if (!d || d.busy) return; this._backupExportDialog = { ...d, busy: true, error: '' }; try { const response: any = await this.hass.callWS({ type: 'houseplan/export/create', kind: d.kind, space_id: d.kind === 'space' ? this._space : undefined, ...(d.kind === 'space' && d.planOnly ? { plan_only: true } : {}), card_version: CARD_VERSION, }); const blob = new Blob([JSON.stringify(response.document, null, 2) + '\n'], { type: 'application/json;charset=utf-8', }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = response.filename || `houseplan-${d.kind}.json`; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); this._backupExportDialog = null; this._showToast(this._t('backup.export_done')); } catch (error: any) { if (this._backupExportDialog) { this._backupExportDialog = { ...this._backupExportDialog, busy: false, error: this._backupErrorText(error) }; } } } private async _pickBackupImport(ev: Event): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; this._settingsDialog = null; this._backupImportDialog = { filename: file.name, size: file.size, token: '', preview: null, expectedConfigRev: this._cfgRev, expectedLayoutRev: this._layoutRev, duplicatePolicy: 'skip', confirmMissing: false, busy: true, error: '', }; try { if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush(); await this._writeChain; if (this._persistLayout.pending()) this._persistLayout.flush(); const path = '/api/houseplan/import/preview?duplicate_policy=skip'; const init = { method: 'POST', body: file, headers: { 'content-type': 'application/json' } }; const response: Response = this.hass?.fetchWithAuth ? await this.hass.fetchWithAuth(path, init) : await fetch(path, { ...init, headers: { ...init.headers, ...(this.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.hass.auth.data.access_token}` } : {}), }, }); const data = await response.json().catch(() => ({})); if (!response.ok || data.error) throw Object.assign(new Error(data.message || data.error || `HTTP ${response.status}`), { code: data.error }); if (this._backupImportDialog) { this._backupImportDialog = { ...this._backupImportDialog, token: data.token, preview: data.preview, expectedConfigRev: data.expected_config_rev, expectedLayoutRev: data.expected_layout_rev, busy: false, }; } } catch (error: any) { if (this._backupImportDialog) { this._backupImportDialog = { ...this._backupImportDialog, busy: false, error: this._backupErrorText(error) }; } } } private async _setBackupDuplicatePolicy(policy: 'skip' | 'virtual'): Promise { const d = this._backupImportDialog; if (!d || d.busy || !d.token || policy === d.duplicatePolicy) return; this._backupImportDialog = { ...d, duplicatePolicy: policy, busy: true, error: '' }; try { const response: any = await this.hass.callWS({ type: 'houseplan/import/revalidate', token: d.token, duplicate_policy: policy, }); if (this._backupImportDialog) { this._backupImportDialog = { ...this._backupImportDialog, preview: { ...d.preview, ...response.preview }, expectedConfigRev: response.expected_config_rev, expectedLayoutRev: response.expected_layout_rev, confirmMissing: false, busy: false, }; } } catch (error: any) { if (this._backupImportDialog) { this._backupImportDialog = { ...this._backupImportDialog, duplicatePolicy: d.duplicatePolicy, preview: d.preview, confirmMissing: d.confirmMissing, busy: false, error: this._backupErrorText(error), }; } } } private async _applyBackupImport(): Promise { const d = this._backupImportDialog; if (!d || d.busy || !d.token || !d.preview) return; if (d.preview.confirmation_required && !d.confirmMissing) return; this._backupImportDialog = { ...d, busy: true, error: '' }; const previousSpace = this._space; try { const result: any = await this.hass.callWS({ type: 'houseplan/import/apply', token: d.token, expected_config_rev: d.expectedConfigRev, expected_layout_rev: d.expectedLayoutRev, duplicate_policy: d.duplicatePolicy, confirm_missing_content: d.confirmMissing, }); const [configResponse, layoutResponse] = await Promise.all([ this.hass.callWS({ type: 'houseplan/config/get' }), this.hass.callWS({ type: 'houseplan/layout/get' }), ]); this._adoptStructuralResponses(configResponse, layoutResponse); this._geometryHistory.clear(); this._dirtyPos.clear(); this._sentPos.clear(); this._defPos = {}; this._cfgEpoch++; this._modelCache = null; this._frame = null; this._visibleDeviceSnapshot = null; this._candidateDeviceSnapshot = null; this._stagedDeviceSnapshotToken = -1; this._capturedSnapshotConfigEpoch = -1; this._regSignature = ''; this._signer.invalidate(this.hass); this._resign(); this._maybeRebuildDevices(); const spaces = this._serverCfg?.spaces || []; const nextSpace = result.kind === 'space' && result.space_id ? result.space_id : spaces.some((space: any) => space.id === previousSpace) ? previousSpace : spaces[0]?.id || this._space; if (this._hasFixedFloor) this._adoptInitialSpace(this._model, true); else this._commitSpace(nextSpace); this._backupImportDialog = null; this._cacheSnapshot(); this.requestUpdate(); const outcomeCounts = result.kind === 'space' ? d.preview.counts : result.counts; this._showToast(this._t(result.kind === 'space' ? 'backup.space_done' : 'backup.full_done', { spaces: String(outcomeCounts?.spaces || 0), rooms: String(outcomeCounts?.rooms || 0), markers: String(outcomeCounts?.markers || 0), refs: String(result.repaired_target_refs || 0), })); } catch (error: any) { if (error?.code === 'conflict' && this._backupImportDialog?.token) { try { const current = this._backupImportDialog; const refreshed: any = await this.hass.callWS({ type: 'houseplan/import/revalidate', token: current.token, duplicate_policy: current.duplicatePolicy, }); this._backupImportDialog = { ...current, preview: { ...current.preview, ...refreshed.preview }, expectedConfigRev: refreshed.expected_config_rev, expectedLayoutRev: refreshed.expected_layout_rev, confirmMissing: false, busy: false, error: this._t('backup.revalidated'), }; return; } catch (refreshError: any) { error = refreshError; } } if (this._backupImportDialog) { this._backupImportDialog = { ...this._backupImportDialog, busy: false, error: this._backupErrorText(error) }; } } } private _renderBackupExportDialog(): TemplateResult { const d = this._backupExportDialog!; const currentSpace = (this._serverCfg?.spaces || []).find((space: any) => space.id === this._space); return html` (this._backupExportDialog = null)}>
${this._t('backup.export_hint')}
${d.kind === 'space' && currentSpace ? html`` : nothing}
${this._t('backup.privacy_warning')}
${d.error ? html`` : nothing}
`; } private _renderBackupImportDialog(): TemplateResult { const d = this._backupImportDialog!; const p = d.preview; const counts = p?.counts || {}; const report = p?.reference_report || {}; const sum = (values: any): number => Object.values(values || {}).reduce( (total: number, value: any) => total + (Number(value) || 0), 0, ); const allReportRows: Array<[ 'incoming_remapped' | 'target_repaired' | 'preserved_unresolved' | 'collisions' | 'dropped_links' | 'bounded_lineages', number, ]> = [ ['incoming_remapped', sum(report.remapped?.incoming)], ['target_repaired', sum(report.remapped?.target)], ['preserved_unresolved', sum(report.preservedUnresolved)], ['collisions', sum(report.collisions)], ['dropped_links', sum(report.droppedIncomingLinks)], ['bounded_lineages', Number(report.boundedLineages) || 0], ]; const reportRows = allReportRows.filter(([, value]) => value > 0); return html` (this._backupImportDialog = null)}>
${d.filename}${(d.size / 1024).toFixed(1)} KB
${d.busy && !p ? html`
${this._t('backup.reading')}
` : nothing} ${d.error ? html`` : nothing} ${p ? html`
${this._t(p.kind === 'full' ? 'backup.full' : 'backup.current_space')} ${p.plan_only ? html`${this._t('backup.plan_only_preview')}` : nothing} ${this._t(p.source === 'same' ? 'backup.same_source' : 'backup.foreign_source')} ${this._t('backup.created', { value: p.created_at || '—' })} ${this._t('backup.versions', { card: p.card_version || '—', integration: p.integration_version || '—', model: String(p.model_version ?? '—'), })}
${this._t('backup.count_spaces', { n: p.kind === 'full' ? `${p.current_counts?.spaces || 0} → ${counts.spaces || 0}` : String(counts.spaces || 0) })} ${this._t('backup.count_rooms', { n: p.kind === 'full' ? `${p.current_counts?.rooms || 0} → ${counts.rooms || 0}` : String(counts.rooms || 0) })} ${this._t('backup.count_walls', { n: p.kind === 'full' ? `${p.current_counts?.walls || 0} → ${counts.walls || 0}` : String(counts.walls || 0) })} ${this._t('backup.count_openings', { n: p.kind === 'full' ? `${p.current_counts?.openings || 0} → ${counts.openings || 0}` : String(counts.openings || 0) })} ${this._t('backup.count_decor', { n: p.kind === 'full' ? `${p.current_counts?.decor || 0} → ${counts.decor || 0}` : String(counts.decor || 0) })} ${this._t('backup.count_markers', { n: p.kind === 'full' ? `${p.current_counts?.markers || 0} → ${counts.markers || 0}` : String(counts.markers || 0) })} ${this._t('backup.count_layout', { n: p.kind === 'full' ? `${p.current_counts?.layout || 0} → ${counts.layout || 0}` : String(counts.layout || 0) })}
${p.bindings ? html`
${this._t('backup.bindings', { device: String(p.bindings.device || 0), entity: String(p.bindings.entity || 0), virtual: String(p.bindings.virtual || 0), legacy: String(p.legacy_positions || 0), })}
${this._t('backup.binding_status', { active: String(p.bindings.active || 0), disabled: String(p.bindings.disabled || 0), missing: String(p.bindings.missing || 0), })}
` : nothing} ${p.missing_areas?.length ? html`
${this._t('backup.missing_areas', { areas: p.missing_areas.join(', '), })}
` : nothing} ${p.dropped_marker_links ? html`
${this._t('backup.dropped_marker_links', { n: String(p.dropped_marker_links), })}
` : nothing} ${p.repaired_target_refs ? html`
${this._t('backup.repaired_target_refs', { n: String(p.repaired_target_refs), })}
` : nothing} ${p.preserved_unresolved_refs ? html`
${this._t('backup.preserved_unresolved_refs', { n: String(p.preserved_unresolved_refs), })}
${this._t('backup.preserved_unresolved_hint')}
` : nothing} ${reportRows.length ? html`
${this._t('backup.import_details')}
${reportRows.map(([key, value]) => html`${this._t( `backup.import_detail.${key}`, { n: String(value) }, )}`)} ${(report.examples || []).slice(0, 8).map((item: any) => html` ${item.owner} → ${item.reference} `)}
` : nothing} ${p.kind === 'full' ? html`
${this._t('backup.replace_warning')}
${p.source === 'foreign' ? html`
${this._t('backup.foreign_bookkeeping')}
` : nothing}` : html`
${this._t('backup.final_name')}${p.space_title}
${this._t('backup.target_settings')}
${p.duplicates ? html`
${this._t('backup.duplicates', { n: String(p.duplicates) })}
` : nothing}`} ${p.content?.length ? html`
${this._t('backup.content')} ${p.content.map((item: any) => html`${item.url} · ${this._t( item.state === 'available' ? 'backup.content_available' : item.state === 'external' ? 'backup.content_external' : 'backup.content_detach_required', )}`)}
` : nothing} ${p.confirmation_required ? html`` : nothing} ` : nothing}
${p ? html`` : nothing}
`; } private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void { const d = this._settingsDialog!; this._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } }; } private async _saveSettingsDialog(): Promise { const d = this._settingsDialog; if (!d || d.busy) return; this._settingsDialog = { ...d, busy: true }; try { const cfg = this._serverCfg!; const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS); const settings: any = { ...cfg.settings }; if (isDefault) delete settings.fill_colors; else settings.fill_colors = d.colors; const cm = this._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100; if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm); else delete settings.glow_radius_cm; if (d.bgColor) settings.bg_color = d.bgColor; else delete settings.bg_color; // Sun background compatibility is explicit: legacy absence means static, // while new installations and spaces materialise daynight. if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359) settings.north_deg = d.northDeg; else delete settings.north_deg; settings.bg_mode = d.bgMode; if (d.sunRays) settings.sun_rays = true; else delete settings.sun_rays; // Legacy compatibility: old configs may still contain this accepted // field, but weather no longer affects sunlight and the UI no longer // exposes it. Saving general settings cleans the obsolete value up. delete settings.weather_entity; this._serverCfg = { ...cfg, settings }; await this._saveConfigNow(); this._settingsDialog = null; this.requestUpdate(); this._showToast(this._t('gs.saved')); } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this._settingsDialog) this._settingsDialog = { ...this._settingsDialog, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } /** Boolean toggle for dialog rows: the native ha-switch when the HA * frontend provides it, the classic checkbox otherwise (older HA, the * smoke env). The ha-* API is undocumented and shifts between HA * releases, so the presence check is the ONLY coupling: both branches * fire `change` and both are read back via `.checked` off the event * target - one handler, two renderers. */ private _boolInput(checked: boolean, onChange: (v: boolean) => void, disabled = false): TemplateResult { const h = (e: Event) => onChange(!!(e.target as HTMLInputElement).checked); return customElements.get('ha-switch') ? html`` : html``; } /** Range slider for dialog rows: ha-slider when available, plain * input[type=range] otherwise. Same fallback contract as _boolInput; * ha-slider emits `input` while dragging and `change` on release * (which of the two carries the final value differs between HA * versions - listen to both, the handler is idempotent). */ private _rangeInput( min: number, max: number, step: number, value: number, onInput: (v: number) => void, disabled = false, ariaLabel?: string, ): TemplateResult { const h = (e: Event) => { const n = Number((e.target as HTMLInputElement).value); if (Number.isFinite(n)) onInput(n); }; return customElements.get('ha-slider') ? html`` : html``; } private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult { const d = this._settingsDialog!; const v = d.colors[key]; return html`
) => { this._setFillColor(key, { c: e.detail.color, a: e.detail.opacity }); }}>
`; } /** Glow radius: stored in cm (config.settings.glow_radius_cm), default 3 m. */ private get _glowRadiusCm(): number { const v = Number((this._settings as any).glow_radius_cm); return Number.isFinite(v) && v > 0 ? v : 300; } private get _imperial(): boolean { return this.hass?.config?.unit_system?.length === 'mi'; } private get _glowRadiusPlaceholder(): string { const cm = this._glowRadiusCm; return this._imperial ? String(Math.round((cm / 30.48) * 10) / 10) : String(cm / 100); } /** * Keep a source node alive while CSS fades it out. A new `on` state cancels * the pending removal, so rapid toggles reverse the same transition instead * of destroying and recreating the SVG/filter stack. */ private _glowTransition( key: string, active: boolean, ): { domId: number; entering: boolean; leaving: boolean } | null { let domId = this._glowRenderedSources.get(key); if (active) { const timer = this._glowFadeTimers.get(key); if (timer != null) { clearTimeout(timer); this._glowFadeTimers.delete(key); } if (domId == null) { this._suspendGlowFeatherForTransition(); domId = ++this._glowSourceSeq; this._glowRenderedSources.set(key, domId); this._glowEnteringSources.add(key); const raf = requestAnimationFrame(() => { if (this._glowEnterRafs.get(key) !== raf) return; this._glowEnterRafs.delete(key); this._glowEnteringSources.delete(key); if (this.isConnected) this.requestUpdate(); }); this._glowEnterRafs.set(key, raf); } return { domId, entering: this._glowEnteringSources.has(key), leaving: false }; } if (domId == null) return null; const enterRaf = this._glowEnterRafs.get(key); if (enterRaf != null) cancelAnimationFrame(enterRaf); this._glowEnterRafs.delete(key); this._glowEnteringSources.delete(key); if (!this._glowFadeTimers.has(key)) { this._suspendGlowFeatherForTransition(); const timer = window.setTimeout(() => { if (this._glowFadeTimers.get(key) !== timer) return; this._glowFadeTimers.delete(key); this._glowRenderedSources.delete(key); this._glowLastAppearance.delete(key); if (this.isConnected) this.requestUpdate(); }, GLOW_FADE_MS + 34); // keep one frame of slack after the CSS transition this._glowFadeTimers.set(key, timer); } return { domId, entering: false, leaving: true }; } /** A whole-layer Gaussian blur is needlessly re-evaluated for every frame * of a source opacity transition. The 1 px penumbra is imperceptible while * the pool itself is moving between transparent and opaque, so bypass it * for that bounded interval and restore it once at the settled frame. */ private _suspendGlowFeatherForTransition(): void { if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; this._glowFeatherSuspendUntil = Math.max( this._glowFeatherSuspendUntil, Date.now() + GLOW_FADE_MS, ); clearTimeout(this._glowFeatherResumeTimer); const delay = Math.max(0, this._glowFeatherSuspendUntil - Date.now()) + 17; const resume = () => { this._glowFeatherResumeTimer = 0; if (Date.now() < this._glowFeatherSuspendUntil) { this._glowFeatherResumeTimer = window.setTimeout( resume, this._glowFeatherSuspendUntil - Date.now() + 17, ); return; } this._glowFeatherSuspendUntil = 0; if (this.isConnected) this.requestUpdate(); }; this._glowFeatherResumeTimer = window.setTimeout(resume, delay); } private _forgetGlowSource(key: string): void { const timer = this._glowFadeTimers.get(key); if (timer != null) clearTimeout(timer); const raf = this._glowEnterRafs.get(key); if (raf != null) cancelAnimationFrame(raf); this._glowFadeTimers.delete(key); this._glowEnterRafs.delete(key); this._glowEnteringSources.delete(key); this._glowRenderedSources.delete(key); this._glowLastAppearance.delete(key); } private _forgetGlowSpace(spaceId: string): void { const prefix = `${spaceId}|`; for (const key of this._glowRenderedSources.keys()) { if (key.startsWith(prefix)) this._forgetGlowSource(key); } } private _warnGlowGeometryFallback( spaceId: string, fingerprint: string, roomId: string, phase: string, ): void { // One diagnostic per geometry revision and room, even if that room happens // to fail in more than one boolean phase while the card is re-rendering. const key = `${spaceId}|${fingerprint}|${roomId}`; if (this._glowGeometryWarnings.has(key)) return; if (this._glowGeometryWarnings.size >= 128) { const oldest = this._glowGeometryWarnings.values().next().value; if (oldest) this._glowGeometryWarnings.delete(oldest); } this._glowGeometryWarnings.add(key); console.warn( `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`, ); } /** * Everything in a space that stops light, and the floor light may land on. * * Opaque: the wall bodies exactly as the plan draws them — with their real * thickness — plus every independent body (partition, column, room draft), * plus the bare outline of any edge that carries no thickness at all. * Transparent: doorways and gates, cut out of the masonry so the * opening is a real gap between two jamb faces; and virtual (open) * boundaries, which are not walls to begin with. A window stays solid: an * indoor lamp must not wash the street, so the light's masonry is cut by * passages only and differs on purpose from the drawn one. * * The result depends only on the plan, so it is shared by every lamp. */ private _lightBarriers( space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[], ): { occluders: LightSegment[]; floor: number[][][]; fingerprint: string; masonryGeometry: any; opaqueBodies: number[][][]; } { // Gates are door-like: their different symbol must not change how light // crosses the clear opening. const openCuts = this._openPairs().flatMap((p) => p.segs); const cuts: number[][] = [...openCuts]; // An opening is transparent only where it leads from floor to floor. A // front door has nothing behind it to light, so for light it is masonry // like a window — otherwise its tunnel glows halfway, up to the centreline // where the room polygon ends, and the plan shows a lit doorway to nowhere. const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5); const onFloor = (point: number[]): boolean => polys.some((x) => this._pointInRoom(point, x.r)); const passages = this._openingsR.filter((o) => { if (!isInteriorLightOpeningType(String(o.type))) return false; const rad = (o.angle * Math.PI) / 180; const nx = -Math.sin(rad); const ny = Math.cos(rad); return onFloor([o.rx + nx * probe, o.ry + ny * probe]) && onFloor([o.rx - nx * probe, o.ry - ny * probe]); }); const roomPassages = this._roomWallOpeningInputs(passages, space); // Openings omitted from `passages` remain part of light's opaque masonry. // Consequently a source centred in an exterior door/gate or any window is // rejected below exactly like a source centred inside the wall itself. for (const opening of roomPassages) { const rad = (opening.angle * Math.PI) / 180; const dx = (Math.cos(rad) * opening.length) / 2; const dy = (Math.sin(rad) * opening.length) / 2; cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]); } const transparentHostedIds = new Set( passages.filter((opening) => opening.partitionHost).map((opening) => opening.id), ); const lightPhysicalKey = [ space.id, this._cfgEpoch, this._cellCm, this._gridPitch, [...transparentHostedIds].sort().join(','), ].join('|'); if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) { this._lightPhysicalBodiesCache = { key: lightPhysicalKey, all: physicalBodyParts( space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002, this._partitionOpeningCuts(space, (opening) => transparentHostedIds.has(opening.id)), ).all, }; } const lightPhysical = this._lightPhysicalBodiesCache.all; // Keyed by what it is made of, never by `_cfgEpoch`: geometry edited in // place leaves the epoch behind, and a stale barrier set is invisible — // the plan simply keeps lighting through a wall that now exists. let hash = 0x811c9dc5; const mix = (value: number) => { hash ^= Math.round((Number.isFinite(value) ? value : 0) * 64); hash = Math.imul(hash, 0x01000193) >>> 0; }; const walls = this._spaceWalls; mix(this._cellCm); mix(this._gridPitch); mix(this._wallKeyPitch); for (const { poly } of polys) { mix(poly.length); for (const p of poly) { mix(p[0]); mix(p[1]); } } for (const cut of cuts) for (const value of cut) mix(value); for (const body of lightPhysical) { mix(body.length); for (const point of body) { mix(point[0]); mix(point[1]); } } for (const wall of walls) { mix(wall.cm); mix(wall.a?.[0] ?? 0); mix(wall.a?.[1] ?? 0); mix(wall.b?.[0] ?? 0); mix(wall.b?.[1] ?? 0); } const fingerprint = hash.toString(36); const cacheKey = `${space.id}|${fingerprint}`; if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value; const eps = this._gridPitch * 0.02; const occluders: LightSegment[] = []; // The masonry the plan draws, cut by passages only — real thickness, real // jamb faces. Treating a wall as its centreline let light bleed half a wall // deep (a bright bar at every opening) and started every shadow half a wall // away from the corner that casts it. const masonry = walls.length || lightPhysical.length ? wallBodiesGeometry( space.rooms, walls, openCuts, roomPassages, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical, ) : null; if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) { for (const component of masonry.components) { for (const ring of geometryAllRings(component.geom)) occluders.push(...polygonSegments(ring)); } } else { // Malformed legacy geometry must remain opaque even when the canonical // boolean pass cannot produce a joined result. for (const body of lightPhysical) occluders.push(...polygonSegments(body)); } // Edges without any thickness are still walls; so is a room outline when // the boolean pass above could not run at all. for (const { poly } of polys) { for (const seg of (cuts.length ? outlineWithout(poly, cuts, eps) : polygonSegments(poly))) { occluders.push(seg as LightSegment); } } const value = { occluders: splitAtIntersections(occluders), floor: polys.map((x) => x.poly), fingerprint, masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra') ? masonry.components.flatMap((component) => component.geom) : [], // The source guard must fail dark against the same type/floor-filtered // bodies used to build the light masonry. The ordinary render bodies are // cut by every hosted opening, including opaque windows and exterior // doors, and therefore cannot be used when the boolean geometry is empty. opaqueBodies: lightPhysical, }; this._lightBarrierCache = { key: cacheKey, value }; return value; } /** Light pools of the current space: dark house, glowing sources. */ private _renderGlowLayer(space: SpaceModel, disp: SpaceDisplay): TemplateResult { const colors = this._fillColors; const defaultR = (this._glowRadiusCm / this._cellCm) * this._gridPitch; const polys = space.rooms .map((r) => ({ r, poly: roomPoly(r) })) .filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly); const enabled = polys.filter(({ r }) => roomGlowOf(disp.glow, r)); if (!enabled.length) { this._forgetGlowSpace(space.id); return svg`` as unknown as TemplateResult; } const { occluders, floor, fingerprint, masonryGeometry, opaqueBodies, } = this._lightBarriers(space, polys); // Resolve against the whole plan: a controller and its passive lamp may // legitimately live in different spaces. Ownership is filtered afterwards. const resolvedSources = resolvedLightSources( this._renderPlanHass, this._renderDevices, null, this._virtualLights, ) .filter((source) => source.device.space === space.id); const sourcesByDevice = new Map(); for (const source of resolvedSources) { if (!source.device.id) continue; const list = sourcesByDevice.get(source.device.id) || []; list.push(source); sourcesByDevice.set(source.device.id, list); } const spots: { key: string; sourceEid: string; domId: number; entering: boolean; leaving: boolean; pos: { x: number; y: number }; c: string; alpha: number; geometry: GlowClipGeometry | null; r: number; }[] = []; const seenSourceKeys = new Set(); for (const d of this._renderDevices) { if (d.space !== space.id) continue; const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []); if (!source) continue; // One marker owns one spatial pool even when its resolved HA source // changes; keeping the key marker-stable avoids a false fade/recreate. const key = `${space.id}|${d.id}`; seenSourceKeys.add(key); const visibleGlow = resolveGlowAppearance( source.passive ? { state: source.on ? 'on' : 'off', attributes: {} } : this._renderPlanHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c, ); // per-source radius (owner's decision v1.36.2): marker override, else global const ownCm = Number(d.marker?.glow_radius_cm); const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR; const pos = this._pos(d); // Invalid placement inside any opaque body must remain dark. The // masonry geometry already contains the exact passage cuts, so a valid // interior doorway remains transparent while a source embedded in the // surrounding wall cannot light one side of its tunnel. The visibility // sweep separately rejects a source that lies exactly on an opaque edge. const sourcePoint = [pos.x, pos.y]; // Exterior opening tunnels deliberately remain in `masonryGeometry`, // so placing the source there suppresses the entire pool rather than // lighting only the indoor half of the tunnel. if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) { // This placement cannot produce a valid previous-frame fade: the old // clip belongs to a different position. Remove its transition state as // well, rather than leaving a timer for a DOM node no longer rendered. this._forgetGlowSource(key); continue; } const transition = this._glowTransition(key, !!visibleGlow); if (!transition) continue; if (visibleGlow) { this._glowLastAppearance.set(key, { c: visibleGlow.c, alpha: glowAlpha(visibleGlow.bri, colors.glow_light.a), }); } const appearance = this._glowLastAppearance.get(key); if (!appearance) continue; let geometry: GlowClipGeometry | null = null; const clipKey = `${space.id}|${fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${R.toFixed(4)}`; const cachedClip = lruRead(this._glowClipCache, clipKey); if (cachedClip.hit) { geometry = cachedClip.value; } else { // The entire light model, in two lines: what can this lamp see, and // where is there floor to light. Doorways, gates and virtual walls are // simply missing from `occluders`, so light crosses them without any // notion of a "spill", a "sector", a "tunnel" or an "open zone" — and // a wall corner two rooms away casts its shadow for exactly the same // reason a column does. const seen = visibilityPolygon([pos.x, pos.y], R, occluders, GLOW_ARC_STEPS); geometry = { lit: seen.length >= 3 ? intersectionPaths([seen], floor, { onBoundsFailure: ({ boundIndex, phase }) => { const room = polys[boundIndex]?.r; this._warnGlowGeometryFallback( space.id, fingerprint, room?.id || `#${boundIndex}`, phase, ); }, }) : [], }; lruWrite(this._glowClipCache, clipKey, geometry, 256); } spots.push({ key, sourceEid: source.eid, domId: transition.domId, entering: transition.entering, leaving: transition.leaving, pos, c: appearance.c, alpha: appearance.alpha, geometry, r: R, }); } const sourcePrefix = `${space.id}|`; for (const key of this._glowRenderedSources.keys()) { if (key.startsWith(sourcePrefix) && !seenSourceKeys.has(key)) this._forgetGlowSource(key); } if (!spots.length) return svg`` as unknown as TemplateResult; // Per-room Glow overrides are visual clips only. The transport calculation // above still crosses a disabled room, but no base/pool pixels are painted // there. For the common all-enabled case this extra clip is omitted. const walls = this._spaceWalls; const openCuts = enabled.length === polys.length ? [] : this._openPairs().flatMap((pair) => pair.segs); const roomWalls = this._wallUnionGeometry()?.roomGeom; const enabledClip = enabled.length === polys.length ? null : enabled.map(({ r, poly }) => { const floorPoly = walls.length && r.id ? (innerContourForRoom( space.rooms, r.id, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, roomWalls, ) || poly) : poly; const clean = this._cleanFloor(r, floorPoly, space).path; const holes = islandsOf( floorPoly, polys.filter((other) => other.r !== r).map((other) => other.poly), ); const path = (points: number[][]) => 'M ' + points.map((point) => point[0] + ' ' + point[1]).join(' L ') + ' Z'; return [clean || path(floorPoly), ...holes.map(path)].join(' '); }); // A hair on SCREEN, so an edge stays a hairline at any zoom instead of // turning into a smear when the plan is enlarged. const view = this._viewOr(this._baseVb()); const perUnit = this._stageEl?.clientWidth && view.w ? this._stageEl.clientWidth / view.w : 1; const nextFeather = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1); const featherEnabled = !this._pinchStart && !this._panStart && Date.now() >= this._glowFeatherSuspendUntil; if (this._glowFeatherUnits == null || featherEnabled) this._glowFeatherUnits = nextFeather; const feather = this._glowFeatherUnits ?? nextFeather; const pad = feather * 4; const featherBox = spots.reduce((box, sp) => ({ x: Math.min(box.x, sp.pos.x - sp.r - pad), y: Math.min(box.y, sp.pos.y - sp.r - pad), maxX: Math.max(box.maxX, sp.pos.x + sp.r + pad), maxY: Math.max(box.maxY, sp.pos.y + sp.r + pad), w: 0, h: 0, }), { x: Infinity, y: Infinity, maxX: -Infinity, maxY: -Infinity, w: 0, h: 0 }); featherBox.w = featherBox.maxX - featherBox.x; featherBox.h = featherBox.maxY - featherBox.y; return svg` ${repeat(spots, (sp) => sp.key, (sp) => { const i = sp.domId; const geom = sp.geometry; return svg` ${''/* One field per source, in plan coordinates. Attenuation is a property of DISTANCE FROM THE LAMP and nothing else, so the floor two rooms away is dimmer because it is far, not because a second gradient was pasted at a doorway. The profile decays over the whole radius: the old flat plateau out to 70% turned every clipped shape into a slab of solid colour with a rim. */} ${GLOW_FALLOFF.map(([offset, scale]) => svg` `)} ${''/* The one and only shape of this light: the floor this lamp can actually see. A shadow is simply floor that is not in here. The single blur below is the penumbra of that shape — one pass over one path, never a second layer of light. */} ${geom ? svg` ` : nothing}`; })} ${enabledClip ? svg`${enabledClip.map((d) => svg``)}` : nothing} ${''/* Penumbra, once for the whole layer. Every region is cut geometrically; blurring the composited result by a hair is what keeps a shadow edge from reading as a cut-out. One pass costs a fraction of one blurred mask per source, and the difference is invisible — neighbouring pools are already smooth where they meet. CSS `filter: blur()` is not usable here: Chromium applies it to an SVG group in name only. */} ${''/* Glow is presentation only. It is painted above room fills, but must not become the pointer target: room hover and its tooltip still belong to the room underneath the light pool. */} ${repeat(spots, (sp) => sp.key, (sp) => { const i = sp.domId; return svg` `; })} ` as unknown as TemplateResult; } /** * The confirmation separates geometry movement from lossless maintenance, * and promises the one-deep undo before either store is changed. */ private _renderAlignDialog(): TemplateResult { const d = this._alignDialog!; const r = d.report; const failed = d.changed && !d.preflight?.ok; const failures = d.preflight?.failures || []; const visibleNames = failures.slice(0, 3).map((failure) => failure.displayName); const spaces = visibleNames.length ? visibleNames.join(', ') : this._t('gs.align_preflight_space', { n: '1' }); const remaining = Math.max(0, failures.length - visibleNames.length); const more = remaining ? this._t('gs.align_preflight_more', { n: String(remaining) }) : ''; const repaired = r.spaceRefsRemapped + r.roomRefsRemapped + r.positionsRemapped + r.markersDetached; const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized + r.wallSegmentsMigrated + r.wallsMerged + r.spansMerged + r.partitionsMerged + r.partitionsReconciled + r.openingsRehosted + r.redundantDraftsRemoved; const gridWarning = r.moved + r.rotated + r.removedDrafts + r.coordsCanonicalized + r.wallsStraightened; const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10; const straightenSpace = (this._serverCfg?.spaces || []).find( (space: any) => String(space?.id || '') === r.maxStraightenSpace, ); const straightenWhere = (this._serverCfg?.spaces || []).length > 1 && straightenSpace ? String(straightenSpace.title || straightenSpace.id) : ''; const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved + r.orphanGroupPositionsRemoved; const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean); const visibleLiveNames = liveNames.slice(0, 3).join(', '); const remainingLiveNames = Math.max(0, liveNames.length - 3); const liveNamesText = visibleLiveNames ? this._t('gs.optimize_live_names', { names: visibleLiveNames, more: remainingLiveNames ? this._t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '', }) : ''; const registryLimited = r.unverifiedPositions.some( (item) => item.reason === 'registry_unavailable', ); const detailStatus = (item: typeof r.removedPositions[number]): string => { if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) { return this._t('gs.optimize_detail_removed'); } if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) { return this._t('gs.optimize_detail_live'); } return this._t('gs.optimize_detail_unverified'); }; const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this._t( kind === 'room_label' ? 'gs.optimize_detail_room_label' : kind === 'group' ? 'gs.optimize_detail_group' : kind === 'device' ? 'gs.optimize_detail_device' : 'gs.optimize_detail_unknown', ); const referenceDetails = [ ...r.removedPositions, ...r.liveMissingPositions.filter((item) => ( !r.removedPositions.some((removedItem) => removedItem.id === item.id) )), ...r.unverifiedPositions, ]; const visibleDetails = referenceDetails.slice(0, 10); const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length); return html` (this._alignDialog = null)}>
${failed ? html`

${this._t('gs.align_preflight_failed', { spaces, more })}

${this._t('gs.align_preflight_hint')}
` : !d.changed ? html`

${this._t( r.liveMissingPositions.length || r.unverifiedPositions.length || r.nestedRefsUnresolved ? 'gs.optimize_no_automatic_changes' : 'gs.align_none', )}

` : html` ${r.moved ? html`

${this._t('gs.align_count', { n: String(r.moved), total: String(r.total), cm: String(d.cm), })}

` : nothing} ${r.latticeCoordinatesCanonicalized ? html`

${this._t('gs.optimize_lattice_summary', { n: String(r.latticeCoordinatesCanonicalized), cm: formatLatticeShiftCm(r.latticeMaxShiftCm), })}

${r.latticeSpaces.map((space) => html`

${this._t( 'gs.optimize_lattice_space', { space: space.space, n: String(space.canonicalized), far: String(space.far), }, )}

`)} ` : nothing} ${d.where ? html`

${this._t('gs.align_where', { s: d.where })}

` : nothing} ${r.rotated ? html`

${this._t('gs.align_turned', { n: String(r.rotated) })}

` : nothing} ${r.removedDrafts ? html`

${this._t('gs.align_removed_drafts', { n: String(r.removedDrafts), })}

` : nothing} ${r.redundantDraftsRemoved ? html`

${this._t('gs.optimize_redundant_drafts', { n: String(r.redundantDraftsRemoved), })}

` : nothing} ${r.wallSegmentsMigrated ? html`

${this._t( 'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) }, )}

` : nothing} ${modelMaintenance ? html`

${this._t('gs.optimize_changes', { m: String(r.migrated), c: String(r.canonicalized), p: String(r.coordsCanonicalized), w: String(r.wallsMerged), s: String(r.spansMerged), i: String(r.partitionsMerged), })}

` : nothing} ${r.partitionsReconciled ? html`

${this._t( 'gs.optimize_coincident_partitions', { n: String(r.partitionsReconciled) }, )}

` : nothing} ${r.openingsRehosted ? html`

${this._t( 'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) }, )}

` : nothing} ${r.wallsStraightened ? html`

${this._t( 'gs.optimize_walls_straightened', { n: String(r.wallsStraightened), cm: String(straightenCm), }, )}

` : nothing} ${straightenWhere ? html`

${this._t( 'gs.optimize_walls_straightened_where', { s: straightenWhere }, )}

` : nothing} ${r.glowSpacesMigrated || r.glowRoomsMigrated ? html`

${this._t('gs.optimize_glow_migration', { spaces: String(r.glowSpacesMigrated), rooms: String(r.glowRoomsMigrated), })}

` : nothing} ${gridWarning ? html`
${this._t('gs.align_warn')}
` : nothing}`} ${!failed && r.wallsStraightenSkipped ? html`

${this._t( 'gs.optimize_walls_straighten_skipped', { n: String(r.wallsStraightenSkipped), }, )}

` : nothing} ${repaired ? html`

${this._t('gs.optimize_references', { spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped), positions: String(r.positionsRemapped), detached: String(r.markersDetached), })}

` : nothing} ${removed ? html`

${this._t('gs.optimize_orphans_removed', { total: String(removed), rooms: String(r.orphanRoomLabelsRemoved), devices: String(r.orphanDevicePositionsRemoved), groups: String(r.orphanGroupPositionsRemoved), })}

` : nothing} ${r.liveMissingPositions.length ? html`

${this._t(d.removeLiveMissingPositions ? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', { n: String(r.liveMissingPositions.length), names: liveNamesText, })}

${d.removeLiveMissingPositions ? html`
${this._t('gs.optimize_live_selected')}
` : nothing}
` : nothing} ${r.unverifiedPositions.length ? html`` : nothing} ${r.nestedRefsUnresolved ? html`` : nothing} ${referenceDetails.length ? html`
${this._t('gs.optimize_details')}
    ${visibleDetails.map((item) => html`
  • ${this._t('gs.optimize_detail_item', { status: detailStatus(item), kind: detailKind(item.kind), id: item.id, space: item.spaceId, })}
  • `)}
${remainingDetails ? html`
${this._t('gs.optimize_details_more', { n: String(remainingDetails), })}
` : nothing}
` : nothing}
${!d.changed || !d.preflight?.ok ? nothing : html` `}
`; } private _renderSettingsDialog(): TemplateResult { return html` (this._settingsDialog = null)}>
${this._t('gs.hint')}
${this._renderColorRow('light_on', 'gs.light_on')} ${this._renderColorRow('light_off', 'gs.light_off')} ${this._renderColorRow('light_none', 'gs.light_none')} ${this._renderColorRow('temp_cold', 'gs.temp_cold')} ${this._renderColorRow('temp_ok', 'gs.temp_ok')} ${this._renderColorRow('temp_hot', 'gs.temp_hot')} ${this._renderColorRow('lqi_low', 'gs.lqi_low')} ${this._renderColorRow('lqi_high', 'gs.lqi_high')} ${this._renderColorRow('glow_base', 'gs.glow_base')} ${this._renderColorRow('glow_light', 'gs.glow_light')}
${this._t('gs.glow_radius')} { const v = strictNumber((e.target as HTMLInputElement).value); if (v != null && v > 0) this._settingsDialog = { ...this._settingsDialog!, glowRadius: v }; }} /> ${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}
${this._renderColorRow('wall_fill', 'gs.wall_fill')}
${this._t('gs.bg_mode')}
${this._settingsDialog!.bgMode === 'static' ? html`
) => { this._settingsDialog = { ...this._settingsDialog!, bgColor: e.detail.color }; }}> ${this._settingsDialog!.bgColor ? html`` : html`${this._t('gs.bg_theme')}`}
` : html`
${this._t('gs.bg_daynight_hint')}
`} ${!sunStateOf(this.hass) ? html`
${this._t('gs.sun_missing')}
` : nothing}
${this._renderCompass()}
${this._t('gs.north')}
{ const raw = (e.target as HTMLInputElement).value.trim(); const n = raw === '' ? null : Math.round(Number(raw)); this._settingsDialog = { ...this._settingsDialog!, northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null, }; }} /> ${this._settingsDialog!.northDeg !== null ? html`` : nothing}
${this._settingsDialog!.northDeg === null ? html`
${this._t('gs.north_hint')}
` : nothing}
${this._canEdit ? html`
${this._t('gs.backup_hint')}
${this._canOptimizeUndo && this._undoKind === 'import' ? html` ` : nothing}
` : nothing}
${this._t('gs.grid_hint')}
${this._canOptimizeUndo && this._undoKind !== 'import' ? html`
` : nothing}
${this._t('gs.about_version', { v: CARD_VERSION })}
${this._t('gs.about_github')} ${this._t('gs.about_telegram')}
`; } // ================= ICON RULES EDITOR ================= private _openRulesDialog = (): void => { if (!this._norm) return; const custom = this._settings.icon_rules; const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r })); this._rulesDialog = { rules, test: '', busy: false }; }; private _rulesSet(rules: IconRule[]): void { this._rulesDialog = { ...this._rulesDialog!, rules }; } private async _saveRules(): Promise { const dlg = this._rulesDialog; if (!dlg || dlg.busy) return; const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim()); this._rulesDialog = { ...dlg, busy: true }; try { const cfg = this._serverCfg!; const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES); const settings: any = { ...cfg.settings }; if (isDefault) delete settings.icon_rules; else settings.icon_rules = cleaned; this._serverCfg = { ...cfg, settings }; await this._saveConfigNow(); this._rulesDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('rules.saved')); } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this._rulesDialog) this._rulesDialog = { ...this._rulesDialog, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private _renderRulesDialog(): TemplateResult { const d = this._rulesDialog!; const compiled = compileIconRules(d.rules); const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null; const move = (i: number, delta: number) => { const r = [...d.rules]; const j = i + delta; if (j < 0 || j >= r.length) return; [r[i], r[j]] = [r[j], r[i]]; this._rulesSet(r); }; return html` (this._rulesDialog = null)}>
${this._t('rules.hint')}
(this._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} /> ${testIcon ? html`${testIcon}` : nothing}
${d.rules.map((r, i) => { const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern); return html`
{ const rules = [...d.rules]; rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value }; this._rulesSet(rules); }} /> { const rules = [...d.rules]; rules[i] = { ...r, icon: (e.target as HTMLInputElement).value }; this._rulesSet(rules); }} /> move(i, -1)}> move(i, 1)}> this._rulesSet(d.rules.filter((_, j) => j !== i))}>
`; })}
`; } private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void { this._kioskScale = { ...this._kioskScale, ...patch }; try { localStorage.setItem(LS_KIOSK, JSON.stringify(this._kioskScale)); } catch { /* ignore */ } this.requestUpdate(); } /** Per-SCREEN size settings (wall tablets differ) — stored locally. */ private _renderKioskDialog(): TemplateResult { const k = this._kioskScale; const row = (key: 'icon' | 'font', label: string) => html`
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))} ${Math.round(k[key] * 100)}%
`; return html` (this._kioskDialog = false)}>
${this._t('kiosk.hint')}
${row('icon', this._t('kiosk.icon_scale'))} ${row('font', this._t('kiosk.font_scale'))}
`; } // ================= render ================= private _fixedFloorValue(value: unknown): string { if (typeof value === 'string') return value || "''"; try { const encoded = JSON.stringify(value); return (encoded === undefined ? String(value) : encoded).slice(0, 160); } catch { return String(value).slice(0, 160); } } protected render(): TemplateResult | typeof nothing { if (!this._config || !this.hass) return nothing; const model = this._model; const diagnostics = this.houseplanDiagnostics(); const fixed = this._fixedFloorState(model); if (fixed.kind === 'pending') { return html`
${this._config.title || this._t('card.title')}

${this._t('fixed_floor.loading')}

`; } if (fixed.kind === 'invalid') { return html`
${this._config.title || this._t('card.title')}
`; } if (!model.length) { return html`
${this._config.title || this._t('card.title')}

${this._t('empty.no_spaces')}

${this._serverStorage ? html`

${this._t('empty.add_first')}

` : html`

${this._t('empty.install')}

`}
${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._importDialog ? this._renderImportDialog() : nothing} ${this._toast ? html`` : nothing}
`; } const space = this._spaceModel(); if (!space) return nothing; const navigationSpaces = fixed.kind === 'valid' ? [space] : model; const vb = space.vb; const projection = this._effectiveProjection(); this._renderProjection = projection; if (this._labs.active.length) noteLabsRender(CARD_VERSION); const iso = projection === 'iso'; // hidden devices render ONLY in the device editor with "show hidden" on // (ghosted); everywhere else the flag removes them from sight — but not // from the build, so room LQI still counts them (docs/FILTERING.md) const showGhosts = this._mode === 'devices' && this._showAll; const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts)); const deviceSnapshot = this._renderDeviceSnapshot; const disp = this._spaceDisplayForRender(); // Stage 2 capability work is deliberately absent from Flat/Labs-off // renders. Decorative capability never enters the structural cache key. const isoLayers = iso ? this._isoDecorationLayers(disp) : null; const isoPanels = isoLayers ? this._isoOpeningPanels(isoLayers) : []; const roomFills = this._resolvedRoomFills(space, disp); const glowBase = this._resolvedGlowBase(space, disp, roomFills); const showLqi = disp.showLqi ?? this._config.show_signal ?? true; const cfgSize = this._config.icon_size ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const deviceBasePct = effectiveDeviceBaseSize(iconPct); const view = this._viewOr(this._baseVb()); const floorView = this._floorView(view); // Background around the plan (view/kiosk; editors keep their own canvas). // Both settings dialogs preview their pending value live. const stageBg = this._editing ? '' : this._stageBg(disp); // opening rulers: the drag of an existing one OR the placement preview const opMeasure = this._opMeasureView; const decorMeasure = this._decorMeasure; const bdLive = this._bdLive; const furnLive = this._furnLive; const editorChromeMode = this._mode === 'view' ? this._editorChromeMode : this._mode; const roomHover = this._roomHoverPaths(space); const backdropHref = space.bg ? this._display(space.bg.href) : ''; const recoveryReason = (this._continuity.overlayVisible || this._continuity.state === 'recovery-error') ? this._continuity.recoveryReason : null; const modeVisual = this._modeTransitionVisual; const dayCycle = this._dayCycleState(); const dayCycleWeight = modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0); const transitionFromMode = this._modeTransition.state?.from.presentedMode; const glowLayerVisible = !this._markup || !!modeVisual && ( modeVisual.presentedMode === 'view' || modeVisual.presentedMode === 'devices' || transitionFromMode === 'view' || transitionFromMode === 'devices' ); const transitionStageBg = modeVisual?.stageColor || stageBg; const transitionBrightness = modeVisual?.sceneBrightness ?? 1; return html` this._notePointer(event)} @pointerdown=${this._touchGestureGuard} @pointermove=${this._touchGestureGuard} @pointerup=${this._touchGestureGuard} @pointercancel=${this._touchGestureGuard} @lostpointercapture=${this._touchGestureGuard} @click=${this._touchGestureGuard}>
${this._config.title || this._t('card.title')}
this._tabPointerMove(e)}> ${navigationSpaces.map( (s) => html``, )} ${''/* «Добавить пространство» is a NAVIGATION action, not a plan-editor tool (owner 2026-08-04): it lives next to the floor names in every mode, exactly where the per-space gear does. Kiosk is a shop window — the whole .hdr is display:none there, but the button is also not RENDERED, so nothing invisible is clickable. */} ${this._canEdit && !this._kiosk && !this._hasFixedFloor ? html`` : nothing}
${this._canEdit ? html`
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map( ([m, ic]) => html``, )}
` : nothing} ${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })} ${this._labsIso && this._mode === 'view' && !this._kiosk ? html`` : nothing}
${''/* docs/CANVAS.md §8: this IS «вписать всё» — the old "reset zoom" renamed rather than duplicated. No longer disabled at zoom 1: at zoom 1 panned off to the side it still has work. */}
${this._norm && this._canEdit ? html`` : nothing}
${this._canEdit && !this._kiosk ? html`
${editorChromeMode === 'plan' ? this._renderMarkupBar() : editorChromeMode === 'devices' ? this._renderDevicesBar() : this._renderDecorBar()}
` : nothing}
this._markupClick(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)} @pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }} @pointermove=${(e: PointerEvent) => this._stagePointerMove(e)} @pointerleave=${(e: PointerEvent) => this._stagePointerLeave(e)} @pointerup=${(e: PointerEvent) => this._stagePointerUp(e)} @pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}> ${renderDayCycleEnvironment(dayCycle, dayCycleWeight)} ${this._renderEditorSecondary()}
${iso && isoLayers?.structural ? svg`` : nothing} ${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner 2026-08-04). Opaque shapes stop the scene background — bg_color or the day-cycle environment — from bleeding through the plan. They follow the ROOM CONTOURS and nothing else: one shape per room in exactly the room's own geometry, so an L-shaped house or a pair of detached buildings never grows a white bounding rectangle, and an empty space has no paper at all. A backdrop image no longer makes paper of its own — it is drawn ON this sheet, one layer below the geometry, so a picture with transparency and no rooms under it shows the scene through, which is the deliberate consequence. `space` comes from _renderCfg, so a live resize preview (_rszPreview) moves the paper together with the rooms. One around ALL paper shapes: the external day-cycle outline (styles.ts) is composited once for the whole sheet, so adjacent rooms never cast seams onto each other's paper. */} ${this._wallHatchDefs(disp.color)}${svg`${this._paperShapes(space.rooms).map((sh) => 'path' in sh ? svg`` : 'poly' in sh ? svg`` : svg``, )}`} ${this._editing ? this._renderMarkupDefs(vb) : nothing} ${''/* the grid is a property of the plane, not of a box: it follows the VIEW so it is there wherever you pan (docs/CANVAS.md §7) */} ${this._editing && !this._markup && this._gridLevels() ? svg`` : nothing} ${space.bg && backdropHref ? svg` this._onBackdropLoaded(space.bg!.href, backdropHref)} transform=${space.bg.angle ? `rotate(${space.bg.angle} ${space.bg.x + space.bg.w / 2} ${space.bg.y + space.bg.h / 2})` : nothing} @dblclick=${(e: Event) => this._openBackdropDialog(e)} preserveAspectRatio="none" />` : nothing} ${(() => { // audit L1: hoisted out of the per-room map — these depend on the // config, not on entity state, and were recomputed per room. const allPairs = this._openPairs(); const polyCache = new Map(); const polyOf = (rr: any) => { if (!polyCache.has(rr)) polyCache.set(rr, roomPoly(rr)); return polyCache.get(rr)!; }; const otherPolys = (rr: any) => space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][]; return space.rooms.filter((r) => r.area || this._mode === 'view' || this._markup || disp.showBorders).map((r) => { let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : ''); if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId)) cls += ' picked'; let style = ''; const effFill = roomFillModeOf(disp.fill, r); if (!this._markup && (disp.showBorders || effFill !== 'none')) { cls += ' styled'; const st: string[] = []; // keep the stroke colour even when borders are hidden, so hover can reveal it st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`); // One frame-local resolver drives both this room and every // thick-wall opening tunnel that continues its floor colour. const fillC = roomFills.byRoom.get(r) || null; if (fillC) { cls += ' filled'; st.push(`--room-fill:${fillC.color}`, `--room-fill-op:${fillC.opacity}`); } else st.push('--room-fill:transparent', '--room-fill-op:0'); style = st.join(';'); } let areaText: string | null | undefined; const tip = (e: PointerEvent) => { if (this._mode !== 'view') return; if (areaText === undefined) areaText = this._roomArea(r); this._showTip( e, r.name || this._t('room.unnamed'), areaText ? this._t('tip.area', { value: areaText }) : '', showLqi ? this._roomLqi(r.area) : null, this._roomTemp(r), this._roomHum(r), ); }; // open boundaries: this room's solid stroke must not run beneath // the dashed stretches — suppress it and draw a trimmed outline. // Applies in the Plan editor too (picked rooms keep their full // amber highlight — the merge/split selection must stay visible). const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId); const openCuts = r.id && !isPicked ? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs) : []; const thickCuts = !isPicked ? this._thickWallCuts() : []; const edgeCuts = openCuts.concat(thickCuts); if (edgeCuts.length) cls += ' noedge'; // island rooms punch holes in their parent's fill (evenodd) const myPoly = polyOf(r); const walls = this._spaceWalls; const fillPoly = (walls.length && r.id && myPoly) ? (innerContourForRoom( space.rooms, r.id, walls, this._openPairs().flatMap((p) => p.segs), this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, this._wallUnionGeometry()?.roomGeom, ) || myPoly) : myPoly; const holes = fillPoly ? islandsOf(fillPoly, otherPolys(r)) : []; const pathD = (pts: number[][]) => 'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'; const obstaclePath = fillPoly ? this._cleanFloor(r, fillPoly, space).path : ''; // docs/STYLING-HOOKS.md §3 — the same three hooks whichever SVG // element this room happens to be drawn as today const hpId = r.id || nothing; const hpArea = r.area || nothing; const shape = obstaclePath && fillPoly ? svg` { this._notePointer(event); if (this._pointerModality.hoverEnabled) { this._hoverRoom = { space: space.id, room: r }; } }} @pointermove=${tip} @pointerleave=${() => this._clearTransientHover()}>` : holes.length && fillPoly ? svg` { this._notePointer(event); if (this._pointerModality.hoverEnabled) { this._hoverRoom = { space: space.id, room: r }; } }} @pointermove=${tip} @pointerleave=${() => this._clearTransientHover()}>` : fillPoly && fillPoly !== myPoly ? svg` { this._notePointer(event); if (this._pointerModality.hoverEnabled) { this._hoverRoom = { space: space.id, room: r }; } }} @pointermove=${tip} @pointerleave=${() => this._clearTransientHover()}>` : r.poly ? svg` { this._notePointer(event); if (this._pointerModality.hoverEnabled) { this._hoverRoom = { space: space.id, room: r }; } }} @pointermove=${tip} @pointerleave=${() => this._clearTransientHover()}>` : svg` { this._notePointer(event); if (this._pointerModality.hoverEnabled) { this._hoverRoom = { space: space.id, room: r }; } }} @pointermove=${tip} @pointerleave=${() => this._clearTransientHover()}>`; const trimmed = edgeCuts.length && myPoly ? outlineWithout(myPoly, edgeCuts, this._gridPitch * 0.02) : null; const outline = trimmed ? svg`` : nothing; return svg`${shape}${outline}`; }); })()} ${this._renderRoomHoverFill(roomHover)} ${this._renderOpeningTunnelFills(space, roomFills)} ${this._renderGlowBaseRooms(space, glowBase)} ${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')} ${''/* Decor is one composition layer above every floor treatment (room fill/hover, opening tunnels and Glow base) and below live lighting, physical plan geometry and devices. Keep hide_decor visual-only: the decor editor must always paint stored shapes so they remain editable. */} ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()} ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing} ${this._renderSunRays(space)} ${this._editing ? svg`${this._renderAlignGuides()}` : nothing} ${this._markup ? svg`${this._renderMarkupLayer(vb)}` : nothing} ${''/* «Скрыть проёмы» (space.hide_openings) — same deal: the plan editor keeps drawing doors, windows and gates so they stay editable, and only the symbols are hidden elsewhere. What an opening MEANS is untouched: light still spills through it, the sun still comes in at its window, and the contact sensor still opens it. */} ${''/* View: virtual geometry still meets real walls on their centreline, but paints BELOW the physical wall body. The hatch therefore masks the visually awkward half-dashes inside thick jambs without changing the stored span. */} ${!this._editing ? this._renderOpenWalls(disp) : nothing} ${this._renderWallBodies(disp)} ${this._markup && this._tool === 'resize' ? this._renderResizeMeasurements() : nothing} ${this._renderRoomHoverOutline(roomHover)} ${''/* Editors: saved virtual boundaries and the live two-click preview deliberately paint AFTER real wall bodies. Their full centreline geometry remains visible for editing. */} ${this._editing ? this._renderOpenWalls(disp) : nothing} ${this._markup ? svg`${this._renderHiddenWallDiagnosticOverlay()}` : nothing} ${this._markup ? svg`${this._renderOpeningPlacementPreview()}` : nothing} ${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing} ${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing} ${''/* Static architectural axes and endpoints are a Plan-editor invariant. Tool-specific hover/snap state is resolved inside the overlay and remains exclusive to Walls. */} ${''/* Active chain ink paints above the wall bodies: persisted draft segments already own opaque masonry, which would otherwise cover the chain axis and nodes (#307). It stays below the snap overlay so snap highlights keep priority. */} ${this._markup ? svg`${this._renderActiveChainInk()}` : nothing} ${this._markup ? svg`${this._renderPlanSnapOverlay()}` : nothing} ${disp.hideOpenings && !this._markup ? nothing : isoLayers && !isoLayers.floorSymbols ? nothing : this._renderOpenings(disp)} ${this._renderWallThickUi()} ${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing} ${''/* editor chrome, not plan content: the backdrop frame sits on top of everything the plan draws so its handles stay grabbable (docs/BACKDROP.md §2). It exists only in the backdrop editor, where rooms and devices are pointer-inert. */} ${this._renderBackdropFrame(view)} ${this._renderTextFrame(view)} ${iso && isoLayers?.structural ? svg` ` : nothing} ${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and scales with it when you zoom — the behaviour the card always had, restored by the owner. `iconCqw` is `iconPct * iconUnit / view.w`: the old expression with the stored `vb.w` replaced by the plan's own base unit, which is the same NORM_W for an ordinary plan (pixel-identical) but grows with a plan drawn past the old square, where a fixed 1000 would have shrunk every marker to a dot. Same expression as the static space-card, so the two renderers agree. The compatibility base is resolved before `iconCqw`; only the per-device and kiosk multipliers still feed --dev-size. */}
${devs.map((d) => this._renderDevice(d, view, showLqi))} ${this._renderVacuums(devs, view)} ${this._renderVacFit(view)} ${this._renderOpeningLocks(view)} ${disp.showNames || this._markup ? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp)) : nothing} ${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
${this._measureAnchor ? html`
${this._renderMeasureLabel(view)}
` : nothing} ${this._rszLive ? html`
${this._rszLive.map((l) => html`
${l.text}
`)}
` : nothing} ${opMeasure ? html`
${opMeasure.labels.map((l) => html`
${l.text}
`)}
` : nothing} ${this._wallDialog ? html`
${this._renderWallThickDialog()}
` : nothing} ${decorMeasure ? html`
${decorMeasure.text}
` : nothing} ${furnLive ? html`
${furnLive.map((l) => html`
${l.text}
`)}
` : nothing} ${bdLive ? html`
${bdLive.text}
` : nothing}
${this._zoom > 1 ? html`
${Math.round(this._zoom * 100)}%
` : nothing} ${this._renderFarHint()} ${this._renderHomeArrow()} ${this._renderRecoveryOverlay()} ${this._booting || this._bootFading ? html`` : nothing}
${this._roomDialog ? this._renderRoomDialog() : nothing} ${this._mergeDialog ? this._renderMergeDialog() : nothing} ${this._openingDialog ? this._renderOpeningDialog() : nothing} ${this._physicalDialog ? this._renderPhysicalDialog() : nothing} ${this._partitionDeleteDialog ? this._renderPartitionDeleteDialog() : nothing} ${this._roomDeleteDialog ? this._renderRoomDeleteDialog() : nothing} ${this._openingInfo ? this._renderOpeningInfoCard() : nothing} ${this._decorTextDialog ? this._renderDecorTextDialog() : nothing} ${this._decorShapeDialog ? this._renderDecorShapeDialog() : nothing} ${this._backdropDialog ? this._renderBackdropDialog() : nothing} ${this._decorEraseConfirm ? this._renderDecorEraseConfirm() : nothing} ${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._markerDialog ? this._renderMarkerDialog() : nothing} ${this._vacCalConfirm ? html` (this._vacCalConfirm = null)}>

${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}

` : nothing} ${this._infoCard ? this._renderInfoCard() : nothing} ${this._rulesDialog ? this._renderRulesDialog() : nothing} ${this._settingsDialog ? this._renderSettingsDialog() : nothing} ${this._alignDialog ? this._renderAlignDialog() : nothing} ${this._backupExportDialog ? this._renderBackupExportDialog() : nothing} ${this._backupImportDialog ? this._renderBackupImportDialog() : nothing} ${this._importDialog ? this._renderImportDialog() : nothing} ${this._tip ? html`
${this._tip.title}${this._tip.meta ? html`${this._tip.meta}` : nothing} ${this._tip.temp != null ? html`${this._t('tip.temp_avg')} ${this._tip.temp}°` : nothing} ${this._tip.hum != null ? html`${this._t('tip.hum_avg')} ${this._tip.hum}%` : nothing} ${this._tip.lqi != null ? html`${this._t('tip.lqi')} ${this._tip.lqi}` : nothing}
` : nothing} ${this._kiosk && !this._hasFixedFloor && this._kioskDots && this._model.length > 1 ? html`
${this._model.map((m) => html``)}
` : nothing} ${this._kioskDialog ? this._renderKioskDialog() : nothing} ${this._vacFit ? html`
${this._t('vac.fit_hint')}
` : nothing} ${this._tapConfirm ? html` (this._tapConfirm = null)}>
${this._tapConfirm.kind === 'run' ? html`

${this._tapConfirm.text}

` : this._tapConfirm.lines.map((line, index) => html`

${line}

`)}
` : nothing} ${this._toast ? html`` : nothing}
`; } // ---------------- live robot vacuums (docs/VACUUM.md) ---------------- private _vacCandidateStatus( entityId: string, candidate: VacSourceCandidate | null, planHass = this._planHass, ): VacSourceStatus { const binding = this._bindingStatus('entity:' + entityId); if (binding.kind === 'ha_disabled') return 'disabled'; if (binding.kind === 'orphaned') return 'missing'; if (binding.kind === 'unverified') return 'unverified'; const state = planHass?.states?.[entityId]; if (state?.state === 'unavailable') return 'unavailable'; return candidate?.hasPosition ? 'ok' : 'unsupported'; } /** Snapshot the expensive global camera classification once per opening. */ private _vacOpenAllCameras(d: DevItem): void { const registry = this._haRegistry.entities || {}; const candidates: VacSourceCandidate[] = []; for (const [entityId, state] of Object.entries(this.hass?.states || {})) { if (!entityId.startsWith('camera.')) continue; const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]); if (candidate) candidates.push(candidate); } this._vacAllCameraCache = { devId: d.id, candidates }; this._vacAllCamerasFor = d.id; } /** One sticky, order-independent source resolver for render/dialog/fit. */ private _vacSourceResolution( d: DevItem, includeAllCameras = false, planHass = this._planHass, ): VacSourceResolution { const vacuum = d.marker?.vacuum; const pinned = typeof vacuum?.source === 'string' && !!vacuum.source; const sameDevice = new Set(d.entities || []); const ids = new Set(sameDevice); if (pinned) ids.add(vacuum!.source!); const registry = planHass?.entities || {}; const candidates: VacSourceCandidate[] = []; for (const entityId of ids) { const state = planHass?.states?.[entityId]; const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]); if (candidate) candidates.push(candidate); else if (pinned && entityId === vacuum!.source) { candidates.push({ entityId, name: String(state?.attributes?.friendly_name || entityId), platform: registry[entityId]?.platform ? String(registry[entityId].platform) : null, category: entityId.startsWith('camera.') ? 'camera' : 'partial', hasPosition: false, hasRooms: false, hasPath: false, hasMapId: false, score: 0, }); } } if (includeAllCameras && this._vacAllCameraCache?.devId === d.id) { const existing = new Set(candidates.map((candidate) => candidate.entityId)); for (const candidate of this._vacAllCameraCache.candidates) { if (!existing.has(candidate.entityId)) candidates.push(candidate); } } const statuses: Record = {}; for (const candidate of candidates) { statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate, planHass); } return resolveVacSource(vacuum?.source, sameDevice, candidates, statuses); } /** The sticky/automatic live-position source entity, or null. */ private _vacSource(d: DevItem, planHass = this._planHass): string | null { if (d.marker?.vacuum?.live === false) return null; const resolution = this._vacSourceResolution(d, false, planHass); // A disabled/unavailable/unverified source must never leak stale HA // attributes into plan overlays. `unsupported` remains usable because it // may still provide rooms/path for calibration and trail rendering. return resolution.status === 'ok' || resolution.status === 'unsupported' ? resolution.entityId : null; } private _vacEntity(d: DevItem): string | null { if (d.primary?.startsWith('vacuum.')) return d.primary; return (d.entities || []).find((e) => e.startsWith('vacuum.')) || null; } private _isVacDev(d: DevItem): boolean { return !!this._vacEntity(d); } /** Start/restart a short semantic event or direct-terminal transition. */ private _activitySourceKey(d: DevItem): string { return this._activitySnapshot(d).sourceKey; } private _activitySnapshot( d: DevItem, planLightSources = resolvedLightSources( this._planHass, this._devices, null, this._virtualLights, ), ): { samples: EntityVisualSample[]; sourceKey: string } { const sources = resolvePresentationSources( this._planHass, d, this._devices, planLightSources, this._fullRegistryHass, ); return { samples: sources.samples, sourceKey: activitySourceSignature(this._planHass, d, sources), }; } private _stampActivity(id: string, kind: 'event' | 'transition', sources?: string): void { let rt = this._activityRt.get(id); if (!rt) { rt = createFiniteActivityRuntime(sources || '', []); this._activityRt.set(id, rt); } if (sources != null) rt.sources = sources; stampFiniteActivity( rt, kind, Date.now(), window.clearTimeout.bind(window), (delay) => window.setTimeout(() => this.requestUpdate(), delay), ); } /** * Bring activity runtime in sync with the marker/source graph without * classifying an edge. Called after every device rebuild as well as before a * hass tick: new sources get a baseline, rebound sources lose the old flash, * and removed markers cannot leave timers behind. */ private _syncActivityRuntime(): Map { const snapshots = new Map(); if (!this.hass) return snapshots; if (this._config?.live_states === false) { for (const runtime of this._activityRt.values()) clearTimeout(runtime.timer); this._activityRt.clear(); return snapshots; } const live = new Set(); const planLightSources = resolvedLightSources( this._planHass, this._devices, null, this._virtualLights, ); for (const d of this._devices) { if (d.hidden) continue; // Alarm/continuous semantics are resolved from the current samples and // need no finite history. Keep that history only while its display mode // can actually show it, so a later mode change cannot replay old edges. if (normalizeDeviceDisplay(d.marker?.display) !== 'icon_ripple') continue; live.add(d.id); const snapshot = this._activitySnapshot(d, planLightSources); snapshots.set(d.id, snapshot); const { samples, sourceKey } = snapshot; let rt = this._activityRt.get(d.id); if (!rt) { rt = createFiniteActivityRuntime(sourceKey, samples); this._activityRt.set(d.id, rt); continue; } if (rt.sources === sourceKey) continue; // A brief unknown/unavailable state can change the effective role graph. // Resetting here prevents recovery from becoming a false fresh event. resetFiniteActivityRuntime(rt, sourceKey, samples, window.clearTimeout.bind(window)); } for (const [id, rt] of this._activityRt) { if (live.has(id)) continue; clearTimeout(rt.timer); this._activityRt.delete(id); } return snapshots; } /** * One pass per hass tick records every effective source. Only a witnessed, * meaningful edge starts a short effect: first load and recovery from * unknown/unavailable establish a baseline and never fake a detection. */ private _activityTick(): void { if (!this.hass) return; const snapshots = this._syncActivityRuntime(); for (const d of this._devices) { if (d.hidden) continue; if (normalizeDeviceDisplay(d.marker?.display) !== 'icon_ripple') continue; const snapshot = snapshots.get(d.id) || this._activitySnapshot(d); const { samples, sourceKey } = snapshot; const rt = this._activityRt.get(d.id); if (!rt || rt.sources !== sourceKey) continue; const edge = advanceFiniteActivity(rt, samples, window.clearTimeout.bind(window)); // A direct closed↔open fallback is only a substitute for integrations // that omit opening/closing. Once a real travelling state is observed, // it owns the ring and the old 3.3 s fallback is discarded. if (edge) this._stampActivity(d.id, edge, sourceKey); } } /** * Trail buffers live OUTSIDE render: every hass tick appends the raw robot * position, so the trail survives view switches and zoom without ever being * part of reactive state (600 points re-rendering the world would hurt). */ private _vacTick(): void { if (!this.hass) return; for (const d of this._devices) { if (d.hidden || !this._isVacDev(d)) continue; if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') { this._vacRt.delete(d.id); continue; } const src = this._vacSource(d); if (!src) continue; const vacEnt = this._vacEntity(d); const moving = isVacMoving(this.hass.states[vacEnt || '']?.state); const tele = readVacTelemetry(this.hass.states[src]?.attributes); let rt = this._vacRt.get(d.id); if (!rt) { rt = { trail: [], lastKey: '', lastTs: 0, moving: false, jump: false, endedTs: 0, lastPos: null }; this._vacRt.set(d.id, rt); } if (moving && !rt.moving) { rt.trail = []; rt.lastPos = null; } // a fresh run const wantTrail = vacTrailMode(d.marker?.vacuum) !== 'never' && !tele?.path.length; if (!moving && rt.moving) { rt.endedTs = Date.now(); // the run is over: the puck has arrived everywhere it was going if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40); rt.lastPos = null; } rt.moving = moving; const pos = tele?.pos; if (moving && pos) { const key = pos.x + ':' + pos.y; if (key !== rt.lastKey) { const now = Date.now(); // a long silence then a far point = sparse cloud data: teleport, no glide rt.jump = rt.lastTs > 0 && now - rt.lastTs > VAC_TELEPORT_GAP_MS; rt.lastKey = key; rt.lastTs = now; // the trail lags ONE point behind: when a new target arrives the puck // has just (visually) reached the previous one — a segment must never // outrun the icon (owner report 2026-07-31) if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40); rt.lastPos = [pos.x, pos.y]; } } } } /** * HP-1540-01: an auto-discovered vacuum has NO config marker until the first * general Save of its dialog, yet the «Живая позиция» section is already * interactive. Every vacuum handler used to `cfg.markers.find(...)` and * silently no-op without one — auto-calibration even toasted success while * saving nothing. Materialise a minimal marker (same id/binding the dialog * Save would produce — see _saveMarker/markerIdForBinding) before any * vacuum write, so the write has somewhere real to land. */ private _vacEnsureMarker(d: DevItem): Marker | null { const cfg = this._serverCfg; if (!cfg) return null; cfg.markers = cfg.markers || []; const existing = cfg.markers.find((x: Marker) => x.id === d.id); if (existing) return existing; if ((d.bindingKind !== 'device' && d.bindingKind !== 'entity') || !d.bindingRef) return null; const m: Marker = { id: d.id, binding: d.bindingKind + ':' + d.bindingRef, space: d.space || null, area: d.area || null, // explicit like _saveMarker: a marker of any kind tells the seeder // "the user decided" (docs/FILTERING.md) hidden: d.hidden ? true : false, }; cfg.markers.push(m); return m; } /** «Живая позиция» in the device dialog — vacuum markers only. */ private _renderVacSection(dlg: any): TemplateResult | typeof nothing { const dev = this._devices.find((x) => x.id === dlg.devId); if (!dev || !this._isVacDev(dev)) return nothing; const v = dev.marker?.vacuum || {}; const includeAllCameras = this._vacAllCamerasFor === dev.id; const resolution = this._vacSourceResolution(dev, includeAllCameras); const src = resolution.entityId; const selected = resolution.candidates.find((candidate) => candidate.entityId === src) || null; const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null; const canUseSource = v.live !== false && (resolution.status === 'ok' || resolution.status === 'unsupported'); const planRooms = this._vacPlanRoomAnchors(dev.space); const roomMatches = tele ? vacRoomNameMatchCount(tele.rooms, planRooms.map((room) => room.name)) : 0; const tierA = !!(canUseSource && tele && roomMatches >= 3); const cals = Object.keys(v.calibration || {}); const setVac = (patch: Record) => { // HP-1540-01: materialise the marker first — find() alone silently // dropped every edit for a vacuum that had never been saved const m = this._vacEnsureMarker(dev); if (!m) return; m.vacuum = { ...(m.vacuum || {}), ...patch }; this._regSignature = ''; // First-use markers are materialised above, so the dialog must rebuild // immediately instead of waiting for an unrelated HA state tick. this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); }; const sameDevice = new Set(dev.entities || []); const primaryCandidates = resolution.candidates.filter((candidate) => sameDevice.has(candidate.entityId) || candidate.entityId === v.source); const globalCandidates = includeAllCameras ? resolution.candidates.filter((candidate) => candidate.entityId.startsWith('camera.') && !primaryCandidates.some((entry) => entry.entityId === candidate.entityId)) : []; const capability = (candidate: VacSourceCandidate) => [ candidate.hasPosition ? this._t('vac.cap_position') : '', candidate.hasRooms ? this._t('vac.cap_rooms_short') : '', candidate.hasPath ? this._t('vac.cap_path') : '', candidate.hasMapId ? this._t('vac.cap_map') : '', ].filter(Boolean).join(' · ') || this._t('vac.cap_none'); const candidateButton = (candidate: VacSourceCandidate) => html` `; const knownSameDeviceXcme = resolution.candidates.some((candidate) => sameDevice.has(candidate.entityId) && candidate.category === 'known_xcme_incomplete'); const showXcmeHint = knownSameDeviceXcme || (!!resolution.pinned && !!src?.startsWith('camera.') && !selected?.hasPosition); const openPicker = () => { const picker = [...this.renderRoot.querySelectorAll('details.vacpicker')] .find((details) => details.dataset.devId === dev.id); if (!picker) return; picker.open = true; picker.querySelector('summary')?.focus(); }; return html`
${this._t('vac.diag_source')}${src || this._t('vac.source_none')}
${selected?.platform ? html`
${this._t('vac.diag_platform')}${selected.platform}
` : nothing}
${this._t('vac.diag_status')}${this._t((`vac.source_status_${resolution.status}`) as any)}
${this._t('vac.diag_position')}${tele?.pos ? this._t('common.yes') : this._t('common.no')}
${this._t('vac.diag_rooms')}${subst(this._t('vac.diag_rooms_value'), { total: String(tele?.rooms.length || 0), matched: String(roomMatches), readiness: this._t(roomMatches >= 3 ? 'vac.autocal_ready' : 'vac.autocal_not_ready'), })}
${this._t('vac.diag_path')}${tele?.path.length ? this._t('common.yes') : this._t('common.no')}
${this._t('vac.diag_map')}${tele?.mapId ?? 'default'}
${(resolution.status === 'missing' || resolution.status === 'disabled' || resolution.status === 'unverified') ? html`
${this._t((`vac.source_banner_${resolution.status}`) as any)}
` : nothing} ${showXcmeHint ? html`
${this._t('vac.xcme_hint')}
attributes:
  - vacuum_position
  - rooms
  - path
  - map_name
` : nothing}
${this._t('vac.choose_source')}
${primaryCandidates.map(candidateButton)}
{ const open = (event.currentTarget as HTMLDetailsElement).open; if (open) this._vacOpenAllCameras(dev); else { this._vacAllCamerasFor = null; this._vacAllCameraCache = null; } }}> ${this._t('vac.all_cameras')}
${this._t('vac.all_cameras_warn')}
${includeAllCameras ? (globalCandidates.length ? globalCandidates.map(candidateButton) : html`
${this._t('vac.all_cameras_empty')}
`) : nothing}
${tierA ? html`` : nothing} ${canUseSource ? html`` : nothing} ${this._t('vac.documentation')}
${tele ? html` ${cals.length ? html`
${subst(this._t('vac.cal_maps'), { maps: cals.join(', ') })}
` : nothing} ` : nothing}
`; } /** * The active-map id. The camera rarely names its map; Dreame keeps the * human-readable one on the vacuum entity (selected_map, verified against a * live X50 Master) — without this both floors would share one matrix. */ private _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this._planHass): string { // HP-1541-01: nullish, not truthy — selected_map: 0 is a real map id and // must equal what trails.py resolve_map_id stores server-side. const ve = this._vacEntity(d); const sel = ve ? planHass?.states?.[ve]?.attributes?.selected_map : null; return vacMapIdWithFallback(tele.mapId, sel); } /** Persist a solved matrix into marker.vacuum.calibration[mapId]. * Returns whether the write actually landed — callers must not toast * success otherwise (HP-1540-01). */ private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean { // HP-1540-01: a first-use vacuum has no marker yet — materialise it const dev = this._devices.find((x) => x.id === markerId); const m = dev ? this._vacEnsureMarker(dev) : this._serverCfg?.markers?.find((x: Marker) => x.id === markerId); if (!m) return false; const v = { ...(m.vacuum || {}) }; v.source = source; v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) }; m.vacuum = v; this._regSignature = ''; this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); return true; } /** The exact plan-room set accepted by auto-calibration and diagnostics. */ private _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{ name: string; cx: number; cy: number; }> { return (this._spaceModelById(spaceId)?.rooms || []) // HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class; // roomPoly() gives the same outline the renderer uses for them. .map((room: any) => ({ room, poly: roomPoly(room) })) .filter(({ room, poly }: any) => room.name && poly) .map(({ room, poly }: any) => { const centroid = areaCentroid(poly); return centroid ? { name: String(room.name), cx: centroid[0], cy: centroid[1] } : null; }) .filter(Boolean) as Array<{ name: string; cx: number; cy: number }>; } /** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */ private _vacAutoCalibrate(d: DevItem): void { const src = this._vacSource(d); const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null; if (!src || !tele || tele.rooms.length < 3) { this._showToast(this._t('vac.autocal_no_rooms')); return; } const planRooms = this._vacPlanRoomAnchors(d.space); const res = autoCalibrate(tele.rooms, planRooms); if (!res) { this._showToast(this._t('vac.autocal_no_match')); return; } const rawSpace = this._serverCfg?.spaces?.find((space: any) => space.id === d.space); const rawCellCm = Number(rawSpace?.cell_cm); const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0 ? rawCellCm : 5; const residualCm = vacCalibrationResidualCm(res.residual, this._gridPitch, cellCm); const mapId = this._vacMapId(d, tele); if (residualCm > VAC_CALIBRATION_WARN_CM) { this._vacCalConfirm = { markerId: d.id, source: src, mapId, matrix: res.matrix, rooms: res.matched.length, error: formatLength(residualCm, this.hass?.config?.unit_system?.length === 'mi'), }; return; } if (!this._vacSaveMatrix(d.id, src, mapId, res.matrix)) return; this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) })); } private _vacApplyCalibrationProposal(manual: boolean): void { const proposal = this._vacCalConfirm; if (!proposal) return; this._vacCalConfirm = null; if (manual) { const dev = this._devices.find((candidate) => candidate.id === proposal.markerId); const fit = fitFromMatrix(proposal.matrix); if (!dev || !fit) return; this._markerDialog = null; if (dev.space !== this._space && !this._commitSpace(dev.space)) return; this._vacFit = { markerId: proposal.markerId, source: proposal.source, mapId: proposal.mapId, p: fit, drag: null, }; return; } if (this._vacSaveMatrix( proposal.markerId, proposal.source, proposal.mapId, proposal.matrix, )) this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(proposal.rooms) })); } /** «Подогнать вручную»: open the fit overlay and leave the dialog. */ private _vacStartFit(d: DevItem): void { const src = this._vacSource(d); const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null; if (!src || !tele) { this._showToast(this._t('vac.cal_need_pos')); return; } const mapId = this._vacMapId(d, tele); const existing = d.marker?.vacuum?.calibration?.[mapId] as Affine | undefined; const sp = this._spaceModelById(d.space); if (!sp) return; const vb = sp.vb as [number, number, number, number]; const p = (existing && existing.length === 6 && fitFromMatrix(existing)) || initialFit(tele.rooms, vb); this._markerDialog = null; if (d.space !== this._space && !this._commitSpace(d.space)) return; this._vacFit = { markerId: d.id, source: src, mapId, p, drag: null }; } private _vacFitSave(): void { const f = this._vacFit; if (!f) return; // HP-1540-01: no success toast for a save that did not happen const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p)); this._vacFit = null; if (ok) this._showToast(this._t('vac.cal_done')); } /** Rotate/mirror around the ghost centre so the map does not fly away. */ private _vacFitTurn(patch: Partial): void { const f = this._vacFit; if (!f) return; const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes); const c = this._vacGhostCentre(tele?.rooms || []); const next = { ...f.p, ...patch } as FitParams; this._vacFit = { ...f, p: reanchorFit(next, f.p, c[0], c[1]) }; } private _vacGhostCentre(rooms: VacRoom[]): VacPt { const xs: number[] = [], ys: number[] = []; for (const r of rooms) { xs.push(r.x0 ?? r.cx, r.x1 ?? r.cx); ys.push(r.y0 ?? r.cy, r.y1 ?? r.cy); } if (!xs.length) return [0, 0]; return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } /** px→canvas-units for a pointer delta, via the current stage size. */ private _vacDelta(view: { w: number; h: number }, dxPx: number, dyPx: number): VacPt { const st = this._stageEl; const w = st?.clientWidth || 1, h = st?.clientHeight || 1; return [(dxPx / w) * view.w, (dyPx / h) * view.h]; } private _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number; h: number }): void { const f = this._vacFit; if (!f) return; ev.stopPropagation(); if (ev.type === 'pointerdown') { const t = ev.target as HTMLElement; const corner = t.getAttribute?.('data-corner'); try { (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId); } catch { /* synthetic pointers (tests) have no active id — capture is a nicety */ } this._vacFit = { ...f, drag: corner ? { kind: 'scale', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: Number(corner.split(',')[0]), fy: Number(corner.split(',')[1]) } : { kind: 'move', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: 0, fy: 0 } }; return; } const d = f.drag; if (!d) return; if (ev.type === 'pointermove') { const [dx, dy] = this._vacDelta(view, ev.clientX - d.sx, ev.clientY - d.sy); if (d.kind === 'move') { this._vacFit = { ...f, p: { ...d.p0, ox: d.p0.ox + dx, oy: d.p0.oy + dy } }; } else { // corner-stretch: uniform scale about the OPPOSITE corner (fx, fy — // the fixed corner in robot coords), like a graphics-editor frame const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes); const c = this._vacGhostCentre(tele?.rooms || []); const m0 = fitMatrix(d.p0); const [gx, gy] = applyAffine(m0, c[0], c[1]); const [px0, py0] = applyAffine(m0, d.fx, d.fy); const span0 = Math.hypot(gx - px0, gy - py0) || 1; // distance change of the dragged corner (opposite of fixed) from centre const [cx0, cy0] = [2 * gx - px0, 2 * gy - py0]; const span1 = Math.hypot(cx0 + dx * 2 - px0, cy0 + dy * 2 - py0) / 2; const k = Math.max(0.05, span1 / span0); const next = { ...d.p0, s: d.p0.s * k } as FitParams; this._vacFit = { ...f, p: reanchorFit(next, d.p0, d.fx, d.fy) }; } return; } if (ev.type === 'pointerup' || ev.type === 'pointercancel') { this._vacFit = { ...f, drag: null }; } } /** The translucent robot map over the plan while fitting. */ private _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const f = this._vacFit; if (!f) return nothing; const tele = readVacTelemetry(this._renderPlanHass?.states?.[f.source]?.attributes); if (!tele) return nothing; const m = fitMatrix(f.p); const rects: TemplateResult[] = []; const xs: number[] = [], ys: number[] = []; for (const r of tele.rooms) { if (r.x0 == null) continue; const cs = [[r.x0!, r.y0!], [r.x1!, r.y0!], [r.x1!, r.y1!], [r.x0!, r.y1!]] .map(([x, y]) => applyAffine(m, x, y)); cs.forEach(([x, y]) => { xs.push(x); ys.push(y); }); const [lx, ly] = applyAffine(m, r.cx, r.cy); rects.push(svg` ${r.name}`); } let dot: TemplateResult | typeof nothing = nothing; if (tele.pos) { const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y); dot = svg``; } // corner handles on the ghost bbox; data-corner carries the FIXED corner // (the opposite one) in robot coordinates const handles: TemplateResult[] = []; if (xs.length) { const inv = ((): ((x: number, y: number) => VacPt) => { const det = m[0] * m[4] - m[1] * m[3]; return (x, y) => [ (m[4] * (x - m[2]) - m[1] * (y - m[5])) / det, (-m[3] * (x - m[2]) + m[0] * (y - m[5])) / det, ]; })(); const x0 = Math.min(...xs), x1 = Math.max(...xs); const y0 = Math.min(...ys), y1 = Math.max(...ys); // hit radius (finger-sized: these are grabbed on tablets) and the bead // you actually see, a quarter of it — same split as .bdhandle/.bdknob const r = view.w * 0.022; const kr = r / 4; for (const [hx, hy, ox2, oy2] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]] as number[][]) { const fixed = inv(ox2, oy2); handles.push(svg` `); } } return html` this._vacFitPointer(e, view)} @pointermove=${(e: PointerEvent) => this._vacFitPointer(e, view)} @pointerup=${(e: PointerEvent) => this._vacFitPointer(e, view)} @pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}`; } /** Every frame: glue the growing tip segment to the animated puck centre. */ private _vacRafLoop(): void { this._vacRaf = requestAnimationFrame(() => { const sr = this.renderRoot as ShadowRoot; const stage = this._stageEl; const view = this._vacLastView; const pucks = sr?.querySelectorAll?.('.vacpuck') || []; if (!stage || !view || !pucks.length) { this._vacRaf = 0; return; } const sb = stage.getBoundingClientRect(); for (const puck of pucks as any) { const mid = puck.getAttribute('data-mid'); const pb = puck.getBoundingClientRect(); const cx = view.x + ((pb.left + pb.width / 2 - sb.left) / sb.width) * view.w; const cy = view.y + ((pb.top + pb.height / 2 - sb.top) / sb.height) * view.h; for (const line of sr.querySelectorAll(`line.tip[data-mid="${mid}"]`)) { line.setAttribute('x2', cx.toFixed(1)); line.setAttribute('y2', cy.toFixed(1)); } } this._vacRafLoop(); }); } /** Puck + trail for every live vacuum of the space. */ private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { if (this._markup || this._mode === 'decor') return nothing; const viewKey = this._space + '|' + view.x + '|' + view.y + '|' + view.w + '|' + view.h; const jumpAll = this._vacJumpOnce || viewKey !== this._vacViewKey; this._vacViewKey = viewKey; this._vacJumpOnce = false; const pucks: TemplateResult[] = []; const trails: TemplateResult[] = []; for (const d of devs) { if (d.hidden || !this._isVacDev(d)) continue; if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue; const fact = this._renderDeviceSnapshot?.facts.get(`vacuum:${d.id}`) as any; const src = fact?.source ?? this._vacSource(d, this._renderPlanHass); if (!src) continue; const tele = fact?.telemetry ?? readVacTelemetry(this._renderPlanHass?.states[src]?.attributes); if (!tele) continue; const mapNow = String(fact?.mapId ?? this._vacMapId(d, tele, this._renderPlanHass)); const matrix = d.marker?.vacuum?.calibration?.[mapNow] as Affine | undefined; if (!matrix || matrix.length !== 6) continue; const rt = fact?.runtime ?? this._vacRt.get(d.id); const moving = rt?.moving ?? false; const tmode = vacTrailMode(d.marker?.vacuum); // owner 2026-07-31: hide when the cleanup is over (default), unless the // mode says always; the previous run only ever shows in 'always' const showCur = tmode === 'always' || (tmode === 'cleaning' && moving); const srv = fact?.server ?? this._vacSrvTrails[d.id]; const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null; const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null; // the PREVIOUS run stays visible even at rest: users compare where the // robot has been against where it has not (owner call 2026-07-31) if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) { const pts = srvPrev.points.map(([x, y]: number[]) => { const [cx2, cy2] = applyAffine(matrix, x, y); const point = this._scenePoint([cx2, cy2]); return point[0].toFixed(1) + ',' + point[1].toFixed(1); }).join(' '); trails.push(svg``); } // One arbitration authority: integration multi-subpath → server run → // local runtime. Only drawable segments participate. if (showCur) { const current = resolveCurrentVacPath(tele, srvCur, rt?.trail || []); // Server/integration paths include the current target. Remove it from // the final subpath only while moving so the line cannot outrun puck. const raw: VacPath = moving && (current.source === 'integration' || current.source === 'server') ? trimVacPathTarget(current.path) : current.path; if (raw.length) { const pathD = raw.map((segment) => segment.map(([x, y], index) => { const [cx, cy] = applyAffine(matrix, x, y); const point = this._scenePoint([cx, cy]); return `${index ? 'L' : 'M'} ${point[0].toFixed(1)} ${point[1].toFixed(1)}`; }).join(' ')).join(' '); // A single SVG path with several M commands keeps gaps literal. trails.push(svg``); const last = raw[raw.length - 1]; if (moving && last?.length >= 2) { const anchor = last[last.length - 1]; const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]); const point = this._scenePoint([ax, ay]); const a1 = point[0].toFixed(1), a2 = point[1].toFixed(1); trails.push(svg``); } } } if (!moving || !tele.pos) continue; const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y); const point = this._scenePoint([cx, cy]); const left = ((point[0] - view.x) / view.w) * 100; const top = ((point[1] - view.y) / view.h) * 100; const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS; const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum'; pucks.push(html`
{ e.stopPropagation(); const ve = this._vacEntity(d); if (ve) this._openMoreInfo(ve); }}>
`); } this._vacLastView = view; if (pucks.length && !this._vacRaf) this._vacRafLoop(); if (!pucks.length && !trails.length) return nothing; return html` ${trails.length ? svg`${trails}` : nothing} ${pucks}`; } private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult { const pos = this._pos(d); const point = this._scenePoint([pos.x, pos.y]); const left = ((point[0] - view.x) / view.w) * 100; const top = ((point[1] - view.y) / view.h) * 100; const presentation = this._devicePresentation(d, showLqi); const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)]; const disabledReason = presentation.disabledReason; const ghostLabel = presentation.haDisabled ? this._t((`marker.ha_disabled_${disabledReason}`) as any) : d.userHidden ? this._t('marker.hidden_ghost') : d.name; const a11yState = deviceA11yState(presentation); const interactive = this._mode === 'view' || this._mode === 'devices'; const deviceAriaLabel = [ ghostLabel, !presentation.haDisabled ? this._t((`marker.state_a11y_${a11yState}`) as I18nKey) : '', presentation.pulse.kind !== 'none' ? this._t((`marker.pulse_a11y_${presentation.pulse.reason}`) as I18nKey) : '', presentation.valueFullText || presentation.valueText || '', valueBadgeTitle(presentation.valueBadge), presentation.lqiText != null && presentation.lqiBand ? this._t((`marker.lqi_a11y_${presentation.lqiBand}`) as I18nKey, { value: presentation.lqiText, }) : '', ].filter(Boolean).join(', '); const metrics = [ d.model, presentation.valueBadge?.fullText || '', presentation.lqiText != null ? 'LQI ' + presentation.lqiText : '', ].filter(Boolean).join(' · '); return html`
this._clickDevice(e, d)} @keydown=${(e: KeyboardEvent) => this._keyDevice(e, d)} @contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)} @pointerover=${(e: PointerEvent) => this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics)} @pointerleave=${() => this._clearTransientHover()} @pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)} @pointermove=${(e: PointerEvent) => { this._pointerMove(e, d); this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics); }} @pointerup=${(e: PointerEvent) => this._pointerUp(e, d)} @pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)} > ${renderDeviceFace(presentation, { surface: 'interactive-plan', newDevice: this._newIds.has(d.id), newDeviceTitle: this._t('device.new'), disabledTitle: presentation.haDisabled ? ghostLabel : '', })}
`; } /** Clean-floor area shown in the room tooltip (same rule as resize labels). */ private _roomArea(r: RoomCfg): string | null { const poly = roomPoly(r); if (!poly) return null; const space = this._spaceModel(); if (!space) return null; const walls = this._spaceWalls; const floor = walls.length && r.id ? (innerContourForRoom( space.rooms, r.id, walls, this._openPairs().flatMap((p) => p.segs), this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, this._wallUnionGeometry()?.roomGeom, ) || poly) : poly; const clean = this._cleanFloor(r, floor); const cmPerUnit = this._cellCm / this._gridPitch; return formatArea( (clean.area * cmPerUnit * cmPerUnit) / 1e4, this._renderPlanHass?.config?.unit_system?.length === 'mi', ); } /** Room temperature honouring the tier-3 source override. */ private _roomTemp(r: RoomCfg): number | null { const src = r.settings?.temp_source; if (src) return sourceValue(this._renderPlanHass, src, 'temp', this._markers); // every sensor of the area, placed on the plan or not (field report) return r.area ? this._climate().get(r.area)?.temp ?? null : null; } /** Room humidity honouring the tier-3 source override. */ private _roomHum(r: RoomCfg): number | null { const src = r.settings?.hum_source; if (src) return sourceValue(this._renderPlanHass, src, 'hum', this._markers); return r.area ? this._climate().get(r.area)?.hum ?? null : null; } private _climateCache: { h: any; r: any; mk: any; m: Map } | null = null; /** * Climate for every area, computed ONCE per hass snapshot (review R2-3). * Home Assistant hands out a new `hass` object on every state change, so * identity is exactly the right cache key: fresh states always recompute, * and the 60 rooms of one render share a single registry pass instead of * triggering one each (two, with humidity on). */ private _climate(): Map { // markers are part of the key: ticking "use the device's temperature // sensor" replaces the markers array, so the average recomputes at once const mk = this._serverCfg?.markers; const planHass = this._renderPlanHass; const c = this._climateCache; if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m; const m = areaClimateMap(planHass, this._iconRules, mk); this._climateCache = { h: planHass, r: this._iconRules, mk, m }; return m; } private _resetRoomDialogFields(): void { this._roomEditId = null; this._roomFill = ''; this._roomCustomFill = null; this._roomTempSrc = ''; this._roomHumSrc = ''; this._roomSrcOpen = null; this._roomSrcFilter = ''; this._roomNameScale = 1; this._roomLabelScale = 1; } /** Open the room dialog for an EXISTING room (the gear on its card). */ private _openRoomEdit(r: RoomCfg): void { if (!r.id) return; this._roomEditId = r.id; this._nameSel = r.name || ''; this._areaSel = r.area || ''; this._roomFill = r.settings?.fill_mode === 'glow' ? '' : ((r.settings?.fill_mode as any) || ''); const rawCustom = r.settings?.custom_fill; this._roomCustomFill = rawCustom && typeof rawCustom === 'object' ? customFillOf(rawCustom, spaceDisplayOf(this._curSpaceCfg).customFill) : null; this._roomTempSrc = r.settings?.temp_source || ''; this._roomHumSrc = r.settings?.hum_source || ''; this._roomNameScale = clampScale(r.settings?.name_scale); this._roomLabelScale = clampScale(r.settings?.label_scale); this._roomSrcOpen = null; this._roomSrcFilter = ''; this._roomDialog = true; } /** Collect the room settings object from the dialog state (null = all inherited). */ private _roomSettingsFromDialog(): RoomCfg['settings'] { const st: any = {}; if (this._roomFill) st.fill_mode = this._roomFill; if (this._roomCustomFill) st.custom_fill = this._roomCustomFill; if (this._roomTempSrc) st.temp_source = this._roomTempSrc; if (this._roomHumSrc) st.hum_source = this._roomHumSrc; if (this._roomNameScale !== 1) st.name_scale = this._roomNameScale; if (this._roomLabelScale !== 1) st.label_scale = this._roomLabelScale; return Object.keys(st).length ? st : null; } /** Save the room edited via the gear (name, area, tier-3 settings). */ private _saveRoomEdit(): void { const sp = this._curSpaceCfg; const room = sp?.rooms.find((x: any) => x.id === this._roomEditId); if (!room) { this._roomDialog = false; this._roomEditId = null; return; } room.name = this._nameSel.trim() || room.name; room.area = this._areaSel || null; // Preserve unknown/future room settings. If this dialog replaces a legacy // fill_mode:'glow', materialize its effective Glow in the same write so a // seemingly unrelated room edit cannot switch the light overlay off. const previous = room.settings || {}; const next: any = { ...previous }; if (this._roomFill) next.fill_mode = this._roomFill; else delete next.fill_mode; if (this._roomCustomFill) next.custom_fill = this._roomCustomFill; else delete next.custom_fill; if (previous.fill_mode === 'glow' && typeof previous.glow !== 'boolean') next.glow = true; if (this._roomTempSrc) next.temp_source = this._roomTempSrc; else delete next.temp_source; if (this._roomHumSrc) next.hum_source = this._roomHumSrc; else delete next.hum_source; if (this._roomNameScale !== 1) next.name_scale = this._roomNameScale; else delete next.name_scale; if (this._roomLabelScale !== 1) next.label_scale = this._roomLabelScale; else delete next.label_scale; if (Object.keys(next).length) room.settings = next; else delete room.settings; this._saveConfig(); this._roomDialog = false; this._roomEditId = null; this._nameSel = ''; this._areaSel = ''; this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); this._showToast(this._t('toast.room_updated')); } /** Devices + sensor entities for the measurement-source picker. */ private _roomSrcCandidates(): { value: string; label: string; sub: string }[] { const h = this._planHass; const removed = removedPlanBindings(this._markers); const q = this._roomSrcFilter.trim().toLowerCase(); const list: { value: string; label: string; sub: string }[] = []; for (const dev of Object.values(h.devices)) { if (dev.entry_type === 'service') continue; if (removed.devices.has(dev.id)) continue; const name = (dev.name_by_user || dev.name || dev.id).trim(); if (q && !name.toLowerCase().includes(q)) continue; list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this._t('marker.sub_device') }); } for (const [eid, reg] of Object.entries(h.entities)) { if (!eid.startsWith('sensor.') || reg.hidden) continue; if (isRemovedPlanEntity(h, eid, removed)) continue; const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid; if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue; list.push({ value: 'entity:' + eid, label, sub: eid }); } list.sort((a, b) => a.label.localeCompare(b.label)); return list.slice(0, 200); } /** Human label of a picked measurement source. */ private _roomSrcLabel(src: string): string { const i = src.indexOf(':'); const k = src.slice(0, i); const ref = src.slice(i + 1); if (k === 'device') { return this._fullRegistryHass.devices[ref]?.name_by_user || this._fullRegistryHass.devices[ref]?.name || ref; } return this._fullRegistryHass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref; } /** Saved label position (layout key rl_) or the room center. */ private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } { const saved = this._layout['rl_' + (r.id || '')]; if (saved && saved.s === spaceId) { return { x: saved.x * NORM_W, y: saved.y * NORM_W }; } // a label nobody has dragged sits at the room's CENTROID, which is not a // node for an odd-sized or polygonal room — put it on the nearest one const c = this._snap(this._roomCenter(r)); return { x: c[0], y: c[1] }; } /** Room-name labels are dragged exactly like device icons (same layout store). */ private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { if (this._mode !== 'plan') return; ev.preventDefault(); ev.stopPropagation(); const p = this._labelPos(r, spaceId); this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; capturePointer(ev); this._tip = null; } private _labelMove(ev: PointerEvent, r: RoomCfg, spaceId: string): void { const id = 'rl_' + (r.id || ''); if (!this._drag || this._drag.id !== id) return; const stage = this._stageEl; if (!stage) return; const space = this._spaceModelById(spaceId); if (!space) return; const vb = space.vb; const rect = stage.getBoundingClientRect(); const v = this._viewOr(vb); const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w; const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h; if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true; // DEV-B58-01, and worse than the marker's: this clamped to the space's // STORED view_box, which for every existing plan is the old unit square. // A room drawn at 2.5 had a name that could not be dragged to its own room. const nx = clampCanvasR(this._drag.ox + dx); const ny = clampCanvasR(this._drag.oy + dy); this._savePos({ id, space: spaceId } as DevItem, nx, ny); } private _labelUp(r: RoomCfg): void { const id = 'rl_' + (r.id || ''); if (!this._drag || this._drag.id !== id) return; const moved = this._drag.moved; this._drag = moved ? this._drag : null; if (moved) window.setTimeout(() => (this._drag = null), 0); } /** Saved room-card scale (layout key rl_, field k), clamped 0.5..3. */ private _labelScale(r: RoomCfg): number { const k = (this._layout['rl_' + (r.id || '')] as any)?.k; return typeof k === 'number' && Number.isFinite(k) ? Math.min(3, Math.max(0.5, k)) : 1; } private _rlResizeDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { if (this._mode !== 'plan') return; ev.preventDefault(); ev.stopPropagation(); const card = (ev.target as HTMLElement).closest('.roomlabel') as HTMLElement | null; if (!card) return; const b = card.getBoundingClientRect(); const cx = b.left + b.width / 2; const cy = b.top + b.height / 2; const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy)); this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 }; capturePointer(ev); } private _rlResizeMove(ev: PointerEvent): void { const rs = this._rlResize; if (!rs) return; ev.stopPropagation(); const dist = Math.max(8, Math.hypot(ev.clientX - rs.cx, ev.clientY - rs.cy)); const k = Math.min(3, Math.max(0.5, rs.k0 * (dist / rs.d0))); const rec: any = this._layout[rs.id]; if (!rec) { // the card was never dragged: pin its current default position first const roomId = rs.id.slice(3); const sp = this._spaceModelById(rs.space); if (!sp) return; const room = sp.rooms.find((x) => x.id === roomId); if (!room) return; const p = this._labelPos(room, rs.space); this._layout = { ...this._layout, [rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k }, }; } else { this._layout = { ...this._layout, [rs.id]: { ...rec, k } }; } this._dirtyPos.add(rs.id); } private _rlResizeUp(): void { if (!this._rlResize) return; this._rlResize = null; this._persistLayout(); } /** The room-settings button: detached from the (movable) label, always at * the geometric CENTRE of the room, sized at 70% of a device icon and * therefore zooming with the plan (owner's spec, 2026-07-29). */ private _gearPtCache = new WeakMap(); private _renderRoomGear( r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, ): TemplateResult | typeof nothing { if (!r.id) return nothing; let c: number[] | null = null; if (r.poly) { // the VISUAL centre (largest inscribed circle) — interiorPoint only // promises "inside", which sat visibly off-centre on an L-shaped room. // The model is memoized, so the poly array is a stable cache key. c = this._gearPtCache.get(r.poly) || null; if (!c) { c = poleOfInaccessibility(r.poly); this._gearPtCache.set(r.poly, c); } } else if (r.x != null && r.y != null) { c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2]; } if (!c) return nothing; const left = ((c[0] - view.x) / view.w) * 100; const top = ((c[1] - view.y) / view.h) * 100; return html``; } private _renderRoomLabel( r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay, ): TemplateResult | typeof nothing { // audit/feedback: rooms without a name still need their gear in the Plan // editor — that is where you name them (field report, 2026-07-27) if (!r.name && !this._markup) return nothing; const p = this._labelPos(r, space.id); const point = this._scenePoint([p.x, p.y]); const left = ((point[0] - view.x) / view.w) * 100; const top = ((point[1] - view.y) / view.h) * 100; const op = Math.min(1, disp.opacity + 0.25); const k = this._labelScale(r); // Optional metrics row. Light sources may use an explicit room_id even // when this room has no HA area; the other aggregate metrics still need it. const rows: TemplateResult[] = []; if (r.area || r.settings?.temp_source || r.settings?.hum_source || disp.labelLight) { if (disp.labelTemp) { const t = this._roomTemp(r); if (t != null) rows.push(html`${t}°`); } if (disp.labelHum) { const hm = this._roomHum(r); if (hm != null) rows.push(html`${hm}%`); } if (disp.labelLqi && r.area) { const l = this._roomLqi(r.area); if (l != null) rows.push(html`${l}`); } if (disp.labelLight) { const ls = resolvedLightStats(resolvedLightSources( this._renderPlanHass, this._renderDevices, r, this._virtualLights, )); if (ls) { const txt = ls.on === 0 ? this._t('roomcard.light_off') : ls.on === ls.total ? this._t('roomcard.light_on') : this._t('roomcard.light_partial', { on: ls.on, total: ls.total }); rows.push(html`${txt}`); } } } // Plan keeps the same name-row geometry as the other modes, but the Area // icon remains part of the draggable label instead of becoming a link. const showAreaLink = !!r.area; const areaLinkInteractive = !this._markup; return html`
this._labelDown(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)} @pointerup=${() => this._labelUp(r)} @pointercancel=${() => this._labelUp(r)} >${r.name || (this._markup ? this._t('room.unnamed') : '')}${showAreaLink ? html` { e.stopPropagation(); this._clickRoom(r); } : nothing} @pointerdown=${areaLinkInteractive ? (e: Event) => e.stopPropagation() : nothing}>` : nothing} ${rows.length ? html`${rows}` : nothing} ${this._mode === 'plan' ? ['tl', 'tr', 'bl', 'br'].map( (c) => html` this._rlResizeDown(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._rlResizeMove(e)} @pointerup=${() => this._rlResizeUp()} @pointercancel=${() => this._rlResizeUp()}>`, ) : nothing}
`; } /** Where the live measurement starts: the last outline point, or the first split point. */ private get _measureAnchor(): number[] | null { if (!this._markup || !this._cursorPt) return null; if (this._tool === 'draw' && this._path.length && !this._contourClosed) return this._path[this._path.length - 1]; if (this._tool === 'split' && this._splitSel?.pts?.length) return this._splitSel.pts[this._splitSel.pts.length - 1]; return null; } /** Length badge that follows the cursor while drawing a segment or a cut. */ private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult { const a = this._measureAnchor!; const b = this._cursorPt!; const left = ((b[0] - view.x) / view.w) * 100; const top = ((b[1] - view.y) / view.h) * 100; // angle badge: length · angle, both green when the angle is a 45° multiple const deg = segmentAngle(a, b); const shown = Math.round(deg * 10) / 10; const on45 = isExact45Vector(a, b, this._gridPitch * 0.0002); return html`
${this._fmtLen(a, b)} · ${shown}°
`; } /** * Live size badge for the shape being drawn in the BACKGROUND (decor) * editor — owner 2026-08-04: «в редакторе подложки у линий писать длину, * как при рисовании комнат в редакторе плана». * * A line gets exactly what a wall gets while a plan is drawn: length · * angle in the HA unit system, green on a 45° multiple — the same * `_fmtLen` (`segmentCm` over `cell_cm`), the same `.measurelabel`. The * only difference is WHERE it sits: a wall badge follows the cursor * because the cursor is the wall's free end, while a decor line is pulled * out by both ends at once, so its badge rides the MIDDLE of the segment * (owner: «плашка на середине линии»). * * Rectangles report «W × H» plus area; ellipses report one radius for a * circle or both radii otherwise. A draft that has not moved yet (the * pointerdown before the drag) shows nothing — a «0» badge under the cursor * is noise, not a measurement. */ private get _decorMeasure(): { x: number; y: number; text: string; on45: boolean } | null { const d = this._decorDraft; if (!d || this._mode !== 'decor') return null; const [ax, ay] = d.a; const [bx, by] = d.b; if (Math.abs(ax - bx) < 1e-6 && Math.abs(ay - by) < 1e-6) return null; const x = (ax + bx) / 2; const y = (ay + by) / 2; if (d.kind === 'line') { const deg = segmentAngle(d.a, d.b); return { x, y, on45: is45(deg), text: `${this._fmtLen(d.a, d.b)} · ${Math.round(deg * 10) / 10}°` }; } const wText = this._fmtLen([ax, ay], [bx, ay]); const hText = this._fmtLen([bx, ay], [bx, by]); if (d.kind === 'ellipse') { const rx = this._fmtLen([0, 0], [Math.abs(bx - ax) / 2, 0]); const ry = this._fmtLen([0, 0], [0, Math.abs(by - ay) / 2]); const circle = Math.abs(Math.abs(bx - ax) - Math.abs(by - ay)) < 1e-6; return { x, y, on45: false, text: circle ? `R ${rx}` : `Rx ${rx} × Ry ${ry}` }; } const area = (decorUnitsToCm(Math.abs(bx - ax), this._cellCm, this._gridPitch) * decorUnitsToCm(Math.abs(by - ay), this._cellCm, this._gridPitch)) / 10000; return { x, y, on45: false, text: `${wText} × ${hText} · ${formatArea(area, this._imperial)}` }; } // ================= alignment guides (smart guides) ================= /** The point being drawn/dragged right now, or null (per editor context). */ private get _alignPoint(): number[] | null { if (this._markup) { if (this._tool === 'draw' && this._path.length && !this._contourClosed && this._cursorPt) return this._cursorPt; if (this._tool === 'split' && this._splitSel?.pts?.length && this._cursorPt) return this._cursorPt; if (this._drag?.id.startsWith('rl_') && this._drag.moved) { const roomId = this._drag.id.slice(3); const room = this._spaceModel()?.rooms.find((r) => r.id === roomId); return room ? (() => { const p = this._labelPos(room, this._space); return [p.x, p.y]; })() : null; } return null; } if (this._mode === 'devices' && this._drag?.moved) { const d = this._devices.find((x) => x.id === this._drag!.id); return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null; } if (this._mode === 'decor') { if (this._decorDraft) return this._decorDraft.b; if (this._decorMove) { const sh = this._decorList.find((x) => x.id === this._decorMove!.id); if (!sh) return null; const W = NORM_W, H = this._decorH; if (sh.kind === 'line') return [sh.x1 * W, sh.y1 * H]; return [sh.x * W, sh.y * H]; } return null; } return null; } /** Alignment candidates for the current context (owner's matrix). */ private _alignCandidates(): number[][] { const out: number[][] = []; const spm = this._spaceModel(); if (this._markup) { if (!spm) return out; if (this._drag?.id.startsWith('rl_')) { // room-card drag: centers of the OTHER room cards const dragged = this._drag.id.slice(3); for (const r of spm.rooms) { if (!r.name || r.id === dragged) continue; const p = this._labelPos(r, this._space); out.push([p.x, p.y]); } return out; } // drawing: room vertices + current path/split points for (const r of spm.rooms) { const poly = roomPoly(r); if (poly) for (const p of poly) out.push(p); } if (this._tool === 'draw') for (const p of this._path) out.push(p); if (this._tool === 'split' && this._splitSel?.pts) for (const p of this._splitSel.pts) out.push(p); return out; } if (this._mode === 'devices') { // other icons of this space only (owner's decision) for (const d of this._devices) { if (d.space !== this._space || d.id === this._drag?.id || d.bindingStatus?.kind === 'ha_disabled') continue; const p = this._pos(d); out.push([p.x, p.y]); } return out; } if (this._mode === 'decor') { const movingId = this._decorMove?.id; out.push(...this._decorSnapGeometry(movingId).points); if (this._decorDraft) out.push(this._decorDraft.a); return out; } return out; } private _renderAlignGuides(): TemplateResult { const pt = this._alignPoint; if (!pt) return svg`` as unknown as TemplateResult; // exact node match for grid-snapped things; half a cell for free-moving cards const tol = this._drag?.id.startsWith('rl_') ? this._gridPitch * 0.5 : this._gridPitch * 0.05; const guides = alignGuides(pt, this._alignCandidates(), tol); if (!guides.length) return svg`` as unknown as TemplateResult; const g = this._gridPitch; const over = g * 1.5; // extend a little past the point return svg` ${guides.map((gd: AlignGuide) => { const [x1, y1, x2, y2] = gd.axis === 'x' ? [gd.at, gd.from[1], gd.at, pt[1] + Math.sign(pt[1] - gd.from[1]) * over] : [gd.from[0], gd.at, pt[0] + Math.sign(pt[0] - gd.from[0]) * over, gd.at]; return svg` `; })} ` as unknown as TemplateResult; } /** Perpendicular dashed tick through the wall's center while a dragged opening * sits exactly in the middle — same look as the alignment guides. Length is * about the wall stroke (2.5) × 6 to each side. */ private _renderOpeningCenterTick(gd: { x: number; y: number; angle: number }): TemplateResult { const rad = ((gd.angle + 90) * Math.PI) / 180; const half = gridVisualUnits(2.5 * 6, this._cellCm); return svg`` as unknown as TemplateResult; } /** Physical dimension segments for a NEW-opening placement preview (#238). * Existing-opening drag labels carry no `dimension` and render nothing here. */ private _renderOpeningDimensionGuides(measure: OpMeasure): TemplateResult { const dimensions = measure.labels.flatMap((label) => label.dimension ? [label.dimension] : []); if (!dimensions.length) return svg`` as unknown as TemplateResult; const tickHalf = this._cssPxToRender(4); return svg`` as unknown as TemplateResult; } private _roomCenter(r: RoomCfg): number[] { if (r.poly) { const n = r.poly.length; return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n]; } return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1]; } /** Live state of an opening's contact, 0..1 drawn amount. */ private _openingAmt(o: OpeningCfg): number { const st = o.contact && this._renderOpeningEntityAvailable(o.contact) ? this._renderPlanHass.states[o.contact]?.state : null; return openingAmount(o.type, st, !!o.invert); } /** Deleted bindings are unavailable to every plan-level consumer. */ private _planEntityAvailable(eid: string | null | undefined): boolean { if (!eid) return false; if (isRemovedPlanEntity(this._fullRegistryHass, eid, removedPlanBindings(this._markers))) return false; return this._bindingStatus('entity:' + eid).kind === 'active'; } /** Availability from the same immutable projection as the painted frame. */ private _renderEntityAvailable(eid: string | null | undefined): boolean { if (!eid) return false; if (isRemovedPlanEntity(this._renderPlanHass, eid, removedPlanBindings(this._markers))) return false; return !!this._renderPlanHass.entities?.[eid] && !!this._renderPlanHass.states?.[eid]; } /** Exact opening references ignore marker tombstones but keep HA status. */ private _openingEntityAvailable(eid: string | null | undefined): boolean { return openingEntityAvailable(this.hass, eid, this._haRegistry); } /** Opening availability from the same immutable projection as the frame. */ private _renderOpeningEntityAvailable(eid: string | null | undefined): boolean { return renderOpeningEntityAvailable(this._renderPlanHass, eid); } /** * Architectural openings, drawn in plan (SVG) coordinates so they scale and * pan with the plan. A passage deliberately contributes no visible symbol; * its editor hitbox is still supplied by the shared metrics below. */ private _renderOpeningPlacementPreview(): TemplateResult { const candidate = this._openingPreview; if (!candidate) return svg``; const visibleSpec: OpeningVisibleSpec = { type: candidate.type, length: candidate.renderedLength, angle: candidate.angle, amount: openingAmount(candidate.type, null), flipH: candidate.flipH, flipV: candidate.flipV, base: 'var(--hp-open)', tone: 'var(--hp-open)', cellCm: this._cellCm, gridPitch: this._gridPitch, face: candidate.face, }; const passageGeometry = candidate.type === 'passage' ? passagePlacementPreviewGeometry( candidate, gridVisualUnits(this._gridPitch, this._cellCm), ) : null; return svg` ` as unknown as TemplateResult; } private _renderOpenings(disp: SpaceDisplay): TemplateResult { const items = this._openingsR; if (!items.length) return svg``; const space = this._spaceModel(); if (!space) return svg``; const base = disp.color; const walls = this._spaceWalls; const openCuts = this._openPairs().flatMap((pair) => pair.segs); const openingWallIndex = this._openingWallIndexFor(space, openCuts).value; return svg`${items.map((o) => { if (o.orphanReason) return svg` { event.stopPropagation(); this._editOpening(o); }}> ! `; const amt = this._openingAmt(o); const active = amt > 0 && !!o.contact && this._renderOpeningEntityAvailable(o.contact); const tone = active ? 'var(--hp-open)' : base; // A normal door/window uses the selected room-side face. A gate's // architectural symbol sits and opens on the opposite (exterior) face; // flip_v still lets the user reverse that side where a shared wall makes // "outside" inherently ambiguous. const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v; // Resolve the side even with zero-thickness walls for gates, while // preserving the cheap classic path for ordinary line-plan openings. const face = o.partitionHost || walls.length || o.type === 'gate' ? this._openingFace(o, openingWallIndex, !!faceFlipV) : { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 }; const visibleSpec: OpeningVisibleSpec = { type: o.type, length: o.rlen, angle: o.angle, amount: amt, flipH: !!o.flip_h, flipV: !!o.flip_v, base, tone, cellCm: this._cellCm, gridPitch: this._gridPitch, face, }; const { half, outlineHalf, hitHalf } = openingVisibleMetrics(visibleSpec); const outlinePad = gridVisualUnits(10, this._cellCm); const hitPad = gridVisualUnits(12, this._cellCm); return svg` ${renderOpeningVisibleGeometry(visibleSpec)} this._opClick(e, o)} @pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)} @pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)} @pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)} @pointercancel=${(e: PointerEvent) => this._opPointerUp(e, o)}> `; })}`; } /** Padlock badges for door-like openings with a lock entity. */ private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult { const items = this._openingsR.filter( (o) => !o.orphanReason && (o.type === 'door' || o.type === 'gate') && o.lock && this._renderOpeningEntityAvailable(o.lock), ); if (!items.length) return html``; const space = this._spaceModel(); if (!space) return html``; const openCuts = this._openPairs().flatMap((pair) => pair.segs); const openingWallIndex = this._openingWallIndexFor(space, openCuts).value; return html`${items.map((o) => { const st = this._renderPlanHass.states[o.lock!]?.state; const locked = st === 'locked'; const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st)); // Perpendicular offset from the opening center, away from the swing side. // Gate exterior depends on the room edge, not merely on normalized wall // angle, so resolve that face explicitly. const rad = ((o.angle + 90) * Math.PI) / 180; const gateFace = o.type === 'gate' ? this._openingFace(o, openingWallIndex, !o.flip_v) : null; const lockOffset = gridVisualUnits(16, this._cellCm); const off = gateFace ? -lockOffset * gateFace.side : lockOffset * (o.flip_v ? -1 : 1); const px = o.rx + Math.cos(rad) * off; const py = o.ry + Math.sin(rad) * off; const point = this._scenePoint([px, py]); const left = ((point[0] - view.x) / view.w) * 100; const top = ((point[1] - view.y) / view.h) * 100; return html`
{ e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
`; })}`; } /** * Explicit lock/unlock from the opening info card. This does NOT violate the * "locks never toggle from the plan" rule: that rule guards against ACCIDENTAL * taps on plan icons; here the user has opened the info card and pressed a * clearly labeled action button — same interaction contract as HA's more-info. */ private _lockAction(entityId: string, action: 'lock' | 'unlock'): void { // THE ONLY sanctioned lock actuation surface (review CR-1, 2026-07-27). // The invariant is "no lock or alarm panel is ever actuated by a TAP on the // plan" — icons, badges, controls[] and the device card all refuse. This // button is a deliberate, labeled control inside an opened card, the same // contract as Home Assistant's own more-info dialog. Unlocking additionally // asks for confirmation; locking does not (locking is never destructive). if (!this._openingEntityAvailable(entityId)) return; if (action === 'unlock') { const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId; if (!confirm(this._t('confirm.unlock', { name }))) return; } this.hass?.callService?.('lock', action, { entity_id: entityId }); } private _renderOpeningInfoCard(): TemplateResult { const o = this._openingInfo!; // A legacy/future writer may have left door bindings on a passage. They // remain readable for lossless round-trip, but are inert on every surface. const contact = o.type !== 'passage' && o.contact && this._openingEntityAvailable(o.contact) ? o.contact : null; const lock = (o.type === 'door' || o.type === 'gate') && o.lock && this._openingEntityAvailable(o.lock) ? o.lock : null; const cSt = contact ? this.hass.states[contact]?.state : null; const amt = this._openingAmt(o); const lSt = lock ? this.hass.states[lock]?.state : null; const titleKey = o.type === 'door' ? 'opening.door' : o.type === 'gate' ? 'opening.gate' : o.type === 'passage' ? 'opening.passage' : 'opening.window'; const openingIcon = o.type === 'door' ? 'mdi:door' : o.type === 'gate' ? 'mdi:gate' : o.type === 'passage' ? 'mdi:arch' : 'mdi:window-closed-variant'; const contactIcon = o.type === 'gate' ? (amt > 0 ? 'mdi:gate-open' : 'mdi:gate') : (amt > 0 ? 'mdi:door-open' : 'mdi:door-closed'); const row = (icon: string, label: string, value: string, cls = '') => html`
${label}${value}
`; return html` (this._openingInfo = null)}>
${contact ? row(contactIcon, this._t('opening.contact_label'), cSt === 'unavailable' || cSt == null ? this._t('opening.state_unknown') : this._t(amt > 0 ? 'opening.open' : 'opening.closed'), amt > 0 ? 'warn' : 'ok') : nothing} ${lock ? row(lSt === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant', this._t('opening.lock_label'), lSt === 'locked' ? this._t('opening.locked') : ['unlocked', 'open'].includes(String(lSt)) ? this._t('opening.unlocked') : this._t('opening.state_unknown'), lSt === 'locked' ? 'ok' : 'warn') : nothing} ${lock && (lSt === 'locked' || ['unlocked', 'open'].includes(String(lSt))) ? html`` : lock && ['locking', 'unlocking'].includes(String(lSt)) ? html`` : nothing} ${!contact && !lock ? html`

${this._t('opening.no_entities')}

` : nothing}
`; } private _renderOpeningDialog(): TemplateResult { const d = this._openingDialog!; const partitions = this._spaceModel()?.partitions || []; const previous = d.id ? this._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === d.id) : null; const dialogOpening: OpeningCfg = { id: d.id || 'preview', type: d.type, x: d.x / NORM_W, y: d.y / this._spaceH, angle: d.angle, length: previous && !d.lengthTouched ? previous.length : this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W, ...(d.host ? { host: d.host } : {}), }; const strict = partitionOpeningNeedsStrictValidation(previous, dialogOpening); const hostResolution = d.host ? strict ? resolvePartitionOpeningStrict( dialogOpening, partitions, NORM_W, this._cellCm, this._gridPitch, ) : resolvePartitionOpeningCompat( dialogOpening, partitions, NORM_W, this._cellCm, this._gridPitch, ) : null; const jambInvalid = hostResolution?.reason === 'does-not-fit-jamb'; const hostPartition = d.host ? partitions.find((partition) => partition.id === d.host!.id) : null; const jambDistance = hostPartition ? formatLength(hostPartition.cm / 2, this._imperial) : ''; const orphan = !!d.host && !hostResolution?.resolved && !jambInvalid; const icon = d.type === 'gate' ? 'mdi:gate' : d.type === 'window' ? 'mdi:window-closed-variant' : d.type === 'passage' ? 'mdi:arch' : 'mdi:door'; const picker = (kind: 'contact' | 'lock', list: { value: string; label: string }[]) => { const cur = kind === 'contact' ? d.contact : d.lock; const open = kind === 'contact' ? !!d.contactOpen : !!d.lockOpen; const filter = kind === 'contact' ? d.contactFilter || '' : d.lockFilter || ''; const selected = list.find((candidate) => candidate.value === cur); const selectedLabel = selected?.label || this.hass.states[cur]?.attributes?.friendly_name || this._fullRegistryHass.entities[cur]?.name || cur; const filtered = filterOpeningEntityCandidates(list, filter); return html` ${open ? html`
this._filterOpeningEntities( kind, (e.target as HTMLInputElement).value, )} />
${filtered.map((candidate) => html` `)} ${!filtered.length ? html`
${this._t('marker.nothing_found')}
` : nothing}
` : nothing}`; }; return html` (this._openingDialog = null)}>
${d.host ? html`
${orphan || jambInvalid ? html`` : nothing} ${orphan ? this._t('opening.partition_orphan') : jambInvalid ? this._t('opening.partition_jamb_margin', { distance: jambDistance }) : this._t('opening.host_partition')}
` : nothing} { const n = strictNumber((e.target as HTMLInputElement).value); if (n != null) this._openingDialog = { ...d, lengthCm: n, lengthTouched: true }; }} /> ${d.type === 'passage' && (d.contact || d.lock) ? html`
${this._t('opening.passage_binding_warning')}
` : nothing} ${d.type !== 'passage' ? html` ${picker('contact', this._contactCandidates())} ${d.contact ? html`` : nothing}` : nothing} ${d.type === 'door' || d.type === 'gate' ? html` ${picker('lock', this._lockCandidates())}` : nothing} ${d.type !== 'gate' && d.type !== 'passage' ? html`` : nothing} ${d.type !== 'passage' ? html`` : nothing}
`; } /** Adaptive grid density for the current view (docs/CANVAS.md §7): * which multiple of the drawing pitch is still legible on screen, and * which coarser one carries the accent dots. */ private _gridLevels(): { fine: number; coarse: number } | null { const stage = this._stageEl; const v = this._viewOr(this._baseVb()); const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1; return gridLevels(this._gridPitch, px); } private _renderMarkupDefs(_vb: number[]): TemplateResult { const lv = this._gridLevels(); if (!lv) return svg`` as unknown as TemplateResult; const g = this._gridPitch * lv.fine; const G = this._gridPitch * lv.coarse; const dotR = this._gridPitch * lv.fine * 0.14; // Two patterns, CAD-style: the fine dots thin out as you zoom away // (gridLevels drops whole decades of them) while every coarse node keeps // a bigger, darker dot, so the eye never loses the scale reference. return svg` `; } private _renderPhysicalEditorLayer(): TemplateResult { const space = this._spaceModel(); if (!space) return svg`` as unknown as TemplateResult; const g = this._gridPitch; const view = this._viewOr(this._baseVb()); const stage = this._stageEl; const unitsPerPx = stage?.clientWidth ? view.w / stage.clientWidth : g / 8; const touchStroke = 24; const handleR = Math.max(g * 0.22, unitsPerPx * 8); const rotateHandleR = Math.max(handleR, unitsPerPx * 12); const path = (poly: number[][]) => `M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; const selected = (kind: string, id: string) => (this._physicalSel?.kind === kind && this._physicalSel.id === id) || (kind === 'column' && this._duplicateColumnId === id); const draftSegs = (space.room_drafts || []).flatMap((d) => d.points.slice(0, -1).map((a, i) => { const b = d.points[i + 1]; const depth = wallCmToUnits(d.segments[i]?.cm || 15, this._cellCm, this._gridPitch); return svg` { this._physicalSel = { kind: 'draft', id: d.id, segment: i }; this._registerPhysicalTap('draft', d.id, i); }}>`; })); const partitions = (space.partitions || []).map((p) => { const body = partitionBody(p.a, p.b, p.cm, this._cellCm, this._gridPitch); if (!body) return nothing; return svg` this._physicalDown(e, 'partition', p.id)} @pointermove=${(e: PointerEvent) => this._physicalMove(e)} @pointerup=${(e: PointerEvent) => this._physicalUp(e)}>`; }); const columns = (space.wall_columns || []).map((c) => { const shown = this._physicalRotate?.id === c.id ? { ...c, angle: this._physicalRotate.angle } as WallColumnCfg : c; const body = columnBody(shown, this._cellCm, this._gridPitch); return svg` this._physicalDown(e, 'column', c.id)} @pointermove=${(e: PointerEvent) => this._physicalMove(e)} @pointerup=${(e: PointerEvent) => this._physicalUp(e)}>`; }); const drag = this._physicalDrag; const ghost = (() => { if (!drag?.moved) return nothing; const poly = drag.kind === 'partition' ? partitionBody((drag.base as PartitionCfg).a, (drag.base as PartitionCfg).b, (drag.base as PartitionCfg).cm, this._cellCm, this._gridPitch) : columnBody(drag.base as WallColumnCfg, this._cellCm, this._gridPitch); return poly ? svg`` : nothing; })(); const chrome = (() => { const sel = this._physicalSel; if (!sel) return nothing; if (sel.kind === 'draft') { const d = space.room_drafts.find((x) => x.id === sel.id); if (!d) return nothing; return svg` p.join(',')).join(' ')}> ${d.points.map((p) => svg``)} `; } if (sel.kind === 'partition') { const p = space.partitions.find((x) => x.id === sel.id); if (!p) return nothing; const body = partitionBody(p.a, p.b, p.cm, this._cellCm, this._gridPitch); if (!body) return nothing; const mx = (p.a[0] + p.b[0]) / 2, my = (p.a[1] + p.b[1]) / 2; return svg` `; } const base = space.wall_columns.find((x) => x.id === sel.id); if (!base) return nothing; const c = this._physicalRotate?.id === base.id ? { ...base, angle: this._physicalRotate.angle } as WallColumnCfg : base; const body = columnBody(c, this._cellCm, this._gridPitch); if (c.shape !== 'square') return svg` `; const size = wallCmToUnits(c.cm, this._cellCm, this._gridPitch); const angle = canonicalColumnAngle(c.angle) * Math.PI / 180; const ux = Math.sin(angle), uy = -Math.cos(angle); const edge = [c.center[0] + ux * size / 2, c.center[1] + uy * size / 2]; const handle = [edge[0] + ux * Math.max(g, unitsPerPx * 24), edge[1] + uy * Math.max(g, unitsPerPx * 24)]; return svg` this._physicalRotateDown(e, base)} @pointermove=${(e: PointerEvent) => this._physicalRotateMove(e)} @pointerup=${(e: PointerEvent) => this._physicalRotateUp(e)}> `; })(); return svg`${draftSegs}${partitions}${columns}${ghost}${chrome}`; } private _renderHiddenWallDiagnosticOverlay(): TemplateResult { if (!this._markup) return svg`` as unknown as TemplateResult; const geometry = this._hiddenWallDiagnosticSnapshot().value; if (!geometry.segments.length) return svg`` as unknown as TemplateResult; const radius = wallCmToUnits(5, this._cellCm, this._gridPitch); return svg`` as unknown as TemplateResult; } private _renderPlanSnapOverlay(): TemplateResult { if (!this._markup) { return svg`` as unknown as TemplateResult; } const geometry = this._planSnapGeometrySnapshot().value; const active = this._activePlanSnapCandidate; const conflictKeys = new Set(this._activePlanSnapConflicts.map((item) => item.key)); const staticRadius = wallCmToUnits(5, this._cellCm, this._gridPitch); const activeRadius = wallCmToUnits(10, this._cellCm, this._gridPitch); return svg`` as unknown as TemplateResult; } /** Update the only pointer-dependent node without scheduling a full card render. */ private _syncPlanSnapActiveMarker(candidate: PlanSnapCandidate | null): void { const marker = this.renderRoot?.querySelector( '[data-hp="plan-snap-active-marker"]', ); if (!marker) return; marker.setAttribute('class', `plan-snap-node${candidate ? ' active' : ''}${ candidate?.kind === 'line' ? ' dynamic' : '' }`); marker.setAttribute('data-active', candidate ? 'true' : 'false'); marker.setAttribute('visibility', candidate ? 'visible' : 'hidden'); if (!candidate) { marker.removeAttribute('data-kind'); marker.removeAttribute('data-key'); return; } marker.setAttribute('data-kind', candidate.kind); marker.setAttribute('data-key', candidate.key); marker.setAttribute('cx', String(candidate.point[0])); marker.setAttribute('cy', String(candidate.point[1])); marker.setAttribute('r', String(wallCmToUnits(10, this._cellCm, this._gridPitch))); } /** Pointer-only ambiguity styling without a full Lit render on large plans. */ private _syncPlanSnapConflictMarkers(conflicts: readonly PlanSnapEndpoint[]): void { const keys = new Set(conflicts.map((item) => item.key)); for (const marker of this.renderRoot?.querySelectorAll( '.plan-snap-node[data-kind="endpoint"]', ) || []) { marker.setAttribute('class', `plan-snap-node${keys.has(marker.dataset.key || '') ? ' conflict' : ''}`); } } /** Physical depth for one immutable architectural snap segment. */ private _planSnapPhysicalSegment(segment: PlanSnapSegment): LinearWallSegment | null { let cm = 0; const space = this._spaceModel(); if (!space) return null; if (segment.sourceKind === 'partition') { cm = Number(space.partitions.find((item) => item.id === segment.sourceId)?.cm) || 0; } else if (segment.sourceKind === 'draft') { const match = /^(.*):(\d+)$/.exec(segment.sourceId); const draft = match ? space.room_drafts.find((item) => item.id === match[1]) : null; cm = draft && match ? Number(draft.segments[Number(match[2])]?.cm) || 0 : 0; } else { cm = intervalCmAt( space.rooms, this._spaceWalls, this._openCuts(), [segment.a[0], segment.a[1], segment.b[0], segment.b[1]], this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, ); } if (!(cm > 0)) return null; return { a: [...segment.a], b: [...segment.b], halfDepth: wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2, }; } /** * Local target patches make a snapped rubber-band meet saved masonry before * the click. The expensive saved union stays cached; pointermove examines * only immutable snap axes touching a preview vertex. */ private _drawPreviewJoinPatchD( points: number[][], halfDepths: number[], ): string { if (points.length < 2) return ''; const preview: LinearWallSegment[] = []; for (let i = 0; i + 1 < points.length; i++) { if (!(halfDepths[i] > 0)) continue; preview.push({ a: points[i], b: points[i + 1], halfDepth: halfDepths[i] }); } if (!preview.length) return ''; const eps = this._gridPitch * 0.0002; const touching = this._planSnapGeometrySnapshot().value.segments .filter((segment) => points.some((point) => distToSegment(point, [ segment.a[0], segment.a[1], segment.b[0], segment.b[1], ]) <= eps)) .map((segment) => this._planSnapPhysicalSegment(segment)) .filter((segment): segment is LinearWallSegment => !!segment); const patches = linearWallJoinPatches([...preview, ...touching], eps); return patches.map((patch) => `M ${patch.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`).join(' '); } private _renderMarkupLayer(vb: number[]): TemplateResult { // derived walls minus the open stretches — those are drawn dashed on top const openCuts = this._openPairs().flatMap((p) => p.segs); const thickCuts = this._thickWallCuts(); const allCuts = openCuts.concat(thickCuts); const segs = allCuts.length ? cutSegments(this._segments, allCuts, this._gridPitch * 0.02) : this._segments; const path = this._path; const g = this._gridPitch; const view = this._viewOr(this._baseVb()); const drawCm = this._tool === 'draw' ? this._drawWallCm : null; const previewPts = (() => { if (this._tool !== 'draw' || !path.length || !(drawCm != null && drawCm > 0)) return null; if (this._contourClosed) return path; if (this._cursorPt) return [...path, this._cursorPt]; return path.length >= 2 ? path : null; })(); // Превью берёт толщины из того же резолвера, что и запись (#234). Раньше // здесь была вторая формула, и расходились они ровно на пропуске: на экране // текущее поле, в конфиге — 15 см. const previewHalfDepths = previewPts ? chainSegmentCms( previewPts.length - 1, this._contourClosed ? [...this._draftSegmentCms, this._closingWallCm ?? undefined] : this._draftSegmentCms, drawCm, DRAW_WALL_DEFAULT_CM, ).map((cm) => wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2) : []; const previewD = previewPts ? drawWallPreviewD( previewPts, wallCmToUnits(drawCm!, this._cellCm, this._gridPitch) / 2, this._contourClosed, previewHalfDepths, ) : ''; const previewJoinPatchD = previewPts ? this._drawPreviewJoinPatchD(previewPts, previewHalfDepths) : ''; const repairPreview = this._wallFaceBatch?.candidates[this._wallFaceBatch.index]?.repair || this._wallRepairDiagnostic; return svg` ${this._gridLevels() ? svg`` : nothing} ${segs.map((s) => svg``)} ${this._renderPhysicalEditorLayer()} ${this._tool === 'column' && this._cursorPt && this._drawWallCm ? svg` { const c: WallColumnCfg = { id: 'preview', shape: 'square', center: this._cursorPt!, cm: this._drawWallCm! }; const body = columnBody(c, this._cellCm, this._gridPitch); return `M ${body.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; })()}>` : nothing} ${previewD ? svg` ` : nothing} ${previewJoinPatchD ? svg` ` : nothing} ${repairPreview ? svg`` : nothing} ${this._tool === 'split' && this._splitSel?.pts?.length ? svg`${this._splitSel.pts.length > 1 ? svg`` : nothing} ${this._splitSel.pts.map((p, i) => svg``)} ${this._cursorPt ? svg`` : nothing}` : nothing} `; } /** * Axis and node ink of the active wall/room chain. Every click persists the * segment into `room_drafts`, whose opaque masonry paints ABOVE the markup * layer, so drawing this ink inside `_renderMarkupLayer` left already-placed * segments looking like bare walls (#307). The chain ink therefore paints in * its own layer after the wall bodies and before the snap overlay: yellow * "work in progress" styling stays distinct from finished walls, and the * snap geometry keeps excluding the active draft (no self-snapping). */ private _renderActiveChainInk(): TemplateResult { const path = this._path; const g = this._gridPitch; return svg` ${path.length > 1 ? svg`` : nothing} ${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed ? svg` ${!this._activePlanSnapCandidate && !this._activePlanSnapConflicts.length ? svg`` : nothing}` : nothing} ${path.map((p, i) => svg``)} ` as unknown as TemplateResult; } private _renderPartitionDeleteDialog(): TemplateResult { const dialog = this._partitionDeleteDialog!; const imperial = this.hass?.config?.unit_system?.length === 'mi'; return html` (this._partitionDeleteDialog = null)}>

${this._t('confirm.delete_partition_openings_body', { count: dialog.openings.length, })}

    ${dialog.openings.map((opening) => html`
  • ${this._t( 'confirm.delete_partition_openings_item', { type: this._t(`opening.${opening.type}` as I18nKey), length: formatLength( (opening.length * NORM_W / this._gridPitch) * this._cellCm, imperial, ), }, )}
  • `)}
`; } private _renderRoomDeleteDialog(): TemplateResult { const dialog = this._roomDeleteDialog!; return html` (this._roomDeleteDialog = null)}>

${this._t('confirm.delete_room_body')}

`; } private _renderPhysicalDialog(): TemplateResult { const d = this._physicalDialog!; const column = d.kind === 'column'; return html` (this._physicalDialog = null)}>
${column ? html` ` : nothing}
(this._physicalDialog = { ...d, cm: (e.target as HTMLInputElement).value, })} /> ${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
${column && d.shape === 'square' ? html` (this._physicalDialog = { ...d, angle: (e.target as HTMLInputElement).value, })} />` : nothing} ${d.length ? html`
${this._t('physical.length')}: ${d.length}
` : nothing}
`; } private _renderMarkupBar(): TemplateResult { const undoName = this._geometryHistory.undoName; const redoName = this._geometryHistory.redoName; const boundaryPending = this._tool === 'boundary' && !!this._openWallAnchor; const undoTitle = boundaryPending ? this._t('markup.boundary_cancel') : undoName ? this._t('history.undo_named', { name: undoName }) : this._t('history.undo_empty'); const redoTitle = boundaryPending ? this._t('markup.boundary_cancel') : redoName ? this._t('history.redo_named', { name: redoName }) : this._t('history.redo_empty'); return html`
${this._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
`; } private _renderDevicesBar(): TemplateResult { return html`
${this._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
`; } /** Entities of a device worth CONTROLLING or reading, in a sensible order. */ private _cardEntities(d: DevItem): { eid: string; kind: 'toggle' | 'value' | 'open' }[] { const h = this._planHass; const out: { eid: string; kind: 'toggle' | 'value' | 'open' }[] = []; const seen = new Set(); const push = (eid: string) => { if (!eid || seen.has(eid) || !h.states[eid]) return; const reg = h.entities[eid]; if (reg?.entity_category === 'config' || reg?.entity_category === 'diagnostic') return; seen.add(eid); const dom = eid.split('.')[0]; if (['light', 'switch', 'fan', 'humidifier', 'siren', 'input_boolean'].includes(dom)) out.push({ eid, kind: 'toggle' }); else if (['cover', 'valve', 'lock', 'climate', 'media_player', 'vacuum', 'water_heater'].includes(dom)) out.push({ eid, kind: 'open' }); // needs the full more-info UI else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom)) out.push({ eid, kind: 'value' }); }; for (const source of resolvedLightSources(h, this._devices, null, this._virtualLights)) { if (source.device.id !== d.id) continue; for (const eid of [...source.serviceEids, ...source.stateEids]) push(eid); } if (d.primary) push(d.primary); for (const e of d.entities) push(e); return out.slice(0, 12); } /** Toggle straight from the device card (safe domains only). */ private _cardToggle(eid: string): void { const dom = eid.split('.')[0]; if (dom === 'lock' || dom === 'alarm_control_panel' || !this._planEntityAvailable(eid)) return; this.hass .callService('homeassistant', 'toggle', { entity_id: eid }) .catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) }))); } private _renderInfoCard(): TemplateResult { const d = this._infoCard!; const st = d.primary ? this.hass.states[d.primary] : undefined; const stateTxt = st ? hassValue(this.hass, d.primary)?.text ?? st.state : null; const controls = (d.controls ?? d.marker?.controls ?? []) .filter(isControllable).filter((eid) => this._planEntityAvailable(eid)); return html`
${(() => { // Field feedback: on a wall tablet this card is for CONTROLLING the // home; model/links/manuals are reference material and belong below. const ents = this._cardEntities(d); if (!ents.length) return nothing; return html`
${ents.map(({ eid, kind }) => { const est = this.hass.states[eid]; const name = this.hass.entities[eid]?.name || est?.attributes?.friendly_name || eid; const val = est ? hassValue(this.hass, eid)?.text ?? est.state : ''; const on = est?.state === 'on' || ['open', 'unlocked', 'playing', 'cleaning'].includes(est?.state); return html`
${name} ${kind === 'toggle' ? html`` : kind === 'open' ? html`` : html`${val}`}
`; })}
`; })()} ${d.model ? html`
${this._t('info.model')}${d.model}
` : nothing} ${stateTxt && !this._cardEntities(d).length ? html`
${this._t('info.state')}${stateTxt}
` : nothing} ${safeUrl(d.link) ? html`
${this._t('info.link')} ${d.link}
` : nothing} ${d.description ? html`
${d.description}
` : nothing} ${d.pdfs && d.pdfs.length ? html`
${this._t('info.manuals')} ${d.pdfs.map( (p) => html` ${p.name}`, )}
` : nothing} ${controls.length ? html`
${this._t('info.controls')} ${controls.map((eid) => { const cs = this.hass.states[eid]; const on = cs?.state === 'on'; return html` ${cs?.attributes?.friendly_name || eid}`; })}
` : nothing} ${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length) && !controls.length ? html`
${this._t('info.none')}
` : nothing}
${d.primary ? html`` : nothing}
`; } /** Preserve absence/future fields until the user explicitly edits the badge. */ private _markerValueBadgeFields( d: NonNullable, ): Pick | Record { return valueBadgeWriteFields({ touched: d.valueBadgeTouched, originalHas: d.originalHasValueBadge, original: d.originalValueBadge, enabled: d.valueBadgeEnabled, source: d.valueBadgeSource, position: d.valueBadgePosition, }); } /** Convert the transactional dialog state into the marker that Save would write. */ private _markerDraft(d: NonNullable): Marker | null { if (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual')) return null; const roomRef = parseRoomRef(d.room); const id = markerIdForBinding(d.binding, d.devId, () => '__hp_device_preview__'); const previous = this._markers.find((marker) => marker.id === id || marker.id === d.devId); const controls = persistedExternalControls( d.binding, d.controls, this._bindingEntities(d.binding), ); const effectiveTapAction = this._effectiveStoredTapAction(d); const marker: Marker = { id, binding: d.binding, name: d.name.trim() || null, icon: d.icon || null, display: d.display !== 'badge' ? d.display : null, ripple_color: d.display === 'icon_ripple' && d.rippleColor ? d.rippleColor : null, ripple_size: d.display === 'icon_ripple' && d.rippleSize !== 1.5 ? d.rippleSize : null, size: d.size !== 1 ? d.size : null, angle: d.angle || null, ...this._markerTapActionFields(d), ...this._markerToggleEntityFields(d), tap_target: effectiveTapAction === 'run' ? d.tapTarget || null : null, tap_confirm: d.tapConfirm ? true : null, controls: controls.length ? controls : null, ...this._markerLightFields(d), ...this._markerValueBadgeFields(d), use_climate_temp: d.useClimateTemp ? true : null, glow_radius_cm: (() => { const value = strictNumber(d.glowRadius); if (value == null || value <= 0) return null; return Math.round(this._imperial ? value * 30.48 : value * 100); })(), model: d.model.trim() || null, link: d.link.trim() || null, description: d.description.trim() || null, pdfs: d.pdfs, hidden: d.hideFromPlan, vacuum: previous?.vacuum || null, }; if (d.binding === 'virtual' || d.room) { marker.space = roomRef?.space || (d.binding === 'virtual' ? this._space : null); marker.area = roomRef?.area || null; marker.room_id = roomRef?.roomId || null; } return marker; } /** Runtime device for the unsaved marker, built by the production pipeline. */ private _markerPreviewDevice(d: NonNullable): DevItem | null { const marker = this._markerDraft(d); if (!marker) return null; const key = `${this._haRegistry.revision}\n${contentFingerprint(this._markers)}\n${JSON.stringify(marker)}`; if (this._markerPreviewMemo?.key === key) return this._markerPreviewMemo.device; const device = deviceFromMarkerDraft({ hass: this.hass, registry: this._haRegistry, areaToSpace: Object.fromEntries( Object.entries(this._areaToSpace).map(([area, value]) => [area, value.space]), ), marker, siblingMarkers: this._markers, settings: this._settings, excluded: this._excluded, showAll: this._showAll, firstSpaceId: this._model[0]?.id || this._space, loc: (key) => this._t(key), iconRules: this._iconRules, }); this._markerPreviewMemo = { key, device }; return device; } /** Preserve array identity while the dialog draft and runtime roster stay * unchanged, allowing light/value resolvers to reuse their WeakMap caches. */ private _markerPreviewDevices(preview: DevItem): readonly DevItem[] { const memo = this._markerPreviewDevicesMemo; if (memo?.base === this._devices && memo.preview === preview) return memo.devices; const devices = [...this._devices.filter((item) => item.id !== preview.id), preview]; this._markerPreviewDevicesMemo = { base: this._devices, preview, devices }; return devices; } private _toggleIntent( device: DevItem, devices: readonly DevItem[] = this._devices, ): ResolvedToggleIntent | null { return resolveToggleIntent({ hass: this._planHass, registryHass: this._fullRegistryHass, devices, device, virtualLights: this._virtualLights, }); } private _toggleIntentForDialog( d: NonNullable, ): ResolvedToggleIntent | null { const preview = this._markerPreviewDevice(d); if (!preview) return null; const devices = this._markerPreviewDevices(preview); return this._toggleIntent(preview, devices); } private _toggleStateText(entityId: string, fallback: string): string { const state = this._planHass?.states?.[entityId] || this.hass?.states?.[entityId]; try { return state && typeof this.hass?.formatEntityState === 'function' ? this.hass.formatEntityState(state) : fallback; } catch { return fallback; } } /** Current-state copy is localized without teaching the dialog command semantics. */ private _toggleConfirmationStateText(target: ResolvedToggleTarget): string { const raw = String(target.state || 'unknown'); const formatted = target.entityId ? this._toggleStateText(target.entityId, raw) : raw; if (formatted.trim().toLocaleLowerCase() !== raw.trim().toLocaleLowerCase()) return formatted; const known: Partial> = { on: 'confirm.state_on', off: 'confirm.state_off', open: 'confirm.state_open', closed: 'confirm.state_closed', opening: 'confirm.state_opening', closing: 'confirm.state_closing', unknown: 'confirm.state_unknown', }; const key = known[raw]; if (key) return this._t(key); // A future integration-specific token stays honest and readable even in a // harness without HA's formatter; underscores are never exposed as UI. return raw.replaceAll('_', ' ').replaceAll('-', ' '); } /** Snapshot lines shown by Toggle confirmation; execution still re-resolves later. */ private _toggleConfirmationLines(intent: ResolvedToggleIntent): string[] { const effectKeys: Record, I18nKey> = { 'turn-on': 'confirm.state_on', 'turn-off': 'confirm.state_off', open: 'confirm.state_open', close: 'confirm.state_closed', stop: 'confirm.state_stopped', }; return formatToggleConfirmation(intent, { state: (target) => this._toggleConfirmationStateText(target), current: (state) => this._t('confirm.current_state', { state }), expected: (state) => this._t('confirm.expected_state', { state }), groupCurrent: (on, total) => this._t('confirm.group_current', { on, total }), groupAllOn: () => this._t('confirm.group_all_on'), groupAllOff: () => this._t('confirm.group_all_off'), unavailable: (count) => this._t('confirm.unavailable_targets', { count }), effect: (effect) => this._t(effectKeys[effect]), expectedByHa: () => this._t('confirm.expected_by_ha'), }); } private _toggleHintLines(intent: ResolvedToggleIntent | null): string[] { if (!intent) return []; const effect = (value: ToggleNextEffect): string => this._t((`marker.toggle_effect_${value.replace('-', '_')}`) as any); const skipReason = (value: ToggleSkipReason): string => this._t((`marker.toggle_skip_${value.replace('-', '_')}`) as any); return formatToggleIntent(intent, { single: (target) => { if ('via' in target && target.via === 'virtual-light') { return this._t('marker.virtual_light_target', { name: target.name }); } const entityId = target.entityId || ('ref' in target ? target.ref : ''); const name = target.name || entityId; return this._t('marker.toggle_hint_single', { name, id: entityId }); }, group: (targets) => this._t('marker.toggle_hint_group', { count: targets.length, names: targets.map((target) => `${target.name} (${target.entityId})`).join(', '), }), currentNext: (target, next) => target.via === 'virtual-light' ? this._t('marker.virtual_light_current', { state: this._t(target.state === 'on' ? 'marker.virtual_light_state_on' : 'marker.virtual_light_state_off'), effect: effect(next), }) : this._t('marker.toggle_hint_current', { state: this._toggleStateText(target.entityId, target.state), effect: effect(next), }), groupCurrentNext: (targets, next) => this._t('marker.toggle_hint_group_current', { on: targets.filter((target) => target.state === 'on').length, count: targets.length, effect: effect(next), }), skipped: (targets) => this._t('marker.toggle_hint_skipped', { count: targets.length, targets: targets.map((target) => { const id = target.entityId || target.ref; return `${target.name || id} (${id}: ${skipReason(target.reason)})`; }).join(', '), }), none: (reason: ToggleNoneReason) => this._t((`marker.toggle_none_${reason.replaceAll('-', '_')}`) as any), }); } /** One projection for the untouched select, hints and draft transitions. */ private _effectiveStoredTapAction( d: NonNullable, primaryDomain?: string, ): string { return d.tapActionTouched ? d.tapAction : projectedTapAction( d.originalHasTapAction ? d.originalTapAction : null, primaryDomain, ); } private _effectiveMarkerTapAction( d: NonNullable, preview = this._markerPreviewDevice(d), ): string { return this._effectiveStoredTapAction(d, preview?.primary?.split('.')[0]); } /** Store one user-triggered announcement; live HA state ticks do not mutate it. */ private _announceToggleDraft( d: NonNullable, ): NonNullable { const preview = this._markerPreviewDevice(d); const next = !d.tapActionTouched ? { ...d, tapAction: this._effectiveMarkerTapAction(d, preview) } : d; const text = next.tapAction === 'toggle' ? this._toggleHintLines(this._toggleIntentForDialog(next)).join(' ') : ''; return { ...next, tapHintAnnouncement: text }; } private _valueBadgeForBinding( d: NonNullable, binding: string, ): Pick, 'valueBadgeEnabled' | 'valueBadgeSource' | 'valueBadgeTouched'> { const draft = { ...d, binding, valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true, }; const device = this._markerPreviewDevice(draft); if (!device) return { valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true }; const lightDevices = [...this._devices.filter((item) => item.id !== device.id), device]; const candidates = valueBadgeCandidates(this._planHass, device, lightDevices); const source = recommendedValueBadgeSource(this._planHass, device, candidates); return { valueBadgeEnabled: d.valueBadgeEnabled && !!source, valueBadgeSource: source, valueBadgeTouched: true, }; } private _markerSpatialSource(d: NonNullable) { const device = this._markerPreviewDevice(d); if (!device) return null; const preview = { ...device, hidden: false }; return selectSpatialGlowSource(resolvedLightSources(this._planHass, [ ...this._devices.filter((item) => item.id !== preview.id), preview, ], null, this._virtualLights).filter((source) => source.device.id === preview.id)); } private _markerAutoHasSpatialSource(d: NonNullable): boolean { const autoDraft = { ...d, lightRole: 'auto' as const, lightRoleTouched: true }; const device = this._markerPreviewDevice(autoDraft); return !!device && hasOwnSpatialSource(this._planHass, { ...device, hidden: false }); } private _setMarkerLightRole(role: 'auto' | 'always' | 'never'): void { const d = this._markerDialog; if (!d) return; this._markerDialog = { ...d, lightRole: role, lightRoleTouched: true }; } private _controlRefInfo(ref: string): { label: string; sub: string; icon: string; warning: boolean } { if (!ref.startsWith('marker:')) { return { label: this.hass.states[ref]?.attributes?.friendly_name || ref, sub: ref, icon: ref.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch', warning: !this._planEntityAvailable(ref), }; } const id = ref.slice('marker:'.length); const marker = this._markers.find((item) => item.id === id); const device = this._devices.find((item) => item.id === id); if (!marker || marker.removed || marker.is_light !== true) { return { label: this._t('marker.control_missing_label'), sub: `${this._t('marker.control_broken')} (${ref})`, icon: 'mdi:alert-outline', warning: true, }; } const space = this._serverCfg?.spaces.find((item: any) => item.id === (device?.space || marker.space)); const stateful = !!device && ownControllableEntities(device).length > 0; return { label: marker.name || device?.name || id, sub: [space?.title || device?.space || marker.space, marker.room_id || device?.area, stateful ? '' : this._t('marker.control_passive')].filter(Boolean).join(' · '), icon: marker.icon || device?.icon || 'mdi:lightbulb-outline', warning: marker.hidden === true || device?.hidden === true, }; } private _valueBadgeCandidateLabel(candidate: ValueBadgeCandidate): string { const source = candidate.source; if (source.kind === 'derived_lqi') return this._t('marker.value_badge_lqi'); if (source.kind === 'derived_marker_state') { return this._t('marker.value_badge_marker_state', { name: candidate.label }); } if (source.kind === 'entity_state') { return this._t('marker.value_badge_state', { name: candidate.label }); } const entityName = this.hass.states[source.entity_id]?.attributes?.friendly_name || this._fullRegistryHass.entities[source.entity_id]?.name || source.entity_id; return this._t((`marker.value_badge_attr_${source.attribute}`) as any, { name: entityName }); } private _controlCandidates(d: NonNullable): { value: string; label: string; sub: string; icon: string; }[] { const currentId = this._markerDraft(d)?.id || d.devId || ''; const plan: { value: string; label: string; sub: string; icon: string; search: string }[] = []; const coveredEntities = new Set(); for (const marker of this._markers) { if (marker.id === currentId || marker.removed || marker.hidden || marker.is_light !== true) continue; if (marker.binding !== 'virtual' && this._bindingStatus(marker.binding).kind !== 'active') continue; const device = this._devices.find((item) => item.id === marker.id); if (!device || device.hidden) continue; const info = this._controlRefInfo(`marker:${marker.id}`); for (const eid of ownControllableEntities(device)) coveredEntities.add(eid); plan.push({ value: `marker:${marker.id}`, label: info.label, sub: info.sub, icon: info.icon, search: `${info.label} ${info.sub} ${marker.id} ${ownControllableEntities(device).join(' ')}`.toLowerCase(), }); } const ha = Object.keys(this.hass.states || {}) .filter((eid) => isControllable(eid) && !coveredEntities.has(eid) && effectiveMarkerControls(d.binding, [eid], this._bindingEntities(d.binding)).length > 0 && this._planEntityAvailable(eid)) .map((eid) => { const info = this._controlRefInfo(eid); return { value: eid, label: info.label, sub: eid, icon: info.icon, search: `${info.label} ${eid}`.toLowerCase() }; }); const q = d.controlsFilter.trim().toLowerCase(); return [...plan, ...ha] .filter((item) => !d.controls.includes(item.value) && (!q || item.search.includes(q))) .slice(0, 12) .map(({ search: _search, ...item }) => item); } private _addControlRef(d: NonNullable, ref: string): void { if (ref.startsWith('marker:')) { const controllerId = this._markerDraft(d)?.id || d.devId || ''; if (!controllerId) { this._showToast(this._t('toast.marker_binding_required')); return; } const targetId = ref.slice('marker:'.length); const draft = this._markerDraft(d); const graph = draft ? [...this._markers.filter((marker) => marker.id !== controllerId), draft] : this._markers; if (markerControlWouldCycle(graph, controllerId, targetId)) { this._showToast(this._t('toast.marker_control_cycle')); return; } } this._markerDialog = this._announceToggleDraft({ ...d, controls: [...d.controls, ref], controlsFilter: '', }); } /** Switch modes without losing manual drafts; entering a manual mode snapshots live values once. */ private _setMarkerGlowMode(mode: 'auto' | 'color' | 'fixed'): void { const d = this._markerDialog; if (!d) return; if (mode === 'auto') { this._markerDialog = { ...d, glowMode: mode, glowTouched: true }; return; } const source = this._markerSpatialSource(d); const values = resolveGlowValues( source ? this._planHass.states[source.eid] : undefined, null, this._fillColors.glow_light.c, ); const needColor = !d.glowColorDrafted; const needBrightness = mode === 'fixed' && !d.glowBrightnessDrafted; this._markerDialog = { ...d, glowMode: mode, glowColor: needColor ? values.c : d.glowColor, glowBrightness: needBrightness ? Math.max(1, Math.round(values.bri * 100)) : d.glowBrightness, glowColorDrafted: true, glowBrightnessDrafted: d.glowBrightnessDrafted || mode === 'fixed', glowTouched: true, }; } private _renderMarkerDialog(): TemplateResult { const d = this._markerDialog!; const isVirtual = d.bindingMode === 'virtual'; const cands = this._bindingCandidates(); const ownEntities = this._bindingEntities(d.binding); const bindingStatus = isVirtual ? null : this._bindingStatus(d.binding); const canOpenBindingInHa = !isVirtual && this._bindingHasHaPage(d.binding); const previewDevice = this._markerPreviewDevice(d); // Untouched defaults are projections, not stored values. Re-resolve the // select from the current preview on every render: HA may reveal a more // meaningful leading entity (most visibly `light.*`) after the dialog was // opened. Runtime already uses that current primary, so keeping the stale // draft value here made the select say "Device card" while a tap toggled // the lamp. An explicit user choice remains authoritative and stable. const effectiveTapAction = this._effectiveMarkerTapAction(d, previewDevice); const previewSpaceDisplay = previewDevice ? spaceDisplayOf(this._serverCfg?.spaces.find((space: any) => space.id === previewDevice.space)) : null; const previewLightDevices = previewDevice ? this._markerPreviewDevices(previewDevice) : this._devices; const toggleIntent = effectiveTapAction === 'toggle' && previewDevice ? this._toggleIntent(previewDevice, previewLightDevices) : null; const toggleHintLines = this._toggleHintLines(toggleIntent); const previewPresentation = previewDevice ? resolveDevicePresentation(this._planHass, previewDevice, { liveStates: this._config?.live_states !== false, showTemperature: this._config?.show_temperature !== false, showSignal: previewSpaceDisplay?.showLqi ?? (this._config?.show_signal !== false), designPreview: true, activityRuntime: this._activityRt.get(previewDevice.id), lightDevices: previewLightDevices, registryHass: this._fullRegistryHass, reducedMotion: this._reducedMotion, }) : null; const badgeCandidates = previewDevice ? valueBadgeCandidates(this._planHass, previewDevice, previewLightDevices) : []; const badgeRecommendation = previewDevice ? recommendedValueBadgeSource(this._planHass, previewDevice, badgeCandidates) : null; const effectiveBadgeEnabled = d.valueBadgeTouched ? d.valueBadgeEnabled : !!previewPresentation?.valueBadge; const effectiveBadgeSource = d.valueBadgeTouched ? d.valueBadgeSource : previewPresentation?.valueBadge?.source || d.valueBadgeSource; const effectiveBadgePosition = d.valueBadgeTouched ? d.valueBadgePosition : previewPresentation?.valueBadge?.position || d.valueBadgePosition; const badgeSourceKey = valueBadgeSourceKey(effectiveBadgeSource); const badgeSourceMissing = !!effectiveBadgeSource && !badgeCandidates.some((item) => item.key === badgeSourceKey); const selectedBadgeCandidate = badgeCandidates.find((item) => item.key === badgeSourceKey); const innerValueSourceKey = previewPresentation?.valueSource ? previewPresentation.valueSource.attribute ? `attr:${previewPresentation.valueSource.eid}:${previewPresentation.valueSource.attribute}` : `state:${previewPresentation.valueSource.eid}` : ''; const autoHasSpatialSource = this._markerAutoHasSpatialSource(d); const statefulSource = !!previewDevice && hasOwnStatefulLightSource(this._planHass, { ...previewDevice, hidden: false }); const lightSettings = resolveDeviceLightSettings( d.lightRole, autoHasSpatialSource, statefulSource, d.glowMode, ); const glowSourceDisabled = !lightSettings.sourceExists; const liveGlowDisabled = !lightSettings.fromSourceEnabled; const passiveSource = lightSettings.passive; const displayedGlowMode = lightSettings.effectiveMode; const glowDisabledHint = d.lightRole === 'never' ? this._t('marker.glow_disabled_never') : d.lightRole === 'auto' && !autoHasSpatialSource ? this._t('marker.glow_disabled_auto') : passiveSource ? this._t('marker.glow_passive_hint') : this._t('marker.glow_disabled_no_entity'); const leadingEntities = previewDevice ? ownControllableEntities(previewDevice) : []; // Keep the fallback text aligned with the production resolver. In // particular, an entity binding or the resolved primary may precede the // registry order used for the remaining candidates. const effectiveLeading = previewDevice ? forcedLightEntityOf(previewDevice) || '' : ''; const staleLeading = !!d.lightEntity && !leadingEntities.includes(d.lightEntity); const toggleEntities = previewDevice ? toggleEntityCandidates(previewDevice) : []; const staleToggleEntity = !!d.toggleEntity && !toggleEntities.includes(d.toggleEntity); const automaticToggleIntent = effectiveTapAction === 'toggle' ? this._toggleIntentForDialog({ ...d, toggleEntity: '', toggleEntityTouched: true }) : null; const automaticToggleTarget = automaticToggleIntent ? [...automaticToggleIntent.targets, ...automaticToggleIntent.skippedTargets] .map((target) => target.entityId || ('ref' in target ? target.ref : '')) .filter(Boolean) .join(', ') : ''; const curLabel = (() => { if (isVirtual) return null; const found = cands.find((c) => c.value === d.binding); if (found) return found.label; const [k, ref] = d.binding.split(':'); if (k === 'device') return this._fullRegistryHass.devices[ref]?.name_by_user || this._fullRegistryHass.devices[ref]?.name || ref; return this._fullRegistryHass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref; })(); return html` (this._markerDialog = null)}>
${bindingStatus?.kind === 'ha_disabled' ? html`
${this._t((`marker.ha_disabled_${bindingStatus.reason}`) as any)} ${canOpenBindingInHa ? html`` : nothing}
` : bindingStatus?.kind === 'unverified' && !!d.binding ? html`
${this._t('marker.ha_registry_limited')}
` : nothing} (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} />
${d.bindingMode === 'ha' ? html` ${d.bindingOpen ? html`
(this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
${cands.map( (c) => html`
{ const next = { ...d, binding: c.value, bindingOpen: false, controls: persistedExternalControls( c.value, d.controls, this._bindingEntities(c.value), ), autoIcon: this._autoIconForBinding(c.value), }; this._markerDialog = this._announceToggleDraft({ ...next, ...this._valueBadgeForBinding(next, c.value), }); }}> ${c.label}${c.sub}
`, )} ${!cands.length ? html`
${this._t('marker.nothing_found')}
` : nothing}
` : nothing}` : nothing}
${this._renderVacSection(d)} ${effectiveTapAction === 'toggle' && (toggleEntities.length > 1 || staleToggleEntity) ? html`
${this._help('marker.toggle_entity.help')}
${staleToggleEntity ? html`

${this._t('marker.toggle_entity_missing', { entity: d.toggleEntity, fallback: automaticToggleTarget || this._t('marker.toggle_entity_none'), })}

` : nothing}
` : nothing} ${effectiveTapAction === 'toggle' ? html`
${toggleHintLines.map((line) => html`
${line}
`)}
${d.tapHintAnnouncement}
` : nothing} ${effectiveTapAction === 'run' ? (() => { const q = d.runFilter.trim().toLowerCase(); const cands = this._runCandidates().filter( (c) => !q || c.label.toLowerCase().includes(q) || c.value.includes(q), ); const cur = d.tapTarget ? this._runCandidates().find((c) => c.value === d.tapTarget) : null; return html` ${d.tapTarget && !cur ? html`
${this._t('marker.run_target_gone', { id: d.tapTarget })}
` : nothing} { (e.target as HTMLInputElement).select(); }} @input=${(e: Event) => (this._markerDialog = { ...d, runFilter: (e.target as HTMLInputElement).value, tapTarget: '' })} /> ${!cur ? html`
${cands.slice(0, 40).map( (c) => html`
(this._markerDialog = { ...d, tapTarget: c.value, runFilter: '' })}> ${c.label}${c.sub}
`, )} ${!cands.length ? html`
${this._t('marker.nothing_found')}
` : nothing}
` : nothing}`; })() : nothing} ${effectiveTapAction === 'run' || effectiveTapAction === 'toggle' ? html`` : nothing}
${this._t('marker.controls_hint')}
${d.controls.length ? html`
${d.controls.map((eid) => { const info = this._controlRefInfo(eid); return html` ${info.label} (this._markerDialog = this._announceToggleDraft({ ...d, controls: d.controls.filter((x) => x !== eid), }))}> `; })}
` : nothing} (this._markerDialog = { ...d, controlsFilter: (e.target as HTMLInputElement).value })} /> ${d.controlsFilter.trim() ? html`
${this._controlCandidates(d).map((candidate) => html``)}
` : nothing} ${this._bindingHasClimate(d.binding) ? html`` : nothing}
${this._t('marker.light_role_label')}${this._help('marker.light_role.help')}
${d.lightRole === 'always' && (leadingEntities.length > 1 || staleLeading) ? html`
${this._help('marker.light_entity.help')}
${staleLeading ? html`

${this._t('marker.light_entity_missing', { entity: d.lightEntity, fallback: effectiveLeading || '—', })}

` : nothing}
` : nothing}
${this._t('marker.glow_color_label')}${this._help('marker.glow_mode.help')}
${displayedGlowMode !== 'auto' ? html`
) => { this._markerDialog = { ...d, glowMode: displayedGlowMode, glowColor: e.detail.color, glowColorDrafted: true, glowTouched: true, }; }}> ${displayedGlowMode === 'fixed' ? html` ${this._t('marker.glow_brightness')} ${this._rangeInput(1, 100, 1, d.glowBrightness, (n) => { this._markerDialog = { ...d, glowMode: displayedGlowMode, glowBrightness: n, glowBrightnessDrafted: true, glowTouched: true, }; }, glowSourceDisabled, this._t('marker.glow_brightness'))} ${Math.round(d.glowBrightness)}%` : nothing}
` : nothing}
${this._help('marker.glow_radius.help')}
(this._markerDialog = { ...d, glowRadius: (e.target as HTMLInputElement).value })} /> ${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}
${glowSourceDisabled || passiveSource ? html`

${glowDisabledHint}

` : nothing} ${customElements.get('ha-icon-picker') // Feed the effective icon to HA's picker so its field renders both // the glyph and the mdi:* label. autoIcon is presentation-only: // untouched dialogs still save d.icon as an empty auto override. ? html` { const icon = e.detail.value || ''; // Some picker versions announce an assigned value. Do not // let that turn the display-only auto icon into an override. if (!d.icon && icon === d.autoIcon) return; this._markerDialog = { ...d, icon }; }}>` : html` (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`} ${!d.icon && d.autoIcon ? html`

${this._t('marker.icon_auto', { icon: d.autoIcon })}

` : nothing}

${this._t(DISPLAY_HINT_KEYS[d.display])}

${d.display === 'static_icon' && this._bindingHasAlarm(d.binding) ? html`
${this._t('marker.static_alarm_warning')}
` : nothing}
${this._t('marker.value_badge_title')}${this._help('marker.value_badge.help')} ${d.display === 'static_icon' ? html`

${this._t('marker.value_badge_static')}

` : !badgeCandidates.length && !d.valueBadgeSource ? html`

${this._t('marker.value_badge_empty')}

` : nothing} ${effectiveBadgeEnabled ? html`
${this._help('marker.value_badge_source.help')}
${selectedBadgeCandidate ? html`

${selectedBadgeCandidate.technical}

` : nothing} ${badgeSourceMissing ? html`

${this._t('marker.value_badge_missing_hint')}

` : nothing} ${d.display === 'value' && badgeSourceKey === innerValueSourceKey ? html`

${this._t('marker.value_badge_duplicate')}

` : nothing}
${this._help('marker.value_badge_position.help')}
` : nothing}
${previewPresentation ? html` ` : html`
${this._t('marker.preview.select_source')}
`} ${d.display === 'icon_ripple' ? html`
) => { this._markerDialog = { ...d, rippleColor: e.detail.color }; }}>
${this._t('marker.ripple_size')} ${this._rangeInput(1, 8, 0.5, d.rippleSize, (n) => (this._markerDialog = { ...d, rippleSize: n }))} ×${d.rippleSize}

${this._t('marker.activity_alarm_note')}

` : nothing}
${this._rangeInput(0.5, 3, 0.1, d.size, (n) => (this._markerDialog = { ...d, size: n }))} ×${d.size.toFixed(1)} ${this._t('marker.angle_label')} ${''/* 5 degrees, not 10 (owner 2026-08-03): a marker often has to line up with a wall that is not on a 10-degree grid. */} ${this._rangeInput(0, 355, 5, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))} ${d.angle}°
(this._markerDialog = { ...d, model: (e.target as HTMLInputElement).value })} /> (this._markerDialog = { ...d, link: (e.target as HTMLInputElement).value })} />
${d.pdfs.map( (p) => html` ${p.name} this._removeMarkerPdf(p.url)}>`, )} this._pickMarkerFiles(e)} />
${d.devId ? html`` : nothing} ${d.devId ? html`` : nothing}
`; } private _renderSpaceDialog(): TemplateResult { const d = this._spaceDialog!; const progress = this._importTotal > 0 && d.mode === 'create' ? this._t('import.progress', { i: this._importTotal - this._importQueue.length, n: this._importTotal, }) : ''; const close = () => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }; return html`
(this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} /> ${d.source === 'file' ? html`
${d.planFile ? html`${d.planFile.name}` : d.planUrl ? html`${this._t('space.plan_alt')}` : html`${this._t('space.no_plan')}`} this._pickPlanFile(e)} />
${d.pickSaved ? this._renderServerPlans(d) : nothing}` : nothing}
{ const raw = (e.target as HTMLInputElement).value; const n = strictNumber(raw); const canonical = n == null ? null : gridCellFieldToCm(n, this._imperial); this._spaceDialog = { ...d, cellCmInput: raw, cellCmTouched: true, cellCm: canonical != null && canonical > 0 ? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, canonical)) : d.cellCm, }; }} /> ${this._t( this._imperial ? 'space.scale_unit_imperial' : 'space.scale_unit', )}
${''/* the two "draw less" switches (owner 2026-08-05). They only hide: the shapes and the openings stay in the config and stay visible in the editor that owns them, so nothing is lost and nothing becomes uneditable. */}
${this._t('space.hide_decor_tip')}
${this._t('space.hide_openings_tip')}
${([['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'], ['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light']] as const).map( ([f, k]) => html``, )}
${this._rangeInput(50, 300, 5, Math.round(d.cardFontScale * 100), (n) => (this._spaceDialog = { ...d, cardFontScale: n / 100 }))} ${Math.round(d.cardFontScale * 100)}%
${this._renderCardPreview(d.cardFontScale, 1, 1)}
) => { this._spaceDialog = { ...d, roomColor: e.detail.color, roomOpacity: e.detail.opacity, }; }}>
${(d.bgMode ?? bgModeOf(this._settings, {})) === 'static' ? html`
) => { this._spaceDialog = { ...d, bgColor: e.detail.color }; }}> ${d.bgColor ? html`` : html`${this._t('space.bg_inherited')}`}
` : nothing}
{ const raw = (e.target as HTMLInputElement).value.trim(); const n = raw === '' ? null : Math.round(Number(raw)); this._spaceDialog = { ...d, northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null }; }} /> ${d.northDeg === null ? this._t('space.north_inherited', { v: northDegOf(this._settings, {}) === null ? '—' : String(northDegOf(this._settings, {})) + '°', }) : '°'}
${SPACE_FILL_UI_MODES.map((v) => [v, 'fill.' + v] as const).map( ([v, k]) => html` ${v === 'custom' && d.fillMode === 'custom' ? html`
${this._t('space.custom_fill')} ) => { this._spaceDialog = { ...d, customFill: { c: e.detail.color, a: e.detail.opacity } }; }}> ${d.customFill ? html`` : nothing}
` : nothing}`, )} ${d.deleteBlockers ? html`` : nothing}
`; } private _renderMergeDialog(): TemplateResult { const d = this._mergeDialog!; const rooms = this._spaceModel()?.rooms || []; const opt = (id: string, key: 'a' | 'b') => { const r = rooms.find((x) => x.id === id); const area = r?.area ? this.hass.areas[r.area]?.name : null; return html``; }; return html` (this._mergeDialog = null)}>

${this._t('merge.hint')}

${opt(d.aId, 'a')} ${opt(d.bId, 'b')}
`; } /** Live sample of a room card at the given multipliers (dialog preview). */ private _renderCardPreview(spaceScale: number, nameScale: number, labelScale: number): TemplateResult { const base = 18 * spaceScale; return html`
${this._t('preview.room_name')} 22.4° · 45% · ${this._t('roomcard.light_partial', { on: 1, total: 3 })}
`; } /** One measurement-source control (average vs an explicit device/entity). */ private _renderRoomSource(kind: 'temp' | 'hum'): TemplateResult { const val = kind === 'temp' ? this._roomTempSrc : this._roomHumSrc; const setVal = (v: string) => { if (kind === 'temp') this._roomTempSrc = v; else this._roomHumSrc = v; this.requestUpdate(); }; const open = this._roomSrcOpen === kind; return html` ${val || open ? html` ${open ? html`
{ this._roomSrcFilter = (e.target as HTMLInputElement).value; this.requestUpdate(); }} />
${this._roomSrcCandidates().map( (c) => html`
{ setVal(c.value); this._roomSrcOpen = null; }}> ${c.label}${c.sub}
`, )}
` : nothing}` : nothing}`; } private _renderRoomDialog(): TemplateResult { const edit = !!this._roomEditId; const faceBatch = !edit ? this._wallFaceBatch : null; const batchProgress = faceBatch && faceBatch.candidates.length > 1 ? this._t('room.queue_progress', { current: faceBatch.index + 1, total: faceBatch.candidates.length, }) : ''; const canSaveNew = !!this._areaSel || !!this._nameSel.trim(); const spaceDisplay = spaceDisplayOf(this._curSpaceCfg); const effectiveFill = this._roomFill || spaceDisplay.fill; const customFill = this._roomCustomFill || spaceDisplay.customFill; // the free-areas list must include the edited room's CURRENT area const areas = [...this._freeAreas]; if (edit && this._areaSel && !areas.some((a) => a.area_id === this._areaSel)) { const cur = this.hass.areas[this._areaSel]; if (cur) areas.unshift(cur); } return html`
${batchProgress ? html`

${batchProgress}

` : nothing} (this._nameSel = (e.target as HTMLInputElement).value)} /> ${([['', 'fill.inherit'], ...ROOM_FILL_MODES.map((v) => [v, 'fill.' + v])] as const).map( ([v, k]) => html``, )} ${effectiveFill === 'custom' ? html`
${this._roomCustomFill ? this._t('room.custom_fill_own') : this._t('room.custom_fill_space')} ) => { this._roomCustomFill = { c: e.detail.color, a: e.detail.opacity }; }}> ${this._roomCustomFill ? html`` : nothing}
` : nothing} ${this._renderRoomSource('temp')} ${this._renderRoomSource('hum')}
${this._rangeInput(50, 300, 5, Math.round(this._roomNameScale * 100), (n) => { this._roomNameScale = n / 100; this.requestUpdate(); })} ${Math.round(this._roomNameScale * 100)}%
${this._rangeInput(50, 300, 5, Math.round(this._roomLabelScale * 100), (n) => { this._roomLabelScale = n / 100; this.requestUpdate(); })} ${Math.round(this._roomLabelScale * 100)}%
${this._renderCardPreview( spaceDisplayOf(this._curSpaceCfg).cardFontScale, this._roomNameScale, this._roomLabelScale, )}
${edit ? html`` : html`${!this._pendingSplit ? html`` : nothing} `}
`; } static styles = [cardStyles, editorSecondaryStyles]; } if (!customElements.get('houseplan-card')) { customElements.define('houseplan-card', HouseplanCard); } (window as any).customCards = (window as any).customCards || []; if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-card')) { (window as any).customCards.push({ type: 'houseplan-card', name: 'House Plan Card', description: 'Interactive house plan: spaces, rooms and devices with live states and drag layout.', }); } // eslint-disable-next-line no-console console.info(`%c HOUSEPLAN-CARD %c v${CARD_VERSION} `, 'background:#3ea6ff;color:#04121f;font-weight:700', '');