Merge dev: v1.32.0..v1.33.5 (background editor, polyline split, editor UX)
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m19s
Validate / backend (push) Failing after 7m38s

This commit is contained in:
Matysh
2026-07-22 15:34:59 +03:00
24 changed files with 1526 additions and 125 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.31.2" VERSION = "1.33.5"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.31.2" "version": "1.33.5"
} }
+24
View File
@@ -96,6 +96,29 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
vol.Optional("fill"): bool},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
extra=vol.ALLOW_EXTRA),
)
SPACE_SCHEMA = vol.Schema( SPACE_SCHEMA = vol.Schema(
{ {
vol.Required("id"): str, vol.Required("id"): str,
@@ -105,6 +128,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)), vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)), vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA], vol.Required("rooms"): [ROOM_SCHEMA],
vol.Optional("decor"): [DECOR_SCHEMA],
vol.Optional("openings"): [ vol.Optional("openings"): [
vol.Schema( vol.Schema(
{ {
+74
View File
@@ -0,0 +1,74 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
out.threeTabs = tabs.length === 3;
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
const g = c._gridPitch;
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
await c.updateComplete;
out.draftPreview = !!sr().querySelector('.ddraft');
c._decorCommitDraft(); await c.updateComplete;
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
// 3) линия и овал
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
c._decorCommitDraft();
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
out.threeShapes = c._decorList.length === 3;
// вырожденная фигура не сохраняется
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
// 5) select: перемещение сохраняет форму, drag двигает
const rect = c._decorList.find((x) => x.kind === 'rect');
c._decorTool = 'select';
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
const before = { ...c._decorList.find((x) => x.id === rect.id) };
c._decorMove.start = [0, 0];
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
const m = c._decorMove; const dx5 = (g * 5) / 1000;
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
c._decorMove = null;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
await esc(); out.escExit = c._mode === 'view';
// 8) в Просмотре фигуры видны, но inert
const shp = sr().querySelector('.decorlayer .dshape');
out.visibleInView = !!shp;
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
// 9) Delete удаляет выбранное
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
out.deleteKey = c._decorList.length === n1 - 1;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+2 -2
View File
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot; const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('.modetab')]; const tabs = () => [...sr().querySelectorAll('.modetab')];
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет // 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 2; out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim()); out.labels = tabs().map((t) => t.textContent.trim());
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0; out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
out.startView = c._mode === 'view'; out.startView = c._mode === 'view';
@@ -23,7 +23,7 @@ const res = await page.evaluate(async () => {
tabs()[1].click(); await c.updateComplete; tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices'; out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar'); out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length; out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет) // 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]'); out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр // 6) крестик на панели → Просмотр
+37
View File
@@ -0,0 +1,37 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
// Просмотр: сетки нет
out.noGridInView = !grid();
// все три редактора: сетка есть
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
// decor: комнаты и устройства полупрозрачны
const room = sr().querySelector('.room');
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
const dl = sr().querySelector('.devlayer');
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
// а декор-слой — нет
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
c.requestUpdate(); await c.updateComplete;
const shp = sr().querySelector('.decorlayer .dshape');
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
// в других редакторах прозрачности нет
c._setMode('devices'); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const room2 = sr().querySelector('.room');
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
c._setMode('view'); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const room3 = sr().querySelector('.room');
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+31
View File
@@ -0,0 +1,31 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('devices'); await c.updateComplete;
// устройство без явной иконки (marker.icon пуст)
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
out.hasAutoDev = !!dev;
c._openMarkerDialog(dev); await c.updateComplete;
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
out.iconEmpty = c._markerDialog.icon === '';
// подсказка с авто-иконкой видна
const hint = sr().querySelector('.iconauto');
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
// пикер (или фолбэк-инпут) получил placeholder
const picker = sr().querySelector('ha-icon-picker');
const inputs = [...sr().querySelectorAll('.dialog input')];
out.placeholderSet = picker
? picker.placeholder === dev.icon
: inputs.some((i) => i.placeholder === dev.icon);
// при явной иконке подсказки нет
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
c._markerDialog = null; await c.updateComplete;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+46
View File
@@ -0,0 +1,46 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('devices'); await c.updateComplete;
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
// подопытный: авто-устройство с сохранённой позицией
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
out.hasDev = !!dev;
const pos0 = { ...c._layout[dev.id] };
// 1) смена комнаты (та же space) → позиция не меняется
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
out.hasOtherRoom = !!otherRoom;
c._openMarkerDialog(dev);
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
await c._saveMarker(); await c.updateComplete;
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
// вернуть комнату назад
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
await c._saveMarker(); await c.updateComplete;
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
!c._devices.some((d) => d.id === 'lg_' + e));
out.hasFreeEnt = !!freeEnt;
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
await c._saveMarker(); await c.updateComplete;
const newId = 'lg_' + freeEnt;
out.migrated = near(c._layout[newId], pos0);
out.oldGone = !c._layout[dev.id];
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
await c._saveMarker(); await c.updateComplete;
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
const center = c._roomCenter(room);
const vpos = c._layout[vid];
const aspect = c._curSpaceCfg.aspect || 1;
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+1 -1
View File
@@ -14,7 +14,7 @@ const res = await page.evaluate(async () => {
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null; out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null; out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
// split-выбор подсвечивается так же // split-выбор подсвечивается так же
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id }; c.requestUpdate(); await c.updateComplete; c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250)); await new Promise((r) => setTimeout(r, 250));
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked')); const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77'); out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
+43
View File
@@ -0,0 +1,43 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
const rooms = c._spaceModel().rooms;
const r = rooms.find((x) => x.id === 'r1');
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
// 1) курсор возле стены → пунктирный призрак есть
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.opghost');
out.ghostNearWall = !!ghost;
if (ghost) {
const cs = getComputedStyle(ghost);
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
// призрак лежит на стене (y координаты концов совпадают с y стены)
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
// длина = 90 см в рендер-единицах
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
}
// 2) курсор далеко от стен → призрака нет
const center = c._roomCenter(r);
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
out.noGhostFarFromWall = !sr().querySelector('.opghost');
// 3) в других инструментах призрака нет
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
out.noGhostOtherTool = !sr().querySelector('.opghost');
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
const op = c._openingsR[0];
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
out.noGhostOverExisting = !sr().querySelector('.opghost');
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+55
View File
@@ -0,0 +1,55 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); await c.updateComplete;
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
const rooms = c._spaceModel().rooms;
const room = rooms.find((r) => r.id === 'r1');
const center = c._roomCenter(room);
c._splitClick(center); await c.updateComplete;
out.roomPicked = c._splitSel?.roomId === 'r1';
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
c._tool = 'split'; await c.updateComplete;
c._splitClick(center);
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
c._splitClick(top);
out.firstOnWall = c._splitSel?.pts.length === 1;
c._splitClick(mid);
out.midAdded = c._splitSel?.pts.length === 2;
c._splitClick(right);
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
// отмена — комната целая
c._roomDialogCancel(); await c.updateComplete;
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
// 3) Esc: пошаговый выход
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
c._tool = 'split'; c._splitSel = null;
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
await esc(); out.escExitsTool = c._tool === 'draw';
// merge: выбор → Esc снимает, ещё Esc — выход
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
await esc(); out.escExitsMerge = c._tool === 'draw';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+161 -19
View File
File diff suppressed because one or more lines are too long
+58
View File
@@ -1,5 +1,63 @@
# Changelog # Changelog
## v1.33.5 — 2026-07-22
- Editor tabs got extended tooltips explaining what each editor is for (plan
geometry vs device icons vs visual decor).
## v1.33.4 — 2026-07-22
- Editing a device no longer makes its icon jump. Changing the HA binding
(which changes the marker id) migrates the saved position to the new id;
changing the room within the same space keeps the icon exactly where it
stands (previously it re-centered in the new room). Only a brand-new icon,
or a move to a room in a different space, is centered.
## v1.33.3 — 2026-07-22
- Device dialog: when no icon is set explicitly, the icon picker no longer
looks empty — it shows the **auto-derived icon** (from the icon rules /
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
Picking an explicit icon replaces it as before; clearing returns to auto.
## v1.33.2 — 2026-07-22
- Removed the **Reset** button from the Device editor. It wiped the entire
layout — positions of all devices, room cards and their scales across every
space — behind a single confirm. Low value, high blast radius.
## v1.33.1 — 2026-07-22
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
not just Plan — an instant visual cue that you are editing.
- In the Background editor, rooms, devices, openings and labels fade to 35%
opacity so the decor you are drawing stands out; decor itself stays fully
opaque. Other modes are unaffected.
## v1.33.0 — 2026-07-22 (background editor)
- New third mode: **Background editor**. Draw purely visual decor on the plan —
lines, rectangles, ovals and text labels that never interact with rooms,
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
text is placed via a small dialog (size S/M/L, color; double-click to edit).
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
Select tool → View.
- The decor layer renders **under rooms** (a true underlay), is visible in all
modes and completely click-transparent outside the editor. Stored per space
in the server config (`space.decor`, validated on the backend, shared across
clients with the usual rev/optimistic locking).
## v1.32.1 — 2026-07-22
- Opening tool: hovering near a wall now shows a **dashed preview** of where
the opening would land — snapped onto the wall, default door length (90 cm),
with a center dot. No preview far from walls or over an existing opening
(a click there edits it instead).
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
- **Split can now cut along a polyline**, not just a straight chord: start on
a wall, click intermediate points inside the room, finish on another wall.
The path is validated (no wall crossings, no self-intersection) and drawn
live with vertices and a preview segment. Two clicks still work as before.
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
pointer while picking the room, then a crosshair while cutting.
- **Esc walks back out of Merge/Split** step by step: last cut point → first
point → room selection → back to the Draw tool. Merge: selection → tool.
## v1.31.2 — 2026-07-22 ## v1.31.2 — 2026-07-22
- Plan editor: the room picked with the **Merge** tool (and the room selected - Plan editor: the room picked with the **Merge** tool (and the room selected
for **Split**) is highlighted amber again. The `.outlined` markup style, for **Split**) is highlighted amber again. The `.outlined` markup style,
+31
View File
@@ -140,6 +140,37 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
changing its room within the same space never moves the icon — the saved
or auto position migrates to the new marker id; only a brand-new icon or
a move to another space centers it in the target room [auto]
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
dialog's icon picker shows the auto-derived icon as its placeholder, plus
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
once an explicit icon is picked [auto]
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
add, show all, icon rules; the layout-wiping Reset is gone [auto]
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
Device and Background editors too (instant "I'm editing" cue), not in
View; in the Background editor rooms/devices/openings/labels fade to 35%
while decor shapes stay fully opaque; no fade in the other editors [auto]
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
drawn with grid snap and live preview; degenerate shapes dropped; text
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
removes, Erase deletes on click; Esc: draft → selection → select tool →
View; decor renders under rooms, visible everywhere, inert outside the
editor; stored in space.decor (rev/lock, backend schema) [auto]
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
dot); no ghost far from walls, over an existing opening (click = edit),
or in other tools [auto]
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
Split shows pointer until a room is picked then crosshair; the cut can be
a polyline — start on a wall, intermediate clicks inside the room, finish
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
last cut point → room pick → back to the Draw tool (same for Merge:
selection → tool) [auto]
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the - [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
Merge tool (and the split-selected room) gets an amber outline + fill; Merge tool (and the split-selected room) gets an amber outline + fill;
visible over the blue markup outlines [auto] visible over the blue markup outlines [auto]
+4 -1
View File
@@ -12,7 +12,10 @@ A segmented control in the card header with three tabs; the active one is visual
highlighted, and edit modes add a colored frame around the stage so the mode is highlighted, and edit modes add a colored frame around the stage so the mode is
obvious at a glance: obvious at a glance:
**[ 📐 Plan editor ] [ 🔧 Device editor ]** — View has NO tab (since v1.30.2). **[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
inert everywhere outside its editor).
- **View** is the implicit default state: no editor tab is active. It is the only - **View** is the implicit default state: no editor tab is active. It is the only
state after every load (edit modes are never restored across reloads). state after every load (edit modes are never restored across reloads).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.31.2", "version": "1.33.5",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+433 -37
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.31.2'; const CARD_VERSION = '1.33.5';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -86,7 +86,14 @@ class HouseplanCard extends LitElement {
/** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry /** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry
* editing, devices = marker placement/config. Never persisted every load * editing, devices = marker placement/config. Never persisted every load
* starts in view. */ * starts in view. */
private _mode: 'view' | 'plan' | 'devices' = 'view'; private _mode: 'view' | 'plan' | 'devices' | 'decor' = 'view';
// ---- decor (background) editor ----
private _decorTool: 'select' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
private _decorStyle: { color: string; width: number; fill: boolean } = { color: '#607d8b', width: 3, fill: false };
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */ /** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean { private get _canEdit(): boolean {
@@ -94,6 +101,11 @@ class HouseplanCard extends LitElement {
} }
/** Legacy alias: markup machinery is active exactly in plan mode. */ /** Legacy alias: markup machinery is active exactly in plan mode. */
/** Any edit mode is active (plan / devices / decor). */
private get _editing(): boolean {
return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor';
}
private get _markup(): boolean { private get _markup(): boolean {
return this._mode === 'plan'; return this._mode === 'plan';
} }
@@ -115,7 +127,7 @@ class HouseplanCard extends LitElement {
private _openingInfo: OpeningCfg | null = null; private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | null = null; private _opDrag: { id: string; moved: boolean } | null = null;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null; 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 private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact // 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 _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
private _areaSel = ''; private _areaSel = '';
@@ -147,6 +159,7 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string; bindingFilter: string;
icon: string; // '' = auto icon: string; // '' = auto
autoIcon: string; // the icon the rules would give — picker placeholder
display: 'badge' | 'ripple' | 'icon_ripple' | 'value'; display: 'badge' | 'ripple' | 'icon_ripple' | 'value';
rippleColor: string; // '' = accent rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters rippleSize: number; // in icon diameters
@@ -224,6 +237,11 @@ class HouseplanCard extends LitElement {
_openingInfo: { state: true }, _openingInfo: { state: true },
_mergeDialog: { state: true }, _mergeDialog: { state: true },
_splitSel: { state: true }, _splitSel: { state: true },
_decorTool: { state: true },
_decorStyle: { state: true },
_decorDraft: { state: true },
_decorSel: { state: true },
_decorTextDialog: { state: true },
_areaSel: { state: true }, _areaSel: { state: true },
_nameSel: { state: true }, _nameSel: { state: true },
_roomDialog: { state: true }, _roomDialog: { state: true },
@@ -265,6 +283,7 @@ class HouseplanCard extends LitElement {
if (this._settingsDialog) { this._settingsDialog = null; return; } if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._markerDialog) { this._markerDialog = null; return; } if (this._markerDialog) { this._markerDialog = null; return; }
if (this._openingDialog) { this._openingDialog = null; return; } if (this._openingDialog) { this._openingDialog = null; return; }
if (this._decorTextDialog) { this._decorTextDialog = null; return; }
if (this._spaceDialog && !this._roomDialog) { if (this._spaceDialog && !this._roomDialog) {
// same semantics as the dialog's Cancel: an import queue is abandoned // same semantics as the dialog's Cancel: an import queue is abandoned
this._spaceDialog = null; this._spaceDialog = null;
@@ -273,6 +292,22 @@ class HouseplanCard extends LitElement {
return; return;
} }
} }
if (this._mode === 'decor') {
if ((e.key === 'Delete' || e.key === 'Backspace') && this._decorSel &&
!(e.target as HTMLElement)?.closest?.('input, textarea, select')) {
e.preventDefault();
this._decorDeleteSel();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
if (this._decorDraft) this._decorDraft = null;
else if (this._decorSel) this._decorSel = null;
else if (this._decorTool !== 'select') this._decorTool = 'select';
else this._setMode('view');
}
return;
}
if (!this._markup) return; if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'); const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
if (!undo) return; if (!undo) return;
@@ -284,6 +319,27 @@ class HouseplanCard extends LitElement {
if (this._tool === 'draw' && this._path.length) { if (this._tool === 'draw' && this._path.length) {
e.preventDefault(); e.preventDefault();
this._undoPoint(); this._undoPoint();
return;
}
if (!undo) return;
// Esc walks back out of merge/split: point by point, then the room pick,
// then the tool itself (back to the neutral draw tool)
if (this._tool === 'split') {
e.preventDefault();
if (this._splitSel?.pts?.length) {
this._splitSel = { ...this._splitSel, pts: this._splitSel.pts.slice(0, -1) };
if (!this._splitSel.pts.length) this._cursorPt = null;
} else if (this._splitSel) {
this._splitSel = null;
} else {
this._tool = 'draw';
}
return;
}
if (this._tool === 'merge') {
e.preventDefault();
if (this._mergeSel) this._mergeSel = null;
else this._tool = 'draw';
} }
} }
@@ -907,6 +963,7 @@ class HouseplanCard extends LitElement {
// do not interfere with icon dragging and markup drawing // do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return; if (this._drag || this._markup) return;
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return; if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb); const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) { if (this._pointers.size === 1) {
@@ -921,6 +978,14 @@ class HouseplanCard extends LitElement {
} }
private _stagePointerMove(ev: PointerEvent): void { private _stagePointerMove(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
return;
}
if (this._decorMove?.pid === ev.pointerId) {
this._decorMoveUpdate(ev);
return;
}
if (!this._pointers.has(ev.pointerId)) { if (!this._pointers.has(ev.pointerId)) {
this._markupMove(ev); this._markupMove(ev);
return; return;
@@ -961,6 +1026,15 @@ class HouseplanCard extends LitElement {
} }
private _stagePointerUp(ev: PointerEvent): void { private _stagePointerUp(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft();
return;
}
if (this._decorMove?.pid === ev.pointerId) {
if (this._decorMove.moved) this._saveConfig();
this._decorMove = null;
return;
}
this._pointers.delete(ev.pointerId); this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null; if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) { if (this._pointers.size === 0) {
@@ -1025,12 +1099,6 @@ class HouseplanCard extends LitElement {
} }
} }
private _resetLayout(): void {
if (!confirm(this._t('confirm.reset_layout'))) return;
this._layout = {};
this._persistLayout();
}
private _showToast(msg: string): void { private _showToast(msg: string): void {
this._toast = msg; this._toast = msg;
clearTimeout(this._toastTimer); clearTimeout(this._toastTimer);
@@ -1082,9 +1150,9 @@ class HouseplanCard extends LitElement {
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]); return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
} }
private _setMode(mode: 'view' | 'plan' | 'devices'): void { private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._mode === mode) return; if (this._mode === mode) return;
if (mode === 'plan' && !this._norm) { if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server')); this._showToast(this._t('toast.markup_needs_server'));
return; return;
} }
@@ -1098,6 +1166,9 @@ class HouseplanCard extends LitElement {
this._pendingSplit = null; this._pendingSplit = null;
this._selId = null; this._selId = null;
this._tip = null; this._tip = null;
this._decorDraft = null;
this._decorSel = null;
this._decorTool = 'select';
} }
@@ -1254,6 +1325,255 @@ class HouseplanCard extends LitElement {
return (cm / this._cellCm) * this._gridPitch; return (cm / this._cellCm) * this._gridPitch;
} }
// ================= decor (background) layer =================
private get _decorList(): any[] {
const sp = this._curSpaceCfg;
return Array.isArray(sp?.decor) ? sp.decor : [];
}
private get _decorH(): number {
return NORM_W / (this._curSpaceCfg?.aspect || 1);
}
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
private _decorPointerDown(ev: PointerEvent): boolean {
const t = this._decorTool;
const onShape = (ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null;
if (onShape) return true; // the shape's own handler deals with it
if (t === 'line' || t === 'rect' || t === 'ellipse') {
ev.preventDefault();
const p = this._snap(this._svgPoint(ev));
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
return true;
}
if (t === 'text') {
const p = this._snap(this._svgPoint(ev));
this._decorTextDialog = {
x: p[0] / NORM_W, y: p[1] / this._decorH,
text: '', size: 'm', color: this._decorStyle.color,
};
return true;
}
this._decorSel = null; // select/erase on empty space clears the selection
return false; // pan is allowed
}
/** Commit the dragged shape (ignore degenerate ones) and persist. */
private _decorCommitDraft(): void {
const d = this._decorDraft;
this._decorDraft = null;
if (!d) return;
const min = this._gridPitch * 0.5;
if (Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) < min) return;
const W = NORM_W, H = this._decorH;
const st = this._decorStyle;
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
let shape: any;
if (d.kind === 'line') {
shape = { id, kind: 'line', x1: d.a[0] / W, y1: d.a[1] / H, x2: d.b[0] / W, y2: d.b[1] / H,
color: st.color, width: st.width };
} else {
const x = Math.min(d.a[0], d.b[0]) / W, y = Math.min(d.a[1], d.b[1]) / H;
const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H;
shape = { id, kind: d.kind, x, y, w, h, color: st.color, width: st.width, fill: st.fill };
}
const sp = this._curSpaceCfg;
sp.decor = [...this._decorList, shape];
this._decorSel = id;
this._saveConfig();
this.requestUpdate();
}
/** Select tool: pointerdown on a shape starts moving it. */
private _decorShapeDown(ev: PointerEvent, shape: any): void {
if (this._mode !== 'decor') return;
ev.stopPropagation();
ev.preventDefault();
if (this._decorTool === 'erase') {
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
if (this._decorSel === shape.id) this._decorSel = null;
this._saveConfig();
this.requestUpdate();
return;
}
if (this._decorTool !== 'select') return;
this._decorSel = shape.id;
this._decorMove = {
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
pid: ev.pointerId, moved: false,
};
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
}
private _decorMoveUpdate(ev: PointerEvent): void {
const m = this._decorMove!;
const p = this._svgPoint(ev);
const g = this._gridPitch;
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
if (dx || dy) m.moved = true;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.map((x) => {
if (x.id !== m.id) return x;
const o = m.orig;
if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy };
return { ...x, x: o.x + dx, y: o.y + dy };
});
this.requestUpdate();
}
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || shape.kind !== 'text') return;
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
text: shape.text, size: shape.size || 'm', color: shape.color };
}
private _decorSaveText(): void {
const d = this._decorTextDialog;
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
if (d.id) {
sp.decor = this._decorList.map((x) => x.id === d.id
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
} else {
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
text: d.text.trim(), size: d.size, color: d.color }];
}
this._decorTextDialog = null;
this._saveConfig();
this.requestUpdate();
}
private _decorDeleteSel(): void {
if (!this._decorSel) return;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== this._decorSel);
this._decorSel = null;
this._saveConfig();
this.requestUpdate();
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
const editing = this._mode === 'decor';
const shapes = this._decorList.map((sh) => {
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
const dbl = () => this._decorShapeDbl(sh);
if (sh.kind === 'line')
return svg`<line class="${cls}" x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
stroke="${sh.color}" stroke-width="${sh.width}" @pointerdown=${down}></line>`;
if (sh.kind === 'rect')
return svg`<rect class="${cls}" x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></rect>`;
if (sh.kind === 'ellipse')
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
if (sh.kind === 'text')
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
return nothing;
});
// живое превью рисуемой фигуры
let draft: unknown = nothing;
const d = this._decorDraft;
if (d) {
const st = this._decorStyle;
if (d.kind === 'line')
draft = svg`<line class="ddraft" x1="${d.a[0]}" y1="${d.a[1]}" x2="${d.b[0]}" y2="${d.b[1]}"
stroke="${st.color}" stroke-width="${st.width}"></line>`;
else {
const x = Math.min(d.a[0], d.b[0]), y = Math.min(d.a[1], d.b[1]);
const w = Math.abs(d.b[0] - d.a[0]), h = Math.abs(d.b[1] - d.a[1]);
draft = d.kind === 'rect'
? svg`<rect class="ddraft" x="${x}" y="${y}" width="${w}" height="${h}" stroke="${st.color}"
stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></rect>`
: svg`<ellipse class="ddraft" cx="${x + w / 2}" cy="${y + h / 2}" rx="${w / 2}" ry="${h / 2}"
stroke="${st.color}" stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></ellipse>`;
}
}
return svg`<g class="decorlayer">${shapes}${draft}</g>` as unknown as TemplateResult;
}
private _renderDecorBar(): TemplateResult {
const tools = [
['select', 'mdi:cursor-default-outline', 'decor.select'],
['line', 'mdi:vector-line', 'decor.line'],
['rect', 'mdi:rectangle-outline', 'decor.rect'],
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
['text', 'mdi:format-text', 'decor.text'],
['erase', 'mdi:eraser', 'decor.erase'],
] as const;
return html`<div class="editbar decorbar">
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
${tools.map(
([t, ic, k]) => html`<button class="btn ${this._decorTool === t ? 'on' : ''}"
@click=${() => { this._decorTool = t; this._decorDraft = null; }}
title=${this._t(k)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(k)}</span>
</button>`,
)}
<input type="color" class="dcolor" .value=${this._decorStyle.color}
title=${this._t('decor.color')}
@input=${(e: Event) => (this._decorStyle = { ...this._decorStyle, color: (e.target as HTMLInputElement).value })} />
<select class="dwidth" title=${this._t('decor.width')}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, width: Number((e.target as HTMLSelectElement).value) })}>
${[[1.5, 'decor.w_thin'], [3, 'decor.w_mid'], [6, 'decor.w_thick']].map(
([v, k]) => html`<option value=${v} ?selected=${this._decorStyle.width === v}>${this._t(k as any)}</option>`,
)}
</select>
<label class="dfill"><input type="checkbox" .checked=${this._decorStyle.fill}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
${this._t('decor.fill')}</label>
<span class="spacer"></span>
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
<div class="body">
<label>${this._t('decor.text_label')}</label>
<input class="namein" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
<label>${this._t('decor.text_size')}</label>
<div class="radiorow">
${(['s', 'm', 'l'] as const).map(
(sz) => html`<label class="srcrow inline">
<input type="radio" name="dtsize" .checked=${d.size === sz}
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
<span>${this._t(('decor.size_' + sz) as any)}</span>
</label>`,
)}
</div>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._decorTextDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn primary" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this._t('btn.save')}</button>
</div>
</div>
</div>`;
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */ /** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void { private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5; const eps = this._gridPitch * 1.5;
@@ -1451,7 +1771,7 @@ class HouseplanCard extends LitElement {
if (!this._splitSel) { if (!this._splitSel) {
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!hit?.id) return; if (!hit?.id) return;
this._splitSel = { roomId: hit.id, a: null }; this._splitSel = { roomId: hit.id, pts: [] };
return; return;
} }
const room = rooms.find((r) => r.id === this._splitSel!.roomId); const room = rooms.find((r) => r.id === this._splitSel!.roomId);
@@ -1469,16 +1789,30 @@ class HouseplanCard extends LitElement {
const eps = this._gridPitch * 0.02; const eps = this._gridPitch * 0.02;
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
const near = closestPointOnBoundary(raw, poly); const near = closestPointOnBoundary(raw, poly);
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null; const wallPt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
if (!pt || !pointOnBoundary(pt, poly, eps)) { const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
const cur = this._splitSel.pts;
if (!cur.length) {
// the cut starts on a wall
if (!onWall) {
this._showToast(this._t('toast.split_pick_wall')); this._showToast(this._t('toast.split_pick_wall'));
return; return;
} }
if (!this._splitSel.a) { this._splitSel = { ...this._splitSel, pts: [wallPt!] };
this._splitSel = { ...this._splitSel, a: pt };
return; return;
} }
const parts = splitRoom(poly, this._splitSel.a, pt, eps); if (!onWall) {
// an interior click adds an intermediate vertex of the cut path
const mid = this._snap(raw);
if (!ptInside(mid, poly, eps)) {
this._showToast(this._t('toast.split_pick_inside'));
return;
}
this._splitSel = { ...this._splitSel, pts: [...cur, mid] };
return;
}
// a wall point finishes the cut
const parts = splitRoomPath(poly, [...cur, wallPt!], eps);
if (!parts) { if (!parts) {
this._showToast(this._t('toast.split_bad_cut')); this._showToast(this._t('toast.split_bad_cut'));
return; return;
@@ -1500,12 +1834,32 @@ class HouseplanCard extends LitElement {
private _markupMove(ev: MouseEvent): void { private _markupMove(ev: MouseEvent): void {
if (!this._markup) return; if (!this._markup) return;
if (this._tool === 'opening') {
// hover preview: where the opening would land (raw point, wall-snapped later)
this._cursorPt = this._svgPoint(ev);
return;
}
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed; const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
const cutting = this._tool === 'split' && !!this._splitSel?.a; const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
if (!drawing && !cutting) return; if (!drawing && !cutting) return;
this._cursorPt = this._snap(this._svgPoint(ev)); this._cursorPt = this._snap(this._svgPoint(ev));
} }
/** Dashed hover preview of an opening: same snap and default length as the click. */
private get _openingPreview(): { x: number; y: number; angle: number; rlen: number } | null {
if (this._tool !== 'opening' || !this._cursorPt) return null;
const raw = this._cursorPt;
// an existing opening under the cursor will be edited, not added — no preview
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
);
if (hit) return null;
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) return null;
return { ...snap, rlen: this._cmToUnits(90) };
}
/** Save a room with a mandatory binding to an HA area. */ /** Save a room with a mandatory binding to an HA area. */
private _saveRoom(): void { private _saveRoom(): void {
if (!this._areaSel) return; if (!this._areaSel) return;
@@ -1631,6 +1985,7 @@ class HouseplanCard extends LitElement {
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '', bindingFilter: '',
icon: d.marker?.icon || '', icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: d.marker?.display || 'badge', display: d.marker?.display || 'badge',
rippleColor: d.marker?.ripple_color || '', rippleColor: d.marker?.ripple_color || '',
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3, rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
@@ -1648,7 +2003,7 @@ class HouseplanCard extends LitElement {
}; };
} else { } else {
this._markerDialog = { this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', model: '', tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
@@ -1863,11 +2218,29 @@ class HouseplanCard extends LitElement {
// remove the previous marker (by the old id and by the new id) // 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 = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
cfg.markers.push(marker); cfg.markers.push(marker);
// position: a new icon OR the room changed → place it at the center of the room/space. // Position rule (owner's decision, v1.33.4): editing an existing icon —
// Write POINT-WISE (layout/update), not the whole layout — a full layout/set overwrites // rebinding it to another HA device/entity or to another room — must NOT
// positions changed in other windows (the v1.4.4 incident). // move it. Its current position (saved or the ephemeral auto one) is
// migrated to the new marker id. Only two cases still center the icon:
// a truly NEW icon, and a move to a room in a DIFFERENT space (keeping
// the old coordinates there would be meaningless).
// Write POINT-WISE (layout/update), not the whole layout — a full layout/set
// overwrites positions changed in other windows (the v1.4.4 incident).
let newPos: { s: string; x: number; y: number } | null = null; let newPos: { s: string; x: number; y: number } | null = null;
if (!this._layout[id] || roomChanged) { const targetSpace = space || prevDev?.space || this._space;
const prevRec = oldId ? this._layout[oldId] : null;
const prevPos = prevRec
? { s: prevRec.s || prevDev?.space || this._space, x: prevRec.x, y: prevRec.y }
: oldId && prevDev && this._defPos[oldId]
? this._normPos(prevDev.space, this._defPos[oldId].x, this._defPos[oldId].y)
: null;
if (prevPos && prevPos.s === targetSpace) {
// stays in place; pin it under the (possibly new) id
if (id !== oldId || !this._layout[id] || roomChanged) {
newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y };
this._layout = { ...this._layout, [id]: newPos };
}
} else if (!this._layout[id] || roomChanged) {
const spm = this._spaceModel(space || undefined); const spm = this._spaceModel(space || undefined);
let cx = spm.vb[0] + spm.vb[2] / 2; let cx = spm.vb[0] + spm.vb[2] / 2;
let cy = spm.vb[1] + spm.vb[3] / 2; let cy = spm.vb[1] + spm.vb[3] / 2;
@@ -2460,9 +2833,9 @@ class HouseplanCard extends LitElement {
</div> </div>
${this._canEdit ${this._canEdit
? html`<div class="modes"> ? html`<div class="modes">
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map( ${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}" ([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m) as any)} title=${this._t(('mode.' + m + '_tip') as any)}
@click=${() => { if (this._mode !== m) this._setMode(m); }}> @click=${() => { if (this._mode !== m) this._setMode(m); }}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span> <ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
${this._mode === m ${this._mode === m
@@ -2487,10 +2860,10 @@ class HouseplanCard extends LitElement {
</button>` </button>`
: nothing} : nothing}
</div> </div>
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing} ${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div> </div>
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}" <div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)" style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2500,10 +2873,14 @@ class HouseplanCard extends LitElement {
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}> @pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap"> <div class="zoomwrap">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet"> <svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${this._markup ? this._renderMarkupDefs(vb) : nothing} ${this._editing ? this._renderMarkupDefs(vb) : nothing}
${this._editing && !this._markup
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
: nothing}
${space.bg ${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" />` ? 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} : nothing}
${this._renderDecorLayer()}
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => { ${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : ''); let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId)) if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
@@ -2570,6 +2947,7 @@ class HouseplanCard extends LitElement {
${this._mergeDialog ? this._renderMergeDialog() : nothing} ${this._mergeDialog ? this._renderMergeDialog() : nothing}
${this._openingDialog ? this._renderOpeningDialog() : nothing} ${this._openingDialog ? this._renderOpeningDialog() : nothing}
${this._openingInfo ? this._renderOpeningInfoCard() : nothing} ${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing} ${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing} ${this._infoCard ? this._renderInfoCard() : nothing}
@@ -2822,7 +3200,8 @@ class HouseplanCard extends LitElement {
if (!this._markup || !this._cursorPt) return null; if (!this._markup || !this._cursorPt) return null;
if (this._tool === 'draw' && this._path.length && !this._contourClosed) if (this._tool === 'draw' && this._path.length && !this._contourClosed)
return this._path[this._path.length - 1]; return this._path[this._path.length - 1];
if (this._tool === 'split' && this._splitSel?.a) return this._splitSel.a; if (this._tool === 'split' && this._splitSel?.pts?.length)
return this._splitSel.pts[this._splitSel.pts.length - 1];
return null; return null;
} }
@@ -3089,10 +3468,23 @@ class HouseplanCard extends LitElement {
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>` x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing} : nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)} ${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> const op = this._openingPreview;
if (!op) return nothing;
const rad = (op.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * op.rlen) / 2;
const dy = (Math.sin(rad) * op.rlen) / 2;
return svg`<line class="opghost" x1="${op.x - dx}" y1="${op.y - dy}"
x2="${op.x + dx}" y2="${op.y + dy}"></line>
<circle class="opghost-dot" cx="${op.x}" cy="${op.y}" r="${g * 0.18}"></circle>`;
})()}
${this._tool === 'split' && this._splitSel?.pts?.length
? svg`${this._splitSel.pts.length > 1
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${this._cursorPt ${this._cursorPt
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[1]}" ? svg`<line class="preview" x1="${this._splitSel.pts[this._splitSel.pts.length - 1][0]}" y1="${this._splitSel.pts[this._splitSel.pts.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>` x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}` : nothing}`
: nothing} : nothing}
@@ -3149,9 +3541,6 @@ class HouseplanCard extends LitElement {
title=${this._t('title.show_all')}> title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')} <ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')}
</button> </button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>${this._t('devbar.reset')}
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}> <button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')} <ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')}
</button> </button>
@@ -3271,10 +3660,17 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.icon_label')}</label> <label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker') ${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon} ? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
.placeholder=${d.autoIcon || undefined}
.fallbackPath=${undefined}
@value-changed=${(e: any) => (this._markerDialog = { ...d, icon: e.detail.value || '' })}></ha-icon-picker>` @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')} : html`<input class="namein" type="text"
placeholder=${d.autoIcon || this._t('marker.icon_ph')}
.value=${d.icon} .value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`} @input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
${!d.icon && d.autoIcon
? html`<p class="muted iconauto"><ha-icon icon=${d.autoIcon}></ha-icon>
${this._t('marker.icon_auto', { icon: d.autoIcon })}</p>`
: nothing}
<label>${this._t('marker.display_label')}</label> <label>${this._t('marker.display_label')}</label>
<select class="areasel" <select class="areasel"
+27 -6
View File
@@ -22,7 +22,6 @@
"title.zoom_reset": "Reset zoom", "title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan", "title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no curation)", "title.show_all": "Show all area devices (no curation)",
"title.reset_layout": "Reset icon positions to auto layout",
"title.markup": "Room markup: grid, lines, outlines", "title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space", "title.configure_space": "Configure space",
"title.add_space": "Add space", "title.add_space": "Add space",
@@ -115,7 +114,6 @@
"device.light_group": "light group", "device.light_group": "light group",
"device.fallback": "device", "device.fallback": "device",
"device.virtual": "virtual device", "device.virtual": "virtual device",
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
"confirm.delete_room": "Delete room \"{name}\"?", "confirm.delete_room": "Delete room \"{name}\"?",
"confirm.remove_marker": "Remove \"{name}\" from the plan?", "confirm.remove_marker": "Remove \"{name}\" from the plan?",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?", "confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
@@ -128,8 +126,8 @@
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap", "toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged", "toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”", "toast.rooms_merged": "Rooms merged into “{name}”",
"toast.split_pick_wall": "Click a grid dot on the rooms wall", "toast.split_pick_wall": "Start the cut on the rooms wall",
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room", "toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
"merge.header": "Merge rooms", "merge.header": "Merge rooms",
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.", "merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
"merge.keep": "Keep", "merge.keep": "Keep",
@@ -251,7 +249,6 @@
"title.close_editor": "Close editor (back to view)", "title.close_editor": "Close editor (back to view)",
"devbar.add": "Add", "devbar.add": "Add",
"devbar.show_all": "Show all", "devbar.show_all": "Show all",
"devbar.reset": "Reset",
"devbar.rules": "Icon rules", "devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:", "space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature", "space.label_temp": "Temperature",
@@ -260,5 +257,29 @@
"space.label_light": "Lights on/off", "space.label_light": "Lights on/off",
"roomcard.light_on": "On", "roomcard.light_on": "On",
"roomcard.light_off": "Off", "roomcard.light_off": "Off",
"roomcard.light_partial": "{on} of {total}" "roomcard.light_partial": "{on} of {total}",
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
"mode.decor": "Background editor",
"decor.select": "Select",
"decor.line": "Line",
"decor.rect": "Rectangle",
"decor.ellipse": "Oval",
"decor.text": "Text",
"decor.erase": "Erase",
"decor.color": "Color",
"decor.width": "Line width",
"decor.w_thin": "Thin",
"decor.w_mid": "Medium",
"decor.w_thick": "Thick",
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks"
} }
+27 -6
View File
@@ -22,7 +22,6 @@
"title.zoom_reset": "Сбросить масштаб", "title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план", "title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)", "title.show_all": "Показывать все устройства зоны (без курирования)",
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
"title.markup": "Разметка комнат: сетка, линии, контуры", "title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство", "title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство", "title.add_space": "Добавить пространство",
@@ -115,7 +114,6 @@
"device.light_group": "группа света", "device.light_group": "группа света",
"device.fallback": "устройство", "device.fallback": "устройство",
"device.virtual": "виртуальное устройство", "device.virtual": "виртуальное устройство",
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
"confirm.delete_room": "Удалить комнату «{name}»?", "confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?", "confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?", "confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
@@ -128,8 +126,8 @@
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться", "toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной", "toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»", "toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты", "toast.split_pick_wall": "Начните разрез на стене комнаты",
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты", "toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
"merge.header": "Объединение комнат", "merge.header": "Объединение комнат",
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.", "merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить", "merge.keep": "Оставить",
@@ -251,7 +249,6 @@
"title.close_editor": "Закрыть редактор (вернуться к просмотру)", "title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить", "devbar.add": "Добавить",
"devbar.show_all": "Показать все", "devbar.show_all": "Показать все",
"devbar.reset": "Сброс",
"devbar.rules": "Правила иконок", "devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:", "space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура", "space.label_temp": "Температура",
@@ -260,5 +257,29 @@
"space.label_light": "Свет вкл/выкл", "space.label_light": "Свет вкл/выкл",
"roomcard.light_on": "Вкл", "roomcard.light_on": "Вкл",
"roomcard.light_off": "Выкл", "roomcard.light_off": "Выкл",
"roomcard.light_partial": "{on} из {total}" "roomcard.light_partial": "{on} из {total}",
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
"mode.decor": "Редактор подложки",
"decor.select": "Выбрать",
"decor.line": "Линия",
"decor.rect": "Прямоугольник",
"decor.ellipse": "Овал",
"decor.text": "Надпись",
"decor.erase": "Стереть",
"decor.color": "Цвет",
"decor.width": "Толщина линии",
"decor.w_thin": "Тонкая",
"decor.w_mid": "Средняя",
"decor.w_thick": "Толстая",
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики"
} }
+33 -10
View File
@@ -310,27 +310,50 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
export function splitRoom( export function splitRoom(
poly: number[][], a: number[], b: number[], eps = 1e-6, poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | null { ): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null; return splitRoomPath(poly, [a, b], eps);
}
/**
* Split a room along a polyline: first and last points on walls, intermediate
* vertices strictly inside the room. A two-point path is the classic straight
* chord. Returns the two parts, or null when the path is not a clean cut.
*/
export function splitRoomPath(
poly: number[][], pts: number[][], eps = 1e-6,
): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
const a = pts[0];
const b = pts[pts.length - 1];
if (samePoint(a, b, eps)) return null;
const ia = edgeIndexOf(poly, a, eps); const ia = edgeIndexOf(poly, a, eps);
const ib = edgeIndexOf(poly, b, eps); const ib = edgeIndexOf(poly, b, eps);
if (ia < 0 || ib < 0) return null; // an end is not on a wall if (ia < 0 || ib < 0) return null; // an end is not on a wall
const mids = pts.slice(1, -1);
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
// no path segment may cross a wall
for (let sI = 0; sI < pts.length - 1; sI++)
for (let i = 0; i < poly.length; i++) for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
// a chord lying along a wall has its midpoint ON the outline, not inside it // the path may not properly self-intersect
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null; for (let sI = 0; sI < pts.length - 1; sI++)
for (let t = sI + 2; t < pts.length - 1; t++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
// a straight chord lying along a wall has its midpoint ON the outline, not inside
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
return null;
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => { const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
const pts: number[][] = [from]; const acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length; let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) { for (let guard = 0; guard <= poly.length; guard++) {
pts.push(poly[i]); acc.push(poly[i]);
if (i === toIdx) break; if (i === toIdx) break;
i = (i + 1) % poly.length; i = (i + 1) % poly.length;
} }
pts.push(to); acc.push(to);
return dropRepeats(pts, eps); return dropRepeats(acc, eps);
}; };
const p1 = walk(a, ia, b, ib); const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
const p2 = walk(b, ib, a, ia); const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
if (p1.length < 3 || p2.length < 3) return null; if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null; if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
return [p1, p2]; return [p1, p2];
+76
View File
@@ -396,6 +396,14 @@ export const cardStyles = css`
display: inline-flex; display: inline-flex;
} }
.roomlabel .rlm.lit { opacity: 1; } .roomlabel .rlm.lit { opacity: 1; }
.iconauto {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin: 4px 0 0;
}
.iconauto ha-icon { --mdc-icon-size: 18px; }
.rlhandle { .rlhandle {
display: none; display: none;
position: absolute; position: absolute;
@@ -432,6 +440,68 @@ export const cardStyles = css`
user-select: none; user-select: none;
z-index: 3; z-index: 3;
} }
/* decor (background) layer */
.decorlayer .dshape { pointer-events: none; }
.stage.mode-decor .decorlayer .dshape {
pointer-events: visiblePainted;
cursor: pointer;
}
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
.decorlayer .ddraft {
opacity: 0.75;
stroke-dasharray: 6 5;
pointer-events: none;
}
.decorlayer text {
font-weight: 600;
user-select: none;
dominant-baseline: middle;
text-anchor: middle;
}
.stage.mode-decor {
outline: 2px solid #26a69a;
outline-offset: -2px;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
}
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
.stage.mode-decor .oplock { pointer-events: none; }
/* decor mode: everything but the decor itself fades back */
.stage.mode-decor .room,
.stage.mode-decor .devlayer,
.stage.mode-decor .opening,
.stage.mode-decor .rlabel {
opacity: 0.35;
}
.decorbar .dcolor {
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
}
.decorbar .dwidth {
font-family: inherit; font-size: 12px; border-radius: 6px;
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
padding: 3px 5px;
}
.decorbar .dfill {
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
}
.opghost {
stroke: var(--hp-open, #ff9800);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 7 6;
opacity: 0.85;
pointer-events: none;
}
.opghost-dot {
fill: var(--hp-open, #ff9800);
opacity: 0.85;
pointer-events: none;
}
.rlabel { .rlabel {
fill: var(--hp-muted); fill: var(--hp-muted);
font-size: 15px; font-size: 15px;
@@ -445,6 +515,12 @@ export const cardStyles = css`
.stage.markup { .stage.markup {
cursor: crosshair; cursor: crosshair;
} }
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-delroom {
cursor: pointer;
}
.stage.markup .room { .stage.markup .room {
pointer-events: none; pointer-events: none;
} }
+36
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
splitRoomPath,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -544,3 +545,38 @@ test('spaceDisplayOf: room-card metric flags default to off', () => {
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } }); const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]); assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
}); });
test('splitRoomPath: polyline cut of a square', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
assert.ok(parts);
const [p1, p2] = parts;
const area = (p) => Math.abs(p.reduce((a, _, i) => {
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
return a + x1 * y2 - x2 * y1;
}, 0)) / 2;
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
assert.ok(area(p1) > 0 && area(p2) > 0);
});
test('splitRoomPath: two points == classic straight chord', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
assert.ok(parts);
});
test('splitRoomPath rejects bad paths', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// промежуточная точка снаружи
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
// сегмент пересекает стену (выходит и возвращается)
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
// конец не на стене
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
// самопересечение пути
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
// менее двух точек
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
});