Merge dev: v1.35.0..v1.38.2

This commit is contained in:
Matysh
2026-07-23 16:48:05 +03:00
29 changed files with 2690 additions and 630 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.34.0" VERSION = "1.38.2"
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.34.0" "version": "1.38.2"
} }
+5 -1
View File
@@ -68,6 +68,7 @@ ROOM_SCHEMA = vol.All(
vol.Required("id"): str, vol.Required("id"): str,
vol.Required("name"): str, vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None), vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Optional("x"): vol.Coerce(float), vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float), vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float), vol.Optional("w"): vol.Coerce(float),
@@ -84,7 +85,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_names"): bool, vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"), vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)), vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]), vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("temp_min"): vol.Coerce(float), vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float), vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool, vol.Optional("show_lqi"): bool,
@@ -168,6 +169,8 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("link"): vol.Any(str, None), vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None), vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None), vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("room_id"): vol.Any(str, None), vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None), vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None), vol.Optional("ripple_color"): vol.Any(str, None),
@@ -186,6 +189,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("markers", default=list): [MARKER_SCHEMA], vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema( vol.Optional("settings", default=dict): vol.Schema(
{ {
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str], vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str], vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema( vol.Optional("fill_colors"): vol.Schema(
+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;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
// glow на текущем пространстве
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c.requestUpdate(); await c.updateComplete;
out.mode = c._mode;
out.suppress = c._suppressClick;
out.holdFired = c._holdFired;
out.drag = !!c._drag;
// реальный клик по включённой лампе (элемент .dev)
const litDev = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
out.hasLit = !!litDev;
const el = [...sr().querySelectorAll('.dev')].find((e) => e.textContent.includes(litDev.name) || true);
// найдём элемент точно: по индексу устройства
const devEls = [...sr().querySelectorAll('.dev')];
out.devCount = devEls.length;
const target = devEls[c._devices.filter((d) => d.space === c._space).indexOf(litDev)] || devEls[0];
const before = calls.length;
c._infoCard = null;
// эмулируем полный цикл: pointerdown/up + click
const opts = { bubbles: true, composed: true };
target.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 5 }));
target.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 5 }));
target.dispatchEvent(new MouseEvent('click', opts));
await c.updateComplete;
out.reaction = calls.length > before ? 'service:' + JSON.stringify(calls.at(-1)) : c._infoCard ? 'info' : 'NOTHING';
out.suppressAfter = c._suppressClick;
// и через прямой вызов
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), litDev);
await c.updateComplete;
out.directReaction = calls.length > before + 1 ? 'service' : c._infoCard ? 'info' : 'NOTHING';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+50
View File
@@ -0,0 +1,50 @@
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;
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
c._openMarkerDialog(); await c.updateComplete;
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
out.twoRadios = radios().length === 2;
out.virtDefault = radios()[0].checked && !radios()[1].checked;
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
const entLabel = c._t('marker.sub_entity');
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
// включить чекбокс → сущности появились
const cb = sr().querySelector('.entcheck input');
cb.click(); await c.updateComplete;
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
// выбрать первый кандидат → панель закрылась, сейв разблокирован
sr().querySelector('.cand').click(); await c.updateComplete;
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
out.panelClosed = !sr().querySelector('.droppanel');
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
c._markerDialog = null; await c.updateComplete;
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
c._openMarkerDialog(dev); await c.updateComplete;
out.editHaMode = c._markerDialog.bindingMode === 'ha';
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
out.editPanelClosed = !sr().querySelector('.droppanel');
c._markerDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+61
View File
@@ -0,0 +1,61 @@
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 calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
// два подопытных light-entity
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
out.twoLights = lights.length === 2;
const setSt = async (m) => {
const st = { ...c.hass.states };
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
c.hass = { ...c.hass, states: st }; await c.updateComplete;
};
// виртуальный маркер-выключатель с controls и tap_action=toggle
c._setMode('devices'); await c.updateComplete;
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, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
c._setMode('view'); await c.updateComplete;
// 1) все выключены → клик включает все
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
c._clickDevice(new MouseEvent('click'), dev2());
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
// 2) частично включено → клик выключает все
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
// 3) значок отражает цели: горит одна → класс on
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
out.stateOn = !!c._stateClass(dev2()).includes('on');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
out.infoNoToggle = calls.length === n && !!c._infoCard;
// 5) инфо-карточка показывает цели
await c.updateComplete;
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
c._infoCard = null; await c.updateComplete;
// 6) замок в controls отфильтрован при клике (защита)
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+105
View File
@@ -0,0 +1,105 @@
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 spId = c._space;
// включить glow-режим
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) все комнаты залиты темнотой одинаково (независимо от ламп)
const rooms = [...sr().querySelectorAll('.room.styled')];
out.roomsDark = rooms.length > 0 && rooms.every((el) => {
const st = el.getAttribute('style') || '';
return st.includes('--room-fill:#0d1b2a');
});
// 2) пятна от включённых ламп
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
out.hasLitLight = !!litLight;
out.spots = sr().querySelectorAll('.glowlayer circle').length;
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
// 3) выключение лампы убирает её пятно
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
const st0 = c.hass.states[eid];
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
await c.updateComplete;
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
await c.updateComplete;
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === 'rgb(255, 0, 0)');
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
// 5) пятно обрезано комнатой (есть clipPath)
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
const spm = c._spaceModel();
const r1 = spm.rooms.find((r) => r.id === 'r1');
const r2 = spm.rooms.find((r) => r.id === 'r2');
// возьмём точку на границе r1, ближайшую к центру r2
const c2 = c._roomCenter(r2);
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
// общая стена вертикальная — дверь ставим на неё
const H = 1000 / (c._curSpaceCfg.aspect || 1);
const doorPt = (() => {
let best = null, bd = 1e9;
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
const d2 = Math.hypot(x - c2[0], y - c2[1]);
if (d2 < bd) { bd = d2; best = [x, y]; }
}
return best;
})();
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const aspect = c._curSpaceCfg.aspect || 1;
const c1 = c._roomCenter(r1);
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
c.requestUpdate(); await c.updateComplete;
// теперь каждый под-контур — отдельный path внутри clipPath
const clipEls = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.sectorAdded = clipEls.some((cp) => cp.querySelectorAll('path').length >= 2);
// и сектор в том же направлении обхода не выедает комнату: каждый path — один контур
out.singleSubpathPerPath = clipEls.every((cp) => [...cp.querySelectorAll('path')]
.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1));
// у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
// 6б) профиль градиента: плато до 80%, спад на внешних 20%
const stops = [...sr().querySelectorAll('defs radialGradient')][0];
const offs = stops ? [...stops.querySelectorAll('stop')].map((st2) => [st2.getAttribute('offset'), st2.getAttribute('stop-opacity')]) : [];
out.plateau80 = offs.length === 3 && offs[0][0] === '0%' && offs[1][0] === '70%'
&& offs[0][1] === offs[1][1] && offs[2][1] === '0';
// 7а) персональный радиус источника перекрывает глобальный
const litMarkerId = litLight.id;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId),
{ id: litMarkerId, binding: litLight.bindingKind === 'virtual' ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef, glow_radius_cm: 150 },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
c.requestUpdate(); await c.updateComplete;
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+35
View File
@@ -0,0 +1,35 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// выбрать f2 и редактор устройств
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.saved = nav.space === 'garden' && nav.mode === 'devices';
// пересоздать карточку (эмуляция закрытия вкладки; кэш конфига в LS уже есть)
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = c.hass;
document.body.appendChild(c2);
await new Promise((r) => setTimeout(r, 300));
c2.hass = { ...c.hass };
await c2.updateComplete;
out.spaceRestored = c2._space === 'garden';
out.modeRestored = c2._mode === 'devices';
// хэш приоритетнее сохранённого
location.hash = '#space=f1';
const c3 = document.createElement('houseplan-card');
c3.setConfig({ type: 'custom:houseplan-card' });
c3.hass = c.hass;
document.body.appendChild(c3);
await new Promise((r) => setTimeout(r, 300));
out.hashWins = c3._space === 'f1';
location.hash = '';
c2.remove(); c3.remove();
// вернуть исходное
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+61
View File
@@ -0,0 +1,61 @@
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 = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000 / (c._curSpaceCfg.aspect || 1);
c._openWallClick([550, 0.25 * H]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
// пунктир отрисован
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
const svgEl = sr().querySelector('svg');
const order = [...svgEl.children].map((el) => el.classList?.[0] || el.tagName);
const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
// клик мимо стен — тост, без изменений
c._openWallClick([100, 100]);
out.missToast = !!c._toast;
// снова открыть для glow-теста
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
const aspect = c._curSpaceCfg.aspect || 1;
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+36
View File
@@ -0,0 +1,36 @@
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'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000 / (c._curSpaceCfg.aspect || 1);
// 1) без наведения: курсор default, превью нет
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
const prev = sr().querySelectorAll('.openwall-preview');
out.previewShown = prev.length > 0;
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
// превью лежит на стене x=550
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
// 3) наведение в центр комнаты: снова default, превью исчезло
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
out.missCursor = getComputedStyle(stage()).cursor === 'default';
out.missNoPreview = !sr().querySelector('.openwall-preview');
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
const prev2 = sr().querySelector('.openwall-preview');
out.willclose = prev2?.classList.contains('willclose') === true;
// прибрать
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+54
View File
@@ -0,0 +1,54 @@
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;
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual && d.primary);
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 3;
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
// 2) правый клик в Просмотре открывает more-info
c._setMode('view'); await c.updateComplete;
let moreInfo = null;
c._openMoreInfo = (eid) => { moreInfo = eid; };
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
c._ctxDevice(ev, dev);
out.ctxMoreInfo = moreInfo === dev.primary;
out.ctxPrevented = ev.defaultPrevented;
// 3) в редакторах правый клик не перехватывается
c._setMode('devices'); await c.updateComplete;
moreInfo = null;
const ev2 = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
c._ctxDevice(ev2, dev);
out.editorNative = moreInfo === null && !ev2.defaultPrevented;
// 4) виртуальное без primary → инфо-карточка
c._setMode('view'); await c.updateComplete;
const virt = c._devices.find((d) => d.virtual && !d.primary) || null;
if (virt) {
c._ctxDevice(new MouseEvent('contextmenu', { cancelable: true }), virt);
out.virtInfo = c._infoCard === virt;
c._infoCard = null;
} else out.virtInfo = 'no-virt';
// 5) card-wide tap_action игнорируется: без явного действия клик = инфо
c._config = { ...c._config, tap_action: 'toggle' };
const calls = [];
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
await c.updateComplete;
const plain = c._devices.find((d) => !d.virtual && d.primary?.startsWith('light.') && !d.tapAction);
if (plain) {
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), plain);
out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
c._infoCard = null;
} else out.cardTapIgnored = 'no-plain-light';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+357 -172
View File
File diff suppressed because one or more lines are too long
+105
View File
@@ -1,5 +1,110 @@
# Changelog # Changelog
## v1.38.2 — 2026-07-23
- The card now **remembers where you were**: the selected space and the active
editor survive navigation and closing the tab (localStorage; edit modes are
restored for admins only). A `#space=` deep link still wins over the saved
space. This reverses the earlier "always start in View" rule — the owner's
call.
## v1.38.1 — 2026-07-23 (tap action cleanup, right-click more-info)
- The per-device action is now one of three: **Device card** (renamed from
"Info card", the default), HA more-info, Toggle. The confusing "As the card
default" option is gone — along with the card editor's global tap setting
(it is ignored if present in old configs). Explicit per-device choices are
untouched. RU wording: «по нажатию» instead of «по тапу».
- **Right click** on a device icon in View mode always opens HA's more-info
dialog (editors keep the native browser menu; a virtual marker without an
entity opens its device card).
## v1.38.0 — 2026-07-23 (binding section redesign)
- The device dialog's binding section is compact now: two radio buttons —
**Virtual device** and **Pick from the HA list** — with a **Show entities**
checkbox (adds every entity of the devices to the list; groups and helpers
are always listed). The searchable dropdown appears only in HA mode and
collapses once you pick. Save is disabled until a binding is chosen.
The binding logic itself is unchanged.
## v1.37.3 — 2026-07-23
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
trimmed under the open stretch (outlineWithout) instead of dashes being
painted over a solid line, and the dashed layer moved **above the glow
pools** so light never covers it.
## v1.37.2 — 2026-07-23
- Glow falloff tuned: full brightness for the inner 70% of the radius,
gradient on the outer 30% (was 80/20).
## v1.37.1 — 2026-07-23
- Open-boundary tool polish: the cursor stays default and only turns into a
pointer near a wall shared by two rooms; hovering previews the exact
stretch that would become open (amber dashed) — or red solid when the
boundary is already open and the click would close it.
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
- Rooms divided only by zoning can now share an **open boundary**: the new
"Open boundary" tool in the Plan editor toggles it with a click on the wall
two rooms share. The stretch renders dashed; while the tool is active open
boundaries highlight amber. Stored as `room.open_to` links by room id, so
redrawing/merging neighbours doesn't break them.
- In the light-sources fill, light flows through open boundaries freely —
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
one open space), still limited by the glow radius. Door sectors now work
from any outer wall of the zone.
## v1.36.4 — 2026-07-23
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
radius, the gradient falloff lives only in the outer 20%.
## v1.36.3 — 2026-07-23
- Glow: fixed dark wedges appearing INSIDE a lit room near some doorways.
The room outline and the door sectors were subpaths of a single clip path;
with opposite winding directions the nonzero fill rule cancelled their
overlap. Each contour is now its own clipPath child (children always
union), so sectors only ever ADD light.
## v1.36.2 — 2026-07-23
- **Glow radius is now per source**: every device dialog gained a "Glow
radius" field (in your HA units; empty = the global default from general
settings, shown as the placeholder). A kettle's night light can glow half
a meter while the ceiling lamp floods the room. Door sectors use the same
per-source radius.
## v1.36.1 — 2026-07-23
- Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity
is **hidden in the registry** (the usual setup when lamps are folded into a
light group): the primary entity fell through to a visible config switch
(do-not-disturb) or an identify button, and the click toggled THAT.
Primary selection now works in tiers — domain priority beats hiddenness,
so a hidden light still wins over a visible config switch, while visible
entities of the same domain keep winning over hidden ones.
## v1.36.0 — 2026-07-23 (wall switches that really switch)
- Markers gained **"Controls light sources"**: bind any set of `light.*` /
`switch.*` entities to an icon. With tap action **Toggle**, a click flips
them all with HA-group semantics — any on → all off, all off → all on — in
one service call. Covers stateless remotes, one-switch-many-lights and
dumb wall switches (place a virtual marker; no HA entity needed).
- The icon mirrors its targets: on when any target is on, tinted by the first
lit RGB light. The info card lists every target with its state. Controls
fire only on the explicit per-marker Toggle (owner's decision); locks and
other domains can never be group-controlled.
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
- New fill mode **"Light sources"**: the whole house is painted with a single
configurable darkness color, and every lit lamp casts a radial pool of light
around itself. The pool color comes from the lamp's `rgb_color`, else its
color temperature (blackbody conversion), else a configurable default;
brightness scales the intensity.
- Pools are clipped by the source's room — **plus the sector through each
doorway** (rays from the source to the door edges, out to the glow radius),
so light spills into neighbouring rooms through doors. Entrance doors (no
room behind) spill nothing; windows don't spill. No shadow casting: islands
and furniture do not block light (deliberate limitation).
- The glow radius is configured in General settings in your HA unit system
(meters or feet; stored in cm, default 3 m). The palette gained a "glow"
group: house darkness + default light color/intensity.
## v1.34.0 — 2026-07-22 (island rooms) ## v1.34.0 — 2026-07-22 (island rooms)
- **Nested rooms are now legal**: draw a contour fully inside an existing room - **Nested rooms are now legal**: draw a contour fully inside an existing room
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe (or around one) — a column in a ring-shaped room, an inner room, a wardrobe
+53
View File
@@ -140,6 +140,59 @@ 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
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
space AND editor mode (admins; localStorage); a #space= deep link beats
the saved space; a stale cache without the saved space retries after the
live config loads [auto]
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
has three options (Device card / HA more-info / Toggle), no "card
default" — the card editor's global tap option is gone and ignored;
right click on an icon in VIEW opens HA more-info (native menu kept in
editors; virtual w/o entity → device card) [auto]
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
the HA list — with a "Show entities" checkbox (tooltip) next to the
second; the dropdown (search inside) appears only in HA mode, opens
itself when nothing is chosen, closes on pick; Save is blocked until a
binding is chosen in HA mode; groups/helpers listed always, device
entities only with the checkbox; editing pre-selects everything [auto]
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
them back), walls elsewhere stay solid; the dashes render ABOVE the
glow pools [auto]
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
near a shared wall it turns pointer and the exact stretch that would
open is previewed (amber dashed); an already-open boundary previews red
solid (the click will close it); preview follows the cursor and clears
on miss [auto]
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
toggles a virtual wall between two rooms by clicking their shared wall
(pull like Split; miss → toast); open stretches render dashed (amber
highlight while the tool is active); glow light floods the whole
connected open zone transitively, door sectors work from the zone's
outer walls; merge/split keep links by room id [auto]
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
the room — room outline and sectors are separate clipPath children
(union), not subpaths of one nonzero path [auto]
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
field (HA units; empty = general-settings default shown as placeholder);
an override changes that source's pool and door sectors only [auto]
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
the registry (folded into a light group) still toggles/reflects the lamp,
not its do-not-disturb switch or identify button; visible entities of the
same domain still win over hidden ones [manual: click hallway lamps]
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
tap action Toggle flips all bound lights/switches at once (any on → all
off, all off → all on, one service call); the icon and its RGB tint
mirror the targets, not the marker's own entity; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
one uniform darkness color; lit lamps glow with a radial gradient
(rgb_color → color temp → default color; brightness scales opacity),
clipped by the source's room plus door sectors into NEIGHBOUR rooms
(entrance doors leak nothing; windows don't spill); radius set in
general settings in HA units (m/ft, stored in cm); no shadow casting —
islands don't block light (documented limitation) [auto]
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room - [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
(or around one) saves as a nested room — column in a ring, inner room; (or around one) saves as a nested room — column in a ring, inner room;
the parent's fill renders with an evenodd hole so the ring paints the parent's fill renders with an evenodd hole so the ring paints
+5 -2
View File
@@ -17,8 +17,11 @@ 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, decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
inert everywhere outside its editor). 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. Since
state after every load (edit modes are never restored across reloads). v1.38.2 the last space AND editor mode are restored across reloads
(localStorage, admins only for edit modes) — closing and reopening the tab
lands you where you were (owner's decision, reversing the earlier
"never restore" rule).
- Activating an editor tab highlights it and opens that editor's bottom toolbar - Activating an editor tab highlights it and opens that editor's bottom toolbar
(both editors have one since v1.30.2). The toolbar and the active tab each (both editors have one since v1.30.2). The toolbar and the active tab each
carry an **X** that closes the editor back to View; re-clicking the active tab carry an **X** that closes the editor back to View; re-clicking the active tab
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.23.2", "version": "1.34.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.23.2", "version": "1.34.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3", "lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.34.0", "version": "1.38.2",
"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",
+20 -7
View File
@@ -53,20 +53,33 @@ export function isTempEntity(hass: any, eid: string): boolean {
} }
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined { export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
const ents = entIds const all = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] })) .map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden); .filter((e) => e.reg);
const usable = ents.filter((e) => !e.reg.entity_category); // Tiers: visible primary entities first, but a HIDDEN light still beats a
const pool = usable.length ? usable : ents; // visible config switch. Real case: individual lamps folded into a light
// group get hidden in the registry — the device's main function is still
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
const tiers = [
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
all.filter((e) => !e.reg.hidden),
all.filter((e) => !e.reg.entity_category),
all,
];
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') { if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => isTempEntity(hass, e.eid)); for (const tier of tiers) {
const t = tier.find((e) => isTempEntity(hass, e.eid));
if (t) return t.eid; if (t) return t.eid;
} }
}
for (const dom of DOMAIN_PRIORITY) { for (const dom of DOMAIN_PRIORITY) {
const found = pool.find((e) => e.eid.split('.')[0] === dom); for (const tier of tiers) {
const found = tier.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid; if (found) return found.eid;
} }
return pool[0]?.eid; }
for (const tier of tiers) if (tier.length) return tier[0].eid;
return undefined;
} }
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */ /** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
-14
View File
@@ -63,19 +63,6 @@ class HouseplanCardEditor extends LitElement {
}, },
}, },
}, },
{
name: 'tap_action',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'info', label: t(L, 'tap.info') },
{ value: 'more-info', label: t(L, 'tap.more_info') },
{ value: 'toggle', label: t(L, 'tap.toggle') },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } }, { name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } }, { name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } }, { name: 'live_states', selector: { boolean: {} } },
@@ -91,7 +78,6 @@ class HouseplanCardEditor extends LitElement {
title: t(L, 'editor.title'), title: t(L, 'editor.title'),
default_floor: t(L, 'editor.default_floor'), default_floor: t(L, 'editor.default_floor'),
language: t(L, 'editor.language'), language: t(L, 'editor.language'),
tap_action: t(L, 'editor.tap_action'),
icon_size: t(L, 'editor.icon_size'), icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'), show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'), live_states: t(L, 'editor.live_states'),
+445 -40
View File
@@ -14,10 +14,10 @@ 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, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout,
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, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -32,14 +32,15 @@ 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.34.0'; const CARD_VERSION = '1.38.2';
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';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const NORM_W = 1000; // width of the render space for normalized configs const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved) const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom'; type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any; const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -145,7 +146,7 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null; private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; busy: boolean } | null = null; private _settingsDialog: { colors: FillColors; glowRadius: number; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null; private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0; private _importTotal = 0;
@@ -156,7 +157,10 @@ class HouseplanCard extends LitElement {
private _markerDialog: { private _markerDialog: {
devId?: string; // the icon being edited (if any) devId?: string; // the icon being edited (if any)
name: string; name: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
bindingMode: 'virtual' | 'ha';
bindingOpen: boolean; // the HA-list dropdown is expanded
showEntities: boolean; // list entities of devices too
bindingFilter: string; bindingFilter: string;
icon: string; // '' = auto icon: string; // '' = auto
autoIcon: string; // the icon the rules would give — picker placeholder autoIcon: string; // the icon the rules would give — picker placeholder
@@ -166,6 +170,9 @@ class HouseplanCard extends LitElement {
size: number; // icon size multiplier size: number; // icon size multiplier
angle: number; // icon rotation, degrees angle: number; // icon rotation, degrees
tapAction: string; // '' = card default tapAction: string; // '' = card default
controls: string[]; // entities this icon toggles as a group
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
model: string; model: string;
link: string; link: string;
description: string; description: string;
@@ -185,7 +192,7 @@ class HouseplanCard extends LitElement {
showNames: boolean; showNames: boolean;
roomColor: string; roomColor: string;
roomOpacity: number; // 0..1 roomOpacity: number; // 0..1
fillMode: 'none' | 'lqi' | 'light' | 'temp'; fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
tempMin: number; tempMin: number;
tempMax: number; tempMax: number;
showLqi: boolean; showLqi: boolean;
@@ -198,6 +205,7 @@ class HouseplanCard extends LitElement {
} | null = null; } | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e); private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _hashApplied = false; private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */ /** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string { private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || ''); const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
@@ -340,6 +348,11 @@ class HouseplanCard extends LitElement {
e.preventDefault(); e.preventDefault();
if (this._mergeSel) this._mergeSel = null; if (this._mergeSel) this._mergeSel = null;
else this._tool = 'draw'; else this._tool = 'draw';
return;
}
if (this._tool === 'openwall' || this._tool === 'opening' || this._tool === 'delroom') {
e.preventDefault();
this._tool = 'draw';
} }
} }
@@ -375,9 +388,13 @@ class HouseplanCard extends LitElement {
this._layout = c.layout || {}; this._layout = c.layout || {};
this._serverStorage = true; this._serverStorage = true;
const hs = this._hashSpace(); const hs = this._hashSpace();
const nav = this._savedNav();
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; } if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor; else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space; else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
// reopenning the tab lands you in the same editor you left (admins only)
if (nav?.mode && nav.mode !== 'view' && this._canEdit) this._mode = nav.mode;
} }
} catch { } catch {
/* ignore */ /* ignore */
@@ -414,6 +431,7 @@ class HouseplanCard extends LitElement {
id: r.id, id: r.id,
name: r.name, name: r.name,
area: r.area ?? null, area: r.area ?? null,
open_to: r.open_to || undefined,
x: r.x != null ? r.x * NORM_W : undefined, x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined, y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined, w: r.w != null ? r.w * NORM_W : undefined,
@@ -538,9 +556,16 @@ class HouseplanCard extends LitElement {
}, 'houseplan_config_updated'); }, 'houseplan_config_updated');
} }
const hs = this._hashSpace(); const hs = this._hashSpace();
const nav = this._savedNav();
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) { if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
this._space = hs; this._space = hs;
this._hashApplied = true; this._hashApplied = true;
} else if (nav?.space && !this._navApplied && !this._hashApplied
&& this._model.find((s) => s.id === nav.space)) {
// the cached config might have been stale (no such space) — retry once
// the live config is in
this._space = nav.space;
this._navApplied = true;
} else if (this._norm && !this._model.find((s) => s.id === this._space)) { } else if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space; this._space = this._model[0]?.id || this._space;
} }
@@ -770,6 +795,11 @@ class HouseplanCard extends LitElement {
private _stateClass(d: DevItem): string { private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return ''; if (!this._config?.live_states) return '';
// an icon with controlled targets mirrors THEM, not its own entity
// (stateless remotes and virtual wall switches have nothing else to show)
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length)
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
const p = d.primary ? this.hass.states[d.primary] : undefined; const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return ''; if (!p) return '';
if (p.state === 'unavailable') return 'unavail'; if (p.state === 'unavailable') return 'unavail';
@@ -812,6 +842,15 @@ class HouseplanCard extends LitElement {
fireEvent(this, 'hass-more-info', { entityId }); fireEvent(this, 'hass-more-info', { entityId });
} }
/** Right click in VIEW mode always opens HA's more-info (owner's decision). */
private _ctxDevice(ev: MouseEvent, d: DevItem): void {
if (this._mode !== 'view') return; // editors keep the native context menu
ev.preventDefault();
ev.stopPropagation();
if (d.primary) this._openMoreInfo(d.primary);
else this._infoCard = d;
}
private _clickDevice(ev: MouseEvent, d: DevItem): void { private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation(); ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return; if (this._drag?.moved || this._suppressClick || this._holdFired) return;
@@ -821,7 +860,18 @@ class HouseplanCard extends LitElement {
return; return;
} }
const domain = d.primary ? d.primary.split('.')[0] : null; const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain); // a switch with bound targets: the EXPLICIT per-marker toggle flips them
// all with HA-group semantics (any on -> all off). Owner's decision:
// controls never fire on the card-wide default action.
const controls = (d.marker?.controls || []).filter(isControllable);
if (d.tapAction === 'toggle' && controls.length) {
const act = controlsAction(controls.map((e) => this.hass.states[e]?.state));
this.hass
.callService('homeassistant', act, { entity_id: controls })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
return;
}
const action = resolveTapAction(d.tapAction, undefined, domain);
if (action === 'toggle' && d.primary) { if (action === 'toggle' && d.primary) {
this.hass this.hass
.callService('homeassistant', 'toggle', { entity_id: d.primary }) .callService('homeassistant', 'toggle', { entity_id: d.primary })
@@ -1150,6 +1200,22 @@ 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 _savedNav(): { space?: string; mode?: 'view' | 'plan' | 'devices' | 'decor' } | null {
try {
return JSON.parse(localStorage.getItem(LS_NAV) || 'null');
} catch {
return null;
}
}
private _saveNav(): void {
try {
localStorage.setItem(LS_NAV, JSON.stringify({ space: this._space, mode: this._mode }));
} catch {
/* private mode etc. */
}
}
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void { private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._mode === mode) return; if (this._mode === mode) return;
if ((mode === 'plan' || mode === 'decor') && !this._norm) { if ((mode === 'plan' || mode === 'decor') && !this._norm) {
@@ -1169,6 +1235,7 @@ class HouseplanCard extends LitElement {
this._decorDraft = null; this._decorDraft = null;
this._decorSel = null; this._decorSel = null;
this._decorTool = 'select'; this._decorTool = 'select';
this._saveNav();
} }
@@ -1271,6 +1338,10 @@ class HouseplanCard extends LitElement {
this._mergeClick(raw); this._mergeClick(raw);
return; return;
} }
if (this._tool === 'openwall') {
this._openWallClick(raw);
return;
}
if (this._tool === 'split') { if (this._tool === 'split') {
this._splitClick(raw); this._splitClick(raw);
return; return;
@@ -1569,6 +1640,95 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
/** Boundary under the cursor in the open-wall tool (hover preview). */
private get _openWallHover(): { segs: number[][]; open: boolean } | null {
if (!this._markup || this._tool !== 'openwall' || !this._cursorPt) return null;
const hit = this._openWallHit(this._cursorPt);
return hit ? { segs: hit.segs, open: hit.open } : null;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(disp?: SpaceDisplay): TemplateResult {
const pairs = this._openPairs();
const hover = this._openWallHover;
if (!pairs.length && !hover) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall';
const stroke = disp?.color || 'var(--hp-muted)';
return svg`<g class="openwalls ${hot ? 'hot' : ''}" style="--ow-stroke:${stroke}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
${hover
? hover.segs.map((sg) => svg`<line class="openwall-preview ${hover.open ? 'willclose' : ''}"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`)
: nothing}
</g>` as unknown as TemplateResult;
}
/** All open-boundary pairs of the current space with their shared segments. */
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const a = rooms[i], b = rooms[j];
const linked = ((a as any).open_to || []).includes(b.id) || ((b as any).open_to || []).includes(a.id);
if (!linked) continue;
const pa = roomPoly(a), pb = roomPoly(b);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
if (segs.length) res.push({ a, b, segs });
}
return res;
}
/** The shared boundary nearest to the cursor (both the tool's click and hover). */
private _openWallHit(raw: number[]): { a: RoomCfg; b: RoomCfg; segs: number[][]; open: boolean } | null {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6;
let best: { a: RoomCfg; b: RoomCfg; segs: number[][]; d: number } | null = null;
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
for (const seg of segs) {
const d = distToSegment(raw, seg);
if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], segs, d };
}
}
if (!best) return null;
const open = (((best.a as any).open_to || []).includes(best.b.id))
|| (((best.b as any).open_to || []).includes(best.a.id));
return { a: best.a, b: best.b, segs: best.segs, open };
}
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */
private _openWallClick(raw: number[]): void {
const best = this._openWallHit(raw);
if (!best) {
this._showToast(this._t('toast.openwall_pick'));
return;
}
const sp = this._curSpaceCfg;
const ra = sp.rooms.find((r: any) => r.id === best.a.id);
const rb = sp.rooms.find((r: any) => r.id === best.b.id);
if (!ra || !rb) return;
const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id);
if (linked) {
ra.open_to = (ra.open_to || []).filter((x: string) => x !== rb.id);
rb.open_to = (rb.open_to || []).filter((x: string) => x !== ra.id);
if (!ra.open_to.length) delete ra.open_to;
if (!rb.open_to.length) delete rb.open_to;
this._showToast(this._t('toast.openwall_closed', { a: ra.name || '', b: rb.name || '' }));
} else {
ra.open_to = [...(ra.open_to || []), rb.id];
rb.open_to = [...(rb.open_to || []), ra.id];
this._showToast(this._t('toast.openwall_opened', { a: ra.name || '', b: rb.name || '' }));
}
this._saveConfig();
this.requestUpdate();
}
/** 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;
@@ -1829,8 +1989,8 @@ 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') { if (this._tool === 'opening' || this._tool === 'openwall') {
// hover preview: where the opening would land (raw point, wall-snapped later) // hover preview: raw cursor point; snapping happens in the preview getters
this._cursorPt = this._svgPoint(ev); this._cursorPt = this._svgPoint(ev);
return; return;
} }
@@ -1978,6 +2138,10 @@ class HouseplanCard extends LitElement {
devId: d.id, devId: d.id,
name: d.name, name: d.name,
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingMode: d.bindingKind === 'virtual' ? 'virtual' : 'ha',
bindingOpen: false,
// a marker bound to an ENTITY of a device only shows up with the box on
showEntities: d.bindingKind === 'entity' && !!this.hass.entities[d.bindingRef || '']?.device_id,
bindingFilter: '', bindingFilter: '',
icon: d.marker?.icon || '', icon: d.marker?.icon || '',
autoIcon: d.icon || '', autoIcon: d.icon || '',
@@ -1987,6 +2151,13 @@ class HouseplanCard extends LitElement {
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1, size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0, angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '', tapAction: d.marker?.tap_action || '',
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
: Math.round(Number(d.marker!.glow_radius_cm)) / 100)
: '',
model: d.model || '', model: d.model || '',
link: d.link || '', link: d.link || '',
description: d.description || '', description: d.description || '',
@@ -1998,9 +2169,10 @@ class HouseplanCard extends LitElement {
}; };
} else { } else {
this._markerDialog = { this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '', name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', model: '', tapAction: '', controls: [], controlsFilter: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
}; };
} }
@@ -2049,11 +2221,9 @@ class HouseplanCard extends LitElement {
sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')), sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')),
}); });
} }
// Individual entities — surfaced only while searching (avoids a huge default list); lets you // Individual entities of devices — behind the "show entities" checkbox
// place a single entity of a multi-entity device (e.g. the humidity of a climate sensor) as // (groups/helpers above are ALWAYS listed: they are standalone objects).
// its own icon. Uses the same entity: binding as helpers/groups. if (this._markerDialog?.showEntities) {
const q = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
if (q) {
const seen = new Set(list.map((o) => o.value)); const seen = new Set(list.map((o) => o.value));
for (const [eid, reg] of Object.entries<any>(h.entities)) { for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid; const v = 'entity:' + eid;
@@ -2062,7 +2232,6 @@ class HouseplanCard extends LitElement {
const label = reg.name || stt?.attributes?.friendly_name || eid; const label = reg.name || stt?.attributes?.friendly_name || eid;
const dev = reg.device_id ? h.devices[reg.device_id] : null; const dev = reg.device_id ? h.devices[reg.device_id] : null;
const devName = dev ? (dev.name_by_user || dev.name || '') : ''; const devName = dev ? (dev.name_by_user || dev.name || '') : '';
if (!(label + ' ' + eid + ' ' + devName).toLowerCase().includes(q)) continue;
list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') }); list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') });
} }
} }
@@ -2165,6 +2334,7 @@ class HouseplanCard extends LitElement {
private async _saveMarker(): Promise<void> { private async _saveMarker(): Promise<void> {
const dlg = this._markerDialog; const dlg = this._markerDialog;
if (!dlg || dlg.busy) return; if (!dlg || dlg.busy) return;
if (dlg.bindingMode === 'ha' && (!dlg.binding || dlg.binding === 'virtual')) return;
if (dlg.binding === 'virtual' && !dlg.name.trim()) { if (dlg.binding === 'virtual' && !dlg.name.trim()) {
this._showToast(this._t('toast.virtual_name_required')); this._showToast(this._t('toast.virtual_name_required'));
return; return;
@@ -2194,6 +2364,12 @@ class HouseplanCard extends LitElement {
size: dlg.size !== 1 ? dlg.size : null, size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null, angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null, tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
return Math.round(this._imperial ? v * 30.48 : v * 100);
})(),
model: dlg.model.trim() || null, model: dlg.model.trim() || null,
link: dlg.link.trim() || null, link: dlg.link.trim() || null,
description: dlg.description.trim() || null, description: dlg.description.trim() || null,
@@ -2578,7 +2754,11 @@ class HouseplanCard extends LitElement {
private _openSettingsDialog = (): void => { private _openSettingsDialog = (): void => {
if (!this._norm) return; if (!this._norm) return;
// deep copy so the dialog edits do not leak into the live palette // deep copy so the dialog edits do not leak into the live palette
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), busy: false }; const cm = this._glowRadiusCm;
const glowRadius = this._imperial
? Math.round((cm / 30.48) * 10) / 10
: Math.round(cm) / 100;
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), glowRadius, busy: false };
}; };
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void { private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
@@ -2596,6 +2776,9 @@ class HouseplanCard extends LitElement {
const settings: any = { ...cfg.settings }; const settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_colors; if (isDefault) delete settings.fill_colors;
else settings.fill_colors = d.colors; else settings.fill_colors = d.colors;
const cm = this._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm);
else delete settings.glow_radius_cm;
this._serverCfg = { ...cfg, settings }; this._serverCfg = { ...cfg, settings };
await this._saveConfigNow(); await this._saveConfigNow();
this._settingsDialog = null; this._settingsDialog = null;
@@ -2620,6 +2803,95 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
/** Glow radius: stored in cm (config.settings.glow_radius_cm), default 3 m. */
private get _glowRadiusCm(): number {
const v = Number((this._settings as any).glow_radius_cm);
return Number.isFinite(v) && v > 0 ? v : 300;
}
private get _imperial(): boolean {
return this.hass?.config?.unit_system?.length === 'mi';
}
private get _glowRadiusPlaceholder(): string {
const cm = this._glowRadiusCm;
return this._imperial ? String(Math.round((cm / 30.48) * 10) / 10) : String(cm / 100);
}
/** Light pools of the current space: dark house, glowing sources. */
private _renderGlowLayer(space: SpaceModel): TemplateResult {
const colors = this._fillColors;
const defaultR = (this._glowRadiusCm / this._cellCm) * this._gridPitch;
const g = this._gridPitch;
const polys = space.rooms
.map((r) => ({ r, poly: roomPoly(r) }))
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
const doors = this._openingsR.filter((o) => o.type === 'door');
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
const lightEid = d.entities.find(
(e) => e.startsWith('light.') && this.hass.states[e]?.state === 'on',
);
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
// per-source radius (owner's decision v1.36.2): marker override, else global
const ownCm = Number(d.marker?.glow_radius_cm);
const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR;
const pos = this._pos(d);
// innermost room under the source (islands win — reverse order)
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
let clip: string[] | null = null;
if (home) {
// open (virtual) boundaries: light flows through the whole connected
// zone of rooms, not just the source's own room (owner's spec)
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
const zoneList = zone.length ? zone : [home];
const shapes: string[] = zoneList.map(
(z) => 'M ' + z.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z',
);
// doorways on the ZONE's walls spill light into rooms outside the zone
const others = polys.filter((x) => !zoneList.includes(x)).map((x) => x.poly);
for (const o of doors) {
const onZoneWall = zoneList.some((z) => {
const near = closestPointOnBoundary([o.rx, o.ry], z.poly);
return near && Math.hypot(near[0] - o.rx, near[1] - o.ry) <= g * 0.75;
});
if (!onZoneWall) continue;
const rad = (o.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
}
// IMPORTANT: separate <path> children — clipPath children always
// UNION. Joining the room and a sector into ONE path made the default
// nonzero fill-rule cancel their overlap when the windings opposed,
// punching a dark wedge INSIDE the room (field report + screenshot).
clip = shapes;
}
spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip, r: R });
}
if (!spots.length) return svg`` as unknown as TemplateResult;
return svg`<defs>
${spots.map((sp, i) => svg`
<radialGradient id="hp-glow-${i}">
<stop offset="0%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
<stop offset="70%" stop-color="${sp.c}" stop-opacity="${sp.alpha.toFixed(3)}"></stop>
<stop offset="100%" stop-color="${sp.c}" stop-opacity="0"></stop>
</radialGradient>
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}">${sp.clip.map((d) => svg`<path d="${d}"></path>`)}</clipPath>` : nothing}`)}
</defs>
<g class="glowlayer">
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})" ${''}
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
</g>` as unknown as TemplateResult;
}
private _renderSettingsDialog(): TemplateResult { private _renderSettingsDialog(): TemplateResult {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}> return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}> <div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
@@ -2637,10 +2909,24 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('gs.lqi_group')}</label> <label class="dispsection">${this._t('gs.lqi_group')}</label>
${this._renderColorRow('lqi_low', 'gs.lqi_low')} ${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${this._renderColorRow('lqi_high', 'gs.lqi_high')} ${this._renderColorRow('lqi_high', 'gs.lqi_high')}
<label class="dispsection">${this._t('gs.glow_group')}</label>
${this._renderColorRow('glow_base', 'gs.glow_base')}
${this._renderColorRow('glow_light', 'gs.glow_light')}
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.glow_radius')}</span>
<input type="number" class="tempin" min="0.5" step="0.5"
.value=${String(this._settingsDialog!.glowRadius)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(v) && v > 0)
this._settingsDialog = { ...this._settingsDialog!, glowRadius: v };
}} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
</div>
</div> </div>
<div class="row"> <div class="row">
<button class="btn ghost" @click=${() => <button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)) })}> (this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3 })}>
${this._t('gs.reset')} ${this._t('gs.reset')}
</button> </button>
<span class="spacer"></span> <span class="spacer"></span>
@@ -2806,7 +3092,9 @@ class HouseplanCard extends LitElement {
@click=${() => { @click=${() => {
this._space = s.id; this._space = s.id;
this._selId = null; this._selId = null;
this._navApplied = true;
this._restoreZoom(); this._restoreZoom();
this._saveNav();
}} }}
> >
${s.title}${this._norm && this._canEdit ${s.title}${this._norm && this._canEdit
@@ -2858,7 +3146,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing} ${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div> </div>
<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}" <div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${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)}
@@ -2886,7 +3174,10 @@ class HouseplanCard extends LitElement {
const st: string[] = []; const st: string[] = [];
// keep the stroke colour even when borders are hidden, so hover can reveal it // keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`); st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
const fillC = r.area const fillC = disp.fill === 'glow'
// glow: uniform darkness over EVERY room (area or not, lit or not)
? this._fillColors.glow_base
: r.area
? roomFillStyle( ? roomFillStyle(
disp.fill, disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null, disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
@@ -2909,6 +3200,14 @@ class HouseplanCard extends LitElement {
r.area ? areaTemp(this.hass, this._devices, r.area) : null); r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = !space.bg && !disp.showNames && !this._markup; const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath
// the dashed stretches — suppress it and draw a trimmed outline
const openCuts = !this._markup && r.id
? this._openPairs()
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
.flatMap((pp) => pp.segs)
: [];
if (openCuts.length) cls += ' noedge';
// island rooms punch holes in their parent's fill (evenodd) // island rooms punch holes in their parent's fill (evenodd)
const myPoly = roomPoly(r); const myPoly = roomPoly(r);
const holes = myPoly const holes = myPoly
@@ -2929,8 +3228,17 @@ class HouseplanCard extends LitElement {
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}" 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} @click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`; @mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`; const trimmed = openCuts.length && myPoly
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
: null;
const outline = trimmed
? svg`<path class="room-outline" d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
style="stroke:${disp.color};stroke-opacity:${disp.showBorders && !this._markup ? disp.opacity : 0}"></path>`
: nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})} })}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderOpenWalls(disp)}
${this._markup ? this._renderMarkupLayer(vb) : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)} ${this._renderOpenings(disp)}
</svg> </svg>
@@ -3003,8 +3311,14 @@ class HouseplanCard extends LitElement {
const icon = this._config?.live_states const icon = this._config?.live_states
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon) ? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
: d.icon; : d.icon;
// RGB lights color the icon (and the ripple, unless a custom ripple color is set) // RGB lights color the icon (and the ripple, unless a custom ripple color is set);
const lightC = this._config?.live_states && domain === 'light' ? lightColorOf(primarySt) : null; // an icon with controlled targets takes the color of its first lit RGB target
const ctrl = (m?.controls || []).filter(isControllable);
const lightC = this._config?.live_states
? ctrl.length
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
: domain === 'light' ? lightColorOf(primarySt) : null
: null;
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode // emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
const alarm = this._config?.live_states const alarm = this._config?.live_states
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state); && isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
@@ -3024,6 +3338,7 @@ class HouseplanCard extends LitElement {
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}" class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}" style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)} @click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@mousemove=${(e: MouseEvent) => @mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name, this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))} d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
@@ -3520,6 +3835,11 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_opening')}> title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')} <ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button> </button>
<button class="btn ${this._tool === 'openwall' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'openwall'; }}
title=${this._t('title.markup_openwall')}>
<ha-icon icon="mdi:border-none-variant"></ha-icon>${this._t('markup.openwall')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')} <button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}> title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')} <ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
@@ -3563,6 +3883,7 @@ class HouseplanCard extends LitElement {
const d = this._infoCard!; const d = this._infoCard!;
const st = d.primary ? this.hass.states[d.primary] : undefined; const st = d.primary ? this.hass.states[d.primary] : undefined;
const stateTxt = st ? this.hass.formatEntityState?.(st) ?? st.state : null; const stateTxt = st ? this.hass.formatEntityState?.(st) ?? st.state : null;
const controls = (d.marker?.controls || []).filter(isControllable);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}> return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}> <div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div> <div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
@@ -3581,7 +3902,19 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`, <ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>` )}</span></div>`
: nothing} : nothing}
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length) ${controls.length
? html`<div class="inforow"><span class="k">${this._t('info.controls')}</span>
<span class="ctrlstates">
${controls.map((eid) => {
const cs = this.hass.states[eid];
const on = cs?.state === 'on';
return html`<span class="ctrlstate ${on ? 'on' : ''}">
<ha-icon icon=${on ? 'mdi:lightbulb-on' : 'mdi:lightbulb-outline'}></ha-icon>
${cs?.attributes?.friendly_name || eid}</span>`;
})}
</span></div>`
: nothing}
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length) && !controls.length
? html`<div class="infodesc muted">${this._t('info.none')}</div>` ? html`<div class="infodesc muted">${this._t('info.none')}</div>`
: nothing} : nothing}
</div> </div>
@@ -3603,7 +3936,7 @@ class HouseplanCard extends LitElement {
private _renderMarkerDialog(): TemplateResult { private _renderMarkerDialog(): TemplateResult {
const d = this._markerDialog!; const d = this._markerDialog!;
const isVirtual = d.binding === 'virtual'; const isVirtual = d.bindingMode === 'virtual';
const cands = this._bindingCandidates(); const cands = this._bindingCandidates();
const curLabel = (() => { const curLabel = (() => {
if (isVirtual) return null; if (isVirtual) return null;
@@ -3624,27 +3957,54 @@ class HouseplanCard extends LitElement {
@input=${(e: Event) => (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} /> @input=${(e: Event) => (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} />
<label>${this._t('marker.binding_label')}</label> <label>${this._t('marker.binding_label')}</label>
<div class="bindsel ${isVirtual ? 'virt' : ''}"> <div class="bindsel">
<button class="opt ${isVirtual ? 'on' : ''}" <label class="srcrow">
@click=${() => (this._markerDialog = { ...d, binding: 'virtual' })}> <input type="radio" name="bmode" .checked=${d.bindingMode === 'virtual'}
<ha-icon icon="mdi:map-marker-outline"></ha-icon>${this._t('marker.virtual_option')} @change=${() => (this._markerDialog = { ...d, bindingMode: 'virtual', binding: 'virtual', bindingOpen: false })} />
<span>${this._t('marker.virtual_option')}</span>
</label>
<div class="bindharow">
<label class="srcrow">
<input type="radio" name="bmode" .checked=${d.bindingMode === 'ha'}
@change=${() => (this._markerDialog = {
...d, bindingMode: 'ha',
binding: d.binding === 'virtual' ? '' : d.binding,
bindingOpen: d.binding === 'virtual' || !d.binding,
})} />
<span>${this._t('marker.from_ha_option')}</span>
</label>
<label class="srcrow inline entcheck" title=${this._t('marker.show_entities_tip')}>
<input type="checkbox" .checked=${d.showEntities}
?disabled=${d.bindingMode !== 'ha'}
@change=${(e: Event) => (this._markerDialog = { ...d, showEntities: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.show_entities')}</span>
</label>
</div>
${d.bindingMode === 'ha'
? html`<button class="dropbtn ${d.bindingOpen ? 'open' : ''}"
@click=${() => (this._markerDialog = { ...d, bindingOpen: !d.bindingOpen })}>
${curLabel
? html`<b>${curLabel}</b><span class="ref">${d.binding}</span>`
: html`<span class="muted">${this._t('marker.pick_ph')}</span>`}
<ha-icon icon=${d.bindingOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button> </button>
${!isVirtual ${d.bindingOpen
? html`<div class="curbind"><ha-icon icon="mdi:link-variant"></ha-icon> ? html`<div class="droppanel">
<b>${curLabel}</b><span class="ref">${d.binding}</span></div>`
: nothing}
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')} <input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
.value=${d.bindingFilter} .value=${d.bindingFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} /> @input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
<div class="candlist"> <div class="candlist">
${cands.map( ${cands.map(
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}" (c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: c.value })}> @click=${() => (this._markerDialog = { ...d, binding: c.value, bindingOpen: false })}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span> <span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`, </div>`,
)} )}
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing} ${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
</div> </div>
</div>`
: nothing}`
: nothing}
</div> </div>
<label>${this._t('marker.room_label')}${isVirtual ? '' : this._t('marker.room_override')}</label> <label>${this._t('marker.room_label')}${isVirtual ? '' : this._t('marker.room_override')}</label>
@@ -3659,11 +4019,54 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.tap_label')}</label> <label>${this._t('marker.tap_label')}</label>
<select class="areasel" <select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}> @change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['', 'tap.auto'], ['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map( ${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || '') === v}>${this._t(k as any)}</option>`, ([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || 'info') === v}>${this._t(k as any)}</option>`,
)} )}
</select> </select>
<label>${this._t('marker.controls_label')}</label>
<div class="rhint">${this._t('marker.controls_hint')}</div>
${d.controls.length
? html`<div class="ctrlchips">
${d.controls.map((eid) => html`<span class="ctrlchip">
${this.hass.states[eid]?.attributes?.friendly_name || eid}
<ha-icon icon="mdi:close" @click=${() =>
(this._markerDialog = { ...d, controls: d.controls.filter((x) => x !== eid) })}></ha-icon>
</span>`)}
</div>`
: nothing}
<input class="namein" type="text" placeholder=${this._t('marker.controls_filter')}
.value=${d.controlsFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, controlsFilter: (e.target as HTMLInputElement).value })} />
${d.controlsFilter.trim()
? html`<div class="ctrllist">
${Object.keys(this.hass.states)
.filter((eid) => isControllable(eid) && !d.controls.includes(eid))
.filter((eid) => {
const q = d.controlsFilter.trim().toLowerCase();
const name = String(this.hass.states[eid]?.attributes?.friendly_name || '');
return eid.toLowerCase().includes(q) || name.toLowerCase().includes(q);
})
.slice(0, 8)
.map((eid) => html`<button class="ctrlopt"
@click=${() => (this._markerDialog = { ...d, controls: [...d.controls, eid], controlsFilter: '' })}>
<ha-icon icon=${eid.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch'}></ha-icon>
${this.hass.states[eid]?.attributes?.friendly_name || eid}
<span class="sub">${eid}</span>
</button>`)}
</div>`
: nothing}
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
placeholder=${this._glowRadiusPlaceholder}
.value=${d.glowRadius}
@input=${(e: Event) => (this._markerDialog = { ...d, glowRadius: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
<span class="opl muted">${this._t('marker.glow_radius_hint')}</span>
</div>
<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}
@@ -3745,7 +4148,9 @@ class HouseplanCard extends LitElement {
: nothing} : nothing}
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._markerDialog = null)}>${this._t('btn.cancel')}</button> <button class="btn ghost" @click=${() => (this._markerDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveMarker} ?disabled=${d.busy}> <button class="btn on" @click=${this._saveMarker}
?disabled=${d.busy || (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual'))}
title=${d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual') ? this._t('marker.pick_ph') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : this._t('btn.save')} <ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : this._t('btn.save')}
</button> </button>
</div> </div>
@@ -3850,7 +4255,7 @@ class HouseplanCard extends LitElement {
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span> <span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div> </div>
<label>${this._t('space.fill_label')}</label> <label>${this._t('space.fill_label')}</label>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map( ${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp'], ['glow', 'fill.glow']].map(
([v, k]) => html`<label class="srcrow"> ([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v} <input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} /> @change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
+24 -4
View File
@@ -176,11 +176,9 @@
"rules.saved": "Icon rules saved", "rules.saved": "Icon rules saved",
"btn.up": "Up", "btn.up": "Up",
"btn.down": "Down", "btn.down": "Down",
"editor.tap_action": "Tap on a device", "tap.info": "Device card",
"tap.info": "Info card",
"tap.more_info": "HA more-info dialog", "tap.more_info": "HA more-info dialog",
"tap.toggle": "Toggle (lights/switches)", "tap.toggle": "Toggle (lights/switches)",
"tap.auto": "As the card default",
"marker.tap_label": "Tap action for this device", "marker.tap_label": "Tap action for this device",
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.", "tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
"import.title": "Create spaces from HA floors", "import.title": "Create spaces from HA floors",
@@ -280,5 +278,27 @@
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)", "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.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.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" "mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
"fill.glow": "Light sources (dark house, glowing lamps)",
"gs.glow_group": "Light-sources fill",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.glow_radius": "Glow radius",
"gs.unit_m": "m",
"gs.unit_ft": "ft",
"marker.controls_label": "Controls light sources",
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again",
"marker.from_ha_option": "Pick from the HA list",
"marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…"
} }
+25 -5
View File
@@ -176,12 +176,10 @@
"rules.saved": "Правила иконок сохранены", "rules.saved": "Правила иконок сохранены",
"btn.up": "Вверх", "btn.up": "Вверх",
"btn.down": "Вниз", "btn.down": "Вниз",
"editor.tap_action": "Тап по устройству", "tap.info": "Карточка устройства",
"tap.info": "Инфо-карточка",
"tap.more_info": "Диалог HA (more-info)", "tap.more_info": "Диалог HA (more-info)",
"tap.toggle": "Переключить (свет/розетки)", "tap.toggle": "Переключить (свет/розетки)",
"tap.auto": "Как в настройках карточки", "marker.tap_label": "Действие по нажатию для этого устройства",
"marker.tap_label": "Действие по тапу для этого устройства",
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.", "tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
"import.title": "Создать пространства из этажей HA", "import.title": "Создать пространства из этажей HA",
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.", "import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
@@ -280,5 +278,27 @@
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)", "marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб", "mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок", "mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики" "mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
"gs.glow_group": "Заливка «Свет по источникам»",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.glow_radius": "Радиус свечения",
"gs.unit_m": "м",
"gs.unit_ft": "фут",
"marker.controls_label": "Управляет источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта",
"marker.from_ha_option": "Выбрать из списка HA",
"marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…"
} }
+212 -2
View File
@@ -535,7 +535,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
// ---------------- room fills & colors ---------------- // ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp'; export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp' | 'glow';
/** Per-space display settings with their defaults resolved. */ /** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay { export interface SpaceDisplay {
@@ -569,7 +569,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
showNames: s.show_names ?? noPlan, showNames: s.show_names ?? noPlan,
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR, color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY, opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none', fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN, tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX, tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null, showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
@@ -598,6 +598,9 @@ export interface FillColors {
temp_hot: FillColorEntry; temp_hot: FillColorEntry;
lqi_low: FillColorEntry; lqi_low: FillColorEntry;
lqi_high: FillColorEntry; lqi_high: FillColorEntry;
/** Glow mode: uniform "darkness" over every room + default light color. */
glow_base: FillColorEntry;
glow_light: FillColorEntry;
} }
export const DEFAULT_FILL_COLORS: FillColors = { export const DEFAULT_FILL_COLORS: FillColors = {
@@ -609,6 +612,8 @@ export const DEFAULT_FILL_COLORS: FillColors = {
temp_hot: { c: '#ffd45c', a: 0.18 }, temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 }, lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 }, lqi_high: { c: '#4bd28f', a: 0.18 },
glow_base: { c: '#0d1b2a', a: 0.5 },
glow_light: { c: '#ffd9a0', a: 0.85 },
}; };
const HEX_RE = /^#[0-9a-f]{6}$/i; const HEX_RE = /^#[0-9a-f]{6}$/i;
@@ -752,6 +757,211 @@ export function lightColorOf(state: any): string | null {
return null; return null;
} }
// ---------------- glow fill (light sources) ----------------
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
export function kelvinToRgb(kelvin: number): [number, number, number] {
const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g = t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
return [cl(r), cl(g), cl(b)];
}
/**
* Color and relative brightness of a light's glow: rgb_color as is, else the
* color temperature via blackbody, else the configured fallback. Off null.
*/
export function glowColorOf(state: any, fallback: string): { c: string; bri: number } | null {
if (!state || state.state !== 'on') return null;
const a = state.attributes || {};
const briRaw = Number(a.brightness);
const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
const rgb = a.rgb_color;
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
if (Number.isFinite(kelvin) && kelvin > 0) {
const [r, g, b] = kelvinToRgb(kelvin);
return { c: `rgb(${r}, ${g}, ${b})`, bri };
}
return { c: fallback, bri };
}
/**
* Light spilling through a doorway: the sector of the glow circle between the
* rays sourceA and sourceB (door edge points), out to radius r. This part of
* the circle is intentionally NOT clipped by the room (owner's spec: no shadow
* casting just the unclipped sector). Null when the door is out of reach or
* the source sits on a door edge; the sweep is clamped to maxDeg.
*/
export function doorSector(
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
): number[][] | null {
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
const max = (maxDeg * Math.PI) / 180;
if (Math.abs(sweep) > max) {
const mid = aa + sweep / 2;
sweep = max * Math.sign(sweep);
aa = mid - sweep / 2;
}
const steps = 8;
const pts: number[][] = [[src[0], src[1]]];
for (let i = 0; i <= steps; i++) {
const ang = aa + (sweep * i) / steps;
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
}
return pts;
}
/**
* Is there a room on the far side of an opening (relative to the light source)?
* Entrance doors lead outside light must not spill there.
*/
export function hasRoomBehind(
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
): boolean {
const rad = (angleDeg * Math.PI) / 180;
const n = [-Math.sin(rad), Math.cos(rad)];
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
const sgn = toSrc > 0 ? -1 : 1;
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
}
/**
* Group toggle for a switch's controlled entities, HA-group semantics:
* any target on -> turn everything off; all off -> turn everything on.
*/
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
}
/** Only lights and plain switches may be group-controlled from the plan. */
export function isControllable(entityId: string): boolean {
return entityId.startsWith('light.') || entityId.startsWith('switch.');
}
// ---------------- open (virtual) boundaries ----------------
/**
* Collinear overlapping stretches of two room outlines their shared
* boundary. Handles the real-house case where neighbouring walls only
* PARTIALLY overlap (collinear, different lengths). Returns segments
* [x1,y1,x2,y2] with length > eps.
*/
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
const res: number[][] = [];
if (!a || !b || a.length < 3 || b.length < 3) return res;
for (let i = 0; i < a.length; i++) {
const p1 = a[i], p2 = a[(i + 1) % a.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
for (let j = 0; j < b.length; j++) {
const q1 = b[j], q2 = b[(j + 1) % b.length];
// both q endpoints must lie on the line of p1-p2
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
// overlap of parameter intervals along the line
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) {
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
}
}
}
return res;
}
/**
* Connected component of rooms joined by open (virtual) boundaries the
* "open zone" light flows through. The open_to link counts in either
* direction. Returns a set of room ids including the start.
*/
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
const zone = new Set<string>([roomId]);
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
let grew = true;
while (grew) {
grew = false;
for (const r of rooms) {
if (!r.id || zone.has(r.id)) continue;
for (const z of rooms) {
if (!z.id || !zone.has(z.id)) continue;
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
}
}
}
return zone;
}
/**
* Room outline pieces with the given collinear stretches removed used to
* draw a TRUE dashed open boundary (the solid stroke must not run beneath).
* Returns segments [x1,y1,x2,y2].
*/
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
const out: number[][] = [];
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
// collect cut intervals on this edge
const iv: [number, number][] = [];
for (const c of cuts) {
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) iv.push([lo, hi]);
}
if (!iv.length) {
out.push([p1[0], p1[1], p2[0], p2[1]]);
continue;
}
iv.sort((a, b) => a[0] - b[0]);
let cur = 0;
for (const [lo, hi] of iv) {
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
cur = Math.max(cur, hi);
}
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
}
return out;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
}
/** Device classes whose active state is an emergency, not a status. */ /** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']); const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
+91
View File
@@ -396,6 +396,59 @@ export const cardStyles = css`
display: inline-flex; display: inline-flex;
} }
.roomlabel .rlm.lit { opacity: 1; } .roomlabel .rlm.lit { opacity: 1; }
.bindharow {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.bindharow .entcheck { opacity: 0.9; }
.dropbtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
border: 1px solid var(--hp-muted);
border-radius: 8px;
background: transparent;
color: var(--hp-txt);
padding: 8px 10px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
margin-top: 6px;
}
.dropbtn .ref { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
.dropbtn ha-icon { --mdc-icon-size: 18px; margin-left: 4px; }
.dropbtn.open { border-color: var(--hp-accent); }
.droppanel {
border: 1px solid var(--hp-accent);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 6px;
margin-top: -4px;
}
.ctrlchips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0; }
.ctrlchip {
display: inline-flex; align-items: center; gap: 4px;
background: var(--hp-accent); color: var(--text-primary-color, #fff);
border-radius: 12px; padding: 3px 8px; font-size: 12px;
}
.ctrlchip ha-icon { --mdc-icon-size: 14px; cursor: pointer; }
.ctrllist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
.ctrlopt {
display: flex; align-items: center; gap: 7px; text-align: left;
border: 0; background: transparent; color: var(--hp-txt);
padding: 5px 7px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px;
}
.ctrlopt:hover { background: var(--secondary-background-color, rgba(128,128,128,0.15)); }
.ctrlopt .sub { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
.ctrlstates { display: flex; flex-direction: column; gap: 3px; }
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
.ctrlstate.on { color: var(--hp-txt); }
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
.iconauto { .iconauto {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -521,6 +574,44 @@ export const cardStyles = css`
.stage.markup.tool-delroom { .stage.markup.tool-delroom {
cursor: pointer; cursor: pointer;
} }
/* open-wall tool: default until a shared wall is under the cursor */
.stage.markup.tool-openwall { cursor: default; }
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
.openwall {
stroke: var(--ow-stroke, var(--hp-muted));
stroke-width: 2.5;
stroke-dasharray: 7 7;
stroke-linecap: butt;
pointer-events: none;
opacity: 0.9;
}
/* rooms with open stretches: the polygon's own stroke is fully off
(hover included) the trimmed .room-outline path draws the walls */
.room.noedge {
stroke-opacity: 0 !important;
}
.room-outline {
fill: none;
stroke-width: 2.5;
pointer-events: none;
}
.openwalls.hot .openwall {
stroke: #ffc14d;
opacity: 1;
}
.openwall-preview {
stroke: #ffc14d;
stroke-width: 5;
stroke-dasharray: 7 7;
stroke-linecap: round;
pointer-events: none;
opacity: 0.95;
}
/* an already-open boundary under the cursor: the click will CLOSE it */
.openwall-preview.willclose {
stroke: #f25a4a;
stroke-dasharray: none;
}
.stage.markup .room { .stage.markup .room {
pointer-events: none; pointer-events: none;
} }
+6
View File
@@ -1,6 +1,8 @@
/** Shared types of the House Plan card. */ /** Shared types of the House Plan card. */
export interface RoomCfg { export interface RoomCfg {
/** Rooms this one has an OPEN (virtual) boundary with — light flows through. */
open_to?: string[] | null;
id?: string; id?: string;
name: string; name: string;
area: string | null; area: string | null;
@@ -44,6 +46,10 @@ export interface Marker {
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3) ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
size?: number | null; // icon size multiplier (default 1) size?: number | null; // icon size multiplier (default 1)
angle?: number | null; // icon rotation, degrees angle?: number | null; // icon rotation, degrees
/** Entities this icon toggles as a group (wall switch → its lights). */
controls?: string[] | null;
/** Per-source glow radius in cm (glow fill); null = the global default. */
glow_radius_cm?: number | null;
} }
/** A door or window: plan geometry (normalized coords), optionally live via entities. */ /** A door or window: plan geometry (normalized coords), optionally live via entities. */
+21
View File
@@ -309,3 +309,24 @@ test('areaHum: averages climate sensors only, integer %', () => {
assert.equal(areaHum(hass, devs, 'living'), 44); assert.equal(areaHum(hass, devs, 'living'), 44);
assert.equal(areaHum(hass, devs, 'nothing'), null); assert.equal(areaHum(hass, devs, 'nothing'), null);
}); });
test('primaryEntity: hidden light beats visible config switch (grouped lamps)', () => {
const hass = {
entities: {
'light.lamp': { hidden: true },
'switch.lamp_do_not_disturb': { entity_category: 'config' },
'button.lamp_identify': { entity_category: 'diagnostic' },
},
states: {},
};
assert.equal(
primaryEntity(hass, ['switch.lamp_do_not_disturb', 'light.lamp', 'button.lamp_identify'], 'mdi:lightbulb'),
'light.lamp',
);
// видимая сущность того же домена всё равно приоритетнее скрытой
const hass2 = {
entities: { 'light.a': { hidden: true }, 'light.b': {} },
states: {},
};
assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b');
});
+123
View File
@@ -4,6 +4,10 @@ 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, polyContainsPoly, islandsOf, splitRoomPath, polyContainsPoly, islandsOf,
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment,
outlineWithout,
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';
@@ -598,3 +602,122 @@ test('splitRoomPath rejects bad paths', () => {
// менее двух точек // менее двух точек
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null); assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
}); });
test('kelvinToRgb: warm is orange-ish, cool is blue-ish white', () => {
const warm = kelvinToRgb(2700);
assert.equal(warm[0], 255);
assert.ok(warm[2] < 200 && warm[1] < 230);
const cool = kelvinToRgb(6600);
assert.ok(cool[0] > 245 && cool[1] > 240 && cool[2] > 245);
// clamps
assert.ok(kelvinToRgb(100)[0] === 255);
});
test('glowColorOf: rgb wins, then color temp, then fallback; off = null', () => {
assert.equal(glowColorOf({ state: 'off', attributes: {} }, '#fff'), null);
assert.equal(glowColorOf(null, '#fff'), null);
const rgb = glowColorOf({ state: 'on', attributes: { rgb_color: [10, 20, 30], brightness: 128 } }, '#fff');
assert.equal(rgb.c, 'rgb(10, 20, 30)');
assert.ok(Math.abs(rgb.bri - 0.5) < 0.01);
const ct = glowColorOf({ state: 'on', attributes: { color_temp_kelvin: 2700 } }, '#fff');
assert.ok(ct.c.startsWith('rgb(255'));
const mireds = glowColorOf({ state: 'on', attributes: { color_temp: 370 } }, '#fff'); // ~2700K
assert.ok(mireds.c.startsWith('rgb(255'));
const fb = glowColorOf({ state: 'on', attributes: {} }, '#abcdef');
assert.equal(fb.c, '#abcdef');
assert.equal(fb.bri, 1);
});
test('doorSector: sector through a door, clamped and guarded', () => {
const s = [0, 0];
const sec = doorSector(s, [10, -2], [10, 2], 50);
assert.ok(sec && sec.length === 10 + 0); // вершина + 9 точек дуги
assert.deepEqual(sec[0], [0, 0]);
for (const p of sec.slice(1)) {
const d = Math.hypot(p[0], p[1]);
assert.ok(Math.abs(d - 50) < 1e-6);
assert.ok(p[0] > 0); // сектор смотрит в сторону двери
}
// дверь за радиусом
assert.equal(doorSector(s, [60, -2], [60, 2], 50), null);
// источник на краю двери
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
});
test('hasRoomBehind: neighbour room yes, street no', () => {
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
assert.ok(hasRoomBehind([10, 0], 90, [5, 0], [neighbour], 1));
// за дверью пусто
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
});
test('controlsAction: HA-group semantics', () => {
assert.equal(controlsAction(['off', 'off']), 'turn_on');
assert.equal(controlsAction(['off', 'on']), 'turn_off');
assert.equal(controlsAction(['on', 'on']), 'turn_off');
assert.equal(controlsAction([undefined, 'off']), 'turn_on');
assert.equal(controlsAction([]), 'turn_on');
});
test('isControllable: lights and switches only', () => {
assert.ok(isControllable('light.kitchen'));
assert.ok(isControllable('switch.pump'));
assert.ok(!isControllable('lock.front'));
assert.ok(!isControllable('cover.gate'));
assert.ok(!isControllable('alarm_control_panel.home'));
});
test('sharedBoundary: exact, partial-collinear and none', () => {
const A = [[0, 0], [2, 0], [2, 2], [0, 2]];
// точное общее ребро x=2
const B = [[2, 0], [4, 0], [4, 2], [2, 2]];
const s1 = sharedBoundary(A, B);
assert.equal(s1.length >= 1, true);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
assert.ok(Math.abs(Math.max(...s1.map(len)) - 2) < 1e-6);
// частичное коллинеарное наложение (дачный случай): стена соседа длиннее
const C = [[2, -1], [4, -1], [4, 3], [2, 3]];
const s2 = sharedBoundary(A, C);
assert.ok(Math.abs(Math.max(...s2.map(len)) - 2) < 1e-6); // перекрытие = наша стена
// нет наложения
assert.equal(sharedBoundary(A, [[5, 5], [6, 5], [6, 6], [5, 6]]).length, 0);
// перпендикулярное касание — не граница
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
});
test('openZoneOf: transitive, either-direction links', () => {
const rooms = [
{ id: 'a', open_to: ['b'] },
{ id: 'b' }, // связь только со стороны a
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
{ id: 'd' }, // не связан
];
const z = openZoneOf('a', rooms);
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
});
test('distToSegment', () => {
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
});
test('outlineWithout: removes the cut stretch, keeps the rest', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// вырез середины нижней стены: 3..7
const pieces = outlineWithout(sq, [[3, 0, 7, 0]]);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
const total = pieces.reduce((a, s) => a + len(s), 0);
assert.ok(Math.abs(total - (40 - 4)) < 1e-6);
// куски нижней стены: 0..3 и 7..10
const bottom = pieces.filter((s) => s[1] === 0 && s[3] === 0);
assert.equal(bottom.length, 2);
// вырез целого ребра
const p2 = outlineWithout(sq, [[0, 0, 10, 0]]);
assert.ok(Math.abs(p2.reduce((a, s) => a + len(s), 0) - 30) < 1e-6);
// без вырезов — весь периметр
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
});