feat v1.16.0: new read-only houseplan-space-card (static single-space schematic, pointer-events:none, deep-link button) + #space=<id> deep-link in the full card. Shared space-geometry/space-render + module-level config-store cache. +6 unit tests, smoke_space_card & smoke_deeplink. Docs in same commit.

This commit is contained in:
Matysh
2026-07-08 19:38:31 +03:00
parent fbd3887e14
commit 1847c7b8ed
22 changed files with 1437 additions and 347 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* Module-level houseplan config cache shared by every embedded card on a board,
* so N cards do NOT issue N identical `houseplan/config/get` requests. The cache
* is invalidated when the integration emits `houseplan_config_updated`
* (fired on config/set); subscribers are then notified to reload.
*
* The full `houseplan-card` already persists a `{config, rev, layout}` snapshot in
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
* so embedded cards paint immediately, then refresh from the server in the background.
*/
const LS_CFG = 'houseplan_card_cfg_v1';
export interface HpConfigSnapshot {
config: any | null;
rev: number;
layout: Record<string, any>;
}
let cache: HpConfigSnapshot | null = null;
let inflight: Promise<HpConfigSnapshot> | null = null;
let subscribed = false;
const listeners = new Set<() => void>();
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
export function cachedSnapshot(): HpConfigSnapshot | null {
if (cache) return cache;
try {
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
if (c && c.config && Array.isArray(c.config.spaces)) {
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
}
} catch {
/* ignore */
}
return null;
}
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
const [cfgResp, layResp] = await Promise.all([
hass.callWS({ type: 'houseplan/config/get' }),
hass.callWS({ type: 'houseplan/layout/get' }),
]);
cache = {
config: cfgResp?.config ?? null,
rev: cfgResp?.rev ?? 0,
layout: layResp?.layout ?? {},
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
try {
await hass.connection.subscribeEvents(() => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
} catch {
subscribed = false;
}
}
return cache;
}
/** Get the shared config snapshot (cached, deduped across cards). */
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
if (cache) return Promise.resolve(cache);
if (inflight) return inflight;
inflight = fetchFresh(hass).finally(() => {
inflight = null;
});
return inflight;
}
/** Subscribe to config-changed notifications; returns an unsubscribe function. */
export function onConfigChange(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
+27 -3
View File
@@ -22,10 +22,11 @@ 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.15.6';
const CARD_VERSION = '1.16.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';
@@ -134,6 +135,21 @@ class HouseplanCard extends LitElement {
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;
@@ -168,10 +184,12 @@ class HouseplanCard extends LitElement {
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;
@@ -252,7 +270,9 @@ class HouseplanCard extends LitElement {
this._cfgRev = c.rev || 0;
this._layout = c.layout || {};
this._serverStorage = true;
if (config.default_floor) this._space = config.default_floor;
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 {
@@ -408,7 +428,11 @@ class HouseplanCard extends LitElement {
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
if (this._norm && !this._model.find((s) => s.id === this._space)) {
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();
+9 -1
View File
@@ -170,5 +170,13 @@
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:"
"tip.temp_avg": "average temperature:",
"space_card.button": "Open the space plan",
"space_card.not_found": "Space “{id}” not found",
"space_card.loading": "Loading…",
"editor.space": "Space",
"editor.show_button": "Show button",
"editor.button_label": "Button label",
"editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)"
}
+9 -1
View File
@@ -170,5 +170,13 @@
"fill.temp": "По температуре",
"space.temp_min": "Комфорт от",
"space.temp_max": "до",
"tip.temp_avg": "средняя температура:"
"tip.temp_avg": "средняя температура:",
"space_card.button": "Перейти к пространству",
"space_card.not_found": "Пространство «{id}» не найдено",
"space_card.loading": "Загрузка…",
"editor.space": "Пространство",
"editor.show_button": "Показывать кнопку",
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)"
}
+235
View File
@@ -0,0 +1,235 @@
/**
* houseplan-space-card a READ-ONLY, static schematic of a single houseplan space,
* embeddable on any dashboard. Renders exactly what is configured (plan + configured
* room borders/names + device markers at their saved positions) with NO interactivity
* (no clicks/hover/tooltips/drag/more-info) and NO live states. A footer button opens
* the space in the full component via a deep-link (`#space=<id>`).
*/
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import './space-editor';
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 });
};
interface SpaceCardConfig {
type: string;
space: string;
title?: string;
show_button?: boolean;
button_label?: string;
button_target?: string;
aspect_ratio?: string;
icon_size?: number;
language?: string;
}
class HouseplanSpaceCard extends LitElement {
public hass?: any;
private _config?: SpaceCardConfig;
private _snap: HpConfigSnapshot | null = null;
private _loading = false;
private _unsub?: () => void;
static properties = {
hass: { attribute: false },
_config: { state: true },
_snap: { state: true },
};
public static getConfigElement() {
return document.createElement('houseplan-space-card-editor');
}
public static getStubConfig(hass: any): Partial<SpaceCardConfig> {
const snap = cachedSnapshot();
const first = spaceModels(snap?.config || null)[0]?.id || '';
return { type: 'custom:houseplan-space-card', space: first, show_button: true };
}
public setConfig(config: SpaceCardConfig): void {
if (!config || !config.space) {
throw new Error('houseplan-space-card: "space" is required');
}
this._config = { show_button: true, button_target: '/plan-doma', ...config };
// instant paint from the full card's localStorage snapshot, refresh in the background
this._snap = this._snap || cachedSnapshot();
}
public connectedCallback(): void {
super.connectedCallback();
this._unsub = onConfigChange(() => {
this._loading = false;
this._snap = null;
this.requestUpdate();
});
}
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
super.disconnectedCallback();
}
protected willUpdate(changed: PropertyValues): void {
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
if (!this._snap || !this._loadedOnce) this._load();
}
}
private _loadedOnce = false;
private async _load(): Promise<void> {
if (!this.hass || this._loading) return;
this._loading = true;
try {
const snap = await getConfig(this.hass);
this._snap = snap;
this._loadedOnce = true;
} catch {
/* keep any localStorage snapshot */
} finally {
this._loading = false;
this.requestUpdate();
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
public getCardSize(): number {
const models = spaceModels(this._snap?.config || null);
const sp = models.find((s) => s.id === this._config?.space);
if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w
return Math.max(3, Math.round(ratio * 8)) + (this._config?.show_button === false ? 0 : 1);
}
return 6;
}
private _errorCard(msg: string): TemplateResult {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
}
const spaceId = this._config.space;
const stage = renderSpaceStatic({
hass: this.hass,
cfg,
layout: this._snap?.layout || {},
spaceId,
iconSize: this._config.icon_size,
lang: this._lang,
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
}
const sp = spaceModels(cfg).find((s) => s.id === spaceId);
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
const showButton = this._config.show_button !== false;
const label = this._config.button_label || t(this._lang, 'space_card.button');
return html`
<ha-card>
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
${stage}
${showButton
? html`<div class="hp-static-foot">
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
</div>`
: nothing}
</ha-card>
`;
}
private _goToSpace = (): void => {
const base = (this._config?.button_target || '/plan-doma').replace(/#.*$/, '');
navigate(`${base}#space=${encodeURIComponent(this._config!.space)}`);
};
static styles = [
cardStyles,
css`
.hp-static-title {
font-weight: 700;
padding: 10px 14px 6px;
font-size: 16px;
color: var(--primary-text-color);
}
.hp-static-stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
pointer-events: none; /* kill ALL interaction on the schematic (§4) */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.hp-static-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
}
.hp-static-foot {
padding: 8px 12px 12px;
pointer-events: auto; /* the button stays clickable */
}
.hp-static-btn {
width: 100%;
padding: 9px 14px;
border: none;
border-radius: 10px;
background: var(--primary-color, #3ea6ff);
color: var(--text-primary-color, #fff);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.hp-static-btn:hover {
filter: brightness(1.08);
}
.hp-static-error {
padding: 24px;
text-align: center;
color: var(--secondary-text-color, #9aa4ad);
}
`,
];
}
if (!customElements.get('houseplan-space-card')) {
customElements.define('houseplan-space-card', HouseplanSpaceCard);
}
(window as any).customCards = (window as any).customCards || [];
if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-space-card')) {
(window as any).customCards.push({
type: 'houseplan-space-card',
name: 'House Plan — Space (static)',
description: 'Read-only static schematic of a single houseplan space, with a deep-link button.',
preview: false,
documentation: 'https://github.com/Matysh/houseplan-card',
});
}
export { HouseplanSpaceCard };
+85
View File
@@ -0,0 +1,85 @@
/** Config editor (Lovelace GUI) for houseplan-space-card. */
import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
class HouseplanSpaceCardEditor extends LitElement {
public hass?: any;
private _config?: any;
private _spaces: { value: string; label: string }[] | null = null;
private _spacesLoading = false;
static properties = {
hass: { attribute: false },
_config: { state: true },
_spaces: { state: true },
};
public setConfig(config: any): void {
this._config = config;
}
private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true;
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({ value: s.id, label: s.title || s.id }));
} catch {
this._spaces = [];
} finally {
this._spacesLoading = false;
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
private get _schema(): any[] {
const spaces = this._spaces || [];
return [
spaces.length
? { name: 'space', selector: { select: { mode: 'dropdown', options: spaces } } }
: { name: 'space', selector: { text: {} } },
{ name: 'title', selector: { text: {} } },
{ name: 'show_button', selector: { boolean: {} } },
{ name: 'button_label', selector: { text: {} } },
{ name: 'button_target', selector: { text: {} } },
{ name: 'aspect_ratio', selector: { text: {} } },
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
];
}
protected render() {
if (!this.hass || !this._config) return nothing;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
space: t(L, 'editor.space'),
title: t(L, 'editor.title'),
show_button: t(L, 'editor.show_button'),
button_label: t(L, 'editor.button_label'),
button_target: t(L, 'editor.button_target'),
aspect_ratio: t(L, 'editor.aspect_ratio'),
icon_size: t(L, 'editor.icon_size'),
};
return html`<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${this._schema}
.computeLabel=${(s: any) => labels[s.name] || s.name}
@value-changed=${this._valueChanged}
></ha-form>`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...this._config, ...ev.detail.value };
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
e.detail = { config };
this.dispatchEvent(e);
}
}
if (!customElements.get('houseplan-space-card-editor')) {
customElements.define('houseplan-space-card-editor', HouseplanSpaceCardEditor);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Pure geometry/model helpers for houseplan spaces no Lit/DOM imports, so they
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
* mirror the full card's private math.
*/
import { declump } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const scale = (r: any): RoomCfg => ({
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),
} as SpaceModel;
});
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
export function 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 };
}
/** Geometric centre of a room (label anchor). */
export function 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];
}
/** Auto grid positions for a single space's area devices (identical to the full card). */
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
const map: Record<string, Pt> = {};
const minDist = (iconPct / 100) * NORM_W * 1.3;
for (const r of model.rooms) {
if (!r.area) continue;
const ds = devs.filter((d) => d.area === r.area);
if (!ds.length) continue;
const b = 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 cw = iw / cols;
const ch = ih / Math.max(Math.ceil(ds.length / cols), 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;
}
/** Marker position in render units: saved layout → default grid → space centre. */
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
}
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
const c = roomCenter(r);
return { x: c[0], y: c[1] };
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Shared STATIC renderer for a single houseplan space used by the read-only
* `houseplan-space-card`. Draws exactly what is CONFIGURED (plan background,
* configured room borders/names, device markers at their saved positions), with
* NO live states, NO status/temperature fills, and NO event handlers.
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices } from './devices';
import { spaceDisplayOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
} from './space-geometry';
export { spaceModels } from './space-geometry';
export interface StaticRenderOpts {
hass: any;
cfg: ServerConfig;
layout: Layout;
spaceId: string;
iconSize?: number;
lang: Lang;
}
/**
* Static schematic of one space. Returns the inner stage template (svg + marker
* layer) or null when the space id is unknown (the caller renders an error card).
*/
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const models = spaceModels(o.cfg);
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
const vb = space.vb;
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const areaToSpace: Record<string, string> = {};
for (const s of o.cfg.spaces || []) for (const r of (s as any).rooms || []) if (r.area) areaToSpace[r.area] = (s as any).id;
const excluded = o.cfg.settings?.exclude_integrations ? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
const loc = (k: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => t(o.lang, k);
const all = buildDevices({
hass: o.hass,
areaToSpace,
markers: o.cfg.markers || [],
settings: o.cfg.settings || {},
excluded,
showAll: !!o.cfg.settings?.show_all,
firstSpaceId: models[0]?.id || '',
loc,
iconRules,
});
const devs = all.filter((d) => d.space === o.spaceId);
const defPos = defaultPositions(devs, space, iconPct);
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
let style = '';
if (disp.showBorders || disp.fill !== 'none') {
cls += ' styled';
// STATIC: only the configured border; status fills (lqi/light/temp) are live and omitted
style = `--room-stroke:${disp.color};--room-stroke-op:${disp.showBorders ? disp.opacity : 0};--room-fill:transparent;--room-fill-op:0`;
}
const svgLabel = !space.bg && !disp.showNames;
const c = roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"></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}"></rect>`;
return svg`${shape}${svgLabel ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
});
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
<ha-icon icon="${d.icon}"></ha-icon>
</div>`;
});
const labels = disp.showNames
? space.rooms
.filter((r) => r.name)
.map((r) => {
const p = labelPos(r, space.id, o.layout, o.cfg);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 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}">${r.name}</div>`;
})
: [];
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${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}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
</div>
`;
}