mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7c1b883c | ||
|
|
7c9731655e | ||
|
|
921facf1ac | ||
|
|
8ddf34f3ac | ||
|
|
5dd14a009a | ||
|
|
ae10befd58 | ||
|
|
f670c8644c | ||
|
|
9288d74f6f | ||
|
|
b706ad4b49 | ||
|
|
1552bff99a | ||
|
|
65268a7985 | ||
|
|
4593d96955 | ||
|
|
18a0d279a7 | ||
|
|
6cbf4ece4e | ||
|
|
6609bfadd5 | ||
|
|
498b852e86 |
@@ -51,6 +51,11 @@ Key advantages in short:
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Via HACS (recommended)
|
||||
|
||||
1. Open **HACS → menu (⋮) → Custom repositories**.
|
||||
@@ -120,6 +125,14 @@ As soon as the outline is closed, the room-save dialog appears. Here you need to
|
||||
|
||||

|
||||
|
||||
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
|
||||
|
||||
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
|
||||
|
||||
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Step 3. Devices appear by themselves
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
@@ -160,6 +173,9 @@ matches, the entity *device class* decides (thermometer for temperature sensors,
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
|
||||
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||

|
||||
|
||||
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
@@ -122,6 +127,14 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||
|
||||
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||
|
||||
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Шаг 3. Устройства появляются сами
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
@@ -164,6 +177,9 @@ title: План дома
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||

|
||||
|
||||
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.16.2"
|
||||
VERSION = "1.23.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"segments": len(s.get("segments", [])),
|
||||
}
|
||||
for s in config.get("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",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.16.2"
|
||||
"version": "1.23.0"
|
||||
}
|
||||
|
||||
@@ -100,6 +100,27 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -118,6 +139,11 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card; c._serverCfg = JSON.parse(s);
|
||||
c._mergeSel = null; c._mergeDialog = null; c._splitSel = null; c._pendingSplit = null; c._roomDialog = false;
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); return c.updateComplete && true;
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
return { rooms: c._serverCfg.spaces.find((s) => s.id==='f1').rooms.map((r)=>({id:r.id,name:r.name,area:r.area})),
|
||||
mergeDlg: !!c._mergeDialog, roomDlg: !!c._roomDialog, pendingSplit: !!c._pendingSplit, toast: c._toast };
|
||||
});
|
||||
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool=t; return true;}, t);
|
||||
const out = {};
|
||||
|
||||
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
|
||||
await enter('merge');
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.3));
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25));
|
||||
out.mergeDialog = (await S()).mergeDlg;
|
||||
await page.evaluate(()=>window.__card._commitMerge());
|
||||
let s = await S();
|
||||
out.mergeRooms = s.rooms.length; // 4→3
|
||||
out.mergeKeptR1 = s.rooms.some(r=>r.id==='r1');
|
||||
out.mergeDroppedR2 = !s.rooms.some(r=>r.id==='r2');
|
||||
await restore();
|
||||
|
||||
// MERGE non-adjacent: living(r1)+garden's-only? use r2+r4 (kitchen & hallway don't share a wall)
|
||||
await enter('merge');
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25)); // kitchen
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.8)); // hallway
|
||||
s = await S();
|
||||
out.nonAdjRefused = !s.mergeDlg && s.rooms.length===4;
|
||||
out.nonAdjToast = !!s.toast;
|
||||
await restore();
|
||||
|
||||
// grid-aligned test room (real rooms are snapped; demo rooms are not)
|
||||
await page.evaluate(()=>{
|
||||
const c=window.__card;
|
||||
c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
|
||||
});
|
||||
// SPLIT rg vertical chord x=0.25 (all on grid nodes)
|
||||
await enter('split');
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28)); // pick rg
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625)); // wall pt a
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5)); // wall pt b
|
||||
out.splitPending = (await S()).pendingSplit;
|
||||
out.splitDialog = (await S()).roomDlg;
|
||||
// cancel keeps it whole
|
||||
await page.evaluate(()=>window.__card._roomDialogCancel());
|
||||
out.cancelWhole = (await S()).rooms.length===4;
|
||||
// redo + confirm with a name (no area)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||
s = await S();
|
||||
out.splitRooms = s.rooms.length; // 5
|
||||
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
|
||||
// SPLIT along a wall → refused (both pts on top wall of rg)
|
||||
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
c._regSignature=''; c._maybeRebuildDevices();});
|
||||
await enter('split');
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.1,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
||||
s = await S();
|
||||
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||
|
||||
console.log(JSON.stringify(out,null,1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,21 @@
|
||||
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
// accidental click in the middle of the room must NOT become a wall point
|
||||
await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingSplit=null; c._splitSel=null;});
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
||||
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
||||
console.log(JSON.stringify(out));
|
||||
await browser.close();
|
||||
+215
-130
File diff suppressed because one or more lines are too long
Vendored
+448
-134
File diff suppressed because one or more lines are too long
+13
-3
@@ -111,12 +111,22 @@ If the server config is empty, the card falls back to the legacy bundle (the dac
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
|
||||
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
|
||||
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
|
||||
outline you never close leaves no trace. Walls are *derived* from the room outlines by
|
||||
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
|
||||
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
|
||||
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
|
||||
still tolerates it on read; see CHANGELOG v1.19.0).
|
||||
|
||||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||||
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
|
||||
|
||||
## Integration WS API
|
||||
|
||||
| Command | Parameters | Response |
|
||||
|
||||
@@ -1,5 +1,160 @@
|
||||
# Changelog
|
||||
|
||||
## v1.23.0 — 2026-07-17 (doors & windows with live open/lock state)
|
||||
Visual language after easy-floorplan (MIT); the placement model is ours.
|
||||
- **New markup tool "Opening"**: click next to a wall → the opening snaps onto the nearest
|
||||
DERIVED room wall (walls have no independent existence — v1.19.0) and takes its angle, then a
|
||||
dialog asks for type (door/window), **length in real cm** (defaults: door 90, window 120 —
|
||||
the per-space scale makes this honest), an open/close sensor (door/window-class
|
||||
`binary_sensor`/`cover`, invertible) and, for doors, a **lock entity**. The opening keeps
|
||||
absolute coordinates, so editing/merging/deleting rooms never breaks it. Click an existing
|
||||
opening with the tool to edit or delete it.
|
||||
- **Live rendering**: a door is a leaf hinged at the jamb with a quarter-circle swing arc that
|
||||
"draws on" (stroke-dashoffset) as it opens; a window is two casement leaves meeting in the
|
||||
middle. Открыто → the moving parts take the accent colour and animate (CSS transitions,
|
||||
`prefers-reduced-motion` honoured). No sensor → the classic static plan: doors drawn open,
|
||||
windows closed. `unavailable`/`unknown` freeze that default — an outage must not fake motion
|
||||
(pure `openingAmount`, unit-tested). Hinge side / swing side via flip toggles.
|
||||
- **Locks**: a padlock badge beside the door — green closed when `locked`, orange open when
|
||||
unlocked, grey question when unknown. The lock is NEVER toggled from the plan (the card's
|
||||
standing security rule); clicking the opening or the badge shows an **info card** with both
|
||||
states.
|
||||
- New pure helpers `snapToWall` (projection + wall angle over derived edges) and
|
||||
`openingAmount`; `space.openings[]` validated server-side. (+2 tests: 74 → 76.)
|
||||
|
||||
## v1.22.0 — 2026-07-17 (presence ripples, per-device icon size/rotation, one-click install)
|
||||
Ideas borrowed from [easy-floorplan](https://github.com/nicosandller/easy-floorplan) — the visuals,
|
||||
not the model.
|
||||
- **Presence ripples**: `display: badge | ripple | icon_ripple` per marker, with `ripple_color`
|
||||
and `ripple_size`. Active → pulsing rings; idle → a faint dot. Gated by the pure
|
||||
`isActiveState` — independent of the card-wide live_states toggle, and `unavailable`/`unknown`
|
||||
count as idle so an outage never leaves a ring pulsing. Honours `prefers-reduced-motion`.
|
||||
- **Per-device icon `size` (×0.5–3) and `angle`** — sizing now hangs off `--dev-size`, so value
|
||||
badges scale with the device.
|
||||
- **One-click install badge** (My Home Assistant → HACS) in both READMEs.
|
||||
- (+1 test.)
|
||||
|
||||
## v1.21.3 — 2026-07-16 (room labels: no text shadow)
|
||||
- Room name labels no longer carry a text shadow — crisper look on both the white
|
||||
hand-drawn canvas and plan images.
|
||||
|
||||
## v1.21.2 — 2026-07-16 (space-dialog polish)
|
||||
- The **"Scale (cm per cell)" input is compact** again — a generic `width:100%`
|
||||
dialog rule was stretching it across the row.
|
||||
- **A space with no background image now gets a white "paper" canvas** instead of
|
||||
the dark stage, so hand-drawn rooms read like a floor plan on paper.
|
||||
|
||||
## v1.21.1 — 2026-07-16 (audit: split snaps to the wall, docs for merge/split)
|
||||
- **Fix (found in audit):** Split required each click to land on a grid node, so it
|
||||
silently refused rooms whose walls are not grid-aligned (imported or older polygons)
|
||||
— the "pick a wall" toast fired no matter where you clicked. The click now snaps to
|
||||
the room's nearest wall (`closestPointOnBoundary`) instead of the grid, with the pull
|
||||
capped at ~6 grid cells so an accidental click in the middle of a room stays a miss
|
||||
rather than becoming a wall point the user never meant; `splitRoom()` still rejects
|
||||
a cut that is not a clean wall-to-wall chord. Also makes aiming easier on the fine
|
||||
(240-cell) grid.
|
||||
- **Docs:** README (en + ru) now documents room Merge, Split, the drawing ruler and the
|
||||
per-space scale — these v1.18–v1.21 features were shipped without user-facing docs.
|
||||
`docs/TESTING.md` gained merge/split rows and a fresh self-run record.
|
||||
- New smokes `demo/smoke_merge_split.mjs` and `smoke_split_nonsnap.mjs`;
|
||||
`closestPointOnBoundary` unit-tested. (+1 test: 72 → 73.)
|
||||
|
||||
## v1.21.0 — 2026-07-16 (merge and split rooms)
|
||||
- **Merge** (toolbar "Merge"): click a room, then a neighbour. Only rooms that **share a wall**
|
||||
can merge — and that is decided by the result rather than a heuristic: `mergeRooms` unions the
|
||||
outlines and accepts the pair only when they collapse into ONE hole-free outline. A corner
|
||||
touch, rooms apart, or a union enclosing a hole are refused. A dialog picks which name and
|
||||
area survive; the kept room keeps its id, so its label position and devices stay put. The
|
||||
dialog warns that the other area is released.
|
||||
- **Split** (toolbar "Split"): click the room, then two points on its walls — the chord cuts it
|
||||
in two, with the live ruler on the cut. **The bigger part stays the room it was** (name, area,
|
||||
devices); the smaller becomes a new room and its dialog asks for name/area. Cancelling the
|
||||
dialog leaves the room whole — the cut is applied only on confirm. Cuts that do not run
|
||||
wall-to-wall inside the room (ends off the wall, chord leaving a concave room, a chord along a
|
||||
wall) are refused.
|
||||
- Boolean geometry via **polyclip-ts** (proper ESM + native types; `polygon-clipping` ships named
|
||||
types but a default-only ESM build, which breaks either tsc or the runtime). Verified against
|
||||
the real plan, where neighbouring walls overlap collinearly instead of matching exactly —
|
||||
the case a hand-rolled union gets wrong. Bundle: 151 KB → 202 KB.
|
||||
- New pure helpers: `polygonArea`, `mergeRooms`, `splitRoom`. (+5 tests: 67 → 72.)
|
||||
|
||||
## v1.20.0 — 2026-07-16 (rooms may not overlap)
|
||||
- **A click strictly inside an existing room is refused** while drawing (toast names the room).
|
||||
Being *on* a wall stays legal — neighbouring rooms share walls, and real walls overlap
|
||||
collinearly rather than match exactly, so new vertices land on existing outlines mid-span
|
||||
all the time. `pointStrictlyInside` excludes the boundary explicitly (ray casting alone is
|
||||
unreliable exactly on an edge).
|
||||
- **Closing an outline that overlaps an existing room is refused** — vertex checks alone are not
|
||||
enough: an outline drawn *around* a room has every vertex outside it. The outline stays open
|
||||
so it can be corrected. Nesting one room inside another counts as an overlap.
|
||||
- New pure geometry in logic.ts: `roomPoly`, `pointOnBoundary`, `pointStrictlyInside`,
|
||||
`segmentsProperlyCross` (touching/collinear is deliberately not a crossing), `roomsOverlap`
|
||||
(edge crossings + containment probe, which also catches duplicate outlines). (+4 tests: 63 → 67.)
|
||||
|
||||
## v1.19.0 — 2026-07-16 (a line is never a thing of its own)
|
||||
**Model change.** A wall can only exist as an edge of a closed room. Consequences:
|
||||
- **Walls are derived from room outlines** (`roomEdges` in logic.ts), not stored. A wall
|
||||
shared by two rooms is emitted once, so **deleting a room keeps the borders its neighbours
|
||||
still contribute** — and drops the walls nobody else uses. This falls out of the model
|
||||
instead of needing bookkeeping.
|
||||
- **An abandoned outline leaves nothing behind.** Previously every click pair was written to
|
||||
`space.segments` immediately, so a contour you never closed left orphan lines on the plan.
|
||||
Now nothing is persisted until the room is saved.
|
||||
- **The "Erase line" tool is gone** — there is no standalone line to erase. Mis-clicks are
|
||||
undone with Esc / Ctrl+Z as before.
|
||||
- **`space.segments` is dropped on every save** (legacy configs shed it on first write).
|
||||
Validation still tolerates the field so a stale browser tab cannot fail a save; diagnostics
|
||||
no longer reports it. Lines that belonged to no room disappear on upgrade — by design.
|
||||
- Dead code removed: `_addSegment`, `_removeSegmentByKey`, `_distToSeg`, `_pathSegs`, `_segKey`.
|
||||
`segKey(a, b, prec)` gained a precision argument (normalized coords need more than render
|
||||
units). (+2 tests: 61 → 63.)
|
||||
|
||||
## v1.18.1 — 2026-07-16 (fix: the drawing ruler was invisible)
|
||||
- Fix on top of v1.18.0: the length badge never showed up while drawing. It was rendered
|
||||
inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
|
||||
in markup mode (so icons do not get in the way) — the badge was in the DOM but invisible.
|
||||
It now lives in its own `.measurelayer` (absolute, `pointer-events: none`), which markup
|
||||
mode does not hide. Verified visually on a real drawn segment ("3.60 m" on screen).
|
||||
- Testing lesson (see docs/TESTING.md): asserting on `textContent` is not enough — a DOM
|
||||
query passes on elements hidden by an ancestor. Check `offsetParent`/rect or look at a
|
||||
screenshot.
|
||||
|
||||
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
|
||||
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
|
||||
length of the current segment (last placed vertex → cursor). Units come from the HA unit
|
||||
system: metric → metres ("1.25 m"), imperial → feet+inches ("4′ 1″").
|
||||
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
|
||||
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
|
||||
plan can have its own real-world size.
|
||||
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
|
||||
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
|
||||
(+3 tests: 58 → 61 frontend.)
|
||||
|
||||
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
|
||||
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
|
||||
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
|
||||
Previously it required the `mdi:water-percent` icon, so a humidity sensor whose name matched
|
||||
another icon rule (e.g. a "Myheat Влажность …" sensor → boiler icon) showed no value.
|
||||
Verified live (45.2 → 45%). (+1 test: 57 → 58.)
|
||||
|
||||
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
|
||||
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
|
||||
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
|
||||
humidity entity placed on its own (v1.17.0) — gets an integer `%` badge and the value in its
|
||||
tooltip. New `isHumEntity`/`humFor` (diagnostic entities excluded), `DevItem.hum`, `.hval`
|
||||
badge, gated by the same "sensor values" option as temperature. (+2 tests: 55 → 57.)
|
||||
|
||||
## v1.17.0 — 2026-07-11 (place individual entities, not just whole devices — issue #1)
|
||||
- **You can now put a single entity on the plan as its own icon** — e.g. a climate sensor
|
||||
exposes temperature AND humidity; add the device (shows temperature) and separately add the
|
||||
humidity entity as a second icon. In the "add device" dialog, start typing in the binding
|
||||
search and individual entities now appear alongside devices/helpers (surfaced only while
|
||||
searching, so the default list stays clean); the sub-label shows the domain and parent device.
|
||||
- Entity markers now get a **sensible auto icon** (name rules → `device_class` → chip) instead of
|
||||
the generic shape, and a **temperature value** when the entity is a thermometer/air-monitor.
|
||||
The `entity:<eid>` binding already existed (used by helpers/light groups); this exposes it for
|
||||
any entity. (+1 test: 54 → 55.)
|
||||
|
||||
## v1.16.2 — 2026-07-11 (docs+log: correct card resource URL — fixes "Custom element doesn't exist")
|
||||
- **Support issue #2**: users adding a Lovelace resource that points at the on-disk path
|
||||
`/custom_components/houseplan/frontend/houseplan-card.js` get a `text/plain` MIME error and
|
||||
|
||||
+2
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.16.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.23.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 54 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 76 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
|
||||
+47
-3
@@ -43,7 +43,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
||||
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
|
||||
- [ ] Draw-space renders an empty canvas (no black hole), markup works on it
|
||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
|
||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
|
||||
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
|
||||
@@ -56,6 +56,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
@@ -63,7 +64,26 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Room markup editor ★
|
||||
|
||||
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
|
||||
- [ ] Grid appears; dots snap; the outline draws pair-by-pair; shared walls reused
|
||||
- [ ] Ruler: while drawing, the length of the current segment follows the cursor
|
||||
(metres, or feet+inches on an imperial HA); scale = space "cm per cell" (default 5)
|
||||
- [ ] A line cannot exist on its own: start an outline, do NOT close it, leave markup —
|
||||
no lines are left behind (nothing was written to the config)
|
||||
- [ ] Deleting a room removes its walls, EXCEPT those shared with a neighbouring room
|
||||
(the neighbour still yields them); deleting the neighbour too removes them as well
|
||||
- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0)
|
||||
- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a
|
||||
toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works
|
||||
- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open
|
||||
- [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving
|
||||
name/area; rooms touching only at a corner or apart are refused with a toast
|
||||
- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the
|
||||
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
||||
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
||||
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
|
||||
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
||||
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
|
||||
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
||||
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||
@@ -120,7 +140,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||
- [ ] Window resize / sidebar collapse refits without distortion
|
||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
|
||||
- [ ] Labels legible on light plans (text shadow) at min/max zoom
|
||||
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
||||
|
||||
## Multi-client & concurrency ★
|
||||
|
||||
@@ -155,6 +175,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Last self-run
|
||||
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
|
||||
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
|
||||
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
|
||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||
was missing. The earlier self-run record follows.
|
||||
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||
@@ -179,3 +209,17 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] Unknown `space` → tidy error card [auto]
|
||||
- [ ] `show_button: false` hides the footer
|
||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
||||
|
||||
## Doors & windows (v1.23.0)
|
||||
|
||||
- [ ] Markup → "Opening": a click away from any wall shows a toast; near a wall — the dialog
|
||||
- [ ] A door placed on a wall renders jambs + leaf + swing arc at the wall's angle; length in cm
|
||||
matches the ruler/scale of the space
|
||||
- [ ] Bind a contact sensor: open → leaf swings and the arc draws on in the accent colour;
|
||||
closed → leaf lies along the wall, arc hidden; invert flips this
|
||||
- [ ] Sensor unavailable → the opening freezes at its static default (door open / window closed)
|
||||
- [ ] A door with a lock shows the padlock badge: green locked / orange unlocked / grey unknown
|
||||
- [ ] Clicking an opening (or the padlock) in view mode opens the info card with both states;
|
||||
the lock can NOT be toggled from the plan
|
||||
- [ ] Flip toggles mirror the hinge side and the swing side
|
||||
- [ ] Click an existing opening with the tool → edit dialog; Delete removes it
|
||||
|
||||
Generated
+29
-3
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.21.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.21.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -597,6 +598,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -797,6 +807,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/polyclip-ts": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
|
||||
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.1.0",
|
||||
"splaytree-ts": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -936,6 +956,12 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree-ts": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
|
||||
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
||||
"license": "BDS-3-Clause"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.16.2",
|
||||
"version": "1.23.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -22,6 +22,7 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -104,6 +104,27 @@ export function tempFor(hass: any, entIds: string[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
|
||||
export function isHumEntity(hass: any, eid: string): boolean {
|
||||
if (hass.entities?.[eid]?.entity_category) return false;
|
||||
const st = hass.states[eid];
|
||||
if (!st) return /_humidity$/.test(eid);
|
||||
const a = st.attributes || {};
|
||||
return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid);
|
||||
}
|
||||
|
||||
/** First readable humidity value (integer %) among the entities, or null. */
|
||||
export function humFor(hass: any, entIds: string[]): number | null {
|
||||
for (const eid of entIds) {
|
||||
if (!isHumEntity(hass, eid)) continue;
|
||||
const st = hass.states[eid];
|
||||
if (!st) continue;
|
||||
const v = parseFloat(st.state);
|
||||
if (!isNaN(v)) return Math.round(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
|
||||
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
|
||||
if (!enabled) return [];
|
||||
@@ -206,6 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
rest.push(item);
|
||||
}
|
||||
|
||||
@@ -254,6 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else if (kind === 'entity') {
|
||||
@@ -261,18 +285,23 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
|
||||
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
||||
const st = h.states[ref];
|
||||
const nm = reg?.name || st?.attributes?.friendly_name || ref;
|
||||
let icon = resolveIcon(h, nm, '', [ref], iconRules);
|
||||
if (ref.startsWith('lock.')) icon = 'mdi:lock';
|
||||
const item: DevItem = {
|
||||
id: m.id,
|
||||
name: reg?.name || st?.attributes?.friendly_name || ref,
|
||||
name: nm,
|
||||
model: '',
|
||||
area,
|
||||
space,
|
||||
icon: 'mdi:shape-outline',
|
||||
icon,
|
||||
entities: [ref],
|
||||
primary: ref,
|
||||
bindingKind: 'entity',
|
||||
bindingRef: ref,
|
||||
};
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
|
||||
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else {
|
||||
|
||||
+729
-98
File diff suppressed because it is too large
Load Diff
+47
-3
@@ -27,13 +27,36 @@
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
|
||||
"title.markup_erase": "Erase a line: click the line",
|
||||
"title.markup_merge": "Merge rooms: click one room, then the neighbour it shares a wall with",
|
||||
"title.markup_split": "Split a room: click the room, then two points on its walls",
|
||||
"title.markup_delroom": "Delete a room: click inside the room",
|
||||
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
|
||||
"title.choose_area": "Select a Home Assistant area",
|
||||
"title.need_plan": "Upload a floor-plan image",
|
||||
"markup.add": "Add",
|
||||
"markup.erase": "Erase",
|
||||
"markup.merge": "Merge",
|
||||
"markup.split": "Split",
|
||||
"markup.opening": "Opening",
|
||||
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
|
||||
"opening.new": "New opening",
|
||||
"opening.edit": "Door / window",
|
||||
"opening.door": "Door",
|
||||
"opening.window": "Window",
|
||||
"opening.type_label": "Type",
|
||||
"opening.length_label": "Length, cm",
|
||||
"opening.contact_label": "Open/close sensor",
|
||||
"opening.lock_label": "Lock",
|
||||
"opening.none": "— none —",
|
||||
"opening.invert": "Invert open/closed",
|
||||
"opening.flip_h": "Hinge on the other jamb",
|
||||
"opening.flip_v": "Opens to the other side",
|
||||
"opening.open": "Open",
|
||||
"opening.closed": "Closed",
|
||||
"opening.locked": "Locked",
|
||||
"opening.unlocked": "Unlocked",
|
||||
"opening.state_unknown": "unavailable",
|
||||
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
|
||||
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
@@ -58,6 +81,13 @@
|
||||
"marker.room_auto": "— by device area (auto) —",
|
||||
"marker.icon_label": "Icon",
|
||||
"marker.icon_ph": "mdi:… (empty = auto)",
|
||||
"marker.display_label": "Display",
|
||||
"display.badge": "Icon badge",
|
||||
"display.ripple": "Ripple only",
|
||||
"display.icon_ripple": "Icon + ripple",
|
||||
"marker.ripple_size": "Ripple size",
|
||||
"marker.size_label": "Icon size / rotation",
|
||||
"marker.angle_label": "Rotate",
|
||||
"marker.model_label": "Model",
|
||||
"marker.model_ph": "e.g. Aqara T&H",
|
||||
"marker.link_label": "Link",
|
||||
@@ -94,6 +124,17 @@
|
||||
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
|
||||
"merge.header": "Merge rooms",
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
"merge.no_area": "no area",
|
||||
"room.split_header": "New room from the split",
|
||||
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
||||
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
|
||||
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||
@@ -152,6 +193,8 @@
|
||||
"import.progress": "Floor {i} of {n}",
|
||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
||||
"btn.skip": "Skip",
|
||||
"space.scale_label": "Scale (grid cell size)",
|
||||
"space.scale_unit": "cm per cell",
|
||||
"space.display_section": "Display",
|
||||
"space.show_borders": "Always show room borders",
|
||||
"space.show_names": "Show room names (drag to move)",
|
||||
@@ -178,5 +221,6 @@
|
||||
"editor.show_button": "Show button",
|
||||
"editor.button_label": "Button label",
|
||||
"editor.button_target": "Target dashboard path",
|
||||
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)"
|
||||
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
|
||||
"marker.sub_entity": "entity"
|
||||
}
|
||||
|
||||
+47
-3
@@ -27,13 +27,36 @@
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||
"title.markup_erase": "Стереть линию: клик по линии",
|
||||
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
|
||||
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
|
||||
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
|
||||
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
|
||||
"title.choose_area": "Выберите зону Home Assistant",
|
||||
"title.need_plan": "Загрузите подложку (план этажа)",
|
||||
"markup.add": "Добавить",
|
||||
"markup.erase": "Стереть",
|
||||
"markup.merge": "Объединить",
|
||||
"markup.split": "Разделить",
|
||||
"markup.opening": "Проём",
|
||||
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
|
||||
"opening.new": "Новый проём",
|
||||
"opening.edit": "Дверь / окно",
|
||||
"opening.door": "Дверь",
|
||||
"opening.window": "Окно",
|
||||
"opening.type_label": "Тип",
|
||||
"opening.length_label": "Длина, см",
|
||||
"opening.contact_label": "Датчик открытия",
|
||||
"opening.lock_label": "Замок",
|
||||
"opening.none": "— нет —",
|
||||
"opening.invert": "Инвертировать открыто/закрыто",
|
||||
"opening.flip_h": "Петли с другой стороны",
|
||||
"opening.flip_v": "Открывается в другую сторону",
|
||||
"opening.open": "Открыто",
|
||||
"opening.closed": "Закрыто",
|
||||
"opening.locked": "Заперто",
|
||||
"opening.unlocked": "Не заперто",
|
||||
"opening.state_unknown": "недоступно",
|
||||
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
|
||||
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
@@ -58,6 +81,13 @@
|
||||
"marker.room_auto": "— по зоне устройства (авто) —",
|
||||
"marker.icon_label": "Иконка",
|
||||
"marker.icon_ph": "mdi:… (пусто = авто)",
|
||||
"marker.display_label": "Отображение",
|
||||
"display.badge": "Значок",
|
||||
"display.ripple": "Только пульсация",
|
||||
"display.icon_ripple": "Значок + пульсация",
|
||||
"marker.ripple_size": "Размер пульсации",
|
||||
"marker.size_label": "Размер / поворот значка",
|
||||
"marker.angle_label": "Поворот",
|
||||
"marker.model_label": "Модель",
|
||||
"marker.model_ph": "напр. Aqara T&H",
|
||||
"marker.link_label": "Ссылка",
|
||||
@@ -94,6 +124,17 @@
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
|
||||
"merge.header": "Объединение комнат",
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
"merge.no_area": "без зоны",
|
||||
"room.split_header": "Новая комната после разделения",
|
||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
@@ -152,6 +193,8 @@
|
||||
"import.progress": "Этаж {i} из {n}",
|
||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"btn.skip": "Пропустить",
|
||||
"space.scale_label": "Масштаб (размер клетки сетки)",
|
||||
"space.scale_unit": "см на клетку",
|
||||
"space.display_section": "Отображение",
|
||||
"space.show_borders": "Всегда отображать границы комнат",
|
||||
"space.show_names": "Отображать названия комнат (перетаскиваются)",
|
||||
@@ -178,5 +221,6 @@
|
||||
"editor.show_button": "Показывать кнопку",
|
||||
"editor.button_label": "Текст кнопки",
|
||||
"editor.button_target": "Путь дашборда (куда вести)",
|
||||
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)"
|
||||
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
|
||||
"marker.sub_entity": "сущность"
|
||||
}
|
||||
|
||||
+298
-3
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
|
||||
*/
|
||||
import { union } from 'polyclip-ts';
|
||||
|
||||
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
|
||||
export function lqiColor(lqi: number): string {
|
||||
@@ -13,10 +14,118 @@ export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
}
|
||||
|
||||
/** Canonical key of a segment (independent of direction). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
|
||||
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
|
||||
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
|
||||
return cells * cellCm;
|
||||
}
|
||||
|
||||
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
|
||||
export function formatLength(cm: number, imperial: boolean): string {
|
||||
if (imperial) {
|
||||
const totalIn = cm / 2.54;
|
||||
let ft = Math.floor(totalIn / 12);
|
||||
let inch = Math.round(totalIn - ft * 12);
|
||||
if (inch === 12) { ft += 1; inch = 0; }
|
||||
return `${ft}′ ${inch}″`;
|
||||
}
|
||||
return `${(cm / 100).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical key of a segment (independent of direction).
|
||||
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
|
||||
* normalized (0..1) coordinates need more (see roomEdges).
|
||||
*/
|
||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall segments derived from room outlines (normalized coordinates in and out).
|
||||
*
|
||||
* A line has no independent existence on the plan: it can only be an edge of a closed
|
||||
* room. Shared walls are emitted once, which is what makes deleting a room keep the
|
||||
* borders its neighbours still contribute — the neighbour's polygon still yields them.
|
||||
*/
|
||||
export function roomPoly(r: any): number[][] | null {
|
||||
if (r?.poly?.length >= 3) return r.poly;
|
||||
if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function roomEdges(rooms: any[]): number[][] {
|
||||
const out: number[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of rooms || []) {
|
||||
const pts = roomPoly(r);
|
||||
if (!pts) continue;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const k = segKey(a, b, 5);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push([a[0], a[1], b[0], b[1]]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a point onto the nearest wall of any room, returning the snapped point and
|
||||
* the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
|
||||
* DERIVED room edges (a line has no independent existence on the plan), so an opening
|
||||
* placed with this stays valid however rooms are later edited — it keeps absolute
|
||||
* coordinates and is not tied to a room id or edge index.
|
||||
*/
|
||||
export function snapToWall(
|
||||
p: number[], rooms: any[], maxDist: number,
|
||||
): { x: number; y: number; angle: number } | null {
|
||||
let best: { x: number; y: number; angle: number } | null = null;
|
||||
let bestD = maxDist;
|
||||
for (const e of roomEdges(rooms)) {
|
||||
const [x1, y1, x2, y2] = e;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const q = [x1 + t * dx, y1 + t * dy];
|
||||
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = { x: q[0], y: q[1], angle: (Math.atan2(dy, dx) * 180) / Math.PI };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far open an opening is drawn, 0..1, from its contact sensor state.
|
||||
* No sensor bound → doors default to open (the familiar swing symbol), windows to
|
||||
* closed (intact glass) — same convention as a static architectural plan.
|
||||
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
|
||||
*/
|
||||
export function openingAmount(
|
||||
type: 'door' | 'window', state: string | null | undefined, invert = false,
|
||||
): number {
|
||||
if (state == null || state === 'unavailable' || state === 'unknown')
|
||||
return type === 'door' ? 1 : 0;
|
||||
const open = isActiveState(state) !== !!invert;
|
||||
return open ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
|
||||
* device and therefore must not depend on the card-wide live_states toggle.
|
||||
* Anything unknown — including `unavailable` — counts as idle: a sensor outage should
|
||||
* calm the plan down, never leave a ring pulsing forever.
|
||||
*/
|
||||
export function isActiveState(state?: string | null): boolean {
|
||||
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
|
||||
}
|
||||
|
||||
/** Point equality within a tolerance. */
|
||||
@@ -35,6 +144,192 @@ export function pointInPolygon(p: number[], poly: number[][]): boolean {
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Distance from p to segment ab. */
|
||||
function distToSeg(p: number[], a: number[], b: number[]): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
|
||||
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
|
||||
* including mid-span, since real walls overlap collinearly rather than match exactly.
|
||||
*/
|
||||
/**
|
||||
* Project a point onto the nearest edge of a polygon and return that point,
|
||||
* or null when the polygon has no edges. Used to snap a Split click onto the
|
||||
* actual wall (rooms may not be grid-aligned — imported polygons, older configs),
|
||||
* so cutting no longer requires hitting a grid node exactly on the outline.
|
||||
*/
|
||||
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
|
||||
if (!poly || poly.length < 2) return null;
|
||||
let best: number[] | null = null;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const q = [a[0] + t * dx, a[1] + t * dy];
|
||||
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 2) return false;
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
|
||||
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 3) return false;
|
||||
if (pointOnBoundary(p, poly, eps)) return false;
|
||||
return pointInPolygon(p, poly);
|
||||
}
|
||||
|
||||
function cross3(a: number[], b: number[], c: number[]): number {
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two segments cross transversally? Touching at an endpoint and collinear overlap
|
||||
* deliberately do NOT count — that is what sharing a wall looks like.
|
||||
*/
|
||||
export function segmentsProperlyCross(
|
||||
p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
|
||||
): boolean {
|
||||
const d1 = cross3(p3, p4, p1);
|
||||
const d2 = cross3(p3, p4, p2);
|
||||
const d3 = cross3(p1, p2, p3);
|
||||
const d4 = cross3(p1, p2, p4);
|
||||
return (
|
||||
((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
|
||||
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
|
||||
);
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
if (pointStrictlyInside(v, b, eps)) return true;
|
||||
if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
|
||||
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
|
||||
* inside another counts as an overlap.
|
||||
*/
|
||||
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
for (let j = 0; j < b.length; j++)
|
||||
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||
}
|
||||
|
||||
/** Shoelace area of an outline (absolute value). */
|
||||
export function polygonArea(poly: number[][]): number {
|
||||
if (!poly || poly.length < 3) return 0;
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
}
|
||||
|
||||
function closedRing(poly: number[][]): number[][][] {
|
||||
return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of two room outlines, or null when they may not be merged.
|
||||
*
|
||||
* "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
|
||||
* genuinely share a wall (fully or partially — real walls overlap collinearly rather than
|
||||
* match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
|
||||
* that are apart, or whose union would enclose a hole do not, and are refused.
|
||||
*/
|
||||
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return null;
|
||||
const res = union(closedRing(a) as any, closedRing(b) as any);
|
||||
if (res.length !== 1) return null; // two pieces → not adjacent
|
||||
if (res[0].length !== 1) return null; // a ring plus holes → not a simple room
|
||||
const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
|
||||
return pts.length >= 3 ? pts : null;
|
||||
}
|
||||
|
||||
/** Index of the outline edge that p sits on, or -1. */
|
||||
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||
const out: number[][] = [];
|
||||
for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
|
||||
if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a room in two with a straight chord between two points on its walls.
|
||||
* Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
|
||||
* an end that is not on a wall, a chord that leaves the room (concave outlines) or that
|
||||
* runs along a wall and would carve off a zero-area sliver.
|
||||
*/
|
||||
export function splitRoom(
|
||||
poly: number[][], a: number[], b: number[], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
|
||||
const ia = edgeIndexOf(poly, a, eps);
|
||||
const ib = edgeIndexOf(poly, b, eps);
|
||||
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
|
||||
// a chord lying along a wall has its midpoint ON the outline, not inside it
|
||||
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
|
||||
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||
const pts: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
pts.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
pts.push(to);
|
||||
return dropRepeats(pts, eps);
|
||||
};
|
||||
const p1 = walk(a, ia, b, ib);
|
||||
const p2 = walk(b, ib, a, ia);
|
||||
if (p1.length < 3 || p2.length < 3) return null;
|
||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||
return [p1, p2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
|
||||
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
|
||||
|
||||
+150
-5
@@ -134,6 +134,9 @@ export const cardStyles = css`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.noplan {
|
||||
background: #ffffff;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -222,6 +225,105 @@ export const cardStyles = css`
|
||||
fill-opacity: 0.22;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* doors & windows */
|
||||
.op-leaf {
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
.op-arc {
|
||||
stroke-width: 1.5;
|
||||
transition: stroke-dashoffset 0.6s ease;
|
||||
}
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: none; /* markup clicks go to the stage tools */
|
||||
}
|
||||
.oplock {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.oplock ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
|
||||
display: flex;
|
||||
line-height: 0;
|
||||
}
|
||||
.oplock.locked { color: #66d17a; border-color: #66d17a; }
|
||||
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
|
||||
.oplock.unknown { color: var(--hp-muted); }
|
||||
.oprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.oprow b { margin-left: auto; }
|
||||
.oprow.ok b { color: #66d17a; }
|
||||
.oprow.warn b { color: var(--hp-open); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.op-leaf, .op-arc { transition: none; }
|
||||
}
|
||||
/* presence ripples: opted into per device, drawn around the anchor point */
|
||||
.dev.noicon {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dev ha-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ripple {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--dev-size) * var(--ripple-scale, 3));
|
||||
height: calc(var(--dev-size) * var(--ripple-scale, 3));
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.ripple i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--ripple-color, var(--hp-accent));
|
||||
opacity: 0;
|
||||
}
|
||||
.ripple.active i {
|
||||
animation: hp-ripple 2.4s ease-out infinite;
|
||||
}
|
||||
.ripple.active i:nth-child(2) { animation-delay: 0.8s; }
|
||||
.ripple.active i:nth-child(3) { animation-delay: 1.6s; }
|
||||
/* idle: a faint dot keeps the spot marked without pulling the eye */
|
||||
.ripple:not(.active) i:nth-child(n + 2) { display: none; }
|
||||
.ripple:not(.active) i {
|
||||
inset: calc(50% - 0.15 * var(--dev-size));
|
||||
opacity: 0.3;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes hp-ripple {
|
||||
0% { transform: scale(0.18); opacity: 0.7; }
|
||||
70% { opacity: 0.22; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ripple.active i { animation: none; opacity: 0.3; }
|
||||
.ripple.active i:nth-child(n + 2) { display: none; }
|
||||
}
|
||||
.roomlabel {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
@@ -232,10 +334,28 @@ export const cardStyles = css`
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
text-shadow: 0 0 4px var(--card-background-color, rgba(0, 0, 0, 0.6));
|
||||
z-index: 1;
|
||||
}
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.measurelabel {
|
||||
position: absolute;
|
||||
transform: translate(12px, -150%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -255,6 +375,11 @@ export const cardStyles = css`
|
||||
.stage.markup .devlayer {
|
||||
display: none; /* in markup mode icons must not get in the way */
|
||||
}
|
||||
.room.picked {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
@@ -312,10 +437,12 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev {
|
||||
position: absolute;
|
||||
/* per-device multiplier on top of the card-wide icon size */
|
||||
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
|
||||
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
|
||||
width: var(--icon-size, 2.5cqw);
|
||||
height: var(--icon-size, 2.5cqw);
|
||||
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
|
||||
width: var(--dev-size);
|
||||
height: var(--dev-size);
|
||||
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
|
||||
border-radius: 22%;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
@@ -376,7 +503,24 @@ export const cardStyles = css`
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dev .hval {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
@@ -461,6 +605,7 @@ export const cardStyles = css`
|
||||
}
|
||||
/* beat the generic .dialog .body .namein { width:100% } rule */
|
||||
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
|
||||
.dialog .body .colorrow .tempin { width: 72px; flex: none; }
|
||||
.srcrow { flex-wrap: nowrap; }
|
||||
.srcrow > span:first-of-type { white-space: nowrap; }
|
||||
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
|
||||
|
||||
@@ -38,6 +38,26 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
|
||||
size?: number | null; // icon size multiplier (default 1)
|
||||
angle?: number | null; // icon rotation, degrees
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
export interface OpeningCfg {
|
||||
id: string;
|
||||
type: 'door' | 'window';
|
||||
x: number; // center, normalized by plan width
|
||||
y: number; // center, normalized by plan height
|
||||
angle: number; // wall angle, degrees
|
||||
length: number; // along the wall, normalized by plan width
|
||||
contact?: string | null; // binary_sensor / cover driving open-closed
|
||||
lock?: string | null; // lock entity (doors only)
|
||||
invert?: boolean;
|
||||
flip_h?: boolean; // hinge on the other jamb
|
||||
flip_v?: boolean; // opens to the other side of the wall
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
@@ -61,6 +81,7 @@ export interface DevItem {
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
temp?: number | null;
|
||||
hum?: number | null;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // linked config marker (metadata, overrides)
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
|
||||
+57
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -224,3 +224,59 @@ test('isTempEntity: excludes chip temperature and diagnostic-category entities',
|
||||
// если в устройстве и чип, и настоящий — берётся настоящий
|
||||
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
|
||||
});
|
||||
|
||||
test('buildDevices: entity marker gets an auto icon + temperature (split a multi-entity device)', () => {
|
||||
const h = mkHass({
|
||||
entities: {
|
||||
'sensor.clim_temp': { entity_id: 'sensor.clim_temp', device_id: 'clim', platform: 'demo' },
|
||||
'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.clim_temp': { state: '22.5', attributes: { device_class: 'temperature', friendly_name: 'Climate Temperature' } },
|
||||
'sensor.clim_hum': { state: '55', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } },
|
||||
},
|
||||
});
|
||||
const ctx = baseCtx(h, { markers: [
|
||||
{ id: 'e1', binding: 'entity:sensor.clim_temp' },
|
||||
{ id: 'e2', binding: 'entity:sensor.clim_hum' },
|
||||
] });
|
||||
const devs = buildDevices(ctx);
|
||||
const t = devs.find((d) => d.id === 'e1');
|
||||
const hum = devs.find((d) => d.id === 'e2');
|
||||
assert.equal(t.icon, 'mdi:thermometer');
|
||||
assert.equal(t.temp, 22.5);
|
||||
// humidity resolves to a real icon (device_class fallback), not the old generic shape
|
||||
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
|
||||
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
|
||||
});
|
||||
|
||||
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
|
||||
const h = mkHass({ states: {
|
||||
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
|
||||
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
|
||||
}});
|
||||
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
|
||||
assert.equal(humFor(h, ['sensor.t']), null);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
|
||||
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
|
||||
});
|
||||
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
|
||||
assert.equal(hum.icon, 'mdi:water-percent');
|
||||
assert.equal(hum.hum, 55);
|
||||
assert.equal(hum.temp, undefined);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
|
||||
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
|
||||
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
|
||||
});
|
||||
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
|
||||
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
||||
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -258,3 +260,175 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
|
||||
assert.equal(o.tempMin, 18.5);
|
||||
assert.equal(o.tempMax, 23);
|
||||
});
|
||||
|
||||
test('segmentCm: cells scaled by cm-per-cell', () => {
|
||||
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
|
||||
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
|
||||
});
|
||||
|
||||
test('formatLength: metric metres with 2 decimals', () => {
|
||||
assert.equal(formatLength(25, false), '0.25 m');
|
||||
assert.equal(formatLength(125, false), '1.25 m');
|
||||
assert.equal(formatLength(0, false), '0.00 m');
|
||||
});
|
||||
|
||||
test('formatLength: imperial feet + inches, with inch rollover', () => {
|
||||
assert.equal(formatLength(124.46, true), '4′ 1″');
|
||||
assert.equal(formatLength(30.48, true), '1′ 0″');
|
||||
assert.equal(formatLength(29.464, true), '1′ 0″');
|
||||
});
|
||||
|
||||
test('roomEdges: a line exists only as a room edge; polygons and rects both yield walls', () => {
|
||||
const sq = { poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
|
||||
assert.equal(roomEdges([sq]).length, 4); // closed outline → 4 walls
|
||||
assert.equal(roomEdges([{ x: 0, y: 0, w: 1, h: 1 }]).length, 4); // legacy rect room
|
||||
assert.equal(roomEdges([]).length, 0); // no rooms → no lines at all
|
||||
assert.equal(roomEdges([{ poly: [[0, 0], [1, 1]] }]).length, 0); // not a closed room → nothing
|
||||
});
|
||||
|
||||
test('roomEdges: a wall shared by two rooms is emitted once, and survives deleting either room', () => {
|
||||
const left = { id: 'a', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] };
|
||||
const right = { id: 'b', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }; // shares x=0.5 wall
|
||||
const both = roomEdges([left, right]);
|
||||
assert.equal(both.length, 7); // 4 + 4 - 1 shared, deduped regardless of direction
|
||||
const shared = (segs) => segs.some((s) => s[0] === 0.5 && s[2] === 0.5);
|
||||
assert.ok(shared(both));
|
||||
// deleting 'left' → the shared wall stays, because 'right' still contributes it
|
||||
assert.ok(shared(roomEdges([right])));
|
||||
// deleting both → no lines remain
|
||||
assert.equal(roomEdges([]).length, 0);
|
||||
});
|
||||
|
||||
const SQ = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
|
||||
test('pointStrictlyInside: a point on a wall is NOT inside (shared walls are normal)', () => {
|
||||
assert.ok(pointStrictlyInside([1, 1], SQ)); // middle
|
||||
assert.ok(!pointStrictlyInside([2, 1], SQ)); // on a wall mid-span (T-junction vertex)
|
||||
assert.ok(!pointStrictlyInside([0, 0], SQ)); // on a corner
|
||||
assert.ok(!pointStrictlyInside([3, 1], SQ)); // outside
|
||||
assert.ok(pointOnBoundary([2, 1], SQ));
|
||||
assert.ok(!pointOnBoundary([1, 1], SQ));
|
||||
});
|
||||
|
||||
test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', () => {
|
||||
const right = [[2, 0], [4, 0], [4, 2], [2, 2]]; // shares the whole x=2 wall
|
||||
assert.ok(!roomsOverlap(SQ, right));
|
||||
// neighbour's wall is LONGER than ours — the real dacha case (collinear partial overlap)
|
||||
const tall = [[2, -1], [4, -1], [4, 3], [2, 3]];
|
||||
assert.ok(!roomsOverlap(SQ, tall));
|
||||
// touching only at a corner
|
||||
assert.ok(!roomsOverlap(SQ, [[2, 2], [3, 2], [3, 3], [2, 3]]));
|
||||
// apart
|
||||
assert.ok(!roomsOverlap(SQ, [[5, 5], [6, 5], [6, 6], [5, 6]]));
|
||||
// genuine partial overlap
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('roomsOverlap: nested, identical and enclosing outlines all count as overlap', () => {
|
||||
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
|
||||
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
|
||||
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it
|
||||
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
|
||||
// a cross: no vertex of either lies inside the other, but the edges cross
|
||||
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
|
||||
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
|
||||
});
|
||||
|
||||
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
||||
assert.equal(roomPoly({ poly: SQ }), SQ);
|
||||
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
|
||||
assert.equal(roomPoly({}), null);
|
||||
});
|
||||
|
||||
test('polygonArea: shoelace, orientation-independent', () => {
|
||||
assert.equal(polygonArea([[0, 0], [2, 0], [2, 2], [0, 2]]), 4);
|
||||
assert.equal(polygonArea([[0, 0], [0, 2], [2, 2], [2, 0]]), 4); // reversed winding
|
||||
assert.equal(polygonArea([[0, 0], [1, 1]]), 0);
|
||||
});
|
||||
|
||||
test('mergeRooms: only rooms sharing a wall merge; the union is one simple outline', () => {
|
||||
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
const full = mergeRooms(a, [[2, 0], [4, 0], [4, 2], [2, 2]]); // whole wall shared
|
||||
assert.equal(polygonArea(full), 8);
|
||||
assert.equal(full.length, 4); // collapses to one rectangle
|
||||
// the neighbour's wall is LONGER than ours — the real dacha case
|
||||
const partial = mergeRooms(a, [[2, -1], [4, -1], [4, 3], [2, 3]]);
|
||||
assert.equal(polygonArea(partial), 4 + 8);
|
||||
assert.ok(partial.length >= 6); // an L/T-shaped outline
|
||||
});
|
||||
|
||||
test('mergeRooms: refuses a corner touch, rooms apart, and a union with a hole', () => {
|
||||
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
assert.equal(mergeRooms(a, [[2, 2], [4, 2], [4, 4], [2, 4]]), null); // corner only
|
||||
assert.equal(mergeRooms(a, [[5, 5], [6, 5], [6, 6], [5, 6]]), null); // apart
|
||||
const u = [[0, 0], [6, 0], [6, 2], [4, 2], [4, 6], [6, 6], [6, 8], [0, 8]];
|
||||
assert.equal(mergeRooms(u, [[6, 2], [8, 2], [8, 6], [6, 6]]), null); // would enclose a hole
|
||||
});
|
||||
|
||||
test('splitRoom: a wall-to-wall chord cuts the room in two, areas are preserved', () => {
|
||||
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||
const parts = splitRoom(sq, [0, 2], [4, 2]); // straight across
|
||||
assert.ok(parts);
|
||||
assert.equal(polygonArea(parts[0]) + polygonArea(parts[1]), polygonArea(sq));
|
||||
assert.equal(polygonArea(parts[0]), 8);
|
||||
assert.equal(polygonArea(parts[1]), 8);
|
||||
// an off-centre cut → a bigger and a smaller part (the bigger one keeps the room)
|
||||
const off = splitRoom(sq, [0, 1], [4, 1]);
|
||||
const areas = [polygonArea(off[0]), polygonArea(off[1])].sort((x, y) => x - y);
|
||||
assert.deepEqual(areas, [4, 12]);
|
||||
});
|
||||
|
||||
test('splitRoom: refuses cuts that are not clean wall-to-wall chords', () => {
|
||||
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||
assert.equal(splitRoom(sq, [1, 1], [3, 3]), null); // ends not on a wall
|
||||
assert.equal(splitRoom(sq, [0, 2], [0, 2]), null); // same point
|
||||
assert.equal(splitRoom(sq, [0, 0], [4, 0]), null); // along a wall → zero-area sliver
|
||||
// an L-shaped room: a chord that would leave the room is refused
|
||||
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
|
||||
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
|
||||
});
|
||||
|
||||
test('closestPointOnBoundary: projects a click onto the nearest wall', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
assert.deepEqual(closestPointOnBoundary([5, -3], sq), [5, 0]); // above the bottom edge
|
||||
assert.deepEqual(closestPointOnBoundary([13, 5], sq), [10, 5]); // right of the right edge
|
||||
assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom)
|
||||
assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges
|
||||
});
|
||||
|
||||
test('isActiveState: a sensor outage calms the plan down, it never pulses forever', () => {
|
||||
assert.ok(isActiveState('on'));
|
||||
assert.ok(isActiveState('open'));
|
||||
assert.ok(isActiveState('home'));
|
||||
assert.ok(isActiveState('detected'));
|
||||
assert.ok(!isActiveState('off'));
|
||||
assert.ok(!isActiveState('unavailable'));
|
||||
assert.ok(!isActiveState('unknown'));
|
||||
assert.ok(!isActiveState(undefined));
|
||||
assert.ok(!isActiveState(null));
|
||||
});
|
||||
|
||||
test('snapToWall: projects onto the nearest derived wall with its angle; misses return null', () => {
|
||||
const rooms = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
|
||||
const s = snapToWall([4, 0.6], rooms, 1); // near the top wall
|
||||
assert.deepEqual([s.x, s.y], [4, 0]);
|
||||
assert.equal(Math.abs(s.angle) % 180, 0); // horizontal wall
|
||||
const v = snapToWall([10.4, 7], rooms, 1); // near the right wall
|
||||
assert.deepEqual([v.x, v.y], [10, 7]);
|
||||
assert.equal(Math.abs(v.angle), 90);
|
||||
assert.equal(snapToWall([5, 5], rooms, 1), null); // middle of the room: no wall within reach
|
||||
});
|
||||
|
||||
test('openingAmount: doors default open, windows closed; outages freeze the default', () => {
|
||||
assert.equal(openingAmount('door', null), 1);
|
||||
assert.equal(openingAmount('window', null), 0);
|
||||
assert.equal(openingAmount('door', 'unavailable'), 1);
|
||||
assert.equal(openingAmount('window', 'unknown'), 0);
|
||||
assert.equal(openingAmount('door', 'on'), 1);
|
||||
assert.equal(openingAmount('door', 'off'), 0);
|
||||
assert.equal(openingAmount('window', 'open'), 1);
|
||||
// invert flips on/off but never the outage default
|
||||
assert.equal(openingAmount('door', 'on', true), 0);
|
||||
assert.equal(openingAmount('door', 'off', true), 1);
|
||||
assert.equal(openingAmount('door', 'unavailable', true), 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user