Files
houseplan-card/src/houseplan-card.ts
T
houseplan dev bb4d4e1f6e Opening rulers also while PLACING a new opening
The shoulder badges, the centre tick and the soft magnet used to live only
in the drag of an EXISTING opening. Placing a new one — the gesture where
you actually choose the spot — showed a bare dashed ghost.

One implementation now serves both: _opRuler() takes a wall snap, the
opening length and the Shift flag, returns the magnetised point plus the
badges/tick, and is called from _opPointerMove (drag), _openingPreview
(hover) and _openingClick (placement). The click therefore creates the
opening exactly where the preview showed it, and clearing _cursorPt makes
ghost, badges and tick disappear together the moment it lands.

Smoke: smoke_opening_measure gains a «PLACING a new opening» section (13 of
its checks fail on the previous build). TESTING.md: checklist row.
Shot: demo/shot_opening_place.mjs.
2026-08-03 22:14:54 +03:00

7771 lines
361 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
} from './rules';
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, openingShoulders, interiorPoint, poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
coverService, coverMoving, COVER_GUARDED_CLASSES,
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayAlpha, rayColor, type SunRay,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
import './editor';
import './space-card';
import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.56.0';
/** 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;
/** 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). */
const warmBoot = new Map<string, { hdrH: number; stageH: number }>();
const warmBootKey = (config: unknown): string =>
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
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 + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** motion flash window: 3 beats of the hp-sense ring (3 × 1.1s in styles.ts).
Owner's rule (2026-08-01): «движение = разовая вспышка в момент
обнаружения; cool-down не пульсирует». */
const SENSE_FLASH_MS = 3300;
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'resize' | 'opening' | 'openwall' | 'delroom';
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<T extends (...a: any[]) => void> {
(...a: Parameters<T>): void;
flush(): void;
pending(): boolean;
}
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced<T> => {
let t: number | undefined;
let last: Parameters<T> | null = null;
const wrapped = ((...a: Parameters<T>) => {
clearTimeout(t);
last = a;
t = window.setTimeout(() => {
t = undefined;
const args = last;
last = null;
if (args) fn(...args);
}, ms);
}) as Debounced<T>;
wrapped.flush = () => {
if (t === undefined) return;
clearTimeout(t);
t = undefined;
const args = last;
last = null;
if (args) fn(...args);
};
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 badges on both shoulders of an opening + the centre-magnet tick.
* The same shape serves the DRAG of an existing opening and the PLACEMENT
* preview of a new one (owner 2026-08-03). */
/** Default length of a freshly placed opening, cm (the dialog's door preset). */
const OPENING_DEFAULT_CM = 90;
interface OpMeasure {
labels: { x: number; y: number; text: string }[];
guide: { x: number; y: number; angle: number } | null;
}
class HouseplanCard extends LitElement {
public hass?: any;
private _config?: CardConfig;
private _space = 'f1';
private _layout: Record<string, { x: number; y: number; s?: string; k?: number }> = {};
private _serverStorage = false;
private _loadOk = false;
private _loading = false;
private _loadTries = 0;
private _serverCfg: ServerConfig | null = null;
private _cfgRev = 0;
private _unsubCfg: (() => void) | null = null;
private _unsubLayout: (() => void) | null = null;
private _layoutRev = 0;
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _newSyncKey = '';
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null;
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';
// ---- decor (background) editor ----
private _decorTool: 'select' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
private _decorStyle: { color: string; width: number; fill: boolean } = { color: '#607d8b', width: 3, fill: false };
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean {
return this._norm && this.hass?.user?.is_admin !== false;
}
/** 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 plan should fly out when the space changes. Empty means no
* animation — a direct pick from the tabs should not slide anywhere.
*/
private _slide: '' | 'left' | 'right' = '';
private _slideTimer?: number;
/** Change the space with the usual sideways transition. */
private _slideTo(id: string, dir: 'left' | 'right'): void {
if (id === this._space) return;
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
this._space = id;
this._selId = null;
this._restoreZoom();
if (reduce) return;
this._slide = dir;
clearTimeout(this._slideTimer);
// long enough to be read as motion, short enough not to be in the way
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
this.requestUpdate();
}
private _cycleTick(): void {
if (!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();
}
}
/** 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';
// room resize tool (docs/RESIZE.md): selection, live drag, its own undo stack
private _rszSel: string | null = null;
private _rszDrag: {
kind: 'edge' | 'scale';
pid: number;
roomId: string;
plan?: EdgeDragPlan;
fixed?: [number, number];
span0?: number;
rooms: { id: string; poly: number[][] }[];
openings: { id: string; x: number; y: number; length: number }[];
snap: string;
moved: boolean;
d: number;
k: number;
changed: string[];
} | null = null;
private _rszUndo: { space: string; snap: string }[] = [];
/** HP-1550-01: the live resize preview, kept OUT of _serverCfg (see _rszApplyPreview). */
private _rszPreview: { space: string; sp: any } | null = null;
private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null;
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _mergeSel: string | null = null;
private _openingDialog: {
id?: string; // editing an existing opening
type: 'door' | 'window';
lengthCm: number;
contact: string;
lock: string;
invert: boolean;
flipH: boolean;
flipV: boolean;
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 } | null = null;
// live ruler badges + the "centered on the wall" tick while an opening is dragged
private _opMeasure: OpMeasure | null = null;
/** Shift during the PLACEMENT hover: opts out of the centre magnet, exactly
* as it does while dragging an existing opening. */
private _opShift = false;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | 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;
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' = ''; // '' = inherit
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<string, number> = {};
/**
* 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<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false;
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 _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: { 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;
private _settingsDialog: {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; weatherEntity: string;
busy: boolean;
} | null = null;
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
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;
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:<id>' | 'entity:<eid>' | '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: 'badge' | 'ripple' | 'icon_ripple' | 'value';
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
angle: number; // icon rotation, degrees
tapAction: string;
tapTarget: string; // 'run': automation./script./scene. entity id
tapConfirm: boolean; // ask before toggle/run
runFilter: string; // '' = the effective default (defaultTap)
defaultTap: 'info' | 'toggle';
controls: string[]; // entities this icon toggles as a group
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
isLight: boolean; // force this marker to glow (dumb fixtures behind a switch)
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;
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' | 'glow';
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
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
/** motion-sensor runtime per marker: the last seen primary state and the
ts of the last off→on trip. Owner's rule (2026-08-01): «движение =
разовая вспышка в момент обнаружения; cool-down не пульсирует» —
the flash is keyed to the TRANSITION, not to the 'on' state. `timer`
is the one setTimeout per entry that repaints the card when the flash
window closes (cleared in disconnectedCallback). */
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number; gen: number }>();
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null }>();
/** 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<string, any> = {};
private _unsubTrail?: () => void;
private _vacJumpOnce = false;
private _vacVisHandler = () => {
if (document.visibilityState === 'visible') {
this._vacJumpOnce = true;
this.requestUpdate();
}
};
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;
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;
private _lastTap = 0;
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
return m ? decodeURIComponent(m[1]) : '';
}
private _onHashChange = (): void => {
const id = this._hashSpace();
if (id && this._model.find((sp) => sp.id === id) && id !== this._space) {
this._space = id;
this._selId = null;
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 = {
_hdrH: { state: true },
_booting: { state: true },
_bootFading: { state: true },
_bootSoft: { state: true },
_tapConfirm: { state: true },
hass: { attribute: false },
_config: { state: true },
_space: { state: true },
_layout: { state: true },
_devices: { state: true },
_tip: { state: true },
_selId: { state: true },
_toast: { state: true },
_serverCfg: { state: true },
_mode: { state: true },
_tool: { state: true },
_rszSel: { state: true },
_rszLive: { state: true },
_opMeasure: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
_openingDialog: { state: true },
_openingInfo: { state: true },
_mergeDialog: { state: true },
_splitSel: { state: true },
_decorTool: { state: true },
_decorStyle: { state: true },
_decorDraft: { state: true },
_decorSel: { state: true },
_decorTextDialog: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
_roomEditId: { state: true },
_roomFill: { 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 },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
_view: { state: true },
};
public connectedCallback(): void {
document.addEventListener('visibilitychange', this._vacVisHandler);
super.connectedCallback();
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));
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
}
window.addEventListener('hashchange', this._onHashChange);
// 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();
}
public disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
for (const rt of this._senseRt.values()) clearTimeout(rt.timer); // pending flash-window repaints
window.removeEventListener('keydown', this._keyHandler);
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 = null;
this._signer.dispose();
clearTimeout(this._toastTimer);
clearTimeout(this._slideTimer);
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);
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;
}
clearTimeout(this._layoutSyncTimer);
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._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._infoCard = null; return; }
if (this._rulesDialog) { this._rulesDialog = 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._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._mode === 'decor') {
if ((e.key === 'Delete' || e.key === 'Backspace') && this._decorSel &&
!(e.target as HTMLElement)?.closest?.('input, textarea, select')) {
e.preventDefault();
this._decorDeleteSel();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
if (this._decorDraft) this._decorDraft = null;
else if (this._decorSel) this._decorSel = null;
else if (this._decorTool !== 'select') this._decorTool = 'select';
else this._setMode('view');
}
return;
}
if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
if (!undo) return;
if (this._roomDialog) {
e.preventDefault();
this._roomDialogCancel();
return;
}
if (this._tool === 'draw' && this._path.length) {
e.preventDefault();
this._undoPoint();
return;
}
if (!undo) return;
if (this._tool === 'resize') {
e.preventDefault();
if (this._rszDrag) {
// Esc (or Ctrl+Z) mid-drag: the original geometry comes back
this._rszCancelDrag();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
this._rszUndoPop();
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._tool === 'openwall' || this._tool === 'opening' || this._tool === 'delroom') {
e.preventDefault();
this._tool = 'draw';
}
}
/** Remove the last placed point. An unfinished outline is never persisted. */
private _undoPoint(): void {
if (!this._path.length) return;
this._path = this._path.slice(0, -1);
}
public static getConfigElement() {
return document.createElement('houseplan-card-editor');
}
public static getStubConfig(): Partial<CardConfig> {
return { type: 'custom:houseplan-card' };
}
/** Test hook (smokes): forget the warm re-mount memo — a cold page again. */
public static _warmBootReset(): void {
warmBoot.clear();
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
else {
// DEV-B703-01: this page already booted an identical card at this
// viewport — adopt its settled header height and skip the veil
// entirely: the card reveals synchronously in the final geometry (the
// saved zoom is armed below, HP-1551). _bootSoft covers any residual
// chrome drift with a glide instead of a snap.
const warm = warmBoot.get(warmBootKey(this._config));
if (warm) {
this._booting = false;
this._bootFading = false;
this._hdrH = warm.hdrH;
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless setConfig re-runs while attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
}
}
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
} catch {
this._zoomBySpace = {};
}
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._layout = c.layout || {};
this._serverStorage = true;
const hs = this._hashSpace();
const nav = this._savedNav();
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
// reopenning the tab lands you in the same editor you left (admins only);
// kiosk screens always stay in View
if (nav?.mode && nav.mode !== 'view' && this._canEdit && !config.kiosk) this._mode = nav.mode;
}
} catch {
/* ignore */
}
// 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;
}
/** Save a snapshot of the config+layout to localStorage for an instant start. */
private _cacheSnapshot(): void {
if (!this._serverCfg) return;
try {
localStorage.setItem(LS_CFG, JSON.stringify({ config: this._serverCfg, rev: this._cfgRev, layout: this._layout }));
} catch {
/* ignore */
}
}
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;
/** 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 + ','
+ (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(id?: string): SpaceModel {
const m = this._model;
return m.find((s) => s.id === (id ?? this._space)) || m[0];
}
private get _areaToSpace(): Record<string, { space: string; room: RoomCfg }> {
const map: Record<string, { space: string; room: RoomCfg }> = {};
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,
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<string> {
const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
}
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
if (changed.has('hass') && this.hass) {
this._hookConnection();
if (!this._loadOk && !this._loading && this._loadTries < 8) {
this._loadFromServer();
}
this._maybeRebuildDevices();
}
}
protected updated(): void {
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);
if (t >= 0 && Math.abs(t - this._hdrH) > 1) 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) {
warmBoot.set(warmBootKey(this._config), { 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();
// 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 =================
private async _loadFromServer(): Promise<void> {
this._loading = true;
this._loadTries++;
try {
const [cfgResp, layResp] = await Promise.all([
this.hass.callWS({ type: 'houseplan/config/get' }),
this.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this._loadOk = true;
this._serverStorage = true;
const cfg = cfgResp?.config;
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
this._cfgEpoch++;
this._cfgRev = cfgResp?.rev || 0;
this._layout = layResp?.layout || {};
this._layoutRev = layResp?.rev ?? 0;
// live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((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).
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
// server-side trails are additive: an older backend without the WS
// command just leaves the map empty and the card shows live-only trails
this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
if (!this._unsubTrail) {
this._unsubTrail = await this.hass.connection.subscribeEvents(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 */ }
}, 'houseplan_trail_updated');
}
if (!this._unsubLayout) {
// Positions are separate state. The static card learned to follow them
// in v1.46.0 and the full one did not, so two full cards side by side
// stayed out of sync until a reload (HP-1460-03).
this._unsubLayout = await this.hass.connection.subscribeEvents(
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
'houseplan_layout_updated',
);
}
const hs = this._hashSpace();
const nav = this._savedNav();
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
this._space = hs;
this._hashApplied = true;
} else if (nav?.space && !this._navApplied && !this._hashApplied
&& this._model.find((s) => s.id === nav.space)) {
// the cached config might have been stale (no such space) — retry once
// the live config is in
this._space = nav.space;
this._navApplied = true;
} else if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space;
}
this._cacheSnapshot();
this._restoreZoom();
} 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();
} 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;
}
this._regSignature = '';
this.requestUpdate();
}
/**
* 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): Promise<void> {
if (!force) {
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(), 400);
return;
}
}
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
const cfg = resp?.config;
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
this._cfgEpoch++;
this._cfgRev = resp?.rev || 0;
this._cacheSnapshot();
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) }));
}
}
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(): 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 (!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 _onConnReady = (): void => {
this._loadTries = 0;
clearTimeout(this._loadRetryTimer);
this._loadRetryTimer = undefined;
if (this._loading) return;
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
// full load path repairs both (every subscribe in it is guarded)
if (!this._loadOk || !this._unsubCfg) this._loadFromServer();
else this._reloadConfigOnly();
};
private _hookConnection(): void {
const conn = (this.hass as any)?.connection;
if (!conn || conn === this._connHooked) return;
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
conn.addEventListener?.('ready', this._onConnReady);
this._connHooked = conn;
}
/**
* Signed urls for the content endpoint (audit follow-up B1 regression).
* A browser cannot authenticate an <image href> or an <a href>: 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<void> {
if (!this._serverStorage || !this.hass?.callWS) return;
// 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<string, any>();
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<string, any> = { ...remote };
for (const [id, pos] of mine) merged[id] = pos;
this._layout = merged;
this._layoutRev = resp?.rev ?? this._layoutRev;
this._cacheSnapshot();
this.requestUpdate();
} catch {
/* a failed refresh just leaves the positions we already had */
}
}
private _dirtyPos = new Set<string>();
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
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 = this._layout[id];
if (!pos) continue;
// 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 {
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 sig =
Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' +
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;
this._devices = buildDevices({
hass: h,
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,
});
this._defPos = this._defaultPositions();
this._syncNewDevices();
this._seedHiddenDevices();
}
/**
* "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<string> {
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._devices) {
if (d.area !== area || d.virtual) continue;
const l = lqiFor(this.hass, 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<string, { x: number; y: number }> {
const map: Record<string, { x: number; y: number }> = {};
const iconPct = this._config?.icon_size ?? 2.5;
const minDist = (iconPct / 100) * NORM_W * 1.3; // no closer than the icon diameter + a margin
for (const s of this._model) {
for (const r of s.rooms) {
if (!r.area) continue;
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);
ds.forEach((d, i) => (map[d.id] = pts[i]));
}
}
return map;
}
/** Device position in render units of the current space. */
private _pos(d: DevItem): { x: number; y: number } {
const s = this._spaceModel(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];
const vb = s.vb;
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
}
private _savePos(d: DevItem, x: number, y: number): void {
if (this._norm) {
// the icon center snaps to the nodes of the same grid as the room markup
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: gx / NORM_W, y: 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 =================
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
// an icon with controlled targets mirrors THEM, not its own entity
// (stateless remotes and virtual wall switches have nothing else to show)
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length)
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
// A shining light yields 'on' here by the SAME condition that lights the
// glow pool, so the pool and the state cannot disagree. Note the renderer
// then STRIPS the class wherever the glow layer is actually visible —
// there the spot is the one indicator (v1.52.0, owner's rule); the badge
// stays yellow only where the spot is not drawn (other fills, the plan
// editor).
if (litLightEntity(this.hass, d)) return 'on';
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
// derive the domain from the entity id we looked the state up by — state
// objects are not guaranteed to carry entity_id (defensive; found by the
// TESTING.md edge-case run)
const dom = d.primary!.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'climate') {
// yellow = actually working right now ("which radiators are heating"),
// not "enabled for the winter": hvac_action when the integration
// reports one, the coarser state only as a fallback
const act = p.attributes?.hvac_action;
if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
return ['off', 'unknown'].includes(p.state) ? '' : 'on';
}
if (dom === 'cover' || dom === 'valve') {
const open = ['open', 'opening'].includes(p.state) ? 'open' : '';
// Owner's rule (2026-08-03): a cover ON THE MOVE does NOT take the
// yellow «включено» plate — it breathes a soft ring instead, the same
// language the vacuum puck and the presence ring speak. The existing
// 'open' frame is orthogonal and stays.
if (dom === 'cover' && coverMoving(p.state)) return (open ? open + ' ' : '') + 'covermove';
return open;
}
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
const dc = p.attributes?.device_class;
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
return p.state === 'on' ? 'open' : '';
// Owner's rule (2026-08-01, вариант «б»): «движение = разовая
// вспышка в момент обнаружения; cool-down не пульсирует; присутствие =
// статичное кольцо пока обитаемо». Neither takes the yellow 'on'
// fill — that stays reserved for «включено».
// MOTION flashes once per off→on trip: 'senseflash' lives only for
// the ~3.3s window after the transition (stamped by _senseTick on
// the hass tick) — a sensor still 'on' after that is in its
// cool-down, not seeing motion, so the class goes away even though
// the state has not changed. A new off→on trip re-arms the flash.
if (dc === 'motion') {
const rt = this._senseRt.get(d.id);
if (!(rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS)) return '';
// HP-1543-02: alternate the animation identity per trip (see
// _senseTick) — odd generations ride the base hp-sense keyframes,
// even ones the identical hp-sense-b twin, so a rapid re-trip
// restarts the flash instead of silently inheriting the old timeline
return rt.gen % 2 === 0 ? 'senseflash sf2' : 'senseflash';
}
// OCCUPANCY/PRESENCE while 'on': a calm STATIC ring ('sensehold',
// no animation in styles.ts) — «комната обитаема» is a state, not
// an event, so it must not blink.
if (['occupancy', 'presence'].includes(dc))
return p.state === 'on' ? 'sensehold' : '';
}
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
return '';
}
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.hass, d.entities);
if (t != null) return t;
}
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return tempFor(this.hass, d.entities);
}
/** Does the dialog's binding carry a climate entity? Gates the opt-in checkbox. */
private _bindingHasClimate(binding: string): boolean {
if (binding.startsWith('entity:')) return binding.slice(7).startsWith('climate.');
if (binding.startsWith('device:')) {
const ref = binding.slice(7);
for (const [eid, reg] of Object.entries<any>(this.hass?.entities || {})) {
if (reg?.device_id === ref && eid.startsWith('climate.')) return true;
}
}
return false;
}
/** The cover entity behind the dialog's binding, or null. */
private _bindingCoverEntity(binding: string): string | null {
if (binding.startsWith('entity:')) {
const eid = binding.slice(7);
return eid.startsWith('cover.') ? eid : null;
}
if (binding.startsWith('device:')) {
const ref = binding.slice(7);
for (const [eid, reg] of Object.entries<any>(this.hass?.entities || {})) {
if (reg?.device_id === ref && eid.startsWith('cover.')) return eid;
}
}
return null;
}
/**
* Does the dialog's binding deserve the «Open/close» tap option? Gates it
* exactly like the climate checkbox above: only a device that HAS a cover
* entity sees it — and never a garage door, a gate or a driveway door
* (COVER_GUARDED_CLASSES; owner 2026-08-03: «нет, только шторы/жалюзи»).
*/
private _bindingCoverTap(binding: string): boolean {
const eid = this._bindingCoverEntity(binding);
if (!eid) return false;
const dc = String(this.hass?.states?.[eid]?.attributes?.device_class || '');
return !COVER_GUARDED_CLASSES.has(dc);
}
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 _openMoreInfo(entityId?: string): void {
if (!entityId) {
this._showToast(this._t('toast.no_entity'));
return;
}
fireEvent(this, 'hass-more-info', { entityId });
}
/** 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 (d.primary) this._openMoreInfo(d.primary);
else this._infoCard = d;
}
private _clickDevice(ev: MouseEvent, 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;
}
const domain = d.primary ? d.primary.split('.')[0] : null;
// 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 (d.marker?.tap_confirm) this._tapConfirm = { text, exec };
else exec();
};
// a switch with bound targets: the EXPLICIT per-marker toggle flips them
// all with HA-group semantics (any on -> all off). Owner's decision:
// controls never fire on the card-wide default action.
const controls = (d.marker?.controls || []).filter(isControllable);
if (d.tapAction === 'toggle' && controls.length) {
const act = controlsAction(controls.map((e) => this.hass.states[e]?.state));
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
this.hass
.callService('homeassistant', act, { entity_id: controls })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
});
return;
}
const action = resolveTapAction(
d.tapAction, undefined, domain,
d.primary ? this.hass.states[d.primary]?.attributes?.device_class : null,
);
if (action === 'run') {
const target = d.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 }), () => {
this.hass
.callService(svc.domain, svc.service, { entity_id: target })
.then(() => this._showToast(this._t('toast.run_started', { name })))
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
});
return;
}
if (action === 'cover' && d.primary) {
// open / close / stop, decided by the CURRENT state (docs/PRODUCT.md);
// a tap while the curtain travels stops it, the next one reverses
const svc = coverService(this.hass.states[d.primary]?.state);
guarded(this._t('confirm.tap_cover', { name: d.name }), () => {
this.hass
.callService('cover', svc, { entity_id: d.primary })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
});
return;
}
if (action === 'toggle' && d.primary) {
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
this.hass
.callService('homeassistant', 'toggle', { entity_id: d.primary })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
});
return;
}
if (action === 'more-info' && d.primary) {
this._openMoreInfo(d.primary);
return;
}
this._infoCard = d;
}
/** Translate a key in the card's current language. */
private _t(key: I18nKey, vars?: Record<string, string | number>): string {
return t(langOf(this.hass, this._config?.language), key, vars);
}
private get _stageEl(): HTMLElement | null {
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
}
/**
* The rectangle "fit to screen" fits. With a background image that is the
* whole canvas — the image IS the plan. Without one it is what has been
* drawn, plus a small margin, so a single room on a big canvas fills the
* screen instead of sitting in it as a speck.
*/
private _baseVb(): number[] {
const m = this._spaceModel();
if (m.bg) return m.vb;
// The EDITORS get the whole square: the content frame also bounds pan,
// zoom and pointer maths, and inside it there is nowhere to draw the next
// room or drag a marker away from the first one (HP-1490-03).
if (this._mode !== 'view') return m.vb;
// devices are content too — they may stand outside every room
const pts = this._devices
.filter((d) => d.space === m.id)
.map((d) => { const p = this._pos(d); return [p.x, p.y] as const; });
const b = contentBounds(m, 0.05, pts);
return b ? [b.x, b.y, b.w, b.h] : m.vb;
}
/**
* Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
* the 'daynight' sky — must never bleed through the plan itself, so opaque
* shapes sit under everything the plan draws. With a backdrop image the
* paper is the image rect (the canvas IS the paper) — this helper returns
* it. For a hand-drawn plan the paper follows the ROOM CONTOURS instead
* (paperRoomShapes in logic.ts): one shape per room in exactly the room's
* own geometry, so an L-shaped house or detached buildings never grow a
* white bounding rectangle — the scene colour is visible right up to the
* exterior walls, and an empty drawn space has no paper at all. Its colour
* is the pre-bg_color canvas (styles.ts .hp-paper): white for drawn plans,
* the theme card background under an image. At night 'daynight' dims it via
* the zoomwrap brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
*/
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } | null {
return m.bg ? { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h } : null;
}
/** 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());
}
/** 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: 8× in, 0.4× out (zoomed out, the plan floats centred). */
private static readonly ZOOM_MAX = 8;
private static readonly ZOOM_MIN = 0.4;
/** Clamp the view to the fit bounds (the content always covers the scene).
* Zoomed OUT the view is larger than the content on an axis — then there is
* nothing to clamp against and the content is centred on that axis instead
* of being pinned to a corner. */
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 } {
return {
w: v.w,
h: v.h,
x: v.w >= fit.w
? fit.x + (fit.w - v.w) / 2
: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
y: v.h >= fit.h
? fit.y + (fit.h - v.h) / 2
: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
};
}
/** 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): void {
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;
this._zoom = z;
this._view = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit);
}
/**
* 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) return;
this._refitView(); // reveal in the final geometry, never mid-jump
this._booting = 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) {
warmBoot.set(warmBootKey(this._config), { hdrH: this._hdrH, stageH: settledH });
}
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;
}
/** Recompute the view for a new scene size, preserving zoom and center. */
private _refitView(): void {
if (!this._stageEl) return;
const cur = this._view;
this._applyView(this._zoom, cur ? cur.x + cur.w / 2 : undefined, cur ? cur.y + cur.h / 2 : undefined);
this.requestUpdate();
}
/** 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));
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();
}
private _resetZoom(): void {
const vb = this._baseVb();
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
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._suppressClick = false;
} else if (this._pointers.size === 2) {
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;
}
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
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) {
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._zoom > 1 && this._view) {
const stage = this._stageEl!;
const v = this._view;
const fit = fitView(this._baseVb(), this._stageAspect());
this._view = this._clampView(
{
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
y: this._panStart.vy - (ddy / stage.clientHeight) * v.h,
w: v.w,
h: v.h,
},
fit,
);
}
}
}
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;
}
const target = 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
this._slideTo(target, dx < 0 ? 'left' : 'right');
this._saveNav();
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._showKioskDots();
}
}
}
if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft();
return;
}
if (this._decorMove?.pid === ev.pointerId) {
if (this._decorMove.moved) this._saveConfig();
this._decorMove = null;
return;
}
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
this._panStart = null;
// reset click suppression on the next tick (so that a click right after a pan does not fire)
setTimeout(() => (this._suppressClick = false), 0);
}
}
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);
this._holdTimer = window.setTimeout(() => {
this._holdFired = true;
this._infoCard = d;
}, 600);
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);
}
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, 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 {
this._toast = msg;
clearTimeout(this._toastTimer);
this._toastTimer = window.setTimeout(() => {
this._toast = '';
}, 3500);
}
/**
* Touch-first surface: no hover tooltips.
*
* The media query alone was not enough (field report, 2026-07-27: tooltips
* still stuck on a OnePlus). Some devices/skins report `hover: hover`, and a
* stylus or a paired mouse flips it too. So this also latches on the FIRST
* touch pointer event and never unlatches for that session — a device that
* has been touched once is a touch device.
*/
private static _touchSeen = false;
private static readonly _noHoverMq =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(hover: none)').matches;
private get _noHover(): boolean {
return HouseplanCard._noHoverMq || HouseplanCard._touchSeen;
}
/** Any touch anywhere marks the session as touch-first and kills open tips. */
private _notePointer(ev: PointerEvent): void {
if (ev.pointerType === 'touch' || ev.pointerType === 'pen') {
HouseplanCard._touchSeen = true;
if (this._tip) this._tip = null;
}
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
if (this._noHover) return;
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
}
// ================= ROOM MARKUP EDITOR =================
private get _gridPitch(): number {
return NORM_W / GRID_N;
}
/** 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; mode?: 'view' | 'plan' | 'devices' | 'decor' } | null {
try {
return JSON.parse(localStorage.getItem(LS_NAV) || 'null');
} catch {
return null;
}
}
private _saveNav(): void {
try {
localStorage.setItem(LS_NAV, JSON.stringify({ space: this._space, mode: this._mode }));
} catch {
/* private mode etc. */
}
}
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
if (this._mode === mode) return;
this._bootSoftCancel(); // editor bars change the stage height DELIBERATELY — snap, no glide
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
if (this._mode === 'view' && mode !== 'view') {
// 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 ? v.x + v.w / 2 : undefined,
cy: v ? v.y + v.h / 2 : undefined,
};
}
this._mode = mode;
if (baseChanges) {
// refit against the new base: the editors measure from the full square,
// the view from the content frame — a view clamped to one is nonsense
// against the other (HP-1490-03)
this._zoom = 1;
this._view = null; // updated() refits on the next frame
}
if (mode === 'view') {
const snap = this._viewModeSnap;
this._viewModeSnap = null;
// restore the snapshot only for the space it was taken in
if (snap && snap.space === this._space) {
this._zoom = snap.zoom;
this._view = null;
requestAnimationFrame(() => {
if (!this._stageEl || this._mode !== 'view' || this._space !== snap.space) return;
this._applyView(snap.zoom, snap.cx, snap.cy);
this._saveZoom(); // editor wheel zoom wrote itself to LS_ZOOM — put the view zoom back
this.requestUpdate();
});
} 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.
this._restoreZoom();
}
}
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
this._mergeSel = null;
this._mergeDialog = null;
this._splitSel = null;
this._pendingSplit = null;
this._selId = null;
this._rszSel = null;
this._rszDrag = null;
this._rszLive = null;
this._rszPreview = null;
this._rszUndo = [];
this._tip = null;
this._decorDraft = null;
this._decorSel = null;
this._decorTool = 'select';
this._saveNav();
}
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);
}
private _snap(p: number[]): number[] {
const g = this._gridPitch;
return [snapToGrid(p[0], g), snapToGrid(p[1], g)];
}
private _samePt(a: number[], b: number[]): boolean {
return samePoint(a, b);
}
/**
* Walls are derived from rooms, so the legacy per-space `segments` array is dead
* weight: drop it on every save. Configs written before v1.19.0 shed it on first write.
*/
private _dropLegacySegments(): void {
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
}
/**
* 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<void> = Promise.resolve();
/** 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<void> {
this._writesPending++;
this._writeChain = this._writeChain
.catch(() => undefined) // a failed write must not poison the queue
.then(async () => {
if (!this._serverCfg) return;
this._dropLegacySegments();
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
});
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();
}
private _saveConfigDebounced = debounce(() => {
if (!this._serverCfg) return;
this._writeConfig().catch((e: any) => {
if (e?.code === 'conflict') {
// a real one now: another window wrote between our read and our write
this._showToast(this._t('toast.conflict'));
this._cancelPath();
this._reloadConfigOnly(true);
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
}
});
}, 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!
);
}
private _markupClick(ev: MouseEvent): void {
if (this._vacFit) return; // the fit overlay owns all pointer input
if (!this._markup) 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;
const raw = this._svgPoint(ev);
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 = [...this._spaceModel().rooms].reverse().find((r) => this._pointInRoom(raw, r));
this._rszSel = room?.id || null;
return;
}
if (this._tool === 'delroom') {
const space = this._spaceModel();
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!room) return;
if (!confirm(this._t('confirm.delete_room', { name: room.name }))) return;
const sp = this._curSpaceCfg;
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
this._saveConfig();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
return;
}
if (this._tool === 'opening') {
this._openingClick(raw, ev.shiftKey);
return;
}
if (this._tool === 'merge') {
this._mergeClick(raw);
return;
}
if (this._tool === 'openwall') {
this._openWallClick(raw);
return;
}
if (this._tool === 'split') {
this._splitClick(raw);
return;
}
// draw: clicks on grid points build the outline. Nothing is written to the config
// until the contour closes — an abandoned outline leaves no lines behind.
const pt = this._snap(raw);
const closing = this._path.length >= 3 && this._samePt(pt, 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) {
this._path = [pt];
return;
}
const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // repeated click on the same point
if (closing) {
// a contour can enclose an existing room without any vertex inside it
const clash = this._overlapRoom(this._path);
if (clash) {
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
return; // leave the outline open so it can be corrected
}
this._path = [...this._path, pt];
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
this._roomDialog = true;
return;
}
this._path = [...this._path, pt];
}
// ================= room resize tool (docs/RESIZE.md) =================
/** Rooms of the current space as render-unit polygons (legacy rects converted). */
private _rszRooms(): { id: string; poly: number[][] }[] {
const out: { id: string; poly: number[][] }[] = [];
for (const r of this._spaceModel().rooms) {
const poly = r.id ? roomPoly(r) : null;
if (poly) out.push({ id: r.id!, poly });
}
return out;
}
private _rszOpenings(): { id: string; x: number; y: number; length: number }[] {
return this._openingsR.map((o) => ({ id: o.id, x: o.rx, y: o.ry, length: o.rlen }));
}
private _rszOpts(): { minDim: number; eps: number } {
return { minDim: this._cmToUnits(MIN_ROOM_CM), eps: this._gridPitch * 0.05 };
}
private _rszSnapshot(): string {
const sp = this._curSpaceCfg;
return JSON.stringify({ rooms: sp?.rooms || [], openings: sp?.openings || [] });
}
private _rszRestore(snap: string): void {
// undo restores are commits: they target the REAL config, never the overlay
const sp = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
if (!sp) return;
const s = JSON.parse(snap);
sp.rooms = s.rooms;
sp.openings = s.openings;
this._cfgEpoch++;
this.requestUpdate();
}
/** 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<string, number[][]>, ops: Record<string, [number, number]>): void {
const g = this._rszDrag;
const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
if (!g || !real) return;
const s = JSON.parse(g.snap); // fresh deep copies every move — free to mutate
const sp = { ...real, rooms: s.rooms, openings: s.openings };
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;
}
this._rszPreview = { space: this._space, sp };
this._cfgEpoch++;
}
private _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
if (this._tool !== 'resize' || this._rszDrag) return;
ev.stopPropagation();
ev.preventDefault();
capturePointer(ev);
const rooms = this._rszRooms();
const plan = planEdgeDrag(rooms, roomId, edge);
if (!plan) return;
this._rszDrag = {
kind: 'edge', pid: ev.pointerId, roomId, plan,
rooms, openings: this._rszOpenings(), snap: this._rszSnapshot(),
moved: false, d: 0, k: 1, changed: [],
};
}
private _rszCornerDown(ev: PointerEvent, roomId: string, corner: number[], fixed: [number, number]): void {
if (this._tool !== 'resize' || this._rszDrag) return;
ev.stopPropagation();
ev.preventDefault();
capturePointer(ev);
this._rszDrag = {
kind: 'scale', pid: ev.pointerId, roomId, fixed,
span0: Math.hypot(corner[0] - fixed[0], corner[1] - fixed[1]) || 1,
rooms: this._rszRooms(), openings: this._rszOpenings(), snap: this._rszSnapshot(),
moved: false, d: 0, k: 1, changed: [],
};
}
private _rszMove(ev: PointerEvent): void {
const g = this._rszDrag;
if (!g || g.pid !== ev.pointerId) return;
ev.stopPropagation();
const p = this._svgPoint(ev);
if (g.kind === 'edge') {
const plan = g.plan!;
const dRaw = (p[0] - plan.a[0]) * plan.n[0] + (p[1] - plan.a[1]) * plan.n[1];
// 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]);
let d = (sn[0] - plan.a[0]) * plan.n[0] + (sn[1] - plan.a[1]) * plan.n[1];
d = clampEdgeDrag(g.rooms, g.openings, plan, d, this._gridPitch, this._rszOpts());
if (d === g.d && g.moved) return;
g.d = d;
g.moved = true;
const res = applyEdgeDrag(g.rooms, g.openings, plan, d, this._rszOpts().eps);
g.changed = Object.keys(res.polys);
this._rszApplyPreview(res.polys, res.openings);
this._rszLive = this._rszEdgeLabels(res, plan);
} else {
const fixed = g.fixed!;
const sn = this._snap(p); // the dragged corner aims at grid nodes
let k = Math.hypot(sn[0] - fixed[0], sn[1] - fixed[1]) / (g.span0 || 1);
k = Math.max(0.05, Math.min(20, k));
k = clampRoomScale(g.rooms, g.openings, g.roomId, fixed, k, this._rszOpts());
if (k === g.k && g.moved) return;
g.k = k;
g.moved = true;
const room = g.rooms.find((r) => r.id === g.roomId)!;
const others = g.rooms.filter((r) => r.id !== g.roomId).map((r) => r.poly);
const res = applyRoomScale(room, g.openings, others, fixed, k, this._rszOpts().eps * 2);
g.changed = [g.roomId];
this._rszApplyPreview({ [g.roomId]: res.poly }, res.openings);
this._rszLive = this._rszScaleLabels(res.poly);
}
this.requestUpdate();
}
private _rszUp(ev: PointerEvent): void {
const g = this._rszDrag;
if (!g || g.pid !== ev.pointerId) return;
ev.stopPropagation();
const preview = this._rszPreview;
this._rszDrag = null;
this._rszLive = null;
this._rszPreview = null; // the overlay is gone either way; renders read the real config again
const changed = g.moved && (g.kind === 'edge' ? Math.abs(g.d) > 1e-9 : Math.abs(g.k - 1) > 1e-9);
if (!changed || !preview) {
// HP-1550-01: nothing to restore — the preview never touched the config
this._cfgEpoch++;
this.requestUpdate();
return;
}
// commit: the preview moves into the REAL config in one step (the only point
// where _writeConfig can see a resize), collinear T-insert leftovers cleaned,
// then ONE undo step + ONE write
const sp = this._curSpaceCfg;
if (sp) {
sp.rooms = preview.sp.rooms;
sp.openings = preview.sp.openings;
for (const id of g.changed) {
const r = sp.rooms.find((x: any) => x.id === id);
if (r?.poly) r.poly = simplifyPoly(r.poly, 1e-9);
}
}
this._rszUndo.push({ space: this._space, snap: g.snap });
if (this._rszUndo.length > 30) this._rszUndo.shift();
// the click synthesized after the drag must not re-pick the selection
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._saveConfig();
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();
}
/** Ctrl+Z in the resize tool: one handle release = one undo step (docs/RESIZE.md). */
private _rszUndoPop(): void {
for (let i = this._rszUndo.length - 1; i >= 0; i--) {
if (this._rszUndo[i].space !== this._space) continue;
const [entry] = this._rszUndo.splice(i, 1);
this._rszRestore(entry.snap);
this._saveConfig();
return;
}
}
private _rszEdgeLabels(
res: { polys: Record<string, number[][]> }, plan: EdgeDragPlan,
): { x: number; y: number; text: string; area?: boolean }[] {
const g = this._rszDrag!;
const labels: { x: number; y: number; text: string; area?: boolean }[] = [];
const own = res.polys[plan.roomId] || g.rooms.find((r) => r.id === plan.roomId)!.poly;
const n = own.length;
const i = plan.edge, j = (i + 1) % n;
// the dragged wall and its two adjacent walls
for (const [a, b] of [[own[(i - 1 + n) % n], own[i]], [own[i], own[j]], [own[j], own[(j + 1) % n]]]) {
labels.push({ x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, text: this._fmtLen(a, b) });
}
// live areas of EVERY room the drag reshapes (both sides of a shared wall)
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const ids = Object.keys(res.polys).length ? Object.keys(res.polys) : [plan.roomId];
for (const id of ids) {
const poly = res.polys[id] || g.rooms.find((r) => r.id === id)!.poly;
const c = poleOfInaccessibility(poly);
labels.push({ x: c[0], y: c[1], text: formatArea(areaM2(poly, this._gridPitch, this._cellCm), imperial), area: true });
}
return labels;
}
private _rszScaleLabels(poly: number[][]): { x: number; y: number; text: string; area?: boolean }[] {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const xs = poly.map((p) => p[0]), ys = poly.map((p) => p[1]);
const w = Math.max(...xs) - Math.min(...xs), h = Math.max(...ys) - Math.min(...ys);
const c = poleOfInaccessibility(poly);
return [
{ x: Math.min(...xs), y: Math.min(...ys), text: `${this._fmtLen([0, 0], [w, 0])} × ${this._fmtLen([0, 0], [h, 0])}` },
{ x: c[0], y: c[1], text: formatArea(areaM2(poly, this._gridPitch, this._cellCm), imperial), area: true },
];
}
/** Handles of the resize tool: wall midpoints + the scale frame of the selected room. */
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();
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);
parts.push(svg`<circle class="rszhandle" cx="${mx}" cy="${my}" r="${f(hr)}"
@pointerdown=${(e: PointerEvent) => 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)}></circle>`);
parts.push(svg`<g class="rszicon" transform="translate(${mx} ${my}) rotate(${ang})"><path class="rszhalo" d="${iconD}"></path><path class="rszink" d="${iconD}"></path></g>`);
}
}
const sel = this._rszSel ? rooms.find((r) => r.id === this._rszSel) : null;
if (sel) {
const xs = sel.poly.map((p) => p[0]), ys = sel.poly.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
parts.push(svg`<rect class="rszframe" x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}"></rect>`);
for (const [cx, cy, fx, fy] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]]) {
parts.push(svg`<circle class="rszhandle rszcorner" cx="${cx}" cy="${cy}" r="${(hr * 1.15).toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._rszCornerDown(e, sel.id, [cx, cy], [fx, fy] as [number, number])}
@pointermove=${(e: PointerEvent) => this._rszMove(e)}
@pointerup=${(e: PointerEvent) => this._rszUp(e)}
@pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)}
@lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}></circle>`);
}
}
return svg`${parts}`;
}
/** Openings of the current space in render units. */
private get _openingsR(): (OpeningCfg & { rx: number; ry: number; rlen: number })[] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.openings || []).map((o: OpeningCfg) => ({
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
}));
}
/** 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(): any[] {
const sp = this._curSpaceCfg;
return Array.isArray(sp?.decor) ? sp.decor : [];
}
private get _decorH(): number {
return NORM_W;
}
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
private _decorPointerDown(ev: PointerEvent): boolean {
const t = this._decorTool;
const onShape = (ev.target as HTMLElement).closest?.('.dshape') as SVGElement | 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._snap(this._svgPoint(ev));
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
capturePointer(ev);
return true;
}
if (t === 'text') {
const p = this._snap(this._svgPoint(ev));
this._decorTextDialog = {
x: p[0] / NORM_W, y: p[1] / this._decorH,
text: '', size: 'm', color: this._decorStyle.color,
};
return true;
}
this._decorSel = null; // select/erase on empty space clears the selection
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 (Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) < min) return;
const W = NORM_W, H = this._decorH;
const st = this._decorStyle;
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
let shape: any;
if (d.kind === 'line') {
shape = { id, kind: 'line', x1: d.a[0] / W, y1: d.a[1] / H, x2: d.b[0] / W, y2: d.b[1] / H,
color: st.color, width: st.width };
} else {
const x = Math.min(d.a[0], d.b[0]) / W, y = 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, color: st.color, width: st.width, fill: st.fill };
}
const sp = this._curSpaceCfg;
sp.decor = [...this._decorList, shape];
this._decorSel = id;
this._saveConfig();
this.requestUpdate();
}
/** Select tool: pointerdown on a shape starts moving it. */
private _decorShapeDown(ev: PointerEvent, shape: any): void {
if (this._mode !== 'decor') return;
ev.stopPropagation();
ev.preventDefault();
if (this._decorTool === 'erase') {
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
if (this._decorSel === shape.id) this._decorSel = null;
this._saveConfig();
this.requestUpdate();
return;
}
if (this._decorTool !== 'select') 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,
};
capturePointer(ev);
}
private _decorMoveUpdate(ev: PointerEvent): void {
const m = this._decorMove!;
const p = this._svgPoint(ev);
const g = this._gridPitch;
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
// a shape could be dragged far outside the viewBox and persisted there.
const o = 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 = 0.25; // a quarter of the plan may hang outside, no more
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
dy = Math.max(-curY - lim, Math.min(1 + 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 = 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();
}
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || shape.kind !== 'text') return;
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
text: shape.text, size: shape.size || 'm', color: shape.color };
}
private _decorSaveText(): void {
const d = this._decorTextDialog;
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
if (d.id) {
sp.decor = this._decorList.map((x) => x.id === d.id
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
} 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: d.text.trim(), size: d.size, color: d.color }];
}
this._decorTextDialog = null;
this._saveConfig();
this.requestUpdate();
}
private _decorDeleteSel(): void {
if (!this._decorSel) return;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== this._decorSel);
this._decorSel = null;
this._saveConfig();
this.requestUpdate();
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
const editing = this._mode === 'decor';
const shapes = this._decorList.map((sh) => {
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
const dbl = () => this._decorShapeDbl(sh);
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`<line class="${cls}" x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
stroke="${sh.color}" stroke-width="${sh.width}" stroke-linecap="round" stroke-linejoin="round" @pointerdown=${down}></line>`;
if (sh.kind === 'rect')
return svg`<rect class="${cls}" x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></rect>`;
if (sh.kind === 'ellipse')
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
if (sh.kind === 'text')
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
return nothing;
});
// живое превью рисуемой фигуры
let draft: unknown = nothing;
const d = this._decorDraft;
if (d) {
const st = this._decorStyle;
if (d.kind === 'line')
draft = svg`<line class="ddraft" x1="${d.a[0]}" y1="${d.a[1]}" x2="${d.b[0]}" y2="${d.b[1]}"
stroke="${st.color}" stroke-width="${st.width}" stroke-linecap="round" stroke-linejoin="round"></line>`;
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`<rect class="ddraft" x="${x}" y="${y}" width="${w}" height="${h}" stroke="${st.color}"
stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></rect>`
: svg`<ellipse class="ddraft" cx="${x + w / 2}" cy="${y + h / 2}" rx="${w / 2}" ry="${h / 2}"
stroke="${st.color}" stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></ellipse>`;
}
}
return svg`<g class="decorlayer">${shapes}${draft}</g>` as unknown as TemplateResult;
}
private _renderDecorBar(): TemplateResult {
const tools = [
['select', 'mdi:cursor-default-outline', 'decor.select'],
['line', 'mdi:vector-line', 'decor.line'],
['rect', 'mdi:rectangle-outline', 'decor.rect'],
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
['text', 'mdi:format-text', 'decor.text'],
['erase', 'mdi:eraser', 'decor.erase'],
] as const;
return html`<div class="editbar decorbar">
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
${tools.map(
([t, ic, k]) => html`<button class="btn ${this._decorTool === t ? 'on' : ''}"
@click=${() => { this._decorTool = t; this._decorDraft = null; }}
title=${this._t(k)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(k)}</span>
</button>`,
)}
<input type="color" class="dcolor" .value=${this._decorStyle.color}
title=${this._t('decor.color')}
@input=${(e: Event) => (this._decorStyle = { ...this._decorStyle, color: (e.target as HTMLInputElement).value })} />
<select class="dwidth" title=${this._t('decor.width')}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, width: Number((e.target as HTMLSelectElement).value) })}>
${[[1.5, 'decor.w_thin'], [3, 'decor.w_mid'], [6, 'decor.w_thick']].map(
([v, k]) => html`<option value=${v} ?selected=${this._decorStyle.width === v}>${this._t(k as any)}</option>`,
)}
</select>
<label class="dfill"><input type="checkbox" .checked=${this._decorStyle.fill}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
${this._t('decor.fill')}</label>
<span class="spacer"></span>
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
<div class="body">
<label>${this._t('decor.text_label')}</label>
<input class="namein" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
<label>${this._t('decor.text_size')}</label>
<div class="radiorow">
${(['s', 'm', 'l'] as const).map(
(sz) => html`<label class="srcrow inline">
<input type="radio" name="dtsize" .checked=${d.size === sz}
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
<span>${this._t(('decor.size_' + sz) as any)}</span>
</label>`,
)}
</div>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._decorTextDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn primary" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this._t('btn.save')}</button>
</div>
</div>
</div>`;
}
/** Boundary under the cursor in the open-wall tool (hover preview). */
private get _openWallHover(): { segs: number[][]; open: boolean } | null {
if (!this._markup || this._tool !== 'openwall' || !this._cursorPt) return null;
const hit = this._openWallHit(this._cursorPt);
return hit ? { segs: hit.segs, open: hit.open } : null;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(disp?: SpaceDisplay): TemplateResult {
const pairs = this._openPairs();
const hover = this._openWallHover;
if (!pairs.length && !hover) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall';
const stroke = disp?.color || 'var(--hp-muted)';
return svg`<g class="openwalls ${hot ? 'hot' : ''}" style="--ow-stroke:${stroke}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
${hover
? hover.segs.map((sg) => svg`<line class="openwall-preview ${hover.open ? 'willclose' : ''}"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`)
: nothing}
</g>` as unknown as TemplateResult;
}
/** All open-boundary pairs of the current space with their shared segments. */
private _openPairsCache: { model: SpaceModel; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
// collinear-overlap math on every HA state push), so it is memoized.
//
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
// string of room ids and open_to links, which said nothing about geometry:
// change the plan, or drag a vertex, and the shared segments were
// recomputed for the outlines but the open boundaries — and the glow cuts
// that follow them — kept their old coordinates until a full reload.
// `_model` is already rebuilt whenever the epoch or the config fingerprint
// moves, and everything below derives from it, so its identity is an exact
// and cheaper key. One cache invalidation strategy, not two.
const sp = this._spaceModel();
if (this._openPairsCache && this._openPairsCache.model === sp) return this._openPairsCache.pairs;
const pairs = this._computeOpenPairs();
this._openPairsCache = { model: sp, pairs };
return pairs;
}
private _computeOpenPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const a = rooms[i], b = rooms[j];
const linked = ((a as any).open_to || []).includes(b.id) || ((b as any).open_to || []).includes(a.id);
if (!linked) continue;
const pa = roomPoly(a), pb = roomPoly(b);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
if (segs.length) res.push({ a, b, segs });
}
return res;
}
/** The shared boundary nearest to the cursor (both the tool's click and hover). */
private _openWallHit(raw: number[]): { a: RoomCfg; b: RoomCfg; segs: number[][]; open: boolean } | null {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6;
let best: { a: RoomCfg; b: RoomCfg; segs: number[][]; d: number } | null = null;
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
for (const seg of segs) {
const d = distToSegment(raw, seg);
if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], segs, d };
}
}
if (!best) return null;
const open = (((best.a as any).open_to || []).includes(best.b.id))
|| (((best.b as any).open_to || []).includes(best.a.id));
return { a: best.a, b: best.b, segs: best.segs, open };
}
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */
private _openWallClick(raw: number[]): void {
const best = this._openWallHit(raw);
if (!best) {
this._showToast(this._t('toast.openwall_pick'));
return;
}
const sp = this._curSpaceCfg;
const ra = sp.rooms.find((r: any) => r.id === best.a.id);
const rb = sp.rooms.find((r: any) => r.id === best.b.id);
if (!ra || !rb) return;
const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id);
if (linked) {
ra.open_to = (ra.open_to || []).filter((x: string) => x !== rb.id);
rb.open_to = (rb.open_to || []).filter((x: string) => x !== ra.id);
if (!ra.open_to.length) delete ra.open_to;
if (!rb.open_to.length) delete rb.open_to;
this._showToast(this._t('toast.openwall_closed', { a: ra.name || '', b: rb.name || '' }));
} else {
ra.open_to = [...(ra.open_to || []), rb.id];
rb.open_to = [...(rb.open_to || []), ra.id];
this._showToast(this._t('toast.openwall_opened', { a: ra.name || '', b: rb.name || '' }));
}
this._saveConfig();
this.requestUpdate();
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[], shift = false): void {
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
);
if (hit) {
this._editOpening(hit);
return;
}
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) {
this._showToast(this._t('toast.opening_no_wall'));
return;
}
// the opening is born where the PREVIEW showed it — magnet included
const place = this._opRuler(snap, this._cmToUnits(OPENING_DEFAULT_CM), shift);
this._openingDialog = {
type: 'door', lengthCm: OPENING_DEFAULT_CM, contact: '', lock: '',
invert: false, flipH: false, flipV: false,
x: place.x, y: place.y, angle: place.angle,
};
// rulers, tick and ghost live only through the placement gesture
this._cursorPt = null;
}
/** Open the properties dialog for an existing opening. */
private _editOpening(o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
this._openingDialog = {
id: o.id,
type: o.type,
lengthCm: Math.round((o.rlen / this._gridPitch) * this._cellCm),
contact: o.contact || '',
lock: o.lock || '',
invert: !!o.invert,
flipH: !!o.flip_h,
flipV: !!o.flip_v,
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') 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 };
}
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 raw = this._svgPoint(ev);
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
if (!snap) return; // too far from any wall: the opening stays where it was
this._opDrag.moved = true;
const sp = this._curSpaceCfg;
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return;
// 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, ev.shiftKey);
this._opMeasure = r.measure;
const nx = r.x / NORM_W;
const ny = r.y / this._spaceH;
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
cfg.x = nx;
cfg.y = ny;
cfg.angle = snap.angle;
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 and Shift opts out of the magnet — the same
* convention as the coarse-angle Shift elsewhere in the editor. The returned
* x/y are ALREADY magnetised, so the caller just writes them.
*/
private _opRuler(
snap: { x: number; y: number; angle: number },
rlen: number,
shift: boolean,
): { 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 && !shift && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
[cx, cy] = sh.wallCenter;
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
}
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 && !shift
? { 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 moved = this._opDrag.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 && this._opDrag.dirty) this._saveConfig();
// 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: OpeningCfg & { rx: number; ry: number; rlen: number }): 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;
if (!d || !sp) return;
const H = this._spaceH;
const o: OpeningCfg = {
id: d.id || 'o' + Date.now().toString(36),
type: d.type,
x: d.x / NORM_W,
y: d.y / H,
angle: d.angle,
length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
contact: d.contact || null,
lock: d.type === 'door' ? d.lock || null : null,
invert: d.invert || undefined,
flip_h: d.flipH || undefined,
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._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
private _deleteOpening(): void {
const d = this._openingDialog;
const sp = this._curSpaceCfg;
if (!d?.id || !sp?.openings) return;
sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id);
this._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
/** Contact-sensor candidates: door/window-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)) {
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.'))
.map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid }))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Merge: first click picks a room, second picks the room to merge it with. */
private _mergeClick(raw: number[]): void {
const rooms = this._spaceModel().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) return;
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._saveConfig();
this._mergeDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
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 rooms = this._spaceModel().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.
const eps = this._gridPitch * 0.02;
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
const near = closestPointOnBoundary(raw, poly);
const wallPt = near && Math.hypot(near[0] - raw[0], near[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 {
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
}
private _markupMove(ev: MouseEvent): void {
if (!this._markup) return;
if (this._tool === 'opening' || this._tool === 'openwall') {
// hover preview: raw cursor point; snapping happens in the preview getters
this._opShift = !!ev.shiftKey; // Shift opts out of the centre magnet
this._cursorPt = this._svgPoint(ev);
return;
}
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
if (!drawing && !cutting) return;
this._cursorPt = this._snap(this._svgPoint(ev));
}
/**
* Dashed hover preview of an opening: same snap, same default length and —
* since 2026-08-03 — the same shoulder rulers and centre magnet as a drag.
* Pure: it writes nothing, the render just reads it.
*/
private get _openingPreview():
{ x: number; y: number; angle: number; rlen: number; measure: OpMeasure | null } | null {
if (this._tool !== 'opening' || !this._cursorPt) return null;
const raw = this._cursorPt;
// an existing opening under the cursor will be edited, not added — no preview
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
);
if (hit) return null;
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) return null;
const rlen = this._cmToUnits(OPENING_DEFAULT_CM);
const r = this._opRuler(snap, rlen, this._opShift);
return { x: r.x, y: r.y, angle: r.angle, rlen, measure: r.measure };
}
/** 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 a mandatory binding to an HA area. */
private _saveRoom(): void {
if (!this._areaSel) return;
this._commitRoom();
}
/** Save a decorative room without an area (only a name is required). */
private _saveRoomNoArea(): void {
if (!this._nameSel.trim()) return;
this._areaSel = '';
this._commitRoom();
}
private _commitRoom(): void {
const sp = this._curSpaceCfg;
if (!sp) return;
const H = this._spaceH;
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 : '';
sp.rooms.push({
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() } : {}),
});
this._saveConfig();
this._path = [];
this._pendingSplit = null;
this._splitSel = null;
const boundArea = this._areaSel;
this._areaSel = '';
this._nameSel = '';
this._roomDialog = false;
this._regSignature = '';
this._maybeRebuildDevices();
// 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._path = [];
this._cursorPt = null;
this._roomDialog = false;
this._pendingSplit = null;
this._splitSel = null;
this._mergeSel = null;
this._mergeDialog = null;
}
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
private _roomDialogCancel(): void {
this._roomDialog = false;
if (this._roomEditId) {
this._roomEditId = null;
this._nameSel = '';
this._areaSel = '';
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<string>();
for (const sp of this._serverCfg?.spaces || [])
for (const r of sp.rooms || []) if (r.area) used.add(r.area);
return Object.values<any>(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 {
if (d) this._ackNewDevice(d.id);
if (!this._norm) {
this._showToast(this._t('toast.marker_needs_server'));
return;
}
if (d) {
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.hass.entities[d.bindingRef || '']?.device_id,
bindingFilter: '',
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: d.marker?.display || 'badge',
rippleColor: d.marker?.ripple_color || '',
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '',
tapTarget: d.marker?.tap_target || '',
tapConfirm: d.marker?.tap_confirm === true,
runFilter: '',
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
isLight: d.marker?.is_light === true,
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: 3, size: 1, angle: 0,
tapAction: '', tapTarget: '', tapConfirm: false, runFilter: '',
defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
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<any>(this.hass.states)) {
if (!eid.startsWith(dom + '.')) 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.hass;
const taken = new Set<string>();
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<string>();
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<any>(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 && 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<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v)) continue;
const isHelper = helperPlatforms.has(reg.platform);
const isGroupEntity = reg.platform === 'group';
if (!isHelper && !isGroupEntity) continue;
if (reg.hidden) 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<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v) || seen.has(v) || reg.hidden) 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);
}
/** 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.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);
}
}
/**
* 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<void> {
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<string, string> = {
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),
};
}
private async _saveMarker(): Promise<void> {
const dlg = this._markerDialog;
if (!dlg || dlg.busy) return;
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 (dlg.tapAction === 'run' && !dlg.tapTarget) {
this._showToast(this._t('toast.run_target_required'));
return;
}
this._markerDialog = { ...dlg, busy: true };
try {
const cfg = this._serverCfg!;
cfg.markers = cfg.markers || [];
// determine the marker id
let id: string;
// a manually chosen room overrides the space/area for any icon
const roomRef = parseRoomRef(dlg.room);
let space: string | null = roomRef?.space || null;
let area: string | null = roomRef?.area || null;
const roomId: string | null = roomRef?.roomId || null;
if (dlg.binding === 'virtual' && !space) space = this._space;
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
// the vacuum block is edited live outside the dialog transaction —
// the rebuild below must carry it over, not erase it
const prevVac = cfg.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 !== 'badge' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display !== 'badge' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null,
tap_target: dlg.tapAction === 'run' ? dlg.tapTarget || null : null,
tap_confirm: dlg.tapConfirm ? true : null,
controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
is_light: dlg.isLight ? true : null,
use_climate_temp: dlg.useClimateTemp ? true : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || 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 prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null;
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) }));
}
}
// 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);
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 targetSpace = space || prevDev?.space || this._space;
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 (prevPos && prevPos.s === targetSpace) {
// 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 (!this._layout[id] || roomChanged) {
const spm = this._spaceModel(space || undefined);
let cx = spm.vb[0] + spm.vb[2] / 2;
let cy = spm.vb[1] + spm.vb[3] / 2;
const room = roomId
? spm.rooms.find((r) => r.id === roomId)
: area
? spm.rooms.find((r) => r.area === area)
: undefined;
if (room) [cx, cy] = this._roomCenter(room);
newPos = this._normPos(space || this._space, cx, cy);
this._layout = { ...this._layout, [id]: newPos };
}
await this._saveConfigNow();
if (newPos) this._noteLayoutRev(await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }));
if (oldId && oldId !== id) {
// rebinding changed the icon id — clean up the old position
delete this._layout[oldId];
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId })
.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<void> {
const dlg = this._markerDialog;
if (!dlg) return;
const d = dlg.devId ? this._devices.find((x) => x.id === dlg.devId) : null;
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 || [];
if (d && d.bindingKind === 'virtual') {
cfg.markers = cfg.markers.filter((m) => m.id !== d.id);
} else if (d && d.marker) {
// there was an explicit marker → either hide or delete: we hide (the auto entry comes back if it is an auto device)
cfg.markers = cfg.markers.filter((m) => m.id !== d.id);
if (d.bindingKind === 'device' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
} else if (d.bindingKind === 'entity' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true });
}
} else if (d && d.bindingKind === 'device' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
} else if (d && d.bindingKind === 'entity' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true });
}
try {
await this._saveConfigNow();
if (d && d.bindingKind === 'virtual' && this._layout[d.id]) {
// the virtual one is deleted for good → its position is no longer needed
delete this._layout[d.id];
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id })
.then((r: any) => this._noteLayoutRev(r)).catch(() => undefined);
}
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.marker_removed'));
} catch (e: any) {
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);
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,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
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,
busy: false,
};
} else {
this._spaceDialog = {
mode, title: '', planUrl: null, planFile: null,
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
bgMode: null, 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: 5,
busy: false,
};
}
}
/** Background file selection: read base64 and determine the aspect ratio. */
private async _pickPlanFile(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !this._spaceDialog) return;
const extMap: Record<string, string> = {
'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<number>((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<void> => {
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<number> | 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<number> {
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<number>((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<void> {
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<typeof this._spaceDialog>): TemplateResult {
if (d.savedBusy) return html`<div class="savedplans muted">${this._t('space.loading')}</div>`;
const list = d.saved || [];
if (!list.length) return html`<div class="savedplans muted">${this._t('space.no_saved')}</div>`;
const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB');
return html`<div class="savedplans">
${list.map((p) => html`
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
<img src=${this._display(p.url)} alt="" loading="lazy" decoding="async" />
<div class="savedmeta">
<b>${p.name}</b>
<span class="muted">${kb(p.size)}${p.used_by.length
? ' · ' + this._t('space.used_by', { list: p.used_by.join(', ') })
: ''}</span>
</div>
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
<button class="btn ghost danger"
title=${p.used_by.length || p.url === d.planUrl ? this._t('space.in_use') : this._t('btn.delete')}
?disabled=${p.used_by.length > 0 || p.url === d.planUrl}
@click=${() => this._deleteServerPlan(p.name)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>`)}
</div>`;
}
private async _saveSpaceDialog(): Promise<void> {
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)
if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; }
// per-space display settings; hand-drawn spaces get borders+names on by default
const draw = d.source === 'draw';
sp.settings = {
...(sp.settings || {}),
show_borders: draw && d.mode === 'create' ? true : d.showBorders,
show_names: draw && d.mode === 'create' ? true : d.showNames,
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,
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 ? 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._space = 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._space = this._serverCfg!.spaces[0]?.id || this._space;
this._mode = 'plan';
this._tool = 'draw';
this._path = [];
this._cursorPt = null;
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'));
}
} 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<void> {
const d = this._spaceDialog;
if (!d || d.mode !== 'edit') return;
const sp = this._serverCfg!.spaces.find((x: any) => x.id === d.spaceId);
if (!confirm(this._t('confirm.delete_space', { title: sp.title }))) return;
this._serverCfg!.spaces = this._serverCfg!.spaces.filter((x: any) => x.id !== d.spaceId);
try {
await this._saveConfigNow();
this._spaceDialog = null;
if (this._space === d.spaceId) this._space = this._serverCfg!.spaces[0]?.id || '';
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.space_deleted'));
} catch (e: any) {
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<void> {
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?.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,
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
bgMode: null, 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: 5,
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._space = this._serverCfg!.spaces[0]?.id || this._space;
this._mode = '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`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:home-floor-1"></ha-icon>${this._t('import.title')}</div>
<div class="body">
<div class="rhint">${this._t('import.hint')}</div>
${d.floors.map(
(f, i) => html`<label class="floorrow">
<input type="checkbox" .checked=${f.checked}
@change=${(e: Event) => {
const floors = [...d.floors];
floors[i] = { ...f, checked: (e.target as HTMLInputElement).checked };
this._importDialog = { floors };
}} />
<span>${f.name}</span>
${f.level != null ? html`<span class="floorlvl">L${f.level}</span>` : nothing}
</label>`,
)}
</div>
<div class="row">
<button class="btn ghost" @click=${() => { this._importDialog = null; this._openSpaceDialog('create'); }}>
${this._t('import.manual')}
</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!n}>
<ha-icon icon="mdi:import"></ha-icon>${this._t('import.start', { n })}
</button>
</div>
</div>
</div>`;
}
// ================= 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,
weather_entity: (gd.weatherEntity || '').trim() || undefined,
};
}
/** 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());
}
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.hass) : 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;
if (this._editing || !this._effSunRays()) return empty;
const north = this._effNorth();
const sun = north !== null ? sunStateOf(this.hass) : null;
if (!sun || sun.elevation <= 0) return empty;
const weather = weatherEntityOf(this._sunGlobal());
const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null);
const alpha = rayAlpha(sun.elevation, cloud);
if (alpha <= 0) return empty;
// 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
.filter((o) => o.type === 'window')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
}
const rays = this._sunRaysCache.rays;
if (!rays.length) return empty;
const color = rayColor(dayPhase(sun.elevation).warmth);
return svg`<defs>
${rays.map((r, i) => {
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
<stop offset="0%" stop-color="${color}" stop-opacity="${alpha.toFixed(3)}"></stop>
<stop offset="100%" stop-color="${color}" stop-opacity="0"></stop>
</linearGradient>`;
})}
</defs>
<g class="sunlayer">
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
</g>` as unknown as TemplateResult;
}
/** 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`<svg class="compass ${deg === null ? 'unset' : ''}" viewBox="-60 -60 120 120"
@pointerdown=${(e: PointerEvent) => {
(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)}>
<circle class="cring" r="50"></circle>
${[0, 45, 90, 135, 180, 225, 270, 315].map(
(t) => svg`<line class="ctick ${t % 90 ? 'minor' : ''}" x1="0" y1="-50" x2="0" y2="${t % 90 ? -46 : -43}"
transform="rotate(${t})"></line>`,
)}
<g class="cneedle" transform="rotate(${deg ?? 0})">
<line x1="0" y1="34" x2="0" y2="-28"></line>
<path d="M -7 -24 L 0 -42 L 7 -24 Z"></path>
<text x="0" y="-12" text-anchor="middle">${this._t('gs.north_letter')}</text>
</g>
<text class="cdeg" x="0" y="26" text-anchor="middle">${deg === null ? '—' : deg + '°'}</text>
</svg>`;
}
/**
* 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. In 'daynight'
* mode (docs/SUN.md) the sun's elevation paints the stage instead.
*/
private _stageBg(disp: SpaceDisplay): string {
if (this._effBgMode() === 'daynight') {
const sun = this._sunNow();
if (sun) return dayPhase(sun.elevation).bg;
}
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, {}),
weatherEntity: weatherEntityOf(this._settings) || '',
busy: false,
};
};
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<void> {
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 on the plan (docs/SUN.md): defaults are never stored
if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)
settings.north_deg = d.northDeg;
else delete settings.north_deg;
if (d.bgMode === 'daynight') settings.bg_mode = 'daynight';
else delete settings.bg_mode;
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
const we = (d.weatherEntity || '').trim();
if (we) settings.weather_entity = we;
else 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`<ha-switch .checked=${checked} .disabled=${disabled} @change=${h}></ha-switch>`
: html`<input type="checkbox" .checked=${checked} ?disabled=${disabled} @change=${h} />`;
}
/** 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): 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`<ha-slider .min=${min} .max=${max} .step=${step} .value=${value} @input=${h} @change=${h}></ha-slider>`
: html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)} @input=${h} />`;
}
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this._settingsDialog!;
const v = d.colors[key];
return html`<div class="colorrow gsrow">
<span class="gsl">${this._t(labelKey as any)}</span>
<input type="color" .value=${v.c}
@input=${(e: Event) => this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
${this._rangeInput(0, 100, 1, Math.round(v.a * 100), (n) => this._setFillColor(key, { a: n / 100 }))}
<span class="opv">${Math.round(v.a * 100)}%</span>
</div>`;
}
/** 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);
}
/** Light pools of the current space: dark house, glowing sources. */
private _renderGlowLayer(space: SpaceModel): TemplateResult {
const colors = this._fillColors;
const defaultR = (this._glowRadiusCm / this._cellCm) * this._gridPitch;
const g = this._gridPitch;
const polys = space.rooms
.map((r) => ({ r, poly: roomPoly(r) }))
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
const doors = this._openingsR.filter((o) => o.type === 'door');
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
if (d.hidden) continue; // an invisible device casts no visible light (docs/FILTERING.md)
// A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
// controls when they are bound.
// the SAME condition that turns the icon yellow (devices.ts)
const lightEid = litLightEntity(this.hass, d);
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
// 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);
// innermost room under the source (islands win — reverse order)
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
let clip: string[] | null = null;
if (home) {
// open (virtual) boundaries: light flows through the whole connected
// zone of rooms, not just the source's own room (owner's spec)
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
const zoneList = zone.length ? zone : [home];
const shapes: string[] = zoneList.map(
(z) => 'M ' + z.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z',
);
// doorways on the ZONE's walls spill light into rooms outside the zone
const others = polys.filter((x) => !zoneList.includes(x)).map((x) => x.poly);
for (const o of doors) {
const onZoneWall = zoneList.some((z) => {
const near = closestPointOnBoundary([o.rx, o.ry], z.poly);
return near && Math.hypot(near[0] - o.rx, near[1] - o.ry) <= g * 0.75;
});
if (!onZoneWall) continue;
const rad = (o.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
}
// IMPORTANT: separate <path> children — clipPath children always
// UNION. Joining the room and a sector into ONE path made the default
// nonzero fill-rule cancel their overlap when the windings opposed,
// punching a dark wedge INSIDE the room (field report + screenshot).
clip = shapes;
}
spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip, r: R });
}
if (!spots.length) return svg`` as unknown as TemplateResult;
return svg`<defs>
${spots.map((sp, i) => svg`
<radialGradient id="hp-glow-${i}">
<stop offset="0%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
<stop offset="70%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
<stop offset="100%" stop-color="${sp.c}" stop-opacity="0"></stop>
</radialGradient>
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}">${sp.clip.map((d) => svg`<path d="${d}"></path>`)}</clipPath>` : nothing}`)}
</defs>
<g class="glowlayer">
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})" ${''}
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
</g>` as unknown as TemplateResult;
}
private _renderSettingsDialog(): TemplateResult {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:cog-outline"></ha-icon>${this._t('gs.title')}</div>
<div class="body">
<div class="rhint">${this._t('gs.hint')}</div>
<label class="dispsection">${this._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
${this._renderColorRow('light_none', 'gs.light_none')}
<label class="dispsection">${this._t('gs.temp_group')}</label>
${this._renderColorRow('temp_cold', 'gs.temp_cold')}
${this._renderColorRow('temp_ok', 'gs.temp_ok')}
${this._renderColorRow('temp_hot', 'gs.temp_hot')}
<label class="dispsection">${this._t('gs.lqi_group')}</label>
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
<label class="dispsection">${this._t('gs.glow_group')}</label>
${this._renderColorRow('glow_base', 'gs.glow_base')}
${this._renderColorRow('glow_light', 'gs.glow_light')}
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.glow_radius')}</span>
<input type="number" class="tempin" min="0.5" step="0.5"
.value=${String(this._settingsDialog!.glowRadius)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(v) && v > 0)
this._settingsDialog = { ...this._settingsDialog!, glowRadius: v };
}} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
</div>
<label class="dispsection">${this._t('gs.bg_group')}</label>
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.bg_mode')}</span>
<select class="areasel"
@change=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgMode: (e.target as HTMLSelectElement).value === 'daynight' ? 'daynight' : 'static' })}>
<option value="static" ?selected=${this._settingsDialog!.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${this._settingsDialog!.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
</select>
</div>
${this._settingsDialog!.bgMode === 'static'
? html`<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.bg_color')}</span>
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
@input=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
${this._settingsDialog!.bgColor
? html`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
</div>`
: html`<div class="rhint">${this._t('gs.bg_daynight_hint')}</div>`}
<label class="dispsection">${this._t('gs.sun_group')}</label>
${!sunStateOf(this.hass)
? html`<div class="rhint">${this._t('gs.sun_missing')}</div>`
: nothing}
<div class="sunrow">
${this._renderCompass()}
<div class="suncol">
<span class="gsl">${this._t('gs.north')}</span>
<div class="colorrow">
<input class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this._t('gs.north_ph')}
.value=${this._settingsDialog!.northDeg === null ? '' : String(this._settingsDialog!.northDeg)}
@input=${(e: Event) => {
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`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, northDeg: null })}>${this._t('gs.north_clear')}</button>`
: nothing}
</div>
${this._settingsDialog!.northDeg === null
? html`<div class="rhint">${this._t('gs.north_hint')}</div>`
: nothing}
</div>
</div>
<label class="srcrow">
${this._boolInput(this._settingsDialog!.sunRays, (v) =>
(this._settingsDialog = { ...this._settingsDialog!, sunRays: v }))}
<span>${this._t('gs.sun_rays')}</span>
</label>
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.weather')}</span>
<input class="namein" type="text" list="hp-weather-list" placeholder=${this._t('gs.weather_ph')}
.value=${this._settingsDialog!.weatherEntity}
@input=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, weatherEntity: (e.target as HTMLInputElement).value })} />
<datalist id="hp-weather-list">
${Object.keys(this.hass?.states || {}).filter((id) => id.startsWith('weather.')).map(
(id) => html`<option value=${id}></option>`,
)}
</datalist>
</div>
<label class="dispsection">${this._t('gs.about_group')}</label>
<div class="aboutver">${this._t('gs.about_version', { v: CARD_VERSION })}</div>
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener">
<ha-icon icon="mdi:github"></ha-icon>${this._t('gs.about_github')}</a>
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener">
<ha-icon icon="mdi:send"></ha-icon>${this._t('gs.about_telegram')}</a>
</div>
<div class="row">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'static', sunRays: false, weatherEntity: '' })}>
${this._t('gs.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._settingsDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSettingsDialog} ?disabled=${this._settingsDialog!.busy}>
<ha-icon icon="mdi:check"></ha-icon>${this._settingsDialog!.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
// ================= 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<void> {
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`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('rules.title')}</div>
<div class="body">
<div class="rhint">${this._t('rules.hint')}</div>
<div class="rtest">
<input class="namein" type="text" placeholder=${this._t('rules.test_ph')}
.value=${d.test}
@input=${(e: Event) => (this._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} />
${testIcon ? html`<ha-icon icon=${testIcon}></ha-icon><span class="rtesticon">${testIcon}</span>` : nothing}
</div>
${d.rules.map((r, i) => {
const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern);
return html`<div class="rrow">
<input class="namein rpat ${bad ? 'bad' : ''}" type="text"
placeholder=${this._t('rules.pattern_ph')}
title=${bad ? this._t('rules.invalid') : ''}
.value=${r.pattern}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<input class="namein ricon" type="text" placeholder=${this._t('rules.icon_ph')}
.value=${r.icon}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, icon: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<ha-icon class="rprev" icon=${r.icon || 'mdi:chip'}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-up" title=${this._t('btn.up')}
@click=${() => move(i, -1)}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-down" title=${this._t('btn.down')}
@click=${() => move(i, 1)}></ha-icon>
<ha-icon class="ract del" icon="mdi:close" title=${this._t('btn.delete')}
@click=${() => this._rulesSet(d.rules.filter((_, j) => j !== i))}></ha-icon>
</div>`;
})}
<button class="btn ghost" @click=${() => this._rulesSet([...d.rules, { pattern: '', icon: '' }])}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t('rules.add')}
</button>
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
${this._t('rules.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._rulesDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveRules} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
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`<label>${label}</label>
<div class="colorrow">
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))}
<span class="opv">${Math.round(k[key] * 100)}%</span>
</div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._kioskDialog = false)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:tablet"></ha-icon>${this._t('kiosk.title')}</div>
<div class="body">
<div class="rhint">${this._t('kiosk.hint')}</div>
${row('icon', this._t('kiosk.icon_scale'))}
${row('font', this._t('kiosk.font_scale'))}
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._saveKioskScale({ icon: 1, font: 1 })}>${this._t('gs.reset')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => (this._kioskDialog = false)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
// ================= render =================
protected render(): TemplateResult | typeof nothing {
if (!this._config || !this.hass) return nothing;
const model = this._model;
if (!model.length) {
return html`<ha-card>
<div class="head">
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || this._t('card.title')}</div>
</div>
<div class="empty">
<ha-icon icon="mdi:floor-plan" class="big"></ha-icon>
<p>${this._t('empty.no_spaces')}</p>
${this._serverStorage
? html`<p class="muted">${this._t('empty.add_first')}</p>
<button class="btn on" @click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t('btn.add_space')}
</button>`
: html`<p class="muted">${this._t('empty.install')}</p>`}
</div>
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>`;
}
const space = this._spaceModel();
const vb = space.vb;
// 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._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg);
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 view = this._viewOr(vb);
// 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);
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
// opening rulers: the drag of an existing one OR the placement preview
const opMeasure = this._opMeasureView;
return html`
<ha-card>
<div class="hdr ${this._kiosk ? 'kioskhide' : ''}">
<div class="head">
<div class="title">
<ha-icon icon="mdi:home-city"></ha-icon>
${this._config.title || this._t('card.title')}
</div>
<div class="tabs">
${model.map(
(s) => html`<button
class="tab ${this._space === s.id ? 'active' : ''}"
@click=${() => {
this._space = s.id;
this._selId = null;
this._navApplied = true;
this._restoreZoom();
this._saveNav();
}}
>
${s.title}${this._norm && this._canEdit
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
title=${this._t('title.configure_space')}
@click=${(e: Event) => {
e.stopPropagation();
this._openSpaceDialog('edit', s.id);
}}></ha-icon>`
: nothing}
</button>`,
)}
${this._norm && this._mode === 'plan'
? html`<button class="tab tabadd" title=${this._t('title.add_space')}
@click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon>
</button>`
: nothing}
</div>
${this._canEdit
? html`<div class="modes">
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m + '_tip') as any)}
@click=${() => { if (this._mode !== m) this._setMode(m); }}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
${this._mode === m
? html`<ha-icon class="closex" icon="mdi:close" title=${this._t('title.close_editor')}
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}></ha-icon>`
: nothing}
</button>`,
)}
</div>`
: nothing}
<span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
<span class="spacer"></span>
<div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
<button class="btn zb" @click=${() => this._resetZoom()} ?disabled=${this._zoom === 1}
title=${this._t('title.zoom_reset')}><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button>
<button class="btn zb" @click=${() => this._stepZoom(1)} title=${this._t('title.zoom_in')}><ha-icon icon="mdi:plus"></ha-icon></button>
</div>
${this._norm && this._canEdit
? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
</button>`
: nothing}
</div>
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${(() => {
// opaque paper: the image rect for image plans, the room
// contours for drawn ones (owner: no white square around an
// L-shaped house — the scene bg reaches the exterior walls).
// `space` comes from _renderCfg, so a live resize preview
// (_rszPreview) moves the paper together with the rooms.
// One <g> around ALL paper shapes: the daynight drop shadow
// (styles.ts) is composited once for the whole sheet, so
// adjacent rooms never cast seams onto each other's paper.
if (space.bg) {
const pp = this._paperRect(space)!;
return svg`<g class="hp-paperg"><rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect></g>`;
}
return svg`<g class="hp-paperg">${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`;
})()}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${this._editing && !this._markup
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
: nothing}
${space.bg && this._display(space.bg.href)
? svg`<image href="${this._display(space.bg.href)}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${this._renderDecorLayer()}
${(() => {
// 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<any, number[][] | null>();
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._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}`);
const fillC = effFill === 'glow'
// glow: uniform darkness (a room override may opt OUT of it)
? this._fillColors.glow_base
: effFill === 'temp'
// temp works without an HA area when a tier-3 source is set
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
disp.tempMin, disp.tempMax, this._fillColors)
: r.area
? roomFillStyle(
effFill,
effFill === 'lqi' ? this._roomLqi(r.area) : null,
effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
null,
disp.tempMin,
disp.tempMax,
this._fillColors,
)
: null;
if (fillC) {
cls += ' filled';
st.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';');
}
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, '',
showLqi ? this._roomLqi(r.area) : null,
this._roomTemp(r));
const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(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)
: [];
if (openCuts.length) cls += ' noedge';
// island rooms punch holes in their parent's fill (evenodd)
const myPoly = polyOf(r);
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
const pathD = (pts: number[][]) =>
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
const shape = holes.length && myPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
d="${[myPoly, ...holes].map(pathD).join(' ')}"
@mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></path>`
: r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>`
: svg`<rect class="${cls}" style="${style}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`;
const trimmed = openCuts.length && myPoly
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
: null;
const outline = trimmed
? svg`<path class="room-outline ${this._markup ? 'outlined' : ''}"
d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
: nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
});
})()}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderSunRays(space)}
${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
${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}
</div>
${this._measureAnchor
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
: nothing}
${this._rszLive
? html`<div class="measurelayer">${this._rszLive.map((l) => html`<div
class="measurelabel ${l.area ? 'rszarea' : ''}"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
${opMeasure
? html`<div class="measurelayer">${opMeasure.labels.map((l) => html`<div
class="measurelabel opshoulder"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
: nothing}
${this._booting || this._bootFading
? html`<div class="bootveil ${this._booting ? '' : 'off'}" aria-hidden="true">
<svg class="boothouse" viewBox="0 0 24 24"><path d="${mdiHomeCityOutline}"></path></svg>
</div>`
: nothing}
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._mergeDialog ? this._renderMergeDialog() : nothing}
${this._openingDialog ? this._renderOpeningDialog() : nothing}
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.temp != null
? html`<span class="m">${this._t('tip.temp_avg')} <b>${this._tip.temp}°</b></span>`
: nothing}
${this._tip.lqi != null
? html`<span class="m">${this._t('tip.lqi')}
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
: nothing}
</div>`
: nothing}
${this._kiosk && this._kioskDots && this._model.length > 1
? html`<div class="kioskdots">
${this._model.map((m) => html`<span class="kdot ${m.id === this._space ? 'on' : ''}"></span>`)}
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._vacFit ? html`<div class="vaccalbar">
<span>${this._t('vac.fit_hint')}</span>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
</div>` : nothing}
${this._tapConfirm
? html`<div class="menuwrap dialogwrap" @click=${() => (this._tapConfirm = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="body"><p>${this._tapConfirm.text}</p></div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._tapConfirm = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${() => { const c = this._tapConfirm!; this._tapConfirm = null; c.exec(); }}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.run')}
</button>
</div>
</div>
</div>`
: nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>
`;
}
// ---------------- live robot vacuums (docs/VACUUM.md) ----------------
/** The live-position source entity for a vacuum device, or null. */
private _vacSource(d: DevItem): string | null {
const v = d.marker?.vacuum;
if (v?.live === false) return null;
if (v?.source && this.hass?.states[v.source]) return v.source;
for (const eid of d.entities || []) {
if (isVacSourceState(this.hass?.states[eid])) return eid;
}
return 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);
}
/**
* Motion-flash bookkeeping, one pass per hass tick (the _vacTick pattern):
* remembers each motion marker's last primary state and stamps flashTs on
* an off→on transition. Only a WITNESSED 'off' → 'on' step counts — a
* sensor first seen already 'on' (page load mid cool-down) or coming back
* from 'unavailable' is a reconnect, not a fresh detection, and must not
* flash. Each stamp (re)arms ONE setTimeout that calls requestUpdate when
* the ~3.3s window closes, so _stateClass drops 'senseflash' even though
* no new hass tick arrives; disconnectedCallback clears the timers.
*/
private _senseTick(): void {
if (!this.hass) return;
for (const d of this._devices) {
if (d.hidden || !d.primary || !d.primary.startsWith('binary_sensor.')) continue;
const p = this.hass.states[d.primary];
if (p?.attributes?.device_class !== 'motion') continue;
const rt = this._senseRt.get(d.id);
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0, gen: 0 }); continue; }
if (p.state === 'on' && rt.last === 'off') {
rt.flashTs = Date.now();
// HP-1543-02: a retrip BEFORE the previous flash finished kept the
// same 'senseflash' class and the same animation-name on the same
// pseudo-element — browsers never restart such a CSS animation, so
// the second detection played nothing once the first one-shot had
// ended (base opacity 0). Every trip bumps the generation;
// _stateClass maps its parity to alternating keyframe names
// (hp-sense / hp-sense-b in styles.ts), which is a NEW animation
// identity and forces a fresh timeline per detection. No class is
// ever removed mid-flash, so a lone first flash still plays whole.
rt.gen++;
clearTimeout(rt.timer);
rt.timer = window.setTimeout(() => this.requestUpdate(), SENSE_FLASH_MS + 60);
}
rt.last = p.state;
}
}
/**
* 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;
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;
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 src = this._vacSource(dev);
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
const tierA = !!(tele && tele.rooms.length >= 3);
const status = tele?.pos
? subst(this._t('vac.status_found'), { name: src || '' })
: this._t('vac.status_none');
const cals = Object.keys(v.calibration || {});
const setVac = (patch: Record<string, unknown>) => {
// 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 = '';
this._saveConfig();
this.requestUpdate();
};
return html`
<label>${this._t('vac.section')}</label>
<div class="bindbox vacbox">
<div class="rhint">${status}</div>
${tele ? html`
<div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this._t('vac.autocal')}</button>` : nothing}
<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this._t('vac.fit')}</button>
</div>
<label class="srcrow">
${this._boolInput(v.live !== false, (on) => setVac({ live: on ? null : false }))}
<span>${this._t('vac.live')}</span>
</label>
<label>${this._t('vac.trail')}</label>
<select class="areasel"
@change=${(e: Event) => setVac({ trail_mode: (e.target as HTMLSelectElement).value, trail: null })}>
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this._t(('vac.trail_' + mv) as any)}</option>`)}
</select>
${cals.length ? html`<div class="rhint">${subst(this._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
` : nothing}
</div>`;
}
/**
* 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 }): 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 ? this.hass?.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._saveConfig();
this.requestUpdate();
return true;
}
/** «Настроить автоматически»: 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 sp = this._spaceModel(d.space);
const planRooms = (sp?.rooms || [])
// HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class
// everywhere else — roomPoly() gives the same 4-corner outline the
// renderer uses, so they must count for name-matching too. The old
// r.poly?.length filter dropped them and then blamed the room names.
.map((r: any) => ({ r, poly: roomPoly(r) }))
.filter(({ r, poly }: any) => r.name && poly)
.map(({ r, poly }: any) => {
const c = poleOfInaccessibility(poly);
return { name: r.name, cx: c[0], cy: c[1] };
});
const res = autoCalibrate(tele.rooms, planRooms);
if (!res) {
this._showToast(this._t('vac.autocal_no_match'));
return;
}
// residual gate: 5% of the canvas ≈ 40 cm in a typical house
// HP-1540-01: toast success ONLY after the matrix verifiably landed in
// the config — the old code always claimed victory, even as a no-op
if (!this._vacSaveMatrix(d.id, src, this._vacMapId(d, tele), res.matrix)) return;
if (res.residual > NORM_W * 0.05) {
this._showToast(subst(this._t('vac.autocal_res_warn'), { rooms: String(res.matched.length) }));
}
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
/** «Подогнать вручную»: 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._spaceModel(d.space);
const vb = (sp?.vb || [0, 0, NORM_W, NORM_W]) 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._space = d.space;
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<FitParams>): 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.hass?.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`<polygon points="${cs.map((q) => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join(' ')}"></polygon>
<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}">${r.name}</text>`);
}
let dot: TemplateResult | typeof nothing = nothing;
if (tele.pos) {
const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y);
dot = svg`<circle class="vacfitdot" cx="${dx2.toFixed(1)}" cy="${dy2.toFixed(1)}" r="${(view.w * 0.012).toFixed(1)}"></circle>`;
}
// 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);
const r = view.w * 0.022; // finger-sized: these are grabbed on tablets
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`<circle class="vacfithandle" data-corner="${fixed[0] + ',' + fixed[1]}"
cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${r.toFixed(1)}"></circle>`);
}
}
return html`<svg class="vacfit" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="none"
@pointerdown=${(e: PointerEvent) => 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}</svg>`;
}
/** 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;
const src = this._vacSource(d);
if (!src) continue;
const tele = readVacTelemetry(this.hass?.states[src]?.attributes);
if (!tele) continue;
const matrix = d.marker?.vacuum?.calibration?.[this._vacMapId(d, tele)] as Affine | undefined;
if (!matrix || matrix.length !== 6) continue;
const rt = 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 = this._vacSrvTrails[d.id];
const mapNow = this._vacMapId(d, tele);
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);
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
}).join(' ');
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
}
// trail source order: server current run (survives reloads, shared by
// every screen) → integration path → the local live buffer
if (showCur && (moving || srvCur)) {
// any server/integration path ends at the CURRENT target — trim the
// live tail while moving so the line never runs ahead of the puck
const full: VacPt[] = (srvCur?.points as VacPt[]) || tele.path || rt?.trail || [];
const trim = moving && (srvCur || tele.path) && full.length > 1;
const raw: VacPt[] = trim ? full.slice(0, -1) : full;
if (raw.length > 1) {
const ptsStr = raw.map(([x, y]) => {
const [cx, cy] = applyAffine(matrix, x, y);
return cx.toFixed(1) + ',' + cy.toFixed(1);
}).join(' ');
// cartography casing: a dark halo under a light core. Neutral and
// visible over ANY room fill — blend modes all have a blind
// luminance where the line vanishes (owner request 2026-07-31).
trails.push(svg`<polyline class="case" points="${ptsStr}"></polyline><polyline class="core" points="${ptsStr}"></polyline>`);
// the LAST segment grows glued to the icon (owner: «след появлялся
// строго за иконкой»): a rAF sampler drags x2/y2 to the puck centre
if (moving) {
const [ax, ay] = applyAffine(matrix, raw[raw.length - 1][0], raw[raw.length - 1][1]);
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
}
}
if (!moving || !tele.pos) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const left = ((cx - view.x) / view.w) * 100;
const top = ((cy - 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`<div
data-mid="${d.id}"
class="vacpuck ${rt?.jump || jumpAll ? 'jump' : ''} ${stale ? 'stale' : ''}"
style="left:${left}%;top:${top}%"
title=${d.name}
@click=${(e: Event) => { e.stopPropagation(); const ve = this._vacEntity(d); if (ve) this._openMoreInfo(ve); }}>
<ha-icon .icon=${icon}></ha-icon>
</div>`);
}
this._vacLastView = view;
if (pucks.length && !this._vacRaf) this._vacRafLoop();
if (!pucks.length && !trails.length) return nothing;
return html`
${trails.length ? svg`<svg class="vactrail" viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none">${trails}</svg>` : nothing}
${pucks}`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true, glowFill = false): TemplateResult {
const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
// a ghost is configuration, not status: no yellow, no open, no unavail —
// and no live numbers either (HP-1510-02): no value text, no temperature,
// no humidity, no LQI badge, no state-morphed icon. The base icon and the
// name stay — enough to recognise the device and open its dialog.
// The owner's rule for LIGHT SOURCES (2026-07-29): where the glow layer
// is VISIBLE, the indicator IS the glow spot — the badge stays standard,
// on or off; wherever the spot is not drawn (other fills, the plan
// editor) a lit source goes plain yellow like a heating TRV. The gate
// must equal the layer's visibility, or a mode ends up with neither
// indicator (HP-1520-01). "Source" is exactly the litLightEntity
// condition that casts the spot, so a lit socket keeps its yellow.
let cls = d.hidden ? '' : this._stateClass(d);
if (glowFill && cls === 'on' && litLightEntity(this.hass, d)) cls = '';
const temp = d.hidden ? null : this._liveTemp(d);
const hum = d.hidden ? null : this._liveHum(d);
const lqi = showLqi && !d.virtual && !d.hidden ? lqiFor(this.hass, d.entities) : null;
const m = d.marker;
const disp = m?.display || 'badge';
// a ghost drops the ripple presentation entirely (HP-1511-02): an
// icon-less pulse is unrecognisable, and the release contract says a
// ghost keeps the base icon and name — display modes are status dressing
const ripple = (disp === 'ripple' || disp === 'icon_ripple') && !d.hidden;
// value-only display: the measurement IS the marker
const primarySt = d.primary ? this.hass.states[d.primary] : undefined;
const valText = disp === 'value' && !d.hidden
? (temp != null ? temp + '°'
: hum != null ? hum + '%'
: primarySt && !isNaN(parseFloat(primarySt.state))
? parseFloat(primarySt.state) + (primarySt.attributes?.unit_of_measurement ? ' ' + primarySt.attributes.unit_of_measurement : '')
: null)
: null;
// live state variants of the auto icon (doors, locks, bulbs), like core HA
const domain = d.primary ? d.primary.split('.')[0] : null;
const icon = this._config?.live_states && !d.hidden
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
: d.icon;
// v1.52.0: a lamp's colour lives in its GLOW and in the ripple fallback
// only — the icon/border tint is gone. lightC is still computed (from the
// marker's first lit RGB target, else the primary light) purely to feed
// --ripple-color when no explicit ripple colour is set.
const ctrl = (m?.controls || []).filter(isControllable);
const lightC = this._config?.live_states && !d.hidden
? ctrl.length
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
: domain === 'light' ? lightColorOf(primarySt) : null
: null;
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
const alarm = this._config?.live_states && !d.hidden
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
const active = ripple && !d.hidden && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
if (ripple) {
st.push(`--ripple-scale:${rScale}`);
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
else if (lightC) st.push(`--ripple-color:${lightC}`);
}
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' && !d.hidden ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
@mouseleave=${() => (this._tip = null)}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
>
${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
: nothing}
${this._newIds.has(d.id) ? html`<span class="newdot" title=${this._t('device.new')}></span>` : nothing}
${valText != null
? html`<span class="valtext">${valText}</span>`
: disp !== 'ripple' || d.hidden
? html`<ha-icon icon="${icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
${temp != null && valText == null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null && valText == null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`;
}
/** 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.hass, src, 'temp');
// 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.hass, src, 'hum');
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
}
private _climateCache: { h: any; r: any; mk: any; m: Map<string, AreaClimate> } | 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<string, AreaClimate> {
// 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 c = this._climateCache;
if (c && c.h === this.hass && c.r === this._iconRules && c.mk === mk) return c.m;
const m = areaClimateMap(this.hass, this._iconRules, mk);
this._climateCache = { h: this.hass, r: this._iconRules, mk, m };
return m;
}
private _resetRoomDialogFields(): void {
this._roomEditId = null;
this._roomFill = '';
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 as any) || '';
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._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;
const st = this._roomSettingsFromDialog();
if (st) room.settings = st;
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.hass;
const q = this._roomSrcFilter.trim().toLowerCase();
const list: { value: string; label: string; sub: string }[] = [];
for (const dev of Object.values<any>(h.devices)) {
if (dev.entry_type === 'service') 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<any>(h.entities)) {
if (!eid.startsWith('sensor.') || reg.hidden) 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.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
return this.hass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref;
}
/** Saved label position (layout key rl_<roomId>) 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 };
}
const c = 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 vb = this._spaceModel(spaceId).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;
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, 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_<roomId>, 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._spaceModel(rs.space);
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<number[][], number[]>();
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`<button class="rlgearbtn" style="left:${left}%;top:${top}%"
title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this._t('room.settings_short')}</span>
</button>`;
}
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 left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
const k = this._labelScale(r);
// optional metrics row (needs an HA area; sub-area rooms show the name only)
const rows: TemplateResult[] = [];
if (r.area || r.settings?.temp_source || r.settings?.hum_source) {
if (disp.labelTemp) {
const t = this._roomTemp(r);
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
}
if (disp.labelHum) {
const hm = this._roomHum(r);
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
}
if (disp.labelLqi && r.area) {
const l = this._roomLqi(r.area);
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
}
if (disp.labelLight && r.area) {
const ls = areaLightStats(this.hass, this._devices, r.area);
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`<span class="rlm ${ls.on ? 'lit' : ''}"><ha-icon icon=${ls.on ? 'mdi:lightbulb-on' : 'mdi:lightbulb-outline'}></ha-icon>${txt}</span>`);
}
}
}
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k};--rl-space:${disp.cardFontScale};--rl-name:${clampScale(r.settings?.name_scale)};--rl-meta:${clampScale(r.settings?.label_scale)}"
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
: nothing}</span>
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
${this._mode === 'plan'
? ['tl', 'tr', 'bl', 'br'].map(
(c) => html`<span class="rlhandle ${c}"
@pointerdown=${(e: PointerEvent) => this._rlResizeDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._rlResizeMove(e)}
@pointerup=${() => this._rlResizeUp()}
@pointercancel=${() => this._rlResizeUp()}></span>`,
)
: nothing}
</div>`;
}
/** 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 = is45(deg);
return html`<div class="measurelabel ${on45 ? 'on45' : ''}" style="left:${left}%;top:${top}%">
${this._fmtLen(a, b)} · ${shown}°</div>`;
}
// ================= 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 (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) continue;
const p = this._pos(d);
out.push([p.x, p.y]);
}
return out;
}
if (this._mode === 'decor') {
const W = NORM_W, H = this._decorH;
const movingId = this._decorMove?.id;
for (const sh of this._decorList) {
if (sh.id === movingId) continue;
if (sh.kind === 'line') { out.push([sh.x1 * W, sh.y1 * H], [sh.x2 * W, sh.y2 * H]); }
else if (sh.kind === 'text') out.push([sh.x * W, sh.y * H]);
else {
out.push([sh.x * W, sh.y * H], [(sh.x + sh.w) * W, sh.y * H],
[sh.x * W, (sh.y + sh.h) * H], [(sh.x + sh.w) * W, (sh.y + sh.h) * H]);
}
}
if (this._decorDraft) out.push(this._decorDraft.a);
for (const r of spm.rooms) {
const poly = roomPoly(r);
if (poly) for (const p of poly) out.push(p);
}
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`<g class="alignguides">
${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`<line class="alignline" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"></line>
<circle class="aligndot" cx="${gd.from[0]}" cy="${gd.from[1]}" r="${g * 0.18}"></circle>`;
})}
</g>` 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 = 2.5 * 6;
return svg`<line class="alignline opcentertick"
x1="${gd.x - Math.cos(rad) * half}" y1="${gd.y - Math.sin(rad) * half}"
x2="${gd.x + Math.cos(rad) * half}" y2="${gd.y + Math.sin(rad) * half}"></line>` 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.hass.states[o.contact]?.state : null;
return openingAmount(o.type, st, !!o.invert);
}
/**
* Doors and windows, drawn in plan (SVG) coordinates so they scale and pan with
* the plan. Symbol geometry after easy-floorplan (MIT): jambs, a leaf that swings
* around its hinge, and a quarter-circle arc that "draws on" via stroke-dashoffset.
*/
private _renderOpenings(disp: SpaceDisplay): TemplateResult {
const items = this._openingsR;
if (!items.length) return svg``;
const base = disp.color;
return svg`${items.map((o) => {
const half = o.rlen / 2;
const amt = this._openingAmt(o);
const active = amt > 0 && !!o.contact;
const tone = active ? 'var(--hp-open)' : base;
const jamb = 8;
const sx = o.flip_h ? -1 : 1;
const sy = o.flip_v ? -1 : 1;
let body;
if (o.type === 'window') {
// two casement leaves hinged at the jambs, meeting in the middle
const arcLen = (Math.PI / 2) * half;
body = svg`
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 0 ${-half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 1 ${half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>
<g transform="translate(${half} 0)">
<g class="op-leaf" style="transform:rotate(${90 * amt}deg)">
<rect x="${-half}" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>`;
} else {
// door leaf hinged at the left jamb, swinging up; arc from tip to tip
const L = o.rlen;
const arcLen = (Math.PI / 2) * L;
body = svg`
<path class="op-arc" d="M ${half} 0 A ${L} ${L} 0 0 0 ${-half} ${-L}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.75" width="${L}" height="3.5" fill="${tone}"></rect>
</g>
</g>`;
}
return svg`<g class="opening" transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
<g transform="scale(${sx} ${sy})">
<line x1="${-half}" y1="${-jamb / 2}" x2="${-half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${half}" y1="${-jamb / 2}" x2="${half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
${body}
</g>
<rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40"
@click=${(e: MouseEvent) => 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)}></rect>
</g>`;
})}`;
}
/** Padlock badges for doors with a lock entity (HTML, so ha-icon just works). */
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const items = this._openingsR.filter((o) => o.type === 'door' && o.lock);
if (!items.length) return html``;
return html`${items.map((o) => {
const st = this.hass.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
const rad = ((o.angle + 90) * Math.PI) / 180;
const off = 16 * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const left = ((px - view.x) / view.w) * 100;
const top = ((py - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</div>`;
})}`;
}
/**
* 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 (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!;
const cSt = o.contact ? this.hass.states[o.contact]?.state : null;
const amt = this._openingAmt(o);
const lSt = o.lock ? this.hass.states[o.lock]?.state : null;
const row = (icon: string, label: string, value: string, cls = '') =>
html`<div class="oprow ${cls}"><ha-icon icon=${icon}></ha-icon><span>${label}</span><b>${value}</b></div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._openingInfo = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${o.type === 'door' ? 'mdi:door' : 'mdi:window-closed-variant'}></ha-icon>
${this._t(o.type === 'door' ? 'opening.door' : 'opening.window')}</div>
<div class="body">
${o.contact
? row(amt > 0 ? 'mdi:door-open' : 'mdi:door-closed',
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}
${o.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}
${o.lock && (lSt === 'locked' || ['unlocked', 'open'].includes(String(lSt)))
? html`<button
class="btn lockact ${lSt === 'locked' ? 'warn' : ''}"
@click=${() => this._lockAction(o.lock!, lSt === 'locked' ? 'unlock' : 'lock')}>
<ha-icon icon=${lSt === 'locked' ? 'mdi:lock-open-variant' : 'mdi:lock'}></ha-icon>
${this._t(lSt === 'locked' ? 'opening.unlock_action' : 'opening.lock_action')}
</button>`
: o.lock && ['locking', 'unlocking'].includes(String(lSt))
? html`<button class="btn lockact" disabled>
<ha-icon icon="mdi:timer-sand"></ha-icon>${this._t('opening.lock_pending')}
</button>`
: nothing}
${!o.contact && !o.lock ? html`<p class="muted">${this._t('opening.no_entities')}</p>` : nothing}
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingInfo = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
private _renderOpeningDialog(): TemplateResult {
const d = this._openingDialog!;
const opt = (list: { value: string; label: string }[], cur: string, set: (v: string) => void) =>
html`<select class="areasel" @change=${(e: Event) => set((e.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!cur}>${this._t('opening.none')}</option>
${list.map((c) => html`<option value=${c.value} ?selected=${c.value === cur}>${c.label}</option>`)}
</select>`;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:door"></ha-icon>
${d.id ? this._t('opening.edit') : this._t('opening.new')}</div>
<div class="body">
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
@change=${() => (this._openingDialog = { ...d, type: 'door', lengthCm: d.id ? d.lengthCm : 90 })} />
<span>${this._t('opening.door')}</span></label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
@change=${() => (this._openingDialog = { ...d, type: 'window', lengthCm: d.id ? d.lengthCm : 120 })} />
<span>${this._t('opening.window')}</span></label>
<label>${this._t('opening.length_label')}</label>
<input class="namein tempin" type="number" min="20" max="600" step="5" .value=${String(d.lengthCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._openingDialog = { ...d, lengthCm: n };
}} />
<label>${this._t('opening.contact_label')}</label>
${opt(this._contactCandidates(), d.contact, (v) => (this._openingDialog = { ...d, contact: v }))}
${d.contact
? html`<label class="srcrow">${this._boolInput(d.invert, (v) => (this._openingDialog = { ...d, invert: v }))}
<span>${this._t('opening.invert')}</span></label>`
: nothing}
${d.type === 'door'
? html`<label>${this._t('opening.lock_label')}</label>
${opt(this._lockCandidates(), d.lock, (v) => (this._openingDialog = { ...d, lock: v }))}`
: nothing}
<label class="srcrow">${this._boolInput(d.flipH, (v) => (this._openingDialog = { ...d, flipH: v }))}
<span>${this._t('opening.flip_h')}</span></label>
<label class="srcrow">${this._boolInput(d.flipV, (v) => (this._openingDialog = { ...d, flipV: v }))}
<span>${this._t('opening.flip_v')}</span></label>
</div>
<div class="row">
${d.id
? html`<button class="btn danger" @click=${this._deleteOpening}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveOpening}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
private _renderMarkupDefs(_vb: number[]): TemplateResult {
const g = this._gridPitch;
const dotR = g * 0.14;
return svg`<defs>
<pattern id="hp-grid" x="0" y="0" width="${g}" height="${g}" patternUnits="userSpaceOnUse">
<circle cx="0" cy="0" r="${dotR}" class="griddot"></circle>
<circle cx="${g}" cy="0" r="${dotR}" class="griddot"></circle>
<circle cx="0" cy="${g}" r="${dotR}" class="griddot"></circle>
<circle cx="${g}" cy="${g}" r="${dotR}" class="griddot"></circle>
</pattern>
</defs>`;
}
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 segs = openCuts.length
? cutSegments(this._segments, openCuts, this._gridPitch * 0.02)
: this._segments;
const path = this._path;
const g = this._gridPitch;
return svg`
<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>
${segs.map((s) => svg`<line class="seg" x1="${s[0]}" y1="${s[1]}" x2="${s[2]}" y2="${s[3]}"></line>`)}
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed
? svg`<line class="preview" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${(() => {
const op = this._openingPreview;
if (!op) return nothing;
const rad = (op.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * op.rlen) / 2;
const dy = (Math.sin(rad) * op.rlen) / 2;
return svg`<line class="opghost" x1="${op.x - dx}" y1="${op.y - dy}"
x2="${op.x + dx}" y2="${op.y + dy}"></line>
<circle class="opghost-dot" cx="${op.x}" cy="${op.y}" r="${g * 0.18}"></circle>`;
})()}
${this._tool === 'split' && this._splitSel?.pts?.length
? svg`${this._splitSel.pts.length > 1
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${this._cursorPt
? svg`<line class="preview" x1="${this._splitSel.pts[this._splitSel.pts.length - 1][0]}" y1="${this._splitSel.pts[this._splitSel.pts.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}`
: nothing}
`;
}
private _renderMarkupBar(): TemplateResult {
return html`<div class="editbar">
<ha-icon icon="mdi:vector-square-edit" class="warn"></ha-icon>
<button class="btn ${this._tool === 'draw' ? 'on' : ''}" @click=${() => (this._tool = 'draw')}
title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button>
<button class="btn ${this._tool === 'merge' ? 'on' : ''}"
@click=${() => { this._tool = 'merge'; this._cancelPath(); this._tool = 'merge'; }}
title=${this._t('title.markup_merge')}>
<ha-icon icon="mdi:vector-union"></ha-icon>${this._t('markup.merge')}
</button>
<button class="btn ${this._tool === 'split' ? 'on' : ''}"
@click=${() => { this._tool = 'split'; this._cancelPath(); this._tool = 'split'; }}
title=${this._t('title.markup_split')}>
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
</button>
<button class="btn ${this._tool === 'resize' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'resize'; this._rszSel = null; }}
title=${this._t('title.markup_resize')}>
<ha-icon icon="mdi:arrow-expand-all"></ha-icon>${this._t('markup.resize')}
</button>
<button class="btn ${this._tool === 'opening' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'opening'; }}
title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'openwall' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'openwall'; }}
title=${this._t('title.markup_openwall')}>
<ha-icon icon="mdi:border-none-variant"></ha-icon>${this._t('markup.openwall')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
</button>
<span class="spacer"></span>
${this._tool === 'draw'
? html`<span class="hint">${this._path.length
? this._t('markup.hint_points', { n: this._path.length })
: this._t('markup.hint_start')}</span>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: nothing}
${this._tool === 'resize' ? html`<span class="hint">${this._t('markup.hint_resize')}</span>` : nothing}
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
private _renderDevicesBar(): TemplateResult {
return html`<div class="editbar devbar">
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
<button class="btn" @click=${() => this._openMarkerDialog()} title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>${this._t('devbar.add')}
</button>
<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')}
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')}
</button>
<span class="spacer"></span>
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
/** 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.hass;
const out: { eid: string; kind: 'toggle' | 'value' | 'open' }[] = [];
const seen = new Set<string>();
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 e of d.marker?.controls || []) push(e);
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') return; // never from a card tap
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 ? this.hass.formatEntityState?.(st) ?? st.state : null;
const controls = (d.marker?.controls || []).filter(isControllable);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
<div class="body">
${(() => {
// 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`<div class="entlist">
${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 ? this.hass.formatEntityState?.(est) ?? est.state : '';
const on = est?.state === 'on' || ['open', 'unlocked', 'playing', 'cleaning'].includes(est?.state);
return html`<div class="entrow ${on ? 'on' : ''}">
<ha-icon icon=${stateIcon(
iconFor(name, '', this._iconRules), eid.split('.')[0],
est?.attributes?.device_class, est?.state, false,
)}></ha-icon>
<span class="en">${name}</span>
${kind === 'toggle'
? html`<button class="entbtn ${on ? 'on' : ''}"
@click=${() => this._cardToggle(eid)}>${val}</button>`
: kind === 'open'
? html`<button class="entbtn"
@click=${() => { this._infoCard = null; this._openMoreInfo(eid); }}>${val}</button>`
: html`<span class="ev">${val}</span>`}
</div>`;
})}
</div>`;
})()}
${d.model ? html`<div class="inforow"><span class="k">${this._t('info.model')}</span><span>${d.model}</span></div>` : nothing}
${stateTxt && !this._cardEntities(d).length
? html`<div class="inforow"><span class="k">${this._t('info.state')}</span><span>${stateTxt}</span></div>` : nothing}
${safeUrl(d.link)
? html`<div class="inforow"><span class="k">${this._t('info.link')}</span>
<a href="${safeUrl(d.link)}" target="_blank" rel="noreferrer noopener">${d.link}</a></div>`
: nothing}
${d.description ? html`<div class="infodesc">${d.description}</div>` : nothing}
${d.pdfs && d.pdfs.length
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
${d.pdfs.map(
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>`
: nothing}
${controls.length
? html`<div class="inforow"><span class="k">${this._t('info.controls')}</span>
<span class="ctrlstates">
${controls.map((eid) => {
const cs = this.hass.states[eid];
const on = cs?.state === 'on';
return html`<span class="ctrlstate ${on ? 'on' : ''}">
<ha-icon icon=${on ? 'mdi:lightbulb-on' : 'mdi:lightbulb-outline'}></ha-icon>
${cs?.attributes?.friendly_name || eid}</span>`;
})}
</span></div>`
: nothing}
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length) && !controls.length
? html`<div class="infodesc muted">${this._t('info.none')}</div>`
: nothing}
</div>
<div class="row">
<button class="btn" @click=${() => { const dd = d; this._infoCard = null; this._openMarkerDialog(dd); }}>
<ha-icon icon="mdi:pencil"></ha-icon>${this._t('btn.edit')}
</button>
${d.primary
? html`<button class="btn" @click=${() => { const p = d.primary; this._infoCard = null; this._openMoreInfo(p); }}>
<ha-icon icon="mdi:open-in-new"></ha-icon>${this._t('btn.open_in_ha')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._infoCard = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
private _renderMarkerDialog(): TemplateResult {
const d = this._markerDialog!;
const isVirtual = d.bindingMode === 'virtual';
const cands = this._bindingCandidates();
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.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
return this.hass.states[ref]?.attributes?.friendly_name || ref;
})();
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus"></ha-icon>
${d.devId ? this._t('info.device_header') : this._t('marker.new_device')}</div>
<div class="body">
<label>${this._t('marker.name_label')}</label>
<input class="namein" type="text" placeholder=${this._t('marker.name_ph')}
.value=${d.name}
@input=${(e: Event) => (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} />
<label>${this._t('marker.binding_label')}</label>
<div class="bindsel">
<label class="srcrow">
<input type="radio" name="bmode" .checked=${d.bindingMode === 'virtual'}
@change=${() => (this._markerDialog = { ...d, bindingMode: 'virtual', binding: 'virtual', bindingOpen: false })} />
<span>${this._t('marker.virtual_option')}</span>
</label>
<div class="bindharow">
<label class="srcrow">
<input type="radio" name="bmode" .checked=${d.bindingMode === 'ha'}
@change=${() => (this._markerDialog = {
...d, bindingMode: 'ha',
binding: d.binding === 'virtual' ? '' : d.binding,
bindingOpen: d.binding === 'virtual' || !d.binding,
})} />
<span>${this._t('marker.from_ha_option')}</span>
</label>
<label class="srcrow inline entcheck" title=${this._t('marker.show_entities_tip')}>
${this._boolInput(d.showEntities, (v) => (this._markerDialog = { ...d, showEntities: v }),
d.bindingMode !== 'ha')}
<span>${this._t('marker.show_entities')}</span>
</label>
</div>
${d.bindingMode === 'ha'
? html`<button class="dropbtn ${d.bindingOpen ? 'open' : ''}"
@click=${() => (this._markerDialog = { ...d, bindingOpen: !d.bindingOpen })}>
${curLabel
? html`<b>${curLabel}</b><span class="ref">${d.binding}</span>`
: html`<span class="muted">${this._t('marker.pick_ph')}</span>`}
<ha-icon icon=${d.bindingOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button>
${d.bindingOpen
? html`<div class="droppanel">
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
.value=${d.bindingFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
<div class="candlist">
${cands.map(
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: c.value, bindingOpen: false })}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
</div>
</div>`
: nothing}`
: nothing}
</div>
<label>${this._t('marker.room_label')}${isVirtual ? '' : this._t('marker.room_override')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, room: (e.target as HTMLSelectElement).value })}>
<option value="">${isVirtual ? this._t('marker.room_choose') : this._t('marker.room_auto')}</option>
${this._allRoomsFlat().map(
(r) => html`<option value=${r.value} ?selected=${r.value === d.room}>${r.label}</option>`,
)}
</select>
${this._renderVacSection(d)}
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${TAP_ACTIONS.filter((v) => v !== 'cover' || this._bindingCoverTap(d.binding))
.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.tapAction === '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`
<label>${this._t('marker.run_target_label')}</label>
${d.tapTarget && !cur
? html`<div class="rhint">${this._t('marker.run_target_gone', { id: d.tapTarget })}</div>`
: nothing}
<input class="namein" type="text" placeholder=${this._t('marker.run_search_ph')}
.value=${cur ? cur.label : d.runFilter}
@focus=${(e: Event) => { (e.target as HTMLInputElement).select(); }}
@input=${(e: Event) => (this._markerDialog = { ...d, runFilter: (e.target as HTMLInputElement).value, tapTarget: '' })} />
${!cur
? html`<div class="candlist">
${cands.slice(0, 40).map(
(c) => html`<div class="cand ${c.value === d.tapTarget ? 'sel' : ''}"
@click=${() => (this._markerDialog = { ...d, tapTarget: c.value, runFilter: '' })}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
</div>`
: nothing}`;
})()
: nothing}
${d.tapAction === 'run' || d.tapAction === 'toggle' || d.tapAction === 'cover' || (!d.tapAction && d.defaultTap === 'toggle')
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
${this._boolInput(d.tapConfirm, (v) => (this._markerDialog = { ...d, tapConfirm: v }))}
<span>${this._t('marker.tap_confirm')}</span>
</label>`
: nothing}
<label>${this._t('marker.controls_label')}</label>
<div class="rhint">${this._t('marker.controls_hint')}</div>
${d.controls.length
? html`<div class="ctrlchips">
${d.controls.map((eid) => html`<span class="ctrlchip">
${this.hass.states[eid]?.attributes?.friendly_name || eid}
<ha-icon icon="mdi:close" @click=${() =>
(this._markerDialog = { ...d, controls: d.controls.filter((x) => x !== eid) })}></ha-icon>
</span>`)}
</div>`
: nothing}
<input class="namein" type="text" placeholder=${this._t('marker.controls_filter')}
.value=${d.controlsFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, controlsFilter: (e.target as HTMLInputElement).value })} />
${d.controlsFilter.trim()
? html`<div class="ctrllist">
${Object.keys(this.hass.states)
.filter((eid) => isControllable(eid) && !d.controls.includes(eid))
.filter((eid) => {
const q = d.controlsFilter.trim().toLowerCase();
const name = String(this.hass.states[eid]?.attributes?.friendly_name || '');
return eid.toLowerCase().includes(q) || name.toLowerCase().includes(q);
})
.slice(0, 8)
.map((eid) => html`<button class="ctrlopt"
@click=${() => (this._markerDialog = { ...d, controls: [...d.controls, eid], controlsFilter: '' })}>
<ha-icon icon=${eid.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch'}></ha-icon>
${this.hass.states[eid]?.attributes?.friendly_name || eid}
<span class="sub">${eid}</span>
</button>`)}
</div>`
: nothing}
${this._bindingHasClimate(d.binding)
? html`<label class="srcrow climrow" title=${this._t('marker.use_climate_temp_tip')}>
${this._boolInput(d.useClimateTemp, (v) => (this._markerDialog = { ...d, useClimateTemp: v }))}
<span>${this._t('marker.use_climate_temp')}</span>
</label>`
: nothing}
<label class="srcrow" title=${this._t('marker.is_light_tip')}>
${this._boolInput(d.isLight, (v) => (this._markerDialog = { ...d, isLight: v }))}
<span>${this._t('marker.is_light')}</span>
</label>
<label class="srcrow" title=${this._t('marker.hide_tip')}>
${this._boolInput(d.hideFromPlan, (v) => (this._markerDialog = { ...d, hideFromPlan: v }))}
<span>${this._t('marker.hide')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
placeholder=${this._glowRadiusPlaceholder}
.value=${d.glowRadius}
@input=${(e: Event) => (this._markerDialog = { ...d, glowRadius: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
<span class="opl muted">${this._t('marker.glow_radius_hint')}</span>
</div>
<label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
.placeholder=${d.autoIcon || undefined}
.fallbackPath=${undefined}
@value-changed=${(e: any) => (this._markerDialog = { ...d, icon: e.detail.value || '' })}></ha-icon-picker>`
: html`<input class="namein" type="text"
placeholder=${d.autoIcon || this._t('marker.icon_ph')}
.value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
${!d.icon && d.autoIcon
? html`<p class="muted iconauto"><ha-icon icon=${d.autoIcon}></ha-icon>
${this._t('marker.icon_auto', { icon: d.autoIcon })}</p>`
: nothing}
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.display === 'ripple' || d.display === 'icon_ripple'
? html`<div class="colorrow">
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('marker.ripple_size')}</span>
${this._rangeInput(2, 8, 0.5, d.rippleSize, (n) => (this._markerDialog = { ...d, rippleSize: n }))}
<span class="opv">×${d.rippleSize}</span>
</div>`
: nothing}
<label>${this._t('marker.size_label')}</label>
<div class="colorrow">
${this._rangeInput(0.5, 3, 0.1, d.size, (n) => (this._markerDialog = { ...d, size: n }))}
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
${this._rangeInput(0, 350, 10, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
<span class="opv">${d.angle}°</span>
</div>
<label>${this._t('marker.model_label')}</label>
<input class="namein" type="text" placeholder=${this._t('marker.model_ph')}
.value=${d.model}
@input=${(e: Event) => (this._markerDialog = { ...d, model: (e.target as HTMLInputElement).value })} />
<label>${this._t('marker.link_label')}</label>
<input class="namein" type="url" placeholder="https://…"
.value=${d.link}
@input=${(e: Event) => (this._markerDialog = { ...d, link: (e.target as HTMLInputElement).value })} />
<label>${this._t('marker.desc_label')}</label>
<textarea class="descin" rows="4" placeholder=${this._t('marker.desc_ph')}
.value=${d.description}
@input=${(e: Event) => (this._markerDialog = { ...d, description: (e.target as HTMLTextAreaElement).value })}></textarea>
<label>${this._t('marker.manuals_label')}</label>
<div class="pdfedit">
${d.pdfs.map(
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
<a href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
)}
<label class="btn filebtn">
<ha-icon icon="mdi:paperclip"></ha-icon>${this._t('btn.attach')}
<input type="file" hidden multiple accept=".pdf,.png,.jpg,.jpeg,.webp,.txt,application/pdf"
@change=${(e: Event) => this._pickMarkerFiles(e)} />
</label>
</div>
</div>
<div class="row">
${d.devId && d.binding === 'virtual'
? html`<button class="btn danger" @click=${this._deleteMarker}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._markerDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveMarker}
?disabled=${d.busy || (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual'))}
title=${d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual') ? this._t('marker.pick_ph') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
? html`<span class="importprog">${this._t('import.progress', {
i: this._importTotal - this._importQueue.length,
n: this._importTotal,
})}</span>`
: nothing}</div>
<div class="body">
<label>${this._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this._t('space.title_ph')}
.value=${d.title}
@input=${(e: Event) => (this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} />
<label>${this._t('space.plan_label')}</label>
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'file'}
@change=${() => (this._spaceDialog = { ...d, source: 'file' })} />
<span>${this._t('space.source_file')}</span>
</label>
${d.source === 'file'
? html`<div class="planrow">
${d.planFile
? html`<span class="planname">${d.planFile.name}</span>`
: d.planUrl
? html`<img class="planprev" src=${this._display(d.planUrl)} alt=${this._t('space.plan_alt')} />`
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
<label class="btn filebtn">
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
@change=${(e: Event) => this._pickPlanFile(e)} />
</label>
<button class="btn ghost" @click=${this._toggleServerPlans}
title=${this._t('space.pick_saved_hint')}>
<ha-icon icon="mdi:folder-image"></ha-icon>${this._t('space.pick_saved')}
</button>
</div>
${d.pickSaved ? this._renderServerPlans(d) : nothing}`
: nothing}
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
<span>${this._t('space.source_draw')}</span>
</label>
<label>${this._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0.1" step="0.1" .value=${String(d.cellCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
this._spaceDialog = { ...d, cellCm: Number.isFinite(n) && n > 0 ? n : d.cellCm };
}} />
<span class="opl">${this._t('space.scale_unit')}</span>
</div>
<label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow">
${this._boolInput(d.showBorders, (v) => (this._spaceDialog = { ...d, showBorders: v }))}
<span>${this._t('space.show_borders')}</span>
</label>
<label class="srcrow">
${this._boolInput(d.showNames, (v) => (this._spaceDialog = { ...d, showNames: v }))}
<span>${this._t('space.show_names')}</span>
</label>
<label class="srcrow">
${this._boolInput(d.showLqi, (v) => (this._spaceDialog = { ...d, showLqi: v }))}
<span>${this._t('space.show_lqi')}</span>
</label>
<label class="dispsection">${this._t('space.roomcard_section')}</label>
${([['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light']] as const).map(
([f, k]) => html`<label class="srcrow">
${this._boolInput(d[f], (v) => (this._spaceDialog = { ...d, [f]: v }))}
<span>${this._t(k)}</span>
</label>`,
)}
<label>${this._t('space.card_font')}</label>
<div class="colorrow gsrow">
${this._rangeInput(50, 300, 5, Math.round(d.cardFontScale * 100), (n) => (this._spaceDialog = { ...d, cardFontScale: n / 100 }))}
<span class="opv">${Math.round(d.cardFontScale * 100)}%</span>
</div>
${this._renderCardPreview(d.cardFontScale, 1, 1)}
<label>${this._t('space.room_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.roomColor}
@input=${(e: Event) => (this._spaceDialog = { ...d, roomColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('space.opacity')}</span>
${this._rangeInput(0, 100, 1, Math.round(d.roomOpacity * 100), (n) => (this._spaceDialog = { ...d, roomOpacity: n / 100 }))}
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.bg_mode')}</label>
<select class="areasel"
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value;
this._spaceDialog = { ...d, bgMode: v === 'static' || v === 'daynight' ? (v as any) : null };
}}>
<option value="" ?selected=${d.bgMode === null}>${this._t('space.sun_inherit')}</option>
<option value="static" ?selected=${d.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${d.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
</select>
${(d.bgMode ?? bgModeOf(this._settings, {})) === 'static'
? html`<label>${this._t('space.bg_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
${d.bgColor
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
${this._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
</div>`
: nothing}
<label>${this._t('space.north')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this._t('space.sun_inherit')}
.value=${d.northDeg === null ? '' : String(d.northDeg)}
@input=${(e: Event) => {
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 };
}} />
<span class="opl">${d.northDeg === null
? this._t('space.north_inherited', {
v: northDegOf(this._settings, {}) === null ? '—' : String(northDegOf(this._settings, {})) + '°',
})
: '°'}</span>
</div>
<label>${this._t('space.sun_rays')}</label>
<select class="areasel"
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value;
this._spaceDialog = { ...d, sunRays: v === '' ? null : v === '1' };
}}>
<option value="" ?selected=${d.sunRays === null}>${this._t('space.sun_inherit')}</option>
<option value="1" ?selected=${d.sunRays === true}>${this._t('space.sun_on')}</option>
<option value="0" ?selected=${d.sunRays === false}>${this._t('space.sun_off')}</option>
</select>
<label>${this._t('space.fill_label')}</label>
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
<span>${this._t(k as any)}</span>
${v === 'temp' && d.fillMode === 'temp'
? html`<span class="temprange">
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMin: n };
}} />
–
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMax: n };
}} />
°C
</span>`
: nothing}
</label>`,
)}
</div>
<div class="row">
${d.mode === 'edit'
? html`<button class="btn danger" @click=${this._deleteSpace}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
</button>`
: nothing}
<span class="spacer"></span>
${this._importTotal > 0 && d.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>${this._t('btn.skip')}</button>`
: nothing}
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSpaceDialog}
?disabled=${!d.title.trim() || (d.source === 'file' && !(d.planFile || d.planUrl)) || d.busy}
title=${d.source === 'file' && !(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
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`<label class="srcrow">
<input type="radio" name="mergekeep" .checked=${d.pick === key}
@change=${() => (this._mergeDialog = { ...d, pick: key })} />
<span>${r?.name || ''} <span class="muted">· ${area || this._t('merge.no_area')}</span></span>
</label>`;
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:vector-union"></ha-icon>${this._t('merge.header')}</div>
<div class="body">
<p class="muted">${this._t('merge.hint')}</p>
<label>${this._t('merge.keep')}</label>
${opt(d.aId, 'a')}
${opt(d.bId, 'b')}
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._mergeDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._commitMerge}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
/** 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`<div class="cardpreview">
<span class="cpname" style="font-size:${(base * nameScale).toFixed(1)}px">
${this._t('preview.room_name')}</span>
<span class="cpmeta" style="font-size:${(base * 0.62 * labelScale).toFixed(1)}px">
<ha-icon icon="mdi:thermometer"></ha-icon>22.4° ·
<ha-icon icon="mdi:water-percent"></ha-icon>45% ·
<ha-icon icon="mdi:lightbulb-on"></ha-icon>${this._t('roomcard.light_partial', { on: 1, total: 3 })}
</span>
</div>`;
}
/** 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`
<label>${this._t(kind === 'temp' ? 'room.temp_src_label' : 'room.hum_src_label')}</label>
<label class="srcrow">
<input type="radio" name="rsrc-${kind}" .checked=${!val}
@change=${() => { setVal(''); this._roomSrcOpen = null; }} />
<span>${this._t('room.src_average')}</span>
</label>
<label class="srcrow">
<input type="radio" name="rsrc-${kind}" .checked=${!!val}
@change=${() => { this._roomSrcOpen = kind; this._roomSrcFilter = ''; this.requestUpdate(); }} />
<span>${this._t('room.src_pick')}</span>
</label>
${val || open
? html`<button class="dropbtn ${open ? 'open' : ''}"
@click=${() => { this._roomSrcOpen = open ? null : kind; this._roomSrcFilter = ''; }}>
${val
? html`<b>${this._roomSrcLabel(val)}</b><span class="ref">${val}</span>`
: html`<span class="muted">${this._t('room.src_ph')}</span>`}
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button>
${open
? html`<div class="droppanel">
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
.value=${this._roomSrcFilter}
@input=${(e: Event) => { this._roomSrcFilter = (e.target as HTMLInputElement).value; this.requestUpdate(); }} />
<div class="candlist">
${this._roomSrcCandidates().map(
(c) => html`<div class="cand ${c.value === val ? 'sel' : ''}"
@click=${() => { setVal(c.value); this._roomSrcOpen = null; }}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
</div>
</div>`
: nothing}`
: nothing}`;
}
private _renderRoomDialog(): TemplateResult {
const edit = !!this._roomEditId;
// 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`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'}></ha-icon>
${edit ? this._t('room.settings_title') : this._t('room.new')}</div>
<div class="body">
<label>${this._t('room.name_label')}</label>
<input class="namein" type="text" placeholder=${this._t('room.name_ph')}
.value=${this._nameSel}
@input=${(e: Event) => (this._nameSel = (e.target as HTMLInputElement).value)} />
<label>${this._t('room.area_label')}</label>
<select class="areasel"
@change=${(e: Event) => {
this._areaSel = (e.target as HTMLSelectElement).value;
if (!this._nameSel && this._areaSel)
this._nameSel = this.hass.areas[this._areaSel]?.name || '';
this.requestUpdate();
}}>
<option value="">${this._t('room.no_area_option')}</option>
${areas.map(
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`,
)}
</select>
<label class="dispsection">${this._t('room.settings_section')}</label>
<label>${this._t('room.fill_label')}</label>
${([['', 'fill.inherit'], ...ROOM_FILL_MODES.map((v) => [v, 'fill.' + v])] as const).map(
([v, k]) => html`<label class="srcrow inline">
<input type="radio" name="rfill" .checked=${this._roomFill === v}
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
<span>${this._t(k as any)}</span>
</label>`,
)}
${this._renderRoomSource('temp')}
${this._renderRoomSource('hum')}
<label class="dispsection">${this._t('room.sizes_section')}</label>
<label>${this._t('room.name_scale')}</label>
<div class="colorrow gsrow">
${this._rangeInput(50, 300, 5, Math.round(this._roomNameScale * 100), (n) => { this._roomNameScale = n / 100; this.requestUpdate(); })}
<span class="opv">${Math.round(this._roomNameScale * 100)}%</span>
</div>
<label>${this._t('room.label_scale')}</label>
<div class="colorrow gsrow">
${this._rangeInput(50, 300, 5, Math.round(this._roomLabelScale * 100), (n) => { this._roomLabelScale = n / 100; this.requestUpdate(); })}
<span class="opv">${Math.round(this._roomLabelScale * 100)}%</span>
</div>
${this._renderCardPreview(
spaceDisplayOf(this._curSpaceCfg).cardFontScale,
this._roomNameScale,
this._roomLabelScale,
)}
</div>
<div class="row">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
${edit
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
title=${this._t('title.no_area_room')}>
${this._t('btn.no_area')}
</button>
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`}
</div>
</div>
</div>`;
}
static styles = cardStyles;
}
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', '');