mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Merge dev: v1.35.0..v1.38.2
This commit is contained in:
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
+364
-179
File diff suppressed because one or more lines are too long
Vendored
+364
-179
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
+22
-9
@@ -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) {
|
||||||
if (t) return t.eid;
|
const t = tier.find((e) => isTempEntity(hass, e.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) {
|
||||||
if (found) return found.eid;
|
const found = tier.find((e) => e.eid.split('.')[0] === dom);
|
||||||
|
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). */
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
+456
-51
@@ -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 })} />
|
||||||
</button>
|
<span>${this._t('marker.virtual_option')}</span>
|
||||||
${!isVirtual
|
</label>
|
||||||
? html`<div class="curbind"><ha-icon icon="mdi:link-variant"></ha-icon>
|
<div class="bindharow">
|
||||||
<b>${curLabel}</b><span class="ref">${d.binding}</span></div>`
|
<label class="srcrow">
|
||||||
: nothing}
|
<input type="radio" name="bmode" .checked=${d.bindingMode === 'ha'}
|
||||||
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
|
@change=${() => (this._markerDialog = {
|
||||||
.value=${d.bindingFilter}
|
...d, bindingMode: 'ha',
|
||||||
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
|
binding: d.binding === 'virtual' ? '' : d.binding,
|
||||||
<div class="candlist">
|
bindingOpen: d.binding === 'virtual' || !d.binding,
|
||||||
${cands.map(
|
})} />
|
||||||
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
|
<span>${this._t('marker.from_ha_option')}</span>
|
||||||
@click=${() => (this._markerDialog = { ...d, binding: c.value })}>
|
</label>
|
||||||
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
<label class="srcrow inline entcheck" title=${this._t('marker.show_entities_tip')}>
|
||||||
</div>`,
|
<input type="checkbox" .checked=${d.showEntities}
|
||||||
)}
|
?disabled=${d.bindingMode !== 'ha'}
|
||||||
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
|
@change=${(e: Event) => (this._markerDialog = { ...d, showEntities: (e.target as HTMLInputElement).checked })} />
|
||||||
|
<span>${this._t('marker.show_entities')}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
${d.bindingOpen
|
||||||
|
? html`<div class="droppanel">
|
||||||
|
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
|
||||||
|
.value=${d.bindingFilter}
|
||||||
|
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
|
||||||
|
<div class="candlist">
|
||||||
|
${cands.map(
|
||||||
|
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
|
||||||
|
@click=${() => (this._markerDialog = { ...d, binding: c.value, bindingOpen: false })}>
|
||||||
|
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
||||||
|
</div>`,
|
||||||
|
)}
|
||||||
|
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: 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
@@ -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
@@ -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
@@ -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 source→A and source→B (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']);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user