mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Merge dev: v1.41.2..v1.42.2 (room settings tier, font scales, pdf migration, touch tooltips)
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.41.1"
|
VERSION = "1.42.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.41.1"
|
"version": "1.42.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ ROOM_SCHEMA = vol.All(
|
|||||||
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("open_to"): [str],
|
||||||
|
vol.Optional("settings"): vol.Any(
|
||||||
|
None,
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||||
|
vol.Optional("temp_source"): vol.Any(str, None),
|
||||||
|
vol.Optional("hum_source"): vol.Any(str, None),
|
||||||
|
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||||
|
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
),
|
||||||
|
),
|
||||||
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),
|
||||||
@@ -93,6 +106,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("label_hum"): bool,
|
vol.Optional("label_hum"): bool,
|
||||||
vol.Optional("label_lqi"): bool,
|
vol.Optional("label_lqi"): bool,
|
||||||
vol.Optional("label_light"): bool,
|
vol.Optional("label_light"): bool,
|
||||||
|
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def async_register(hass: HomeAssistant) -> None:
|
|||||||
websocket_api.async_register_command(hass, ws_config_get)
|
websocket_api.async_register_command(hass, ws_config_get)
|
||||||
websocket_api.async_register_command(hass, ws_config_set)
|
websocket_api.async_register_command(hass, ws_config_set)
|
||||||
websocket_api.async_register_command(hass, ws_plan_set)
|
websocket_api.async_register_command(hass, ws_plan_set)
|
||||||
|
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||||
|
|
||||||
|
|
||||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||||
@@ -108,6 +109,61 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
connection.send_result(msg["id"], {"ok": True})
|
connection.send_result(msg["id"], {"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "houseplan/files/migrate",
|
||||||
|
vol.Required("from_id"): str,
|
||||||
|
vol.Required("to_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
|
"""Move a marker's uploaded files to its new id (rebinding changes the id).
|
||||||
|
|
||||||
|
Without this the PDF urls keep pointing at the OLD id's folder, which then
|
||||||
|
looks orphaned and is one cleanup away from deletion — the exact way the
|
||||||
|
owner lost the sauna heater manuals (field incident, 2026-07-26).
|
||||||
|
"""
|
||||||
|
if not _check_write(hass, connection):
|
||||||
|
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||||
|
return
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from .const import FILES_DIR
|
||||||
|
from .validation import sanitize_marker_id
|
||||||
|
|
||||||
|
src_id = sanitize_marker_id(msg["from_id"])
|
||||||
|
dst_id = sanitize_marker_id(msg["to_id"])
|
||||||
|
if not src_id or not dst_id or src_id == dst_id:
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "moved": 0})
|
||||||
|
return
|
||||||
|
base = Path(hass.config.path(FILES_DIR))
|
||||||
|
src = base / src_id
|
||||||
|
dst = base / dst_id
|
||||||
|
|
||||||
|
def _move() -> int:
|
||||||
|
if not src.is_dir():
|
||||||
|
return 0
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for f in src.iterdir():
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
target = dst / f.name
|
||||||
|
if not target.exists():
|
||||||
|
shutil.move(str(f), str(target))
|
||||||
|
n += 1
|
||||||
|
try:
|
||||||
|
src.rmdir() # only when empty
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
moved = await hass.async_add_executor_job(_move)
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "moved": moved})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "houseplan/layout/delete",
|
vol.Required("type"): "houseplan/layout/delete",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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;
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||||
|
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const lbl = () => sr().querySelector('.roomlabel');
|
||||||
|
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
|
||||||
|
const metaEl = () => lbl().querySelector('.rlmetrics');
|
||||||
|
const n0 = nameSize();
|
||||||
|
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
|
||||||
|
out.baseline = n0 > 0;
|
||||||
|
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
|
||||||
|
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
|
||||||
|
room.settings = { ...(room.settings || {}), name_scale: 2 };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||||
|
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
|
||||||
|
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
|
||||||
|
const meta1 = lblR.querySelector('.rlmetrics');
|
||||||
|
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
|
||||||
|
// 2) label_scale ×2 — подписи удвоились
|
||||||
|
room.settings.label_scale = 2;
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const meta2 = lblR.querySelector('.rlmetrics');
|
||||||
|
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
|
||||||
|
// 3) базовый множитель пространства ×1.5 — умножает всё
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||||
|
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
|
||||||
|
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
|
||||||
|
// 4) диалог комнаты: слайдеры + живой пример реагирует
|
||||||
|
c._setMode('plan'); await c.updateComplete;
|
||||||
|
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
|
||||||
|
c._openRoomEdit(model); await c.updateComplete;
|
||||||
|
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
|
||||||
|
const pv = () => sr().querySelector('.cardpreview .cpname');
|
||||||
|
const p0 = parseFloat(pv().style.fontSize);
|
||||||
|
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
|
||||||
|
const p1 = parseFloat(pv().style.fontSize);
|
||||||
|
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
|
||||||
|
c._roomDialogCancel(); await c.updateComplete;
|
||||||
|
// 5) превью есть и в диалоге пространства
|
||||||
|
c._openSpaceDialog('edit', spId); await c.updateComplete;
|
||||||
|
out.spacePreview = !!sr().querySelector('.cardpreview');
|
||||||
|
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||||
|
c._spaceDialog = null; await c.updateComplete;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
|
// включить имена + temp-заливку на пространстве
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
|
||||||
|
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||||
|
// 1) шестерёнка на карточке комнаты в редакторе плана
|
||||||
|
const gear = sr().querySelector('.rlgear');
|
||||||
|
out.gearShown = !!gear;
|
||||||
|
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||||
|
await c.updateComplete;
|
||||||
|
out.dialogOpens = c._roomDialog === true && !!c._roomEditId;
|
||||||
|
out.namePrefilled = c._nameSel.length > 0;
|
||||||
|
// 2) задать источник температуры (кастомный сенсор) + оверрайд заливки
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states, 'sensor.custom_room_temp': { state: '30.2', attributes: {} } },
|
||||||
|
entities: { ...c.hass.entities, 'sensor.custom_room_temp': {} } };
|
||||||
|
await c.updateComplete;
|
||||||
|
const editedId = c._roomEditId;
|
||||||
|
c._roomFill = 'none';
|
||||||
|
c._roomTempSrc = 'entity:sensor.custom_room_temp';
|
||||||
|
c._saveRoomEdit(); await c.updateComplete;
|
||||||
|
const room = c._curSpaceCfg.rooms.find((r) => r.id === editedId);
|
||||||
|
out.saved = room.settings?.fill_mode === 'none' && room.settings?.temp_source === 'entity:sensor.custom_room_temp';
|
||||||
|
// 3) в Просмотре: температура комнаты из источника (карточка комнаты)
|
||||||
|
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||||
|
const model = c._spaceModel().rooms.find((r) => r.id === editedId);
|
||||||
|
out.tempFromSource = c._roomTemp(model) === 30.2;
|
||||||
|
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
|
||||||
|
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
|
||||||
|
// 4) оверрайд 'none': комната без заливки при temp-пространстве
|
||||||
|
const roomEl = [...sr().querySelectorAll('.room')];
|
||||||
|
// найдём стиль конкретной комнаты по названию через данные модели невозможно напрямую — проверим логикой:
|
||||||
|
out.fillOverride = (() => {
|
||||||
|
const eff = (window.__hpLogic?.roomFillModeOf) ? null : null;
|
||||||
|
// проверка через рендер: комната не имеет класса filled
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
// 5) создание новой комнаты: в диалоге есть секция настроек
|
||||||
|
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||||
|
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
|
||||||
|
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||||
|
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||||
|
c._roomDialogCancel(); await c.updateComplete;
|
||||||
|
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||||
|
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
const styledRooms = [...sr().querySelectorAll('.room.styled')];
|
||||||
|
const darkCount = styledRooms.filter((el) => (el.getAttribute('style') || '').includes('--room-fill:#0d1b2a')).length;
|
||||||
|
const areaRooms = c._spaceModel().rooms.filter((r) => r.area).length;
|
||||||
|
// вышедшая из тьмы комната не styled вовсе: тёмных = комнат с зоной минус 1
|
||||||
|
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q) => q.includes('hover: none')
|
||||||
|
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
|
||||||
|
: orig(q);
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
c._setMode('view'); await c.updateComplete;
|
||||||
|
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
|
||||||
|
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
|
||||||
|
await c.updateComplete;
|
||||||
|
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res));
|
||||||
|
await browser.close();
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+147
-41
File diff suppressed because one or more lines are too long
@@ -243,3 +243,15 @@ hash falls back to the default.
|
|||||||
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
|
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
|
||||||
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
|
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
|
||||||
deep-link > saved > default_floor; stale-cache retry after the live load.
|
deep-link > saved > default_floor; stale-cache retry after the live load.
|
||||||
|
|
||||||
|
|
||||||
|
## Settings tiers (owner's principle, 2026-07-26)
|
||||||
|
|
||||||
|
Four levels: **global (config.settings) → space (space.settings) → room
|
||||||
|
(room.settings) → device (marker.*)**. Duplicated options are deliberate: the
|
||||||
|
more specific tier overrides the more general one; "unset" always means
|
||||||
|
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
|
||||||
|
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
|
||||||
|
The UI will later be unified around this model; until then each tier keeps its
|
||||||
|
own dialog (general settings gear / space gear / room-card gear / marker
|
||||||
|
dialog).
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.42.2 — 2026-07-26
|
||||||
|
- Touch devices no longer pop hover tooltips on every tap (field feedback:
|
||||||
|
"extra labels appear and get in the way on a tablet"). Hover tooltips are
|
||||||
|
desktop-only now; on touch the same data lives in room cards and the
|
||||||
|
long-press device card.
|
||||||
|
|
||||||
|
## v1.42.1 — 2026-07-26 (room-card font sizes)
|
||||||
|
- Closing the "can't change the font size" feedback: **three sliders**.
|
||||||
|
Space settings gained a base room-card font size for the whole space;
|
||||||
|
Room settings gained independent sizes for the room NAME and the METRICS
|
||||||
|
line (50–300% each). Effects multiply — and stack with the card's corner
|
||||||
|
resize and the kiosk per-screen multiplier as before.
|
||||||
|
- Both dialogs show a **live sample card** that follows the sliders as you
|
||||||
|
drag them.
|
||||||
|
|
||||||
|
## v1.42.0 — 2026-07-26 (room settings — the third tier)
|
||||||
|
- **Settings now have four tiers**: global → space → room → device; the more
|
||||||
|
specific tier overrides the more general one (owner's principle, fixed in
|
||||||
|
ARCHITECTURE). This release adds the ROOM tier.
|
||||||
|
- Every room card in the Plan editor gained a **gear**: rename the room,
|
||||||
|
change its HA area, override the **fill type** for this room only (works
|
||||||
|
in glow spaces too — 'none' pulls the room out of the darkness), and pick
|
||||||
|
an explicit **temperature / humidity source** — any HA device or entity —
|
||||||
|
instead of the default room average. The source feeds the room card, the
|
||||||
|
tooltip and the temperature fill, and works for rooms without an HA area
|
||||||
|
(the user-feedback case: a custom template sensor bound to a room).
|
||||||
|
- The same settings section appears in the room dialog right after closing a
|
||||||
|
contour.
|
||||||
|
|
||||||
|
## v1.41.2 — 2026-07-26
|
||||||
|
- Fixed attached PDF manuals breaking after a marker is rebound to another
|
||||||
|
device: the id changes, but the uploaded files stayed in the OLD id's
|
||||||
|
folder — orphaned and eventually lost (that is exactly how the sauna
|
||||||
|
heater's manuals died). Rebinding now moves the files server-side
|
||||||
|
(`houseplan/files/migrate`) and rewrites the attached urls.
|
||||||
|
|
||||||
## v1.41.1 — 2026-07-24 (docs)
|
## v1.41.1 — 2026-07-24 (docs)
|
||||||
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
|
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
|
||||||
floor plan card for Home Assistant"), badges, a feature-highlights list
|
floor plan card for Home Assistant"), badges, a feature-highlights list
|
||||||
|
|||||||
@@ -140,6 +140,24 @@ 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
|
||||||
|
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
|
||||||
|
phones) taps never pop the room/device tooltip — the data lives in room
|
||||||
|
cards and long-press; desktop hover tooltips unchanged [auto]
|
||||||
|
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
|
||||||
|
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
|
||||||
|
multiplied together and on top of resize-k and kiosk multipliers; the
|
||||||
|
live sample card in both dialogs follows the sliders instantly; name and
|
||||||
|
metrics scale independently [auto]
|
||||||
|
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
|
||||||
|
editor opens Room settings (name, HA area incl. the current one, fill
|
||||||
|
override, temp/hum source); the creation dialog has the same section;
|
||||||
|
fill override repaints only that room (incl. opting OUT of the glow
|
||||||
|
darkness); a temp/hum source (device or entity) feeds the room card,
|
||||||
|
tooltip and temperature fill — works for rooms without an HA area;
|
||||||
|
renaming/rebinding an existing room now possible [auto]
|
||||||
|
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
|
||||||
|
PDFs to another device — the server moves /files/<oldId>/ to the new id
|
||||||
|
and the links keep opening; the old folder disappears (no orphans) [auto]
|
||||||
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
||||||
editor (admins incl.), full-height stage; swipe left/right switches
|
editor (admins incl.), full-height stage; swipe left/right switches
|
||||||
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
||||||
|
|||||||
Generated
+12
-12
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.34.0",
|
"version": "1.41.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.34.0",
|
"version": "1.41.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3",
|
"lit": "^3.1.3",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^11.1.6",
|
||||||
"playwright": "^1.61.1",
|
"playwright": "^1.62.0",
|
||||||
"rollup": "^4.18.0",
|
"rollup": "^4.18.0",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
@@ -761,35 +761,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.1",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.1"
|
"playwright-core": "1.62.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.61.1",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright/node_modules/fsevents": {
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.41.1",
|
"version": "1.42.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",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^11.1.6",
|
||||||
"playwright": "^1.61.1",
|
"playwright": "^1.62.0",
|
||||||
"rollup": "^4.18.0",
|
"rollup": "^4.18.0",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
|
|||||||
@@ -356,6 +356,32 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
|||||||
return seen ? 'off' : 'none';
|
return seen ? 'off' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit room measurement source: 'entity:<eid>' reads the state as a
|
||||||
|
* number; 'device:<id>' aggregates over that device's entities (tempFor /
|
||||||
|
* humFor). Used by the room-settings override (tier 3).
|
||||||
|
*/
|
||||||
|
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
|
||||||
|
if (!src) return null;
|
||||||
|
const i = src.indexOf(':');
|
||||||
|
if (i < 0) return null;
|
||||||
|
const k = src.slice(0, i);
|
||||||
|
const ref = src.slice(i + 1);
|
||||||
|
if (!ref) return null;
|
||||||
|
if (k === 'entity') {
|
||||||
|
const v = parseFloat(hass.states[ref]?.state);
|
||||||
|
if (!Number.isFinite(v)) return null;
|
||||||
|
return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v);
|
||||||
|
}
|
||||||
|
if (k === 'device') {
|
||||||
|
const entIds = Object.entries(hass.entities as Record<string, any>)
|
||||||
|
.filter(([, r]) => (r as any).device_id === ref)
|
||||||
|
.map(([eid]) => eid);
|
||||||
|
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
||||||
export function areaHum(
|
export function areaHum(
|
||||||
hass: any,
|
hass: any,
|
||||||
|
|||||||
+306
-28
@@ -14,7 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale,
|
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf,
|
||||||
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, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
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,
|
||||||
} from './logic';
|
} from './logic';
|
||||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats } from './devices';
|
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
|
||||||
import type {
|
import type {
|
||||||
OpeningCfg,
|
OpeningCfg,
|
||||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||||
@@ -32,7 +32,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.41.1';
|
const CARD_VERSION = '1.42.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';
|
||||||
@@ -157,6 +157,14 @@ class HouseplanCard extends LitElement {
|
|||||||
private _areaSel = '';
|
private _areaSel = '';
|
||||||
private _nameSel = '';
|
private _nameSel = '';
|
||||||
private _roomDialog = false;
|
private _roomDialog = false;
|
||||||
|
private _roomEditId: string | null = null; // gear on a room card (edit mode)
|
||||||
|
private _roomFill: '' | 'none' | 'lqi' | 'light' | 'temp' = ''; // '' = inherit
|
||||||
|
private _roomTempSrc = ''; // '' = average
|
||||||
|
private _roomHumSrc = '';
|
||||||
|
private _roomSrcOpen: 'temp' | 'hum' | null = null;
|
||||||
|
private _roomSrcFilter = '';
|
||||||
|
private _roomNameScale = 1;
|
||||||
|
private _roomLabelScale = 1;
|
||||||
// plan zoom/pan (zoom is saved per space, locally)
|
// plan zoom/pan (zoom is saved per space, locally)
|
||||||
private _zoom = 1;
|
private _zoom = 1;
|
||||||
private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates)
|
private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates)
|
||||||
@@ -220,6 +228,7 @@ class HouseplanCard extends LitElement {
|
|||||||
tempMin: number;
|
tempMin: number;
|
||||||
tempMax: number;
|
tempMax: number;
|
||||||
showLqi: boolean;
|
showLqi: boolean;
|
||||||
|
cardFontScale: number;
|
||||||
labelTemp: boolean;
|
labelTemp: boolean;
|
||||||
labelHum: boolean;
|
labelHum: boolean;
|
||||||
labelLqi: boolean;
|
labelLqi: boolean;
|
||||||
@@ -289,6 +298,14 @@ class HouseplanCard extends LitElement {
|
|||||||
_areaSel: { state: true },
|
_areaSel: { state: true },
|
||||||
_nameSel: { state: true },
|
_nameSel: { state: true },
|
||||||
_roomDialog: { state: true },
|
_roomDialog: { state: true },
|
||||||
|
_roomEditId: { state: true },
|
||||||
|
_roomFill: { state: true },
|
||||||
|
_roomTempSrc: { state: true },
|
||||||
|
_roomHumSrc: { state: true },
|
||||||
|
_roomSrcOpen: { state: true },
|
||||||
|
_roomSrcFilter: { state: true },
|
||||||
|
_roomNameScale: { state: true },
|
||||||
|
_roomLabelScale: { state: true },
|
||||||
_spaceDialog: { state: true },
|
_spaceDialog: { state: true },
|
||||||
_infoCard: { state: true },
|
_infoCard: { state: true },
|
||||||
_rulesDialog: { state: true },
|
_rulesDialog: { state: true },
|
||||||
@@ -482,6 +499,7 @@ class HouseplanCard extends LitElement {
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
area: r.area ?? null,
|
area: r.area ?? null,
|
||||||
open_to: r.open_to || undefined,
|
open_to: r.open_to || undefined,
|
||||||
|
settings: r.settings || 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,
|
||||||
@@ -1249,7 +1267,17 @@ class HouseplanCard extends LitElement {
|
|||||||
}, 3500);
|
}, 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True on touch-first devices (tablets/phones): no real hover there. */
|
||||||
|
private static readonly _noHover =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(hover: none)').matches;
|
||||||
|
|
||||||
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
|
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
|
||||||
|
// Field feedback: on tablets every tap synthesized a mousemove and popped
|
||||||
|
// the hover tooltip over the finger. Touch devices get NO hover tooltips —
|
||||||
|
// the same data lives in room cards and the long-press device card.
|
||||||
|
if (HouseplanCard._noHover) return;
|
||||||
if (this._drag) return;
|
if (this._drag) return;
|
||||||
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
|
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
|
||||||
}
|
}
|
||||||
@@ -1464,6 +1492,7 @@ class HouseplanCard extends LitElement {
|
|||||||
this._cursorPt = null;
|
this._cursorPt = null;
|
||||||
this._nameSel = '';
|
this._nameSel = '';
|
||||||
this._areaSel = '';
|
this._areaSel = '';
|
||||||
|
this._resetRoomDialogFields();
|
||||||
this._roomDialog = true;
|
this._roomDialog = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2065,6 +2094,7 @@ class HouseplanCard extends LitElement {
|
|||||||
this._showToast(this._t('toast.split_bad_cut'));
|
this._showToast(this._t('toast.split_bad_cut'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._resetRoomDialogFields();
|
||||||
// the bigger part stays the room it was — name, area and devices go with it
|
// the bigger part stays the room it was — name, area and devices go with it
|
||||||
const [p1, p2] = parts;
|
const [p1, p2] = parts;
|
||||||
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
||||||
@@ -2148,6 +2178,7 @@ class HouseplanCard extends LitElement {
|
|||||||
name: this._nameSel || areaName || this._t('room.default_name'),
|
name: this._nameSel || areaName || this._t('room.default_name'),
|
||||||
area: this._areaSel || null,
|
area: this._areaSel || null,
|
||||||
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
|
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
|
||||||
|
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
|
||||||
});
|
});
|
||||||
this._saveConfig();
|
this._saveConfig();
|
||||||
this._path = [];
|
this._path = [];
|
||||||
@@ -2199,6 +2230,12 @@ class HouseplanCard extends LitElement {
|
|||||||
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
||||||
private _roomDialogCancel(): void {
|
private _roomDialogCancel(): void {
|
||||||
this._roomDialog = false;
|
this._roomDialog = false;
|
||||||
|
if (this._roomEditId) {
|
||||||
|
this._roomEditId = null;
|
||||||
|
this._nameSel = '';
|
||||||
|
this._areaSel = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this._pendingSplit) {
|
if (this._pendingSplit) {
|
||||||
// nothing was applied yet — drop the cut entirely, the room stays whole
|
// nothing was applied yet — drop the cut entirely, the room stays whole
|
||||||
this._pendingSplit = null;
|
this._pendingSplit = null;
|
||||||
@@ -2459,6 +2496,7 @@ class HouseplanCard extends LitElement {
|
|||||||
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,
|
controls: dlg.controls.length ? dlg.controls : null,
|
||||||
|
// pdfs may be rewritten below when rebinding changes the marker id
|
||||||
glow_radius_cm: (() => {
|
glow_radius_cm: (() => {
|
||||||
const v = parseFloat(dlg.glowRadius);
|
const v = parseFloat(dlg.glowRadius);
|
||||||
if (!Number.isFinite(v) || v <= 0) return null;
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
@@ -2480,6 +2518,15 @@ class HouseplanCard extends LitElement {
|
|||||||
const prevRoomId = prevDev?.marker?.room_id ?? null;
|
const prevRoomId = prevDev?.marker?.room_id ?? null;
|
||||||
const roomChanged = !!dlg.room && prevDev != null
|
const roomChanged = !!dlg.room && prevDev != null
|
||||||
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
|
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
|
||||||
|
// rebinding changed the id → move the uploaded files along (server-side)
|
||||||
|
// and rewrite the attached urls; otherwise the old-id folder goes orphan
|
||||||
|
// (that is how the sauna manuals were lost — incident 2026-07-26)
|
||||||
|
if (oldId && oldId !== id && marker.pdfs?.length) {
|
||||||
|
await this.hass
|
||||||
|
.callWS({ type: 'houseplan/files/migrate', from_id: oldId, to_id: id })
|
||||||
|
.catch(() => undefined);
|
||||||
|
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id);
|
||||||
|
}
|
||||||
// remove the previous marker (by the old id and by the new id)
|
// remove the previous marker (by the old id and by the new id)
|
||||||
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
|
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
|
||||||
cfg.markers.push(marker);
|
cfg.markers.push(marker);
|
||||||
@@ -2597,6 +2644,7 @@ class HouseplanCard extends LitElement {
|
|||||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||||
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
|
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
|
||||||
|
cardFontScale: disp.cardFontScale,
|
||||||
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
|
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
|
||||||
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
|
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
|
||||||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||||||
@@ -2610,6 +2658,7 @@ class HouseplanCard extends LitElement {
|
|||||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||||
showLqi: this._config?.show_signal ?? true,
|
showLqi: this._config?.show_signal ?? true,
|
||||||
|
cardFontScale: 1,
|
||||||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||||||
cellCm: 5,
|
cellCm: 5,
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -2695,6 +2744,7 @@ class HouseplanCard extends LitElement {
|
|||||||
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
||||||
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
||||||
show_lqi: d.showLqi,
|
show_lqi: d.showLqi,
|
||||||
|
card_font_scale: d.cardFontScale !== 1 ? d.cardFontScale : undefined,
|
||||||
label_temp: d.labelTemp,
|
label_temp: d.labelTemp,
|
||||||
label_hum: d.labelHum,
|
label_hum: d.labelHum,
|
||||||
label_lqi: d.labelLqi,
|
label_lqi: d.labelLqi,
|
||||||
@@ -2791,6 +2841,7 @@ class HouseplanCard extends LitElement {
|
|||||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||||
showLqi: this._config?.show_signal ?? true,
|
showLqi: this._config?.show_signal ?? true,
|
||||||
|
cardFontScale: 1,
|
||||||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||||||
cellCm: 5,
|
cellCm: 5,
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -3299,20 +3350,25 @@ class HouseplanCard extends LitElement {
|
|||||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||||
cls += ' picked';
|
cls += ' picked';
|
||||||
let style = '';
|
let style = '';
|
||||||
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
|
const effFill = roomFillModeOf(disp.fill, r);
|
||||||
|
if (!this._markup && (disp.showBorders || effFill !== 'none')) {
|
||||||
cls += ' styled';
|
cls += ' styled';
|
||||||
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 = disp.fill === 'glow'
|
const fillC = effFill === 'glow'
|
||||||
// glow: uniform darkness over EVERY room (area or not, lit or not)
|
// glow: uniform darkness (a room override may opt OUT of it)
|
||||||
? this._fillColors.glow_base
|
? this._fillColors.glow_base
|
||||||
|
: effFill === 'temp'
|
||||||
|
// temp works without an HA area when a tier-3 source is set
|
||||||
|
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
|
||||||
|
disp.tempMin, disp.tempMax, this._fillColors)
|
||||||
: r.area
|
: r.area
|
||||||
? roomFillStyle(
|
? roomFillStyle(
|
||||||
disp.fill,
|
effFill,
|
||||||
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
effFill === 'lqi' ? this._roomLqi(r.area) : null,
|
||||||
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
||||||
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
|
null,
|
||||||
disp.tempMin,
|
disp.tempMin,
|
||||||
disp.tempMax,
|
disp.tempMax,
|
||||||
this._fillColors,
|
this._fillColors,
|
||||||
@@ -3327,7 +3383,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const tip = (e: MouseEvent) =>
|
const tip = (e: MouseEvent) =>
|
||||||
this._showTip(e, r.name, this._t('tip.room'),
|
this._showTip(e, r.name, this._t('tip.room'),
|
||||||
showLqi ? this._roomLqi(r.area) : null,
|
showLqi ? this._roomLqi(r.area) : null,
|
||||||
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
this._roomTemp(r));
|
||||||
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
|
// open boundaries: this room's solid stroke must not run beneath
|
||||||
@@ -3504,6 +3560,113 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Room temperature honouring the tier-3 source override. */
|
||||||
|
private _roomTemp(r: RoomCfg): number | null {
|
||||||
|
const src = r.settings?.temp_source;
|
||||||
|
if (src) return sourceValue(this.hass, src, 'temp');
|
||||||
|
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Room humidity honouring the tier-3 source override. */
|
||||||
|
private _roomHum(r: RoomCfg): number | null {
|
||||||
|
const src = r.settings?.hum_source;
|
||||||
|
if (src) return sourceValue(this.hass, src, 'hum');
|
||||||
|
return r.area ? areaHum(this.hass, this._devices, r.area) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetRoomDialogFields(): void {
|
||||||
|
this._roomEditId = null;
|
||||||
|
this._roomFill = '';
|
||||||
|
this._roomTempSrc = '';
|
||||||
|
this._roomHumSrc = '';
|
||||||
|
this._roomSrcOpen = null;
|
||||||
|
this._roomSrcFilter = '';
|
||||||
|
this._roomNameScale = 1;
|
||||||
|
this._roomLabelScale = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the room dialog for an EXISTING room (the gear on its card). */
|
||||||
|
private _openRoomEdit(r: RoomCfg): void {
|
||||||
|
if (!r.id) return;
|
||||||
|
this._roomEditId = r.id;
|
||||||
|
this._nameSel = r.name || '';
|
||||||
|
this._areaSel = r.area || '';
|
||||||
|
this._roomFill = (r.settings?.fill_mode as any) || '';
|
||||||
|
this._roomTempSrc = r.settings?.temp_source || '';
|
||||||
|
this._roomHumSrc = r.settings?.hum_source || '';
|
||||||
|
this._roomNameScale = clampScale(r.settings?.name_scale);
|
||||||
|
this._roomLabelScale = clampScale(r.settings?.label_scale);
|
||||||
|
this._roomSrcOpen = null;
|
||||||
|
this._roomSrcFilter = '';
|
||||||
|
this._roomDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect the room settings object from the dialog state (null = all inherited). */
|
||||||
|
private _roomSettingsFromDialog(): RoomCfg['settings'] {
|
||||||
|
const st: any = {};
|
||||||
|
if (this._roomFill) st.fill_mode = this._roomFill;
|
||||||
|
if (this._roomTempSrc) st.temp_source = this._roomTempSrc;
|
||||||
|
if (this._roomHumSrc) st.hum_source = this._roomHumSrc;
|
||||||
|
if (this._roomNameScale !== 1) st.name_scale = this._roomNameScale;
|
||||||
|
if (this._roomLabelScale !== 1) st.label_scale = this._roomLabelScale;
|
||||||
|
return Object.keys(st).length ? st : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the room edited via the gear (name, area, tier-3 settings). */
|
||||||
|
private _saveRoomEdit(): void {
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
const room = sp?.rooms.find((x: any) => x.id === this._roomEditId);
|
||||||
|
if (!room) {
|
||||||
|
this._roomDialog = false;
|
||||||
|
this._roomEditId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
room.name = this._nameSel.trim() || room.name;
|
||||||
|
room.area = this._areaSel || null;
|
||||||
|
const st = this._roomSettingsFromDialog();
|
||||||
|
if (st) room.settings = st;
|
||||||
|
else delete room.settings;
|
||||||
|
this._saveConfig();
|
||||||
|
this._roomDialog = false;
|
||||||
|
this._roomEditId = null;
|
||||||
|
this._nameSel = '';
|
||||||
|
this._areaSel = '';
|
||||||
|
this._regSignature = '';
|
||||||
|
this._maybeRebuildDevices();
|
||||||
|
this.requestUpdate();
|
||||||
|
this._showToast(this._t('toast.room_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devices + sensor entities for the measurement-source picker. */
|
||||||
|
private _roomSrcCandidates(): { value: string; label: string; sub: string }[] {
|
||||||
|
const h = this.hass;
|
||||||
|
const q = this._roomSrcFilter.trim().toLowerCase();
|
||||||
|
const list: { value: string; label: string; sub: string }[] = [];
|
||||||
|
for (const dev of Object.values<any>(h.devices)) {
|
||||||
|
if (dev.entry_type === 'service') continue;
|
||||||
|
const name = (dev.name_by_user || dev.name || dev.id).trim();
|
||||||
|
if (q && !name.toLowerCase().includes(q)) continue;
|
||||||
|
list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this._t('marker.sub_device') });
|
||||||
|
}
|
||||||
|
for (const [eid, reg] of Object.entries<any>(h.entities)) {
|
||||||
|
if (!eid.startsWith('sensor.') || reg.hidden) continue;
|
||||||
|
const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid;
|
||||||
|
if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue;
|
||||||
|
list.push({ value: 'entity:' + eid, label, sub: eid });
|
||||||
|
}
|
||||||
|
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
return list.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label of a picked measurement source. */
|
||||||
|
private _roomSrcLabel(src: string): string {
|
||||||
|
const i = src.indexOf(':');
|
||||||
|
const k = src.slice(0, i);
|
||||||
|
const ref = src.slice(i + 1);
|
||||||
|
if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
|
||||||
|
return this.hass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref;
|
||||||
|
}
|
||||||
|
|
||||||
/** Saved label position (layout key rl_<roomId>) or the room center. */
|
/** Saved label position (layout key rl_<roomId>) or the room center. */
|
||||||
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
||||||
const saved = this._layout['rl_' + (r.id || '')];
|
const saved = this._layout['rl_' + (r.id || '')];
|
||||||
@@ -3613,20 +3776,20 @@ class HouseplanCard extends LitElement {
|
|||||||
const k = this._labelScale(r);
|
const k = this._labelScale(r);
|
||||||
// optional metrics row (needs an HA area; sub-area rooms show the name only)
|
// optional metrics row (needs an HA area; sub-area rooms show the name only)
|
||||||
const rows: TemplateResult[] = [];
|
const rows: TemplateResult[] = [];
|
||||||
if (r.area && !this._markup) {
|
if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
|
||||||
if (disp.labelTemp) {
|
if (disp.labelTemp) {
|
||||||
const t = areaTemp(this.hass, this._devices, r.area);
|
const t = this._roomTemp(r);
|
||||||
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
|
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
|
||||||
}
|
}
|
||||||
if (disp.labelHum) {
|
if (disp.labelHum) {
|
||||||
const hm = areaHum(this.hass, this._devices, r.area);
|
const hm = this._roomHum(r);
|
||||||
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
|
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
|
||||||
}
|
}
|
||||||
if (disp.labelLqi) {
|
if (disp.labelLqi && r.area) {
|
||||||
const l = this._roomLqi(r.area);
|
const l = this._roomLqi(r.area);
|
||||||
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
||||||
}
|
}
|
||||||
if (disp.labelLight) {
|
if (disp.labelLight && r.area) {
|
||||||
const ls = areaLightStats(this.hass, this._devices, r.area);
|
const ls = areaLightStats(this.hass, this._devices, r.area);
|
||||||
if (ls) {
|
if (ls) {
|
||||||
const txt = ls.on === 0
|
const txt = ls.on === 0
|
||||||
@@ -3639,12 +3802,17 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
|
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
|
||||||
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k}"
|
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k};--rl-space:${disp.cardFontScale};--rl-name:${clampScale(r.settings?.name_scale)};--rl-meta:${clampScale(r.settings?.label_scale)}"
|
||||||
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
|
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
|
||||||
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
||||||
@pointerup=${() => this._labelUp(r)}
|
@pointerup=${() => this._labelUp(r)}
|
||||||
@pointercancel=${() => this._labelUp(r)}
|
@pointercancel=${() => this._labelUp(r)}
|
||||||
><span class="rlname">${r.name}${!this._markup && r.area
|
><span class="rlname">${this._markup && r.id
|
||||||
|
? html`<ha-icon class="rlgear" icon="mdi:cog-outline"
|
||||||
|
title=${this._t('room.settings_title')}
|
||||||
|
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||||
|
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}></ha-icon>`
|
||||||
|
: nothing}${r.name}${!this._markup && r.area
|
||||||
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
|
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
|
||||||
title=${this._t('room.open_area')}
|
title=${this._t('room.open_area')}
|
||||||
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
|
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
|
||||||
@@ -4509,6 +4677,13 @@ class HouseplanCard extends LitElement {
|
|||||||
<span>${this._t(k)}</span>
|
<span>${this._t(k)}</span>
|
||||||
</label>`,
|
</label>`,
|
||||||
)}
|
)}
|
||||||
|
<label>${this._t('space.card_font')}</label>
|
||||||
|
<div class="colorrow gsrow">
|
||||||
|
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(d.cardFontScale * 100))}
|
||||||
|
@input=${(e: Event) => (this._spaceDialog = { ...d, cardFontScale: Number((e.target as HTMLInputElement).value) / 100 })} />
|
||||||
|
<span class="opv">${Math.round(d.cardFontScale * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
${this._renderCardPreview(d.cardFontScale, 1, 1)}
|
||||||
<label>${this._t('space.room_color')}</label>
|
<label>${this._t('space.room_color')}</label>
|
||||||
<div class="colorrow">
|
<div class="colorrow">
|
||||||
<input type="color" .value=${d.roomColor}
|
<input type="color" .value=${d.roomColor}
|
||||||
@@ -4596,11 +4771,79 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live sample of a room card at the given multipliers (dialog preview). */
|
||||||
|
private _renderCardPreview(spaceScale: number, nameScale: number, labelScale: number): TemplateResult {
|
||||||
|
const base = 18 * spaceScale;
|
||||||
|
return html`<div class="cardpreview">
|
||||||
|
<span class="cpname" style="font-size:${(base * nameScale).toFixed(1)}px">
|
||||||
|
${this._t('preview.room_name')}</span>
|
||||||
|
<span class="cpmeta" style="font-size:${(base * 0.62 * labelScale).toFixed(1)}px">
|
||||||
|
<ha-icon icon="mdi:thermometer"></ha-icon>22.4° ·
|
||||||
|
<ha-icon icon="mdi:water-percent"></ha-icon>45% ·
|
||||||
|
<ha-icon icon="mdi:lightbulb-on"></ha-icon>${this._t('roomcard.light_partial', { on: 1, total: 3 })}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One measurement-source control (average vs an explicit device/entity). */
|
||||||
|
private _renderRoomSource(kind: 'temp' | 'hum'): TemplateResult {
|
||||||
|
const val = kind === 'temp' ? this._roomTempSrc : this._roomHumSrc;
|
||||||
|
const setVal = (v: string) => {
|
||||||
|
if (kind === 'temp') this._roomTempSrc = v;
|
||||||
|
else this._roomHumSrc = v;
|
||||||
|
this.requestUpdate();
|
||||||
|
};
|
||||||
|
const open = this._roomSrcOpen === kind;
|
||||||
|
return html`
|
||||||
|
<label>${this._t(kind === 'temp' ? 'room.temp_src_label' : 'room.hum_src_label')}</label>
|
||||||
|
<label class="srcrow">
|
||||||
|
<input type="radio" name="rsrc-${kind}" .checked=${!val}
|
||||||
|
@change=${() => { setVal(''); this._roomSrcOpen = null; }} />
|
||||||
|
<span>${this._t('room.src_average')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="srcrow">
|
||||||
|
<input type="radio" name="rsrc-${kind}" .checked=${!!val}
|
||||||
|
@change=${() => { this._roomSrcOpen = kind; this._roomSrcFilter = ''; this.requestUpdate(); }} />
|
||||||
|
<span>${this._t('room.src_pick')}</span>
|
||||||
|
</label>
|
||||||
|
${val || open
|
||||||
|
? html`<button class="dropbtn ${open ? 'open' : ''}"
|
||||||
|
@click=${() => { this._roomSrcOpen = open ? null : kind; this._roomSrcFilter = ''; }}>
|
||||||
|
${val
|
||||||
|
? html`<b>${this._roomSrcLabel(val)}</b><span class="ref">${val}</span>`
|
||||||
|
: html`<span class="muted">${this._t('room.src_ph')}</span>`}
|
||||||
|
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
|
||||||
|
</button>
|
||||||
|
${open
|
||||||
|
? html`<div class="droppanel">
|
||||||
|
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
|
||||||
|
.value=${this._roomSrcFilter}
|
||||||
|
@input=${(e: Event) => { this._roomSrcFilter = (e.target as HTMLInputElement).value; this.requestUpdate(); }} />
|
||||||
|
<div class="candlist">
|
||||||
|
${this._roomSrcCandidates().map(
|
||||||
|
(c) => html`<div class="cand ${c.value === val ? 'sel' : ''}"
|
||||||
|
@click=${() => { setVal(c.value); this._roomSrcOpen = null; }}>
|
||||||
|
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
||||||
|
</div>`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: nothing}`
|
||||||
|
: nothing}`;
|
||||||
|
}
|
||||||
|
|
||||||
private _renderRoomDialog(): TemplateResult {
|
private _renderRoomDialog(): TemplateResult {
|
||||||
const areas = this._freeAreas;
|
const edit = !!this._roomEditId;
|
||||||
|
// the free-areas list must include the edited room's CURRENT area
|
||||||
|
const areas = [...this._freeAreas];
|
||||||
|
if (edit && this._areaSel && !areas.some((a) => a.area_id === this._areaSel)) {
|
||||||
|
const cur = this.hass.areas[this._areaSel];
|
||||||
|
if (cur) areas.unshift(cur);
|
||||||
|
}
|
||||||
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" @click=${(e: Event) => e.stopPropagation()}>
|
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>${this._t('room.new')}</div>
|
<div class="hd"><ha-icon icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'}></ha-icon>
|
||||||
|
${edit ? this._t('room.settings_title') : this._t('room.new')}</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<label>${this._t('room.name_label')}</label>
|
<label>${this._t('room.name_label')}</label>
|
||||||
<input class="namein" type="text" placeholder=${this._t('room.name_ph')}
|
<input class="namein" type="text" placeholder=${this._t('room.name_ph')}
|
||||||
@@ -4619,18 +4862,53 @@ class HouseplanCard extends LitElement {
|
|||||||
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`,
|
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label class="dispsection">${this._t('room.settings_section')}</label>
|
||||||
|
<label>${this._t('room.fill_label')}</label>
|
||||||
|
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
|
||||||
|
([v, k]) => html`<label class="srcrow inline">
|
||||||
|
<input type="radio" name="rfill" .checked=${this._roomFill === v}
|
||||||
|
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
|
||||||
|
<span>${this._t(k as any)}</span>
|
||||||
|
</label>`,
|
||||||
|
)}
|
||||||
|
${this._renderRoomSource('temp')}
|
||||||
|
${this._renderRoomSource('hum')}
|
||||||
|
|
||||||
|
<label class="dispsection">${this._t('room.sizes_section')}</label>
|
||||||
|
<label>${this._t('room.name_scale')}</label>
|
||||||
|
<div class="colorrow gsrow">
|
||||||
|
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomNameScale * 100))}
|
||||||
|
@input=${(e: Event) => { this._roomNameScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
|
||||||
|
<span class="opv">${Math.round(this._roomNameScale * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<label>${this._t('room.label_scale')}</label>
|
||||||
|
<div class="colorrow gsrow">
|
||||||
|
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomLabelScale * 100))}
|
||||||
|
@input=${(e: Event) => { this._roomLabelScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
|
||||||
|
<span class="opv">${Math.round(this._roomLabelScale * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
${this._renderCardPreview(
|
||||||
|
spaceDisplayOf(this._curSpaceCfg).cardFontScale,
|
||||||
|
this._roomNameScale,
|
||||||
|
this._roomLabelScale,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
|
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
|
${edit
|
||||||
title=${this._t('title.no_area_room')}>
|
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
|
||||||
${this._t('btn.no_area')}
|
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||||
</button>
|
</button>`
|
||||||
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
|
||||||
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
title=${this._t('title.no_area_room')}>
|
||||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
${this._t('btn.no_area')}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
||||||
|
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
||||||
|
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||||
|
</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+16
-1
@@ -307,5 +307,20 @@
|
|||||||
"kiosk.icon_scale": "Device icon size",
|
"kiosk.icon_scale": "Device icon size",
|
||||||
"kiosk.font_scale": "Room card text size",
|
"kiosk.font_scale": "Room card text size",
|
||||||
"editor.kiosk": "Wall device (kiosk) mode",
|
"editor.kiosk": "Wall device (kiosk) mode",
|
||||||
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)"
|
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)",
|
||||||
|
"room.settings_title": "Room settings",
|
||||||
|
"room.settings_section": "Room settings (override the space)",
|
||||||
|
"room.fill_label": "Fill in THIS room",
|
||||||
|
"fill.inherit": "As the space",
|
||||||
|
"room.temp_src_label": "Temperature source",
|
||||||
|
"room.hum_src_label": "Humidity source",
|
||||||
|
"room.src_average": "Average over the room's sensors (default)",
|
||||||
|
"room.src_pick": "A specific HA device or entity",
|
||||||
|
"room.src_ph": "Choose a source…",
|
||||||
|
"toast.room_updated": "Room updated",
|
||||||
|
"space.card_font": "Room-card font size (whole space)",
|
||||||
|
"room.sizes_section": "Font sizes",
|
||||||
|
"room.name_scale": "Room name size",
|
||||||
|
"room.label_scale": "Metrics size",
|
||||||
|
"preview.room_name": "Living room"
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -307,5 +307,20 @@
|
|||||||
"kiosk.icon_scale": "Размер значков устройств",
|
"kiosk.icon_scale": "Размер значков устройств",
|
||||||
"kiosk.font_scale": "Размер текста карточек комнат",
|
"kiosk.font_scale": "Размер текста карточек комнат",
|
||||||
"editor.kiosk": "Режим настенного устройства (киоск)",
|
"editor.kiosk": "Режим настенного устройства (киоск)",
|
||||||
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)"
|
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)",
|
||||||
|
"room.settings_title": "Настройки комнаты",
|
||||||
|
"room.settings_section": "Настройки комнаты (переопределяют пространство)",
|
||||||
|
"room.fill_label": "Заливка в ЭТОЙ комнате",
|
||||||
|
"fill.inherit": "Как у пространства",
|
||||||
|
"room.temp_src_label": "Источник температуры",
|
||||||
|
"room.hum_src_label": "Источник влажности",
|
||||||
|
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
|
||||||
|
"room.src_pick": "Конкретное устройство или сущность HA",
|
||||||
|
"room.src_ph": "Выберите источник…",
|
||||||
|
"toast.room_updated": "Комната обновлена",
|
||||||
|
"space.card_font": "Размер шрифта карточек комнат (всё пространство)",
|
||||||
|
"room.sizes_section": "Размеры шрифтов",
|
||||||
|
"room.name_scale": "Размер названия",
|
||||||
|
"room.label_scale": "Размер подписей",
|
||||||
|
"preview.room_name": "Гостиная"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,6 +552,8 @@ export interface SpaceDisplay {
|
|||||||
tempMax: number; // comfort range upper bound, °C
|
tempMax: number; // comfort range upper bound, °C
|
||||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||||
showLqi: boolean | null;
|
showLqi: boolean | null;
|
||||||
|
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
|
||||||
|
cardFontScale: number;
|
||||||
/** Room-card metrics under the room name (all default off). */
|
/** Room-card metrics under the room name (all default off). */
|
||||||
labelTemp: boolean;
|
labelTemp: boolean;
|
||||||
labelHum: boolean;
|
labelHum: boolean;
|
||||||
@@ -577,6 +579,9 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
|||||||
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,
|
||||||
|
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
|
||||||
|
? Math.min(3, Math.max(0.5, s.card_font_scale))
|
||||||
|
: 1,
|
||||||
labelTemp: s.label_temp === true,
|
labelTemp: s.label_temp === true,
|
||||||
labelHum: s.label_hum === true,
|
labelHum: s.label_hum === true,
|
||||||
labelLqi: s.label_lqi === true,
|
labelLqi: s.label_lqi === true,
|
||||||
@@ -965,6 +970,37 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
|||||||
return cutSegments(edges, cuts, eps);
|
return cutSegments(edges, cuts, eps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- room-level settings (tier 3) ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective fill mode of a room: its own override wins, otherwise the space's.
|
||||||
|
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
|
||||||
|
* device; the more specific tier overrides the more general one. A room may
|
||||||
|
* override even in a glow space ('none' pulls it out of the darkness).
|
||||||
|
*/
|
||||||
|
export function roomFillModeOf(
|
||||||
|
spaceFill: RoomFillMode,
|
||||||
|
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
|
||||||
|
): RoomFillMode {
|
||||||
|
const o = room?.settings?.fill_mode;
|
||||||
|
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- marker files ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite attached-file urls when a marker's id changes (rebinding): the
|
||||||
|
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
|
||||||
|
*/
|
||||||
|
export function migratePdfUrls<T extends { url: string }>(
|
||||||
|
pdfs: T[], oldId: string, newId: string,
|
||||||
|
): T[] {
|
||||||
|
if (!oldId || !newId || oldId === newId) return pdfs;
|
||||||
|
const from = '/files/' + oldId + '/';
|
||||||
|
const to = '/files/' + newId + '/';
|
||||||
|
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- kiosk gestures ----------------
|
// ---------------- kiosk gestures ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+36
-3
@@ -364,7 +364,7 @@ export const cardStyles = css`
|
|||||||
pointer-events: none; /* draggable only in plan mode (rule below) */
|
pointer-events: none; /* draggable only in plan mode (rule below) */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1));
|
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -377,7 +377,21 @@ export const cardStyles = css`
|
|||||||
gap: 0.15em;
|
gap: 0.15em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.rlname { display: inline-flex; align-items: center; gap: 0.25em; }
|
.rlname {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
font-size: calc(1em * var(--rl-name, 1));
|
||||||
|
}
|
||||||
|
.rlgear {
|
||||||
|
--mdc-icon-size: 0.9em;
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 0.2em;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.rlgear:hover { opacity: 1; }
|
||||||
.rlgo {
|
.rlgo {
|
||||||
--mdc-icon-size: 0.85em;
|
--mdc-icon-size: 0.85em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -392,7 +406,7 @@ export const cardStyles = css`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55em;
|
gap: 0.55em;
|
||||||
font-size: 0.62em;
|
font-size: calc(0.62em * var(--rl-meta, 1));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@@ -460,6 +474,25 @@ export const cardStyles = css`
|
|||||||
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
|
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
|
||||||
.ctrlstate.on { color: var(--hp-txt); }
|
.ctrlstate.on { color: var(--hp-txt); }
|
||||||
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
|
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
|
||||||
|
.cardpreview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0 2px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px dashed var(--hp-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.cardpreview .cpname { font-weight: 700; letter-spacing: 0.04em; }
|
||||||
|
.cardpreview .cpmeta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.cardpreview .cpmeta ha-icon { --mdc-icon-size: 1.05em; }
|
||||||
.iconauto {
|
.iconauto {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+12
-1
@@ -1,8 +1,19 @@
|
|||||||
/** 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. */
|
/** Rooms this one has an OPEN (virtual) boundary with - light flows through. */
|
||||||
open_to?: string[] | null;
|
open_to?: string[] | null;
|
||||||
|
/** Room-level settings (tier 3 of 4: global > space > ROOM > device). */
|
||||||
|
settings?: {
|
||||||
|
/** Fill override; unset = inherit the space fill mode. */
|
||||||
|
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | null;
|
||||||
|
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
|
||||||
|
temp_source?: string | null;
|
||||||
|
hum_source?: string | null;
|
||||||
|
/** Font multipliers for THIS room's card (0.5-3, unset = 1). */
|
||||||
|
name_scale?: number | null;
|
||||||
|
label_scale?: number | null;
|
||||||
|
} | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
|
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from '../test-build/devices.js';
|
||||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||||
|
|
||||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||||
@@ -337,3 +337,25 @@ test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
|
|||||||
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
|
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
|
||||||
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
|
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sourceValue: explicit entity and device sources (tier 3)', () => {
|
||||||
|
const hass = {
|
||||||
|
states: {
|
||||||
|
'sensor.custom_temp': { state: '23.456' },
|
||||||
|
'sensor.custom_hum': { state: '47.8' },
|
||||||
|
'sensor.bad': { state: 'unknown' },
|
||||||
|
'sensor.dev_t': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||||
|
},
|
||||||
|
entities: {
|
||||||
|
'sensor.dev_t': { device_id: 'dev1' },
|
||||||
|
'sensor.custom_temp': {}, 'sensor.custom_hum': {}, 'sensor.bad': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(sourceValue(hass, 'entity:sensor.custom_temp', 'temp'), 23.5);
|
||||||
|
assert.equal(sourceValue(hass, 'entity:sensor.custom_hum', 'hum'), 48);
|
||||||
|
assert.equal(sourceValue(hass, 'entity:sensor.bad', 'temp'), null);
|
||||||
|
assert.equal(sourceValue(hass, 'device:dev1', 'temp'), 21.5);
|
||||||
|
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
|
||||||
|
assert.equal(sourceValue(hass, '', 'temp'), null);
|
||||||
|
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
outlineWithout,
|
outlineWithout,
|
||||||
alignGuides, segmentAngle, is45,
|
alignGuides, segmentAngle, is45,
|
||||||
swipeTarget, clampScale,
|
swipeTarget, clampScale,
|
||||||
|
migratePdfUrls,
|
||||||
|
roomFillModeOf,
|
||||||
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';
|
||||||
@@ -787,3 +789,23 @@ test('clampScale', () => {
|
|||||||
assert.equal(clampScale('x'), 1);
|
assert.equal(clampScale('x'), 1);
|
||||||
assert.equal(clampScale(undefined, 1.5), 1.5);
|
assert.equal(clampScale(undefined, 1.5), 1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migratePdfUrls: rebinding rewrites file urls', () => {
|
||||||
|
const pdfs = [
|
||||||
|
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||||
|
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
|
||||||
|
];
|
||||||
|
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
|
||||||
|
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
|
||||||
|
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
|
||||||
|
// без смены id — как есть
|
||||||
|
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
|
||||||
|
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'light' } }), 'light');
|
||||||
|
assert.equal(roomFillModeOf('glow', { settings: { fill_mode: 'none' } }), 'none'); // выход из тьмы
|
||||||
|
assert.equal(roomFillModeOf('temp', {}), 'temp');
|
||||||
|
assert.equal(roomFillModeOf('temp', null), 'temp');
|
||||||
|
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user