Files
houseplan-card/src/houseplan-card.ts
T

2037 lines
71 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* House Plan Card — интерактивный план дома как нативная Lovelace-карточка.
* Источники конфигурации:
* 1) СЕРВЕР (интеграция houseplan, WS houseplan/config/get) — пространства, планы,
* комнаты, оверрайды устройств, виртуальные устройства. Координаты НОРМИРОВАННЫЕ (0..1).
* 2) LEGACY-фолбэк — вшитые данные дачи (src/data/*), координаты в холсте 1489×1053.
* Раскладка иконок хранится на сервере (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit';
import { ROOMS, FLOOR_VB, FLOOR_TITLES, Room } from './data/house';
import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
import './editor';
const CARD_VERSION = '1.5.1';
const LS_KEY = 'houseplan_card_layout_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
// Натуральные размеры legacy-планов (для миграции): SVG viewBox РЕМПЛАННЕР
const LEGACY_PLAN_SIZE: Record<string, [number, number]> = {
f1: [1196.6656, 1467.26],
f2: [1170.0986, 1073],
};
interface RoomCfg {
id?: string;
name: string;
area: string | null;
x?: number;
y?: number;
w?: number;
h?: number;
poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг)
}
const GRID_N = 120; // точек сетки по ширине плана
type MarkupTool = 'draw' | 'erase' | 'delroom';
interface SpaceModel {
id: string;
title: string;
vb: number[]; // render units
bg: { href: string; x: number; y: number; w: number; h: number } | null;
rooms: RoomCfg[]; // render units
}
interface VirtualDevice {
id: string;
space: string;
name: string;
icon: string;
x: number; // normalized
y: number;
note?: string | null;
entity_id?: string | null;
}
interface ServerConfig {
spaces: any[];
device_overrides: Record<string, { hidden?: boolean; icon?: string | null; name?: string | null }>;
virtual_devices: VirtualDevice[];
settings: { exclude_integrations?: string[]; group_lights?: boolean };
}
interface DevItem {
id: string;
name: string;
model: string;
area: string;
space: string;
icon: string;
entities: string[];
primary?: string;
temp?: number | null;
virtual?: VirtualDevice;
}
interface CardConfig {
type: string;
title?: string;
default_floor?: string;
icon_size?: number;
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
rooms?: Room[];
}
/** Цвет LQI: ≤40 — красный, ≥180 — зелёный, между — градиент. */
const lqiColor = (lqi: number): string => {
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
return `hsl(${Math.round(hue)}, 85%, 55%)`;
};
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 _edit = false;
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
private _serverStorage = false;
private _loaded = false;
private _serverCfg: ServerConfig | null = null;
private _cfgRev = 0;
private _unsubCfg: (() => void) | null = null;
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
private _migrating = false;
// --- редактор разметки комнат ---
private _markup = false;
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // текущий контур (рендер-единицы, вершины по сетке)
private _pathSegs: (string | null)[] = []; // ключи сегментов, добавленных шагами контура
private _cursorPt: number[] | null = null;
private _areaSel = '';
private _nameSel = '';
private _roomDialog = false;
private _spaceDialog: {
mode: 'edit' | 'create';
spaceId?: string;
title: string;
planUrl: string | null;
planFile: { ext: string; b64: string; aspect: number; name: string } | null;
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
static properties = {
hass: { attribute: false },
_config: { state: true },
_space: { state: true },
_edit: { state: true },
_layout: { state: true },
_devices: { state: true },
_tip: { state: true },
_selId: { state: true },
_toast: { state: true },
_serverCfg: { state: true },
_migrating: { state: true },
_markup: { state: true },
_tool: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
_spaceDialog: { state: true },
};
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
}
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
}
super.disconnectedCallback();
}
private _onKey(e: KeyboardEvent): void {
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();
}
}
/** Убрать последнюю поставленную точку (и её линию, если она была добавлена этим шагом). */
private _undoPoint(): void {
if (!this._path.length) return;
if (this._path.length === 1) {
this._path = [];
this._pathSegs = [];
return;
}
const segKey = this._pathSegs[this._pathSegs.length - 1];
this._pathSegs = this._pathSegs.slice(0, -1);
if (segKey) this._removeSegmentByKey(segKey);
this._path = this._path.slice(0, -1);
}
private _removeSegmentByKey(key: string): void {
const sp = this._curSpaceCfg;
if (!sp?.segments) return;
const idx = this._segments.findIndex(
(s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key,
);
if (idx >= 0) {
sp.segments.splice(idx, 1);
this._saveConfig();
}
}
public static getConfigElement() {
return document.createElement('houseplan-card-editor');
}
public static getStubConfig(): Partial<CardConfig> {
return { type: 'custom:houseplan-card', title: 'План дома' };
}
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;
}
public getCardSize(): number {
return 12;
}
// ================= РЕЗОЛВ МОДЕЛИ (сервер или legacy) =================
private get _norm(): boolean {
return !!(this._serverCfg && this._serverCfg.spaces.length);
}
/** Пространства в рендер-единицах: сервер → NORM_W×NORM_W/aspect; legacy → холст 1489×1053. */
private get _model(): SpaceModel[] {
if (this._norm) {
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),
};
});
}
// legacy: вшитые данные
const legacyRooms = this._config?.rooms?.length ? this._config.rooms : ROOMS;
return Object.keys(FLOOR_VB).map((fl) => {
const rect = FLOOR_BG_RECT[fl];
const bgHref = FLOOR_BG[fl];
return {
id: fl,
title: FLOOR_TITLES[fl] || fl,
vb: FLOOR_VB[fl],
bg: bgHref && rect ? { href: bgHref, x: rect[0], y: rect[1], w: rect[2], h: rect[3] } : null,
rooms: legacyRooms
.filter((r) => r.floor === fl)
.map((r) => ({ name: r.name, area: r.area || null, x: r.x, y: r.y, w: r.w, h: r.h })),
};
});
}
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 _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._loaded) {
this._loaded = true;
this._loadFromServer();
}
this._maybeRebuildDevices();
}
}
// ================= сервер: конфиг + раскладка =================
private async _loadFromServer(): Promise<void> {
try {
const [cfgResp, layResp] = await Promise.all([
this.hass.callWS({ type: 'houseplan/config/get' }),
this.hass.callWS({ type: 'houseplan/layout/get' }),
]);
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-синхронизация: конфиг изменён в другом окне → перечитать
if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space;
}
} catch (e) {
this._serverStorage = false;
this._serverCfg = null;
try {
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
} catch {
this._layout = {};
}
this._showToast('Интеграция houseplan не найдена — позиции сохраняются локально');
}
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._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
} catch {
/* ignore */
}
}
private _dirtyPos = new Set<string>();
private _persistLayout = debounce(() => {
if (this._serverStorage) {
// точечные обновления: не затираем позиции, изменённые в других окнах
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('Не удалось сохранить позицию: ' + (e?.message || e)));
}
} else {
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
}
}, 600);
// ================= устройства из реестров =================
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');
if (sig === this._regSignature && this._devices.length) return;
this._regSignature = sig;
this._devices = this._buildDevices();
this._defPos = this._defaultPositions();
}
private _entitiesByDevice(): Record<string, string[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(this.hass.entities)) {
if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
}
return map;
}
private _domainOfDevice(dev: any, entIds: string[]): string {
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
for (const eid of entIds) {
const p = this.hass.entities[eid]?.platform;
if (p) return p;
}
return '';
}
private _primaryEntity(entIds: string[], icon: string): string | undefined {
const ents = entIds
.map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden);
const usable = ents.filter((e) => !e.reg.entity_category);
const pool = usable.length ? usable : ents;
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => this._isTempEntity(e.eid));
if (t) return t.eid;
}
for (const dom of DOMAIN_PRIORITY) {
const found = pool.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
return pool[0]?.eid;
}
private _isTempEntity(eid: string): boolean {
const st = this.hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
return (
a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid)
);
}
private _lqiFor(entIds: string[]): number | null {
const vals: number[] = [];
for (const eid of entIds) {
const st = this.hass.states[eid];
const isLqi = /_linkquality$/.test(eid) || (st?.attributes?.unit_of_measurement || '') === 'lqi';
if (!isLqi || !st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) vals.push(v);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
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 = this._lqiFor(d.entities);
if (l != null) vals.push(l);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
private _tempFor(entIds: string[]): number | null {
for (const eid of entIds) {
if (!this._isTempEntity(eid)) continue;
const st = this.hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** Групповые световые сущности: HA light-group (platform=group) и Z2M-группы (device model=Group). */
private _lightGroups(): { eid: string; name: string; area: string }[] {
if (this._settings.group_lights === false) return [];
const h = this.hass;
const res: { eid: string; name: string; area: string }[] = [];
for (const [eid, reg] of Object.entries<any>(h.entities)) {
if (!eid.startsWith('light.') || reg.hidden) continue;
let area: string | null = null;
if (reg.platform === 'group') {
area = reg.area_id || null;
} else if (reg.device_id) {
const dev = h.devices[reg.device_id];
if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null;
else continue;
} else {
continue;
}
if (!area) continue;
const st = h.states[eid];
res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area });
}
return res;
}
/** Зоны, в которых есть группа света (одиночные лампы там скрываются). */
private _groupedLightAreas(): Set<string> {
return new Set(this._lightGroups().map((g) => g.area));
}
/** Курирование + группы света + оверрайды + виртуальные устройства. */
private _buildDevices(): DevItem[] {
const h = this.hass;
const areaMap = this._areaToSpace;
const overrides = this._serverCfg?.device_overrides || {};
const groupLights = this._settings.group_lights !== false;
const groupedAreas = this._groupedLightAreas();
const excluded = this._excluded;
const entsBy = this._entitiesByDevice();
const seen: Record<string, 1> = {};
const out: DevItem[] = [];
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaMap[area]) continue;
if (dev.entry_type === 'service') continue;
const ov = overrides[dev.id] || {};
if (ov.hidden) continue;
const entIds = entsBy[dev.id] || [];
const dom = this._domainOfDevice(dev, entIds);
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
if (dom === 'myheat' && dev.via_device_id) continue;
const name = (ov.name || dev.name_by_user || dev.name || 'без имени').trim();
const key = name + '|' + area;
if (seen[key]) continue;
seen[key] = 1;
let icon = iconFor(name, dev.model);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (ov.icon) icon = ov.icon;
// лампы в комнатах с группой света заменяются сущностью группы
if (groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
const item: DevItem = {
id: dev.id,
name,
model: dev.model || '',
area,
space: areaMap[area].space,
icon,
entities: entIds,
};
item.primary = this._primaryEntity(entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
out.push(item);
}
// группы света: реальные сущности групп HA (platform group) и Z2M (device model=Group)
const rest: DevItem[] = [...out];
for (const g of this._lightGroups()) {
if (!areaMap[g.area]) continue;
rest.push({
id: 'lg_' + g.eid,
name: g.name,
model: 'группа света',
area: g.area,
space: areaMap[g.area].space,
icon: 'mdi:lightbulb-group',
entities: [g.eid],
primary: g.eid,
});
}
// виртуальные устройства
for (const v of this._serverCfg?.virtual_devices || []) {
rest.push({
id: 'virt_' + v.id,
name: v.name,
model: v.note || 'виртуальное устройство',
area: '',
space: v.space,
icon: v.icon,
entities: v.entity_id ? [v.entity_id] : [],
primary: v.entity_id || undefined,
virtual: v,
});
}
return rest;
}
// ================= позиции =================
private _defaultPositions(): Record<string, { x: number; y: number }> {
const map: Record<string, { x: number; y: number }> = {};
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 pad = Math.min(r.w, r.h) * 0.12;
const iw = r.w - pad * 2;
const ih = r.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);
ds.forEach((d, i) => {
const c = i % cols;
const rw = Math.floor(i / cols);
map[d.id] = { x: r.x + pad + cw * (c + 0.5), y: r.y + pad + ch * (rw + 0.5) };
});
}
}
return map;
}
/** Позиция устройства в рендер-единицах текущего пространства. */
private _pos(d: DevItem): { x: number; y: number } {
const s = this._spaceModel(d.space);
const H = s.vb ? undefined : undefined;
if (d.virtual) {
const height = this._norm ? NORM_W / (this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1) : 0;
return { x: d.virtual.x * (this._norm ? NORM_W : 1), y: d.virtual.y * (this._norm ? height : 1) };
}
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) {
// центр иконки привязывается к узлам той же сетки, что и разметка комнат
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();
}
// ================= живые состояния =================
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
const dom = p.entity_id.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
const dc = p.attributes?.device_class;
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
return p.state === 'on' ? 'open' : '';
}
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
return '';
}
private _liveTemp(d: DevItem): number | null {
if (!this._config?.show_temperature) return null;
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return this._tempFor(d.entities);
}
// ================= взаимодействие =================
private _openMoreInfo(entityId?: string): void {
if (!entityId) {
this._showToast('У устройства нет подходящей сущности');
return;
}
fireEvent(this, 'hass-more-info', { entityId });
}
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved) return;
if (this._edit) {
this._selId = d.id;
return;
}
if (d.virtual && !d.primary) {
this._showToast(d.name + (d.virtual.note ? ' — ' + d.virtual.note : ''));
return;
}
this._openMoreInfo(d.primary);
}
private _clickRoom(r: RoomCfg): void {
if (this._edit || !r.area) return;
navigate('/config/areas/area/' + r.area);
}
private _pointerDown(ev: PointerEvent, d: DevItem): void {
if (!this._edit || d.virtual) return;
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
if (!this._drag || this._drag.id !== d.id) return;
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
if (!stage) return;
const vb = this._spaceModel().vb;
const rect = stage.getBoundingClientRect();
const dx = ((ev.clientX - this._drag.sx) / rect.width) * vb[2];
const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3];
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(d, nx, ny);
}
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
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 _applyXY(axis: 'x' | 'y', val: string): void {
if (!this._selId) return;
const n = parseFloat(val);
if (isNaN(n)) return;
const d = this._devices.find((x) => x.id === this._selId);
if (!d || d.virtual) return;
const p = { ...this._pos(d) };
p[axis] = n;
this._savePos(d, p.x, p.y);
}
private _resetLayout(): void {
if (!confirm('Сбросить позиции всех иконок к авто-раскладке?')) return;
this._layout = {};
this._persistLayout();
}
private _showToast(msg: string): void {
this._toast = msg;
clearTimeout(this._toastTimer);
this._toastTimer = window.setTimeout(() => {
this._toast = '';
}, 3500);
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null): void {
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
}
// ================= РЕДАКТОР РАЗМЕТКИ КОМНАТ =================
private get _gridPitch(): number {
return NORM_W / GRID_N;
}
private get _curSpaceCfg(): any {
return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
}
private get _spaceH(): number {
const sp = this._curSpaceCfg;
return sp ? NORM_W / sp.aspect : NORM_W;
}
/** Сегменты текущего пространства в рендер-единицах. */
private get _segments(): number[][] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
}
private _toggleMarkup(): void {
if (!this._norm) {
this._showToast('Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)');
return;
}
this._markup = !this._markup;
this._edit = false;
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
}
private _svgPoint(ev: MouseEvent): number[] {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
const vb = this._spaceModel().vb;
const r = stage.getBoundingClientRect();
return [vb[0] + ((ev.clientX - r.left) / r.width) * vb[2], vb[1] + ((ev.clientY - r.top) / r.height) * vb[3]];
}
private _snap(p: number[]): number[] {
const g = this._gridPitch;
return [Math.round(p[0] / g) * g, Math.round(p[1] / g) * g];
}
private _samePt(a: number[], b: number[]): boolean {
return Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
}
private _segKey(a: number[], b: number[]): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
}
private _saveConfig = debounce(() => {
if (!this._serverCfg) return;
this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => {
this._cfgRev = r?.rev ?? this._cfgRev + 1;
})
.catch((e: any) => {
if (e?.code === 'conflict') {
this._showToast('Конфиг изменён в другом окне — данные обновлены, повторите последнее действие');
this._cancelPath();
this._reloadConfigOnly();
} else {
this._showToast('Не удалось сохранить конфиг: ' + (e?.message || e));
}
});
}, 500);
/** Добавить сегмент (рендер-единицы) в каркас пространства (без дублей). true = новый. */
private _addSegment(a: number[], b: number[]): boolean {
const sp = this._curSpaceCfg;
if (!sp) return false;
const H = this._spaceH;
const key = this._segKey(a, b);
const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key);
if (exists) return false;
sp.segments = sp.segments || [];
sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]);
this._saveConfig();
return true;
}
private _distToSeg(p: number[], s: number[]): number {
const [x, y] = p;
const [x1, y1, x2, y2] = s;
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy || 1;
let t = ((x - x1) * dx + (y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const px = x1 + t * dx;
const py = y1 + t * dy;
return Math.hypot(x - px, y - py);
}
private _pointInRoom(p: number[], r: RoomCfg): boolean {
if (r.poly) {
let inside = false;
const poly = r.poly;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
return (
r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h!
);
}
private _markupClick(ev: MouseEvent): void {
if (!this._markup) return;
const raw = this._svgPoint(ev);
if (this._tool === 'erase') {
const sp = this._curSpaceCfg;
if (!sp?.segments?.length) return;
const segs = this._segments;
let best = -1;
let bestD = this._gridPitch * 0.5;
segs.forEach((s, i) => {
const d = this._distToSeg(raw, s);
if (d < bestD) {
bestD = d;
best = i;
}
});
if (best >= 0) {
sp.segments.splice(best, 1);
this._saveConfig();
this.requestUpdate();
}
return;
}
if (this._tool === 'delroom') {
const space = this._spaceModel();
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!room) return;
if (!confirm(`Удалить комнату «${room.name}»?`)) return;
const sp = this._curSpaceCfg;
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
this._saveConfig();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
return;
}
// draw: клики по точкам сетки, пары точек соединяются линией
const pt = this._snap(raw);
if (!this._path.length) {
this._path = [pt];
this._pathSegs = [];
return;
}
const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // повторный клик по той же точке
const added = this._addSegment(last, pt);
this._pathSegs = [...this._pathSegs, added ? this._segKey(last, pt) : null];
this._path = [...this._path, pt];
// замыкание: клик по первой вершине → диалог сохранения
if (this._path.length >= 4 && this._samePt(pt, this._path[0])) {
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._roomDialog = true;
}
}
private get _contourClosed(): boolean {
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
}
private _markupMove(ev: MouseEvent): void {
if (!this._markup || this._tool !== 'draw' || !this._path.length || this._contourClosed) {
return;
}
this._cursorPt = this._snap(this._svgPoint(ev));
}
private _saveRoom(): void {
if (!this._contourClosed || !this._areaSel && !this._nameSel) return;
const sp = this._curSpaceCfg;
if (!sp) return;
const H = this._spaceH;
const verts = this._path.slice(0, -1); // без дублированной замыкающей
const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : '';
sp.rooms.push({
id: 'r' + Date.now().toString(36),
name: this._nameSel || areaName || 'Комната',
area: this._areaSel || null,
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
});
this._saveConfig();
this._path = [];
this._pathSegs = [];
this._areaSel = '';
this._nameSel = '';
this._roomDialog = false;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast('Комната сохранена');
}
private _cancelPath(): void {
this._path = [];
this._pathSegs = [];
this._cursorPt = null;
this._roomDialog = false;
}
/** Отмена в диалоге: контур снова открыт (замыкающая точка снимается). */
private _roomDialogCancel(): void {
this._roomDialog = false;
this._undoPoint();
}
/** Зоны HA, ещё не назначенные ни одной комнате конфига. */
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 || ''));
}
// ================= УПРАВЛЕНИЕ ПРОСТРАНСТВАМИ =================
private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
if (!this._norm) {
this._showToast('Управление пространствами доступно после переноса конфига на сервер');
return;
}
if (mode === 'edit') {
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
if (!sp) return;
this._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, busy: false };
} else {
this._spaceDialog = { mode, title: '', planUrl: null, planFile: null, busy: false };
}
}
/** Выбор файла подложки: читаем base64 и определяем пропорции. */
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('Поддерживаются SVG, PNG, JPG, WebP');
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);
// пропорции: рендерим в 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;
this._spaceDialog = { ...d, busy: true };
try {
const cfg = this._serverCfg!;
let sp: any;
if (d.mode === 'create') {
sp = {
id: 's' + Date.now().toString(36),
title: d.title.trim(),
plan_url: null,
aspect: 1.414,
view_box: [0, 0, 1, 1],
rooms: [],
segments: [],
};
cfg.spaces.push(sp);
} else {
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
sp.title = d.title.trim();
}
if (d.planFile) {
const resp = await this.hass.callWS({
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
});
sp.plan_url = resp.url;
sp.aspect = d.planFile.aspect;
}
await this._saveConfigNow();
this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(d.mode === 'create' ? 'Пространство добавлено' : 'Пространство сохранено');
} catch (e: any) {
this._spaceDialog = { ...this._spaceDialog!, busy: false };
this._showToast('Ошибка: ' + (e?.message || 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(`Удалить пространство «${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('Пространство удалено');
} catch (e: any) {
this._showToast('Ошибка удаления: ' + (e?.message || e));
}
}
/** Немедленное сохранение конфига с ревизией (без дебаунса). */
private async _saveConfigNow(): Promise<void> {
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
}
// ================= МИГРАЦИЯ legacy → сервер =================
private async _migrateToServer(): Promise<void> {
if (!this._serverStorage || this._norm || this._migrating) return;
if (!confirm('Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?')) return;
this._migrating = true;
try {
const spaces: any[] = [];
for (const fl of Object.keys(FLOOR_VB)) {
const vb = FLOOR_VB[fl];
const rect = FLOOR_BG_RECT[fl] || vb; // yard: нормируем относительно viewBox
const [rx, ry, rw, rh] = rect;
let planUrl: string | null = null;
let aspect = rw / rh;
const bg = FLOOR_BG[fl];
if (bg && FLOOR_BG_RECT[fl]) {
const nat = LEGACY_PLAN_SIZE[fl];
if (nat) aspect = nat[0] / nat[1];
const b64 = bg.split(',')[1];
const resp = await this.hass.callWS({
type: 'houseplan/plan/set',
space_id: fl,
ext: 'svg',
data: b64,
});
planUrl = resp.url;
}
const rooms = ROOMS.filter((r) => r.floor === fl).map((r, i) => ({
id: fl + '_r' + i,
name: r.name,
area: r.area || null,
x: (r.x - rx) / rw,
y: (r.y - ry) / rh,
w: r.w / rw,
h: r.h / rh,
}));
spaces.push({
id: fl,
title: FLOOR_TITLES[fl] || fl,
plan_url: planUrl,
aspect,
view_box: [(vb[0] - rx) / rw, (vb[1] - ry) / rh, vb[2] / rw, vb[3] / rh],
rooms,
});
}
const config = {
spaces,
device_overrides: {},
virtual_devices: [],
settings: { exclude_integrations: [...EXCLUDED_DOMAINS], group_lights: true },
};
// раскладка: канвас → нормированные координаты пространства
const layout: Record<string, any> = {};
for (const d of this._devices) {
if (d.virtual) continue;
const saved = this._layout[d.id] || this._defPos[d.id];
if (!saved) continue;
const rect = FLOOR_BG_RECT[d.space] || FLOOR_VB[d.space];
layout[d.id] = {
s: d.space,
x: (saved.x - rect[0]) / rect[2],
y: (saved.y - rect[1]) / rect[3],
};
}
await this.hass.callWS({ type: 'houseplan/config/set', config });
await this.hass.callWS({ type: 'houseplan/layout/set', layout });
this._serverCfg = config as any;
this._layout = layout;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast('Конфигурация перенесена на сервер — карта работает от server config');
} catch (e: any) {
this._showToast('Ошибка миграции: ' + (e?.message || e));
} finally {
this._migrating = false;
}
}
// ================= рендер =================
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="empty">Нет настроенных пространств</div></ha-card>`;
const space = this._spaceModel();
const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id);
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
return html`
<ha-card>
<div class="hdr">
<div class="head">
<div class="title">
<ha-icon icon="mdi:home-city"></ha-icon>
${this._config.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;
}}
>
${s.title}${this._norm
? html`<ha-icon class="tabedit" icon="mdi:pencil"
title="Настроить пространство"
@click=${(e: Event) => {
e.stopPropagation();
this._openSpaceDialog('edit', s.id);
}}></ha-icon>`
: nothing}
</button>`,
)}
${this._norm
? html`<button class="tab tabadd" title="Добавить пространство"
@click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon>
</button>`
: nothing}
</div>
<span class="count">${devs.length} устр.</span>
<span class="spacer"></span>
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
this._edit = !this._edit;
this._markup = false;
this._selId = null;
}} title="Режим правки: перетаскивание иконок">
<ha-icon icon="mdi:cursor-move"></ha-icon>
</button>
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
title="Разметка комнат: сетка, линии, контуры">
<ha-icon icon="mdi:vector-square-edit"></ha-icon>
</button>
</div>
${this._edit ? this._renderEditbar() : nothing}
${this._markup ? this._renderMarkupBar() : nothing}
</div>
<div class="stage ${this._edit ? 'edit' : ''} ${this._markup ? 'markup' : ''}"
style="aspect-ratio:${vb[2]}/${vb[3]}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@mousemove=${(e: MouseEvent) => this._markupMove(e)}>
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
${this._markup ? this._renderMarkupDefs(vb) : nothing}
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${space.rooms.filter((r) => r.area || this._markup).map((r) => {
const cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, 'комната — открыть зону',
this._config?.show_signal ? this._roomLqi(r.area) : null);
const label = !space.bg || this._markup;
const c = this._roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>`
: svg`<rect class="${cls}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">
${devs.map((d) => this._renderDevice(d, vb))}
</div>
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.lqi != null
? html`<span class="m">средний сигнал zigbee:
<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, vb: number[]): TemplateResult {
const p = this._pos(d);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const cls = this._stateClass(d);
const temp = this._liveTemp(d);
const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null;
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
@mouseleave=${() => (this._tip = null)}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
>
<ha-icon icon="${d.icon}"></ha-icon>
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`;
}
private _roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
}
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
}
private _renderMarkupDefs(_vb: number[]): TemplateResult {
const g = this._gridPitch;
const dotR = g * 0.14;
return svg`<defs>
<pattern id="hp-grid" x="0" y="0" width="${g}" height="${g}" patternUnits="userSpaceOnUse">
<circle cx="0" cy="0" r="${dotR}" class="griddot"></circle>
<circle cx="${g}" cy="0" r="${dotR}" class="griddot"></circle>
<circle cx="0" cy="${g}" r="${dotR}" class="griddot"></circle>
<circle cx="${g}" cy="${g}" r="${dotR}" class="griddot"></circle>
</pattern>
</defs>`;
}
private _renderMarkupLayer(vb: number[]): TemplateResult {
const segs = this._segments;
const path = this._path;
const g = this._gridPitch;
return svg`
<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>
${segs.map((s) => svg`<line class="seg" x1="${s[0]}" y1="${s[1]}" x2="${s[2]}" y2="${s[3]}"></line>`)}
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed
? svg`<line class="preview" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
`;
}
private _renderMarkupBar(): TemplateResult {
return html`<div class="editbar">
<ha-icon icon="mdi:vector-square-edit" class="warn"></ha-icon>
<button class="btn ${this._tool === 'draw' ? 'on' : ''}" @click=${() => (this._tool = 'draw')}
title="Добавить комнату: соединяйте точки сетки линиями до замкнутого контура">
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>Добавить
</button>
<button class="btn ${this._tool === 'erase' ? 'on' : ''}" @click=${() => (this._tool = 'erase')}
title="Стереть линию: клик по линии">
<ha-icon icon="mdi:eraser"></ha-icon>Стереть
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title="Удалить комнату: клик внутри комнаты">
<ha-icon icon="mdi:delete-outline"></ha-icon>Удалить
</button>
<span class="spacer"></span>
${this._tool === 'draw'
? html`<span class="hint">${this._path.length
? 'точек: ' + this._path.length + ' · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой'
: 'кликните точку сетки, чтобы начать контур'}</span>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>Сброс</button>` : nothing}`
: nothing}
</div>`;
}
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._spaceDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? 'Новое пространство' : 'Пространство'}</div>
<div class="body">
<label>Название</label>
<input class="namein" type="text" placeholder="Например: Гараж"
.value=${d.title}
@input=${(e: Event) => (this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} />
<label>Подложка (план)</label>
<div class="planrow">
${d.planFile
? html`<span class="planname">${d.planFile.name}</span>`
: d.planUrl
? html`<img class="planprev" src=${d.planUrl} alt="план" />`
: html`<span class="planname muted">нет подложки</span>`}
<label class="btn filebtn">
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? 'Заменить…' : 'Загрузить…'}
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
@change=${(e: Event) => this._pickPlanFile(e)} />
</label>
</div>
</div>
<div class="row">
${d.mode === 'edit'
? html`<button class="btn danger" @click=${this._deleteSpace}>
<ha-icon icon="mdi:delete-outline"></ha-icon>Удалить
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._spaceDialog = null)}>Отмена</button>
<button class="btn on" @click=${this._saveSpaceDialog} ?disabled=${!d.title.trim() || d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : 'Сохранить'}
</button>
</div>
</div>
</div>`;
}
private _renderRoomDialog(): TemplateResult {
const areas = this._freeAreas;
return html`<div class="menuwrap dialogwrap" @click=${this._roomDialogCancel}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>Новая комната</div>
<div class="body">
<label>Отображаемое имя</label>
<input class="namein" type="text" placeholder="Например: Терраса"
.value=${this._nameSel}
@input=${(e: Event) => (this._nameSel = (e.target as HTMLInputElement).value)} />
<label>Зона Home Assistant (свободные)</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="">— без зоны —</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}>Отмена</button>
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel && !this._nameSel}>
<ha-icon icon="mdi:check"></ha-icon>Сохранить
</button>
</div>
</div>
</div>`;
}
private _renderEditbar(): TemplateResult {
const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null;
const p = sel ? this._pos(sel) : null;
return html`<div class="editbar">
<ha-icon icon="mdi:cursor-move" class="warn"></ha-icon>
<span class="sname">${sel ? sel.name : 'Режим правки — тащите иконки мышью'}</span>
${sel && p
? html`<label>X</label><input type="number" .value=${String(Math.round(p.x))}
@change=${(e: Event) => this._applyXY('x', (e.target as HTMLInputElement).value)} />
<label>Y</label><input type="number" .value=${String(Math.round(p.y))}
@change=${(e: Event) => this._applyXY('y', (e.target as HTMLInputElement).value)} />`
: nothing}
<span class="spacer"></span>
<span class="hint">
${this._serverStorage
? this._norm
? 'конфиг и раскладка: сервер'
: 'раскладка: сервер · конфиг: встроенный'
: 'сохранение: этот браузер'}
</span>
${this._serverStorage && !this._norm
? html`<button class="btn" ?disabled=${this._migrating} @click=${this._migrateToServer}
title="Перенести планы, комнаты и раскладку в хранилище HA">
<ha-icon icon="mdi:cloud-upload"></ha-icon>${this._migrating ? '…' : 'На сервер'}
</button>`
: nothing}
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
</div>`;
}
static styles = css`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
--hp-txt: var(--primary-text-color, #e6edf3);
--hp-muted: var(--secondary-text-color, #8aa0b3);
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
}
ha-card {
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
}
.empty {
padding: 32px;
color: var(--hp-muted);
text-align: center;
}
.hdr {
position: sticky;
top: var(--header-height, 56px);
z-index: 20;
background: var(--card-background-color, var(--hp-bg));
border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0;
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--hp-line);
flex-wrap: wrap;
}
.title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.title ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 18px;
}
.tabs {
display: flex;
gap: 4px;
background: rgba(127, 127, 127, 0.12);
padding: 3px;
border-radius: 10px;
}
.tab {
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 6px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
}
.tab:hover {
color: var(--hp-txt);
}
.tab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
.count {
font-size: 12px;
color: var(--hp-muted);
}
.spacer {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--hp-line);
background: transparent;
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
font-size: 12.5px;
}
.btn ha-icon {
--mdc-icon-size: 17px;
}
.btn:hover {
border-color: var(--hp-accent);
}
.btn.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
border-color: var(--hp-accent);
}
.btn.ghost {
border: none;
}
.btn[disabled] {
opacity: 0.5;
pointer-events: none;
}
.stage {
position: relative;
width: 100%;
container-type: inline-size;
}
.stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.room {
transition: 0.12s;
cursor: pointer;
}
.room.overlay {
fill: transparent;
stroke: transparent;
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
}
.room.yard:hover {
fill: rgba(75, 140, 90, 0.24);
stroke: #6fbf86;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
font-weight: 600;
pointer-events: none;
text-anchor: middle;
}
.stage.edit .room {
pointer-events: none;
}
.stage.markup {
cursor: crosshair;
}
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer {
display: none; /* в разметке иконки не мешают */
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.4;
}
.seg {
stroke: var(--hp-accent);
stroke-width: 2.5;
stroke-linecap: round;
}
.pathline {
stroke: #ffc14d;
stroke-width: 3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview {
stroke: #ffc14d;
stroke-width: 2;
stroke-dasharray: 6 5;
opacity: 0.7;
}
.vertex {
fill: #ffc14d;
stroke: #4a2800;
stroke-width: 1;
}
.vertex.first {
fill: #4bd28f;
stroke: #04121f;
}
.areasel,
.namein {
background: var(--hp-bg);
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
}
.namein {
width: 130px;
}
.devlayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.dev {
position: absolute;
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: pointer;
pointer-events: auto;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
z-index: 2;
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
}
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev.on {
background: var(--hp-on);
border-color: var(--hp-on);
color: #503c00;
box-shadow: 0 0 8px rgba(255, 212, 92, 0.7);
}
.dev.open {
background: var(--hp-open);
border-color: var(--hp-open);
color: #4a2800;
}
.dev.unavail {
opacity: 0.35;
}
.dev.virtual {
border-style: dashed;
}
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
}
.stage.edit .dev {
cursor: grab;
}
.dev .tval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: #dff1ff;
white-space: nowrap;
pointer-events: none;
}
.dev .lqi {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
pointer-events: none;
}
.editbar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--hp-line);
font-size: 13px;
flex-wrap: wrap;
}
.tab .tabedit {
--mdc-icon-size: 13px;
margin-left: 6px;
opacity: 0.4;
vertical-align: middle;
}
.tab:hover .tabedit {
opacity: 0.9;
}
.tab.tabadd {
padding: 6px 8px;
}
.tab.tabadd ha-icon {
--mdc-icon-size: 15px;
}
.planrow {
display: flex;
align-items: center;
gap: 10px;
}
.planprev {
max-width: 120px;
max-height: 70px;
border: 1px solid var(--hp-line);
border-radius: 6px;
background: #fff;
}
.planname {
font-size: 12.5px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planname.muted {
color: var(--hp-muted);
}
.filebtn {
cursor: pointer;
}
.btn.danger {
border-color: #b3402a;
color: #ff7a5c;
}
.dialog .row .spacer {
flex: 1;
}
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
width: min(360px, 92vw);
overflow: hidden;
}
.dialog .hd {
padding: 12px 16px;
font-weight: 600;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 8px;
}
.dialog .hd ha-icon {
color: var(--hp-accent);
}
.dialog .body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.dialog .body label {
font-size: 12px;
color: var(--hp-muted);
margin-top: 6px;
}
.dialog .body .namein,
.dialog .body .areasel {
width: 100%;
box-sizing: border-box;
}
.dialog .row {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--hp-line);
}
.editbar .warn {
color: #ffc14d;
}
.editbar .sname {
font-weight: 600;
}
.editbar input {
width: 74px;
background: transparent;
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 5px 7px;
font-size: 13px;
}
.editbar label,
.editbar .hint {
color: var(--hp-muted);
font-size: 12px;
}
.menuwrap {
position: fixed;
inset: 0;
z-index: 80;
}
.menu {
position: fixed;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
border-radius: 10px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
min-width: 210px;
max-width: 300px;
overflow: hidden;
transform: translate(0, 8px);
}
.menu .hd {
padding: 8px 12px;
font-weight: 600;
font-size: 12.5px;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 6px;
}
.menu .hd ha-icon,
.menu .it.all ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 16px;
}
.menu .it {
padding: 8px 12px;
font-size: 12.5px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.menu .it ha-icon {
--mdc-icon-size: 16px;
color: var(--hp-muted);
}
.menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.menu .it.all {
color: var(--hp-accent);
font-weight: 600;
}
.tip {
position: fixed;
pointer-events: none;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
font-size: 12.5px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 99;
max-width: 260px;
}
.tip .m {
color: var(--hp-muted);
font-size: 11px;
display: block;
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 9px 16px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 120;
max-width: 90vw;
}
`;
}
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: 'Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой.',
});
}
// eslint-disable-next-line no-console
console.info(`%c HOUSEPLAN-CARD %c v${CARD_VERSION} `, 'background:#3ea6ff;color:#04121f;font-weight:700', '');