mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Visuals borrowed from easy-floorplan (github.com/nicosandller/easy-floorplan), model kept ours. isActiveState is independent of the live_states toggle; unavailable/unknown count as idle. Honours prefers-reduced-motion. Badges scale with --dev-size. +1 test (74).
2808 lines
119 KiB
TypeScript
Executable File
2808 lines
119 KiB
TypeScript
Executable File
/**
|
||
* 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, pointStrictlyInside, roomsOverlap,
|
||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||
} from './logic';
|
||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
|
||
import type {
|
||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||
} from './types';
|
||
import './editor';
|
||
import './space-card';
|
||
import { cardStyles } from './styles';
|
||
import { langOf, t, type I18nKey } from './i18n';
|
||
|
||
const CARD_VERSION = '1.22.0';
|
||
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' | 'merge' | 'split' | '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; temp?: 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 _cursorPt: number[] | null = null;
|
||
private _mergeSel: string | null = null; // first room picked for a merge
|
||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
|
||
// 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;
|
||
// 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
|
||
display: 'badge' | 'ripple' | 'icon_ripple';
|
||
rippleColor: string; // '' = accent
|
||
rippleSize: number; // in icon diameters
|
||
size: number; // icon size multiplier
|
||
angle: number; // icon rotation, degrees
|
||
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;
|
||
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
|
||
orientation: 'landscape' | 'portrait' | 'square';
|
||
showBorders: boolean;
|
||
showNames: boolean;
|
||
roomColor: string;
|
||
roomOpacity: number; // 0..1
|
||
fillMode: 'none' | 'lqi' | 'light' | 'temp';
|
||
tempMin: number;
|
||
tempMax: number;
|
||
cellCm: number; // real-world cm represented by one grid cell
|
||
busy: boolean;
|
||
} | null = null;
|
||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||
private _hashApplied = false;
|
||
/** 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 _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 },
|
||
_mergeSel: { state: true },
|
||
_mergeDialog: { state: true },
|
||
_splitSel: { 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);
|
||
window.addEventListener('hashchange', this._onHashChange);
|
||
}
|
||
|
||
public disconnectedCallback(): void {
|
||
window.removeEventListener('keydown', this._keyHandler);
|
||
window.removeEventListener('hashchange', this._onHashChange);
|
||
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. 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' };
|
||
}
|
||
|
||
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;
|
||
const hs = this._hashSpace();
|
||
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = 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;
|
||
}
|
||
} 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');
|
||
}
|
||
const hs = this._hashSpace();
|
||
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
|
||
this._space = hs;
|
||
this._hashApplied = 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) {
|
||
// 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';
|
||
// 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 === '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);
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
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, temp?: number | null): void {
|
||
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 {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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 _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';
|
||
this._mergeSel = null;
|
||
this._mergeDialog = null;
|
||
this._splitSel = null;
|
||
this._pendingSplit = null;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
private _saveConfig = debounce(() => {
|
||
if (!this._serverCfg) return;
|
||
this._dropLegacySegments();
|
||
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);
|
||
|
||
/**
|
||
* 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._markup) return;
|
||
const raw = this._svgPoint(ev);
|
||
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 === 'merge') {
|
||
this._mergeClick(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]);
|
||
// rooms must not overlap: a vertex may sit on a wall (shared walls are normal),
|
||
// never strictly inside another room
|
||
if (!closing) {
|
||
const busy = this._roomAt(pt);
|
||
if (busy) {
|
||
this._showToast(this._t('toast.point_in_room', { name: busy.name || '' }));
|
||
return;
|
||
}
|
||
}
|
||
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._roomDialog = true;
|
||
return;
|
||
}
|
||
this._path = [...this._path, pt];
|
||
}
|
||
|
||
/** 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, a: null };
|
||
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 pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
|
||
if (!pt || !pointOnBoundary(pt, poly, eps)) {
|
||
this._showToast(this._t('toast.split_pick_wall'));
|
||
return;
|
||
}
|
||
if (!this._splitSel.a) {
|
||
this._splitSel = { ...this._splitSel, a: pt };
|
||
return;
|
||
}
|
||
const parts = splitRoom(poly, this._splitSel.a, pt, eps);
|
||
if (!parts) {
|
||
this._showToast(this._t('toast.split_bad_cut'));
|
||
return;
|
||
}
|
||
// 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;
|
||
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
|
||
const cutting = this._tool === 'split' && !!this._splitSel?.a;
|
||
if (!drawing && !cutting) 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 {
|
||
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._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 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._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._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 (!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 || '',
|
||
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 || '',
|
||
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: '',
|
||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||
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')),
|
||
});
|
||
}
|
||
// Individual entities — surfaced only while searching (avoids a huge default list); lets you
|
||
// place a single entity of a multi-entity device (e.g. the humidity of a climate sensor) as
|
||
// its own icon. Uses the same entity: binding as helpers/groups.
|
||
const q = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
|
||
if (q) {
|
||
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 || '') : '';
|
||
if (!(label + ' ' + eid + ' ' + devName).toLowerCase().includes(q)) continue;
|
||
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) 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,
|
||
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,
|
||
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;
|
||
const disp = spaceDisplayOf(sp);
|
||
this._spaceDialog = {
|
||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
|
||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||
busy: false,
|
||
};
|
||
} else {
|
||
this._spaceDialog = {
|
||
mode, title: '', planUrl: null, planFile: null,
|
||
source: 'file', orientation: 'landscape',
|
||
showBorders: false, showNames: false,
|
||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||
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 } };
|
||
}
|
||
|
||
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 cfg = this._serverCfg!;
|
||
let sp: any;
|
||
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||
if (d.mode === 'create') {
|
||
sp = {
|
||
id: 's' + Date.now().toString(36),
|
||
title: d.title.trim(),
|
||
plan_url: null,
|
||
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||
view_box: [0, 0, 1, 1],
|
||
rooms: [],
|
||
};
|
||
cfg.spaces.push(sp);
|
||
} else {
|
||
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
|
||
sp.title = d.title.trim();
|
||
}
|
||
if (d.source === 'file' && 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;
|
||
}
|
||
// 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;
|
||
// 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,
|
||
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,
|
||
};
|
||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||
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> {
|
||
this._dropLegacySegments();
|
||
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,
|
||
source: 'file', orientation: 'landscape',
|
||
showBorders: false, showNames: false,
|
||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||
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._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 disp = spaceDisplayOf(this._curSpaceCfg);
|
||
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' : ''} ${space.bg ? '' : 'noplan'}"
|
||
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 || 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 = '';
|
||
if (!this._markup && (disp.showBorders || disp.fill !== '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 = r.area
|
||
? roomFillColor(
|
||
disp.fill,
|
||
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
||
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
||
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
|
||
disp.tempMin,
|
||
disp.tempMax,
|
||
)
|
||
: null;
|
||
if (fillC) {
|
||
cls += ' filled';
|
||
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||
} else st.push('--room-fill:transparent', '--room-fill-op:0');
|
||
style = st.join(';');
|
||
}
|
||
const tip = (e: MouseEvent) =>
|
||
this._showTip(e, r.name, this._t('tip.room'),
|
||
this._config?.show_signal ? this._roomLqi(r.area) : null,
|
||
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
||
const label = (!space.bg && !disp.showNames) || this._markup;
|
||
const c = this._roomCenter(r);
|
||
const shape = r.poly
|
||
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
|
||
@click=${() => this._clickRoom(r)} @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}"
|
||
@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))}
|
||
${disp.showNames && !this._markup
|
||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||
: nothing}
|
||
</div>
|
||
${this._measureAnchor
|
||
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
||
: nothing}
|
||
</div>
|
||
${this._zoom > 1
|
||
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
|
||
: nothing}
|
||
</div>
|
||
|
||
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
||
${this._mergeDialog ? this._renderMergeDialog() : 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.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._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 hum = this._liveHum(d);
|
||
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||
const m = d.marker;
|
||
const disp = m?.display || 'badge';
|
||
const ripple = disp === 'ripple' || disp === 'icon_ripple';
|
||
const active = ripple && !!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}`);
|
||
}
|
||
return html`<div
|
||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''}"
|
||
style="${st.join(';')}"
|
||
@click=${(e: MouseEvent) => this._clickDevice(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}
|
||
${disp !== 'ripple'
|
||
? html`<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
|
||
: nothing}
|
||
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
|
||
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
|
||
</div>`;
|
||
}
|
||
|
||
/** 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) {
|
||
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||
}
|
||
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._markup) 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 };
|
||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||
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);
|
||
}
|
||
|
||
private _renderRoomLabel(
|
||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
|
||
): TemplateResult | typeof nothing {
|
||
if (!r.name) 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);
|
||
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}"
|
||
@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)}
|
||
>${r.name}</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?.a) return this._splitSel.a;
|
||
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;
|
||
return html`<div class="measurelabel" style="left:${left}%;top:${top}%">${this._fmtLen(a, b)}</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>`)}
|
||
${this._tool === 'split' && this._splitSel?.a
|
||
? svg`<circle class="vertex first" cx="${this._splitSel.a[0]}" cy="${this._splitSel.a[1]}" r="${g * 0.22}"></circle>
|
||
${this._cursorPt
|
||
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[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 === '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.display_label')}</label>
|
||
<select class="areasel"
|
||
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
|
||
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple']].map(
|
||
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
|
||
)}
|
||
</select>
|
||
${d.display !== 'badge'
|
||
? 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>
|
||
<input type="range" min="2" max="8" step="0.5" .value=${String(d.rippleSize)}
|
||
@input=${(e: Event) => (this._markerDialog = { ...d, rippleSize: Number((e.target as HTMLInputElement).value) })} />
|
||
<span class="opv">×${d.rippleSize}</span>
|
||
</div>`
|
||
: nothing}
|
||
|
||
<label>${this._t('marker.size_label')}</label>
|
||
<div class="colorrow">
|
||
<input type="range" min="0.5" max="3" step="0.1" .value=${String(d.size)}
|
||
@input=${(e: Event) => (this._markerDialog = { ...d, size: Number((e.target as HTMLInputElement).value) })} />
|
||
<span class="opv">×${d.size.toFixed(1)}</span>
|
||
<span class="opl">${this._t('marker.angle_label')}</span>
|
||
<input type="range" min="0" max="350" step="10" .value=${String(d.angle)}
|
||
@input=${(e: Event) => (this._markerDialog = { ...d, angle: Number((e.target as HTMLInputElement).value) })} />
|
||
<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(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 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=${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>`
|
||
: 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>
|
||
${d.source === 'draw' && d.mode === 'create'
|
||
? html`<label>${this._t('space.orientation')}</label>
|
||
<select class="areasel"
|
||
@change=${(e: Event) => (this._spaceDialog = { ...d, orientation: (e.target as HTMLSelectElement).value as any })}>
|
||
${[['landscape', 'orient.landscape'], ['portrait', 'orient.portrait'], ['square', 'orient.square']].map(
|
||
([v, k]) => html`<option value=${v} ?selected=${d.orientation === v}>${this._t(k as any)}</option>`,
|
||
)}
|
||
</select>`
|
||
: nothing}
|
||
|
||
<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">
|
||
<input type="checkbox" .checked=${d.showBorders}
|
||
@change=${(e: Event) => (this._spaceDialog = { ...d, showBorders: (e.target as HTMLInputElement).checked })} />
|
||
<span>${this._t('space.show_borders')}</span>
|
||
</label>
|
||
<label class="srcrow">
|
||
<input type="checkbox" .checked=${d.showNames}
|
||
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
|
||
<span>${this._t('space.show_names')}</span>
|
||
</label>
|
||
<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>
|
||
<input type="range" min="0" max="100" .value=${String(Math.round(d.roomOpacity * 100))}
|
||
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
|
||
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
||
</div>
|
||
<label>${this._t('space.fill_label')}</label>
|
||
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].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>`;
|
||
}
|
||
|
||
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', '');
|