Files
houseplan-card/src/houseplan-card.ts
T
Matysh 37604fa17e fix+test v1.13.2: audit round 3
- 12-test buildDevices suite on a fake hass (curation, markers, groups, dedup,
  icon rules, lock override, device_class fallback, primary/LQI/temp)
- t() substitutes all placeholder occurrences (pure subst() + regression test)
- _saveConfigNow resyncs config on rev conflict before rethrowing
- pointercancel clears the long-press timer (phantom info card on touch)
- repairs.py: plan-file check re-runs after every config save
- test build compiles devices.ts/types.ts; fix-test-build.mjs adds .js to ESM imports
2026-07-06 03:37:30 +03:00

2269 lines
92 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, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
} from './logic';
import { buildDevices, lqiFor, tempFor } from './devices';
import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
import './editor';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.13.2';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // width of the render space for normalized configs
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' | 'erase' | '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 });
};
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number) => {
let t: number | undefined;
return (...a: Parameters<T>) => {
clearTimeout(t);
t = window.setTimeout(() => fn(...a), ms);
};
};
class HouseplanCard extends LitElement {
public hass?: any;
private _config?: CardConfig;
private _space = 'f1';
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
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 _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
// --- room markup editor ---
private _markup = false;
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _pathSegs: (string | null)[] = []; // keys of the segments added by outline steps
private _cursorPt: number[] | null = null;
private _areaSel = '';
private _nameSel = '';
private _roomDialog = false;
// 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> = {};
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 _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
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)
name: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = auto
tapAction: string; // '' = card default
model: string;
link: string;
description: string;
pdfs: PdfRef[];
room: string; // '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;
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _holdTimer?: number;
private _holdFired = false;
static properties = {
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 },
_markup: { state: true },
_tool: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
_spaceDialog: { state: true },
_infoCard: { state: true },
_rulesDialog: { state: true },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
_view: { state: true },
};
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
}
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
}
super.disconnectedCallback();
}
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
if (this._infoCard) { this._infoCard = null; return; }
if (this._markerDialog) { this._markerDialog = null; 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();
}
}
/** Remove the last placed point (and its line, if it was added by that step). */
private _undoPoint(): void {
if (!this._path.length) return;
if (this._path.length === 1) {
this._path = [];
this._pathSegs = [];
return;
}
const segKey = this._pathSegs[this._pathSegs.length - 1];
this._pathSegs = this._pathSegs.slice(0, -1);
if (segKey) this._removeSegmentByKey(segKey);
this._path = this._path.slice(0, -1);
}
private _removeSegmentByKey(key: string): void {
const sp = this._curSpaceCfg;
if (!sp?.segments) return;
const idx = this._segments.findIndex(
(s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key,
);
if (idx >= 0) {
sp.segments.splice(idx, 1);
this._saveConfig();
}
}
public static getConfigElement() {
return document.createElement('houseplan-card-editor');
}
public static getStubConfig(): Partial<CardConfig> {
return { type: 'custom:houseplan-card' };
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
} catch {
this._zoomBySpace = {};
}
// 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._cfgRev = c.rev || 0;
this._layout = c.layout || {};
this._serverStorage = true;
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;
}
} catch {
/* ignore */
}
}
/** 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/aspect). */
private get _model(): SpaceModel[] {
if (!this._serverCfg) return [];
return this._serverCfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const scale = (r: any) => ({
id: r.id,
name: r.name,
area: r.area ?? null,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
h: r.h != null ? r.h * H : undefined,
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
});
return {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: s.rooms.map(scale),
};
});
}
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 || {};
}
private get _showAll(): boolean {
return !!this._settings.show_all;
}
private _toggleShowAll(): void {
if (!this._serverCfg) return;
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } };
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;
}
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.hass) {
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._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._cfgRev = cfgResp?.rev || 0;
this._layout = layResp?.layout || {};
// live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
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) {
// not the last attempt — silently wait for the next hass update (WS warm-up)
if (this._loadTries >= 8) {
this._serverStorage = false;
this._serverCfg = null;
try {
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
} catch {
this._layout = {};
}
}
} finally {
this._loading = false;
}
this._regSignature = '';
this.requestUpdate();
}
private async _reloadConfigOnly(): Promise<void> {
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._cfgRev = resp?.rev || 0;
this._cacheSnapshot();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
} catch {
/* ignore */
}
}
private _dirtyPos = new Set<string>();
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;
this.hass
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })));
}
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();
}
/** Curation + 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) {
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
} 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 aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
this._layout = {
...this._layout,
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect) },
};
} 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 '';
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
const dom = p.entity_id.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? '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' : '';
}
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;
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return tempFor(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 });
}
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._markup) return;
const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
if (action === 'toggle' && d.primary) {
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;
}
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
const s = this._stageEl;
const vb = this._spaceModel().vb;
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._spaceModel().vb);
const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
}
/** Clamp the view to the fit bounds (the content always covers the scene). */
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: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
y: 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._spaceModel().vb;
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, 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);
}
/** 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._spaceModel().vb;
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, 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._spaceModel().vb;
this._zoom = 1;
this._view = fitView(vb, this._stageAspect());
this._saveZoom();
}
/** Save the current space zoom to localStorage. */
private _saveZoom(): void {
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;
this._view = null;
requestAnimationFrame(() => {
if (!this._stageEl) return;
const vb = this._spaceModel().vb;
this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
this.requestUpdate();
});
}
private _stagePointerDown(ev: PointerEvent): void {
// do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return;
if ((ev.target as HTMLElement).closest('.dev')) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb);
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._pointers.has(ev.pointerId)) {
this._markupMove(ev);
return;
}
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
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;
if (this._zoom > 1 && this._view) {
const stage = this._stageEl!;
const v = this._view;
const fit = fitView(this._spaceModel().vb, 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 {
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._markup) return; // in markup mode icons are not dragged
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 };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
// long-press always opens the info card — keeps metadata reachable
// even when the tap action is set to toggle
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
this._holdFired = true;
this._drag = null;
this._infoCard = d;
}
}, 600);
}
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._spaceModel().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;
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 _resetLayout(): void {
if (!confirm(this._t('confirm.reset_layout'))) return;
this._layout = {};
this._persistLayout();
}
private _showToast(msg: string): void {
this._toast = msg;
clearTimeout(this._toastTimer);
this._toastTimer = window.setTimeout(() => {
this._toast = '';
}, 3500);
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null): void {
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
}
// ================= ROOM MARKUP EDITOR =================
private get _gridPitch(): number {
return NORM_W / GRID_N;
}
private get _curSpaceCfg(): any {
return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
}
private get _spaceH(): number {
const sp = this._curSpaceCfg;
return sp ? NORM_W / sp.aspect : NORM_W;
}
/** Segments of the current space in render units. */
private get _segments(): number[][] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
}
private _toggleMarkup(): void {
if (!this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
this._markup = !this._markup;
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
}
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);
}
private _segKey(a: number[], b: number[]): string {
return segKeyOf(a, b);
}
private _saveConfig = debounce(() => {
if (!this._serverCfg) return;
this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => {
this._cfgRev = r?.rev ?? this._cfgRev + 1;
})
.catch((e: any) => {
if (e?.code === 'conflict') {
this._showToast(this._t('toast.conflict'));
this._cancelPath();
this._reloadConfigOnly();
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
}
});
}, 500);
/** Add a segment (render units) to the space skeleton (no duplicates). true = new. */
private _addSegment(a: number[], b: number[]): boolean {
const sp = this._curSpaceCfg;
if (!sp) return false;
const H = this._spaceH;
const key = this._segKey(a, b);
const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key);
if (exists) return false;
sp.segments = sp.segments || [];
sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]);
this._saveConfig();
return true;
}
private _distToSeg(p: number[], s: number[]): number {
const [x, y] = p;
const [x1, y1, x2, y2] = s;
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy || 1;
let t = ((x - x1) * dx + (y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const px = x1 + t * dx;
const py = y1 + t * dy;
return Math.hypot(x - px, y - py);
}
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._markup) return;
const raw = this._svgPoint(ev);
if (this._tool === 'erase') {
const sp = this._curSpaceCfg;
if (!sp?.segments?.length) return;
const segs = this._segments;
let best = -1;
let bestD = this._gridPitch * 0.5;
segs.forEach((s, i) => {
const d = this._distToSeg(raw, s);
if (d < bestD) {
bestD = d;
best = i;
}
});
if (best >= 0) {
sp.segments.splice(best, 1);
this._saveConfig();
this.requestUpdate();
}
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;
}
// draw: clicks on grid points, pairs of points get connected with a line
const pt = this._snap(raw);
if (!this._path.length) {
this._path = [pt];
this._pathSegs = [];
return;
}
const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // repeated click on the same point
const added = this._addSegment(last, pt);
this._pathSegs = [...this._pathSegs, added ? this._segKey(last, pt) : null];
this._path = [...this._path, pt];
// closing the outline: a click on the first vertex → the save dialog
if (this._path.length >= 4 && this._samePt(pt, this._path[0])) {
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 || this._tool !== 'draw' || !this._path.length || this._contourClosed) {
return;
}
this._cursorPt = this._snap(this._svgPoint(ev));
}
/** 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 {
if (!this._contourClosed) return;
const sp = this._curSpaceCfg;
if (!sp) return;
const H = this._spaceH;
const 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._saveConfig();
this._path = [];
this._pathSegs = [];
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 aspect = this._serverCfg?.spaces.find((x: any) => x.id === this._space)?.aspect || 1;
const H2 = NORM_W / aspect;
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._pathSegs = [];
this._cursorPt = null;
this._roomDialog = false;
}
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
private _roomDialogCancel(): void {
this._roomDialog = false;
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 (!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,
bindingFilter: '',
icon: d.marker?.icon || '',
tapAction: d.marker?.tap_action || '',
model: d.model || '',
link: d.link || '',
description: d.description || '',
pdfs: [...(d.pdfs || [])],
room: d.space && d.area ? d.space + '#' + d.area : '',
busy: false,
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
}
/** 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')),
});
}
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) continue;
res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name });
}
}
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.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 || 25 }),
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.binding === 'virtual' && !dlg.name.trim()) {
this._showToast(this._t('toast.virtual_name_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 [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', ''];
let space: string | null = roomSp || null;
let area: string | null = roomAr || null;
if (dlg.binding === 'virtual' && !space) space = this._space;
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
const marker: Marker = {
id,
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
tap_action: dlg.tapAction || null,
model: dlg.model.trim() || null,
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
pdfs: dlg.pdfs,
};
// 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;
}
// the room changed → move the icon to its center
const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null;
const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area);
// 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: a new icon OR the room changed → place it at the center of the room/space.
// 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;
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;
if (area) {
const room = spm.rooms.find((r) => r.area === area);
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) 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 }).catch(() => undefined);
}
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.marker_saved'));
} catch (e: any) {
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 }).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 } {
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1;
return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) };
}
// ================= 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;
this._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, busy: false };
} else {
this._spaceDialog = { mode, title: '', planUrl: null, planFile: null, 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 } };
}
private async _saveSpaceDialog(): Promise<void> {
const d = this._spaceDialog;
if (!d || d.busy || !d.title.trim()) return;
if (!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 cfg = this._serverCfg!;
let sp: any;
if (d.mode === 'create') {
sp = {
id: 's' + Date.now().toString(36),
title: d.title.trim(),
plan_url: null,
aspect: 1.414,
view_box: [0, 0, 1, 1],
rooms: [],
segments: [],
};
cfg.spaces.push(sp);
} else {
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
sp.title = d.title.trim();
}
if (d.planFile) {
const resp = await this.hass.callWS({
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
});
sp.plan_url = resp.url;
sp.aspect = d.planFile.aspect;
}
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._markup = true;
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) {
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> {
try {
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
} 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, 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._markup = true;
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>`;
}
// ================= 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) {
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>`;
}
// ================= 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;
const devs = this._devices.filter((d) => d.space === space.id);
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
return html`
<ha-card>
<div class="hdr">
<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._restoreZoom();
}}
>
${s.title}${this._norm
? html`<ha-icon class="tabedit" icon="mdi:pencil"
title=${this._t('title.configure_space')}
@click=${(e: Event) => {
e.stopPropagation();
this._openSpaceDialog('edit', s.id);
}}></ha-icon>`
: nothing}
</button>`,
)}
${this._norm
? 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>
<span class="count">${this._t('count.devices', { n: devs.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
? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>
</button>`
: nothing}
${this._norm
? html`<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>
</button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button>`
: nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
title=${this._t('title.markup')}>
<ha-icon icon="mdi:vector-square-edit"></ha-icon>
</button>
</div>
${this._markup ? this._renderMarkupBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup' : ''}"
style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${this._markup ? this._renderMarkupDefs(vb) : nothing}
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${space.rooms.filter((r) => r.area || this._markup).map((r) => {
const cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
this._config?.show_signal ? this._roomLqi(r.area) : null);
const label = !space.bg || this._markup;
const c = this._roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>`
: svg`<rect class="${cls}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))}
</div>
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
: nothing}
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : 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.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._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>
`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult {
const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const cls = this._stateClass(d);
const temp = this._liveTemp(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (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)}
>
<ha-icon icon="${d.icon}"></ha-icon>
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`;
}
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];
}
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 {
const segs = 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>`)}
`;
}
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 === 'erase' ? 'on' : ''}" @click=${() => (this._tool = 'erase')}
title=${this._t('title.markup_erase')}>
<ha-icon icon="mdi:eraser"></ha-icon>${this._t('markup.erase')}
</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}
</div>`;
}
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;
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">
${d.model ? html`<div class="inforow"><span class="k">${this._t('info.model')}</span><span>${d.model}</span></div>` : nothing}
${stateTxt ? 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(p.url) || '#'}" target="_blank" rel="noreferrer noopener">
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>`
: nothing}
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.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.binding === '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 ${isVirtual ? 'virt' : ''}">
<button class="opt ${isVirtual ? 'on' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: 'virtual' })}>
<ha-icon icon="mdi:map-marker-outline"></ha-icon>${this._t('marker.virtual_option')}
</button>
${!isVirtual
? html`<div class="curbind"><ha-icon icon="mdi:link-variant"></ha-icon>
<b>${curLabel}</b><span class="ref">${d.binding}</span></div>`
: nothing}
<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 })}>
<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>
<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>
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['', 'tap.auto'], ['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || '') === v}>${this._t(k as any)}</option>`,
)}
</select>
<label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
@value-changed=${(e: any) => (this._markerDialog = { ...d, icon: e.detail.value || '' })}></ha-icon-picker>`
: html`<input class="namein" type="text" placeholder=${this._t('marker.icon_ph')}
.value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
<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(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
? 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}>
<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" @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>
<div class="planrow">
${d.planFile
? html`<span class="planname">${d.planFile.name}</span>`
: d.planUrl
? html`<img class="planprev" src=${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>
</div>
</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.planFile || d.planUrl) || d.busy}
title=${!(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 _renderRoomDialog(): TemplateResult {
const areas = this._freeAreas;
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:floor-plan"></ha-icon>${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>
</div>
<div class="row">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
<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', '');