Compare commits

...
13 Commits
Author SHA1 Message Date
Matysh 4cd49d2c5d Merge limit-50: manual-upload limit 50 MB v1.23.2
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m22s
Validate / backend (push) Failing after 2m34s
2026-07-21 08:04:53 +03:00
Matysh 1fa6c6cdee feat v1.23.2: raise the manual-upload limit to 50 MB
MAX_FILE_BYTES 25→50 MB (still enforced while the multipart body streams in);
frontend fallback in the error toast updated; TESTING.md row updated.
2026-07-21 08:02:53 +03:00
Matysh 70d3453f78 docs: catch all documentation up to v1.23.1
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
- READMEs (en+ru): doors/windows/locks section (openings tool, live rendering, drag,
  double-click properties), presence ripples + per-device icon size/rotation in the
  manual-marker section.
- ARCHITECTURE: room geometry rules (derived walls, no-overlap, merge/split via
  polyclip-ts and why not polygon-clipping), doors & windows model (space.openings,
  snapToWall, absolute coords, security), per-marker display fields + cell_cm ruler.
- DEVELOPMENT: gotchas — polygon-clipping ESM/types mismatch; same-version redeploys
  keep ?v so browsers cache the old bundle.
- STATUS: snapshot refreshed (releases to v1.23.1, dacha deployed v1.23.1), milestones
  v1.17–v1.23.1 incl. the lost-sources lesson (push right after building).
- ROADMAP: 8 shipped Quality-Scale items ticked (done since v1.12.0).
- TESTING: restored the v1.22.0 ripple checklist lost in the sandbox reset.
2026-07-17 07:53:16 +03:00
Matysh 3e53b012be feat v1.23.1: openings — hover outline, drag along walls, double-click properties
- Hover: an accent outline hugs the opening's wall strip + grab cursor.
- Drag (view mode): re-snaps continuously to the nearest derived wall; snapToWall now
  normalizes the angle to [-90,90) — neighbouring rooms yield the same wall in opposite
  directions, which used to flip the hinge while dragging across segment boundaries.
  setPointerCapture wrapped in try/catch (an inactive pointerId must not kill the drag).
- Click still opens the status card (via a 250ms timer); double click opens the
  properties dialog from view mode. Hit zone is now a slightly thicker strip along the
  wall instead of the whole swing square (fewer accidental hovers). +1 test (77).
Verified live: drag moved a test door exactly along a vertical wall (angle 90 -> -90),
click/dblclick/markup-tool paths all работают; the user's 12 real openings untouched.
2026-07-17 07:43:10 +03:00
Matysh ff7c1b883c feat v1.23.0: doors & windows with live open/lock state
New markup tool 'Opening': click a wall — the opening snaps onto the nearest DERIVED
room wall (v1.19.0 model: openings keep absolute coords, so room edits never break
them) and takes its angle. Dialog: type, length in real cm (door 90 / window 120 —
per-space scale makes it honest), contact sensor (invertible), lock (doors).
Rendering after easy-floorplan (MIT): hinged leaf + swing arc drawing on via
stroke-dashoffset; window = two casements. No sensor → static plan defaults (door
open, window closed); unavailable freezes the default (pure openingAmount, tested).
Lock: padlock badge (green locked / orange unlocked / grey unknown); the lock is
never toggled from the plan — clicking shows an info card with both states.
snapToWall + openingAmount in logic.ts; space.openings validated server-side.
+2 tests (76). Verified live on the dacha (real TTLock + TRV window sensor).
2026-07-17 07:21:20 +03:00
Matysh 7c9731655e feat v1.22.0: presence ripples, per-device icon size/rotation, one-click HACS badge
Visuals borrowed from easy-floorplan (github.com/nicosandller/easy-floorplan), model kept ours.
isActiveState is independent of the live_states toggle; unavailable/unknown count as idle.
Honours prefers-reduced-motion. Badges scale with --dev-size. +1 test (74).
2026-07-17 07:02:22 +03:00
Matysh 921facf1ac Merge ux-1213: no text shadow on room labels v1.21.3
Validate / hacs (push) Waiting to run
Validate / hassfest (push) Waiting to run
Validate / frontend (push) Waiting to run
Validate / backend (push) Waiting to run
2026-07-16 15:11:56 +03:00
Matysh 8ddf34f3ac style v1.21.3: drop the text shadow on room name labels 2026-07-16 15:08:45 +03:00
Matysh 5dd14a009a Merge ux-1212: space-dialog polish v1.21.2 2026-07-16 14:34:14 +03:00
Matysh ae10befd58 fix v1.21.2: space-dialog polish
- scale (cm/cell) input compact again (generic width:100% dialog rule stretched it)
- space with no background image gets a white paper canvas (.stage.noplan), so
  hand-drawn rooms read like a plan on paper
- TESTING.md updated in the same commit
2026-07-16 14:32:07 +03:00
Matysh f670c8644c Merge audit-4: audit of v1.16-v1.21, split wall-snap fix, docs v1.21.1
Validate / hacs (push) Waiting to run
Validate / hassfest (push) Waiting to run
Validate / frontend (push) Waiting to run
Validate / backend (push) Waiting to run
2026-07-16 11:51:33 +03:00
Matysh 9288d74f6f fix+docs v1.21.1: audit of v1.16-v1.21
- fix: Split snaps the click to the room's nearest wall instead of the grid, so
  it works on non-grid-aligned rooms (imported/legacy polygons); the pull is
  capped (~6 cells) so accidental mid-room clicks stay a miss; splitRoom() still
  rejects a bad cut. closestPointOnBoundary() + unit test.
- docs: README (en+ru) documents Merge/Split/ruler/per-space scale (were undocumented);
  TESTING.md merge/split rows + fresh self-run; new smokes smoke_merge_split,
  smoke_split_nonsnap (incl. the capped-pull case).
- 73 frontend + 12 backend tests green; module eval + both cards register.
2026-07-16 11:43:15 +03:00
Matysh b706ad4b49 feat v1.21.0: merge and split rooms
- Merge: click a room, then a neighbour. Adjacency is decided by the RESULT, not a
  heuristic: mergeRooms unions the outlines and accepts only when they collapse into one
  hole-free outline (corner touch / apart / hole => refused). A dialog picks the surviving
  name+area; the kept room keeps its id so its label and devices stay put.
- Split: click the room, then two wall points; the chord cuts it, live ruler on the cut.
  The bigger part stays the room (name/area/devices), the smaller opens the new-room
  dialog. The cut is applied only on confirm — Cancel leaves the room whole.
- Boolean geometry via polyclip-ts (proper ESM + native types; polygon-clipping ships
  named types but a default-only ESM build, breaking either tsc or the runtime).
  Verified on the real plan, where neighbouring walls overlap collinearly rather than
  match exactly — the case a hand-rolled union gets wrong. Bundle 151->202 KB.
+5 tests (72). Verified live: merge of Сауна+с/у -> 4-vertex outline, non-adjacent
refused, split preserves area (26667 -> 13333+13333), cancel leaves the room whole.
2026-07-16 08:10:24 +03:00
25 changed files with 2732 additions and 445 deletions
+34
View File
@@ -51,6 +51,11 @@ Key advantages in short:
## Installation
One click if you already run HACS:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](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,29 @@ As soon as the outline is closed, the room-save dialog appears. Here you need to
![Marking up a room and saving it](docs/images/05-room-dialog.png)
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.
### Doors, windows and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
walls** (it slides around corners too), and a **double click opens its properties**.
### 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.
@@ -165,6 +193,12 @@ You can also place a **single entity** (not just a whole device): start typing i
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**.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
---
+34
View File
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
## Установка
В один клик, если у вас уже есть HACS:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
### Через HACS (рекомендуется)
1. Откройте **HACS → меню (⋮) → Custom repositories**.
@@ -122,6 +127,29 @@ title: План дома
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
@@ -169,6 +197,12 @@ title: План дома
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
---
+1 -1
View File
@@ -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.20.0"
VERSION = "1.23.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.20.0"
"version": "1.23.2"
}
+24 -1
View File
@@ -12,7 +12,7 @@ import voluptuous as vol
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
MAX_FILE_BYTES = 25 * 1024 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
@@ -100,6 +100,24 @@ 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.
@@ -121,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)
],
+83
View File
@@ -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();
+21
View File
@@ -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();
File diff suppressed because one or more lines are too long
+429 -134
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -86,6 +86,15 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
`cell_cm` (default 5 cm per grid cell).
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
@@ -109,6 +118,18 @@ Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/p
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
## Room geometry rules (v1.19–v1.21)
A **line is never an entity of its own**: walls are *derived* from room outlines
(`roomEdges`, deduped by `segKey`), so an abandoned outline leaves nothing behind and deleting
a room keeps the walls its neighbours still contribute. Rooms may not overlap
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
**polyclip-ts** (chosen over `polygon-clipping`, whose ESM build exports only a default while
its types declare named exports — breaking either tsc or the runtime): merge accepts a pair only
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
bigger part keeps the room identity (name/area/devices).
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
@@ -127,6 +148,26 @@ While drawing, the length of the current segment follows the cursor (`_fmtLen`
`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).
## Doors & windows (v1.23.0+)
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
wall, while markers are free points whose positions live in the layout store. Model:
`{id, type: door|window, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
rooms share a wall with opposite edge directions, and without that a drag across segment
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
or deleting rooms never breaks it.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
are two casement leaves. `openingAmount` (pure) maps the contact state to 0..1: no sensor →
door drawn open / window closed (static-plan convention); `unavailable`/`unknown` freeze that
default. The lock renders as an HTML padlock badge (`.oplock`) in the device layer; a lock is
**never** toggled from the plan (TOGGLE_FORBIDDEN_DOMAINS rule). View-mode UX: hover outline,
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
## Integration WS API
| Command | Parameters | Response |
+97
View File
@@ -1,5 +1,102 @@
# Changelog
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
The limit is still enforced while the multipart body streams in, so an oversized
upload is cut off early rather than buffered whole; the error toast reads the
actual limit from the server response.
## v1.23.1 — 2026-07-17 (openings: hover, drag along walls, double-click properties)
- **Hover affordance**: an accent outline hugs the opening's wall strip on hover, with a
grab cursor — placed openings now look grabbable.
- **Drag along walls** (view mode): an opening re-snaps continuously to the nearest derived
wall while dragged; too far from any wall → it stays put. `snapToWall` now normalizes the
angle to [-90, 90) — two rooms share a wall with OPPOSITE edge directions, and without this
a drag across segment boundaries flipped the hinge side back and forth. Saved on release.
- **Click / double click**: a single click still opens the status card (now via a 250 ms
timer); a **double click opens the properties dialog** right from view mode. The markup
"Opening" tool behaves as before. Hit zone made slightly thicker and is now a strip along
the wall (previously it covered the whole swing square, causing accidental hovers).
- (+1 test: 76 → 77.)
## 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
+9
View File
@@ -69,6 +69,15 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
## Dependency and cache gotchas
- **polygon-clipping is a trap**: its `.d.ts` declares named exports but the ESM build has only
a default export — tsc or the runtime breaks, whichever you appease. Use **polyclip-ts**
(proper ESM + native types; same results, +~50 KB bundle via bignumber.js).
- **Redeploying the same version keeps the resource URL** (`/houseplan_files/houseplan-card.js?v=X`),
so browsers may serve the previous bundle from cache. Bump the version for anything users must
pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy.
## Release
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
+8 -8
View File
@@ -14,27 +14,27 @@ This roadmap replaces them.
Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt + comment).
- [ ] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
- [x] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
`entry.runtime_data` (`type HouseplanConfigEntry = ConfigEntry[HouseplanData]`).
Keep WS command registration in `async_setup` (commands must survive entry reloads).
- [ ] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
- [x] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
update listeners via `entry.async_on_unload`; document what stays (static paths cannot
be unregistered — exempt comment).
- [ ] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
- [x] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
raise `ConfigEntryNotReady` on failure.
- [ ] `unique-config-entry`: replace the manual `_async_current_entries()` check with
- [x] `unique-config-entry`: replace the manual `_async_current_entries()` check with
`single_config_entry: true` in manifest.
- [ ] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
- [x] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
conflict/rev, admin_only enforcement, layout point ops, upload limits).
- [ ] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
- [x] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
before the config schema evolves further (Bronze-adjacent, cheap while schema is stable).
- [ ] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
- [x] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
## Phase 8 — Quality Scale: Silver + selected Gold
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
- [ ] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
LQI thresholds, group_lights default.
+26 -5
View File
@@ -9,18 +9,18 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-05)
## Snapshot (2026-07-17)
| Item | State |
|---|---|
| Version | **v1.20.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 |
| Version | **v1.23.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
| 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 |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
| 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 | 67 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 | 77 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)
@@ -67,6 +67,27 @@
space-geometry/space-render + module-level config cache (config-store).
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
no longer omitted; +shared areaLqi().
- **v1.17.x** — entity markers get auto icon/temp (issue #1); correct resource URL documented
(issue #2); humidity badge (gated on device_class, not the icon).
- **v1.18.x** — live ruler while drawing rooms (metres / feet+inches) + per-space scale
`cell_cm`; visibility fix (the badge lived in the markup-hidden devlayer).
- **v1.19.0** — a line is never an entity of its own: walls derived from room outlines
(`roomEdges`), unfinished contours persist nothing, Erase tool removed, `space.segments`
stripped on save.
- **v1.20.0** — rooms may not overlap (strictly-inside clicks refused, overlapping contours
refused at close; shared walls stay legal).
- **v1.21.x** — merge & split rooms (boolean geometry via polyclip-ts; merge = union collapses
to one hole-free outline; split = wall-to-wall chord, bigger part keeps identity) + UX fixes.
- **v1.22.0** — presence ripples (badge/ripple/icon_ripple + colour/size, `isActiveState`),
per-device icon size/rotation (`--dev-size`), one-click HACS badge. NOTE: sources were lost
in a sandbox reset after deploy and restored from conversation patches — push immediately
after building, never wait for verification.
- **v1.23.0** — doors & windows: "Opening" markup tool (snap onto derived walls, absolute
coords), length in real cm, contact sensor + lock, animated leaf/arc, padlock badge, status
card; lock never toggled from the plan.
- **v1.23.1** — openings UX: hover outline + grab cursor, drag along walls (angle normalized
to [-90,90) so the hinge never flips), click=status / double-click=properties, thicker hit
strip. Release v1.23.1 published on GitHub (covers v1.22.0–v1.23.1).
## Where things live
+55 -3
View File
@@ -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
@@ -74,6 +75,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] 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
@@ -102,7 +112,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Virtual device: requires name; room required; renders dashed
- [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >25 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
@@ -130,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 ★
@@ -165,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:
@@ -189,3 +209,35 @@ 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]
## Presence ripples / per-device icon (v1.22.0)
- [ ] Marker dialog → Display = "Ripple only": the icon badge disappears, rings pulse while the
entity is on, and collapse to a faint dot when it goes off
- [ ] Display = "Icon + ripple": both the icon and the rings are drawn
- [ ] Ripple colour and size (×2..×8) apply per device
- [ ] An entity going `unavailable` stops the pulsing (idle dot), never leaves it running
- [ ] Icon size ×0.5..×3 and rotation 0..350° apply per device; the temp/humidity badges
scale with the icon
- [ ] Ripples still work with the card-wide "live states" toggle OFF (they are opt-in per device)
- [ ] With OS "reduce motion" enabled, rings do not animate
## 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
- [ ] (v1.23.1) Hovering an opening in view mode shows the accent outline + grab cursor
- [ ] Dragging an opening slides it along walls (re-snapping, incl. around corners) and saves
on release; dragging far away from walls leaves it in place; hinge does not flip while
crossing wall-segment boundaries
- [ ] Single click still opens the status card; double click opens the properties dialog;
a drag does NOT open either
+29 -3
View File
@@ -1,15 +1,16 @@
{
"name": "houseplan-card",
"version": "1.15.0",
"version": "1.23.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.15.0",
"version": "1.23.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
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.20.0",
"version": "1.23.2",
"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"
}
}
+688 -17
View File
@@ -14,12 +14,15 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
import './editor';
@@ -27,14 +30,14 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.20.0';
const CARD_VERSION = '1.23.2';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -81,6 +84,25 @@ class HouseplanCard extends LitElement {
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _mergeSel: string | null = null;
private _openingDialog: {
id?: string; // editing an existing opening
type: 'door' | 'window';
lengthCm: number;
contact: string;
lock: string;
invert: boolean;
flipH: boolean;
flipV: boolean;
x: number; y: number; angle: number; // render units (from the wall snap)
} | null = null;
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | null = null;
private _opClickTimer: number | null = null; // first room picked for a merge
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
private _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
private _areaSel = '';
private _nameSel = '';
private _roomDialog = false;
@@ -109,6 +131,11 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = auto
display: 'badge' | 'ripple' | 'icon_ripple';
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
angle: number; // icon rotation, degrees
tapAction: string; // '' = card default
model: string;
link: string;
@@ -170,6 +197,11 @@ class HouseplanCard extends LitElement {
_tool: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
_openingDialog: { state: true },
_openingInfo: { state: true },
_mergeDialog: { state: true },
_splitSel: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
@@ -966,6 +998,10 @@ class HouseplanCard extends LitElement {
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
this._mergeSel = null;
this._mergeDialog = null;
this._splitSel = null;
this._pendingSplit = null;
}
private _svgPoint(ev: MouseEvent): number[] {
@@ -1052,6 +1088,18 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
return;
}
if (this._tool === 'opening') {
this._openingClick(raw);
return;
}
if (this._tool === 'merge') {
this._mergeClick(raw);
return;
}
if (this._tool === 'split') {
this._splitClick(raw);
return;
}
// draw: clicks on grid points build the outline. Nothing is written to the config
// until the contour closes — an abandoned outline leaves no lines behind.
const pt = this._snap(raw);
@@ -1088,14 +1136,284 @@ class HouseplanCard extends LitElement {
this._path = [...this._path, pt];
}
/** Openings of the current space in render units. */
private get _openingsR(): (OpeningCfg & { rx: number; ry: number; rlen: number })[] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.openings || []).map((o: OpeningCfg) => ({
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
}));
}
/** cm → render units via the space scale (cm per grid cell). */
private _cmToUnits(cm: number): number {
return (cm / this._cellCm) * this._gridPitch;
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
);
if (hit) {
this._editOpening(hit);
return;
}
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) {
this._showToast(this._t('toast.opening_no_wall'));
return;
}
this._openingDialog = {
type: 'door', lengthCm: 90, contact: '', lock: '',
invert: false, flipH: false, flipV: false,
x: snap.x, y: snap.y, angle: snap.angle,
};
}
/** Open the properties dialog for an existing opening. */
private _editOpening(o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
this._openingDialog = {
id: o.id,
type: o.type,
lengthCm: Math.round((o.rlen / this._gridPitch) * this._cellCm),
contact: o.contact || '',
lock: o.lock || '',
invert: !!o.invert,
flipH: !!o.flip_h,
flipV: !!o.flip_v,
x: o.rx, y: o.ry, angle: o.angle,
};
}
/** Drag an opening along the walls (view mode): it re-snaps continuously. */
private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
if (this._markup) return;
ev.preventDefault();
ev.stopPropagation();
try {
(ev.target as Element).setPointerCapture?.(ev.pointerId);
} catch {
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
}
this._opDrag = { id: o.id, moved: false };
}
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return;
const raw = this._svgPoint(ev);
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
if (!snap) return; // too far from any wall: the opening stays where it was
this._opDrag.moved = true;
const sp = this._curSpaceCfg;
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return;
cfg.x = snap.x / NORM_W;
cfg.y = snap.y / this._spaceH;
cfg.angle = snap.angle;
this.requestUpdate();
}
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return;
const moved = this._opDrag.moved;
if (moved) this._saveConfig();
// keep the flag until the click event that follows pointerup, then let it go
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
else this._opDrag = null;
}
/** Click: the status card (delayed so a double click can cancel it). */
private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
ev.stopPropagation();
if (this._markup) return;
if (this._opDrag?.moved) return; // that click was the tail of a drag
if (this._opClickTimer) window.clearTimeout(this._opClickTimer);
this._opClickTimer = window.setTimeout(() => {
this._opClickTimer = null;
this._openingInfo = o;
}, 250);
}
/** Double click: the properties dialog. */
private _opDblClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
ev.stopPropagation();
if (this._markup) return;
if (this._opClickTimer) {
window.clearTimeout(this._opClickTimer);
this._opClickTimer = null;
}
this._openingInfo = null;
this._editOpening(o);
}
private _saveOpening(): void {
const d = this._openingDialog;
const sp = this._curSpaceCfg;
if (!d || !sp) return;
const H = this._spaceH;
const o: OpeningCfg = {
id: d.id || 'o' + Date.now().toString(36),
type: d.type,
x: d.x / NORM_W,
y: d.y / H,
angle: d.angle,
length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
contact: d.contact || null,
lock: d.type === 'door' ? d.lock || null : null,
invert: d.invert || undefined,
flip_h: d.flipH || undefined,
flip_v: d.flipV || undefined,
};
sp.openings = sp.openings || [];
const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id);
if (i >= 0) sp.openings[i] = o;
else sp.openings.push(o);
this._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
private _deleteOpening(): void {
const d = this._openingDialog;
const sp = this._curSpaceCfg;
if (!d?.id || !sp?.openings) return;
sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id);
this._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
/** Contact-sensor candidates: door/window-like classes first, then the rest. */
private _contactCandidates(): { value: string; label: string }[] {
const out: [string, string, number][] = [];
for (const eid of Object.keys(this.hass.states)) {
const dom = eid.split('.')[0];
if (dom !== 'binary_sensor' && dom !== 'cover') continue;
const st = this.hass.states[eid];
const dc = st?.attributes?.device_class || '';
const doorish = ['door', 'window', 'opening', 'garage_door', 'garage'].includes(dc);
if (dom === 'cover' && !doorish) continue;
out.push([eid, st?.attributes?.friendly_name || eid, doorish ? 0 : 1]);
}
return out
.sort((a, b) => a[2] - b[2] || a[1].localeCompare(b[1]))
.map(([value, label]) => ({ value, label }));
}
private _lockCandidates(): { value: string; label: string }[] {
return Object.keys(this.hass.states)
.filter((eid) => eid.startsWith('lock.'))
.map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid }))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Merge: first click picks a room, second picks the room to merge it with. */
private _mergeClick(raw: number[]): void {
const rooms = this._spaceModel().rooms;
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!hit?.id) return;
const hitId = hit.id;
if (!this._mergeSel || this._mergeSel === hitId) {
this._mergeSel = this._mergeSel === hitId ? null : hitId; // click again = deselect
return;
}
const a = rooms.find((r) => r.id === this._mergeSel);
const pa = a ? roomPoly(a) : null;
const pb = roomPoly(hit);
const merged = pa && pb ? mergeRooms(pa, pb) : null;
if (!merged) {
// only rooms sharing a wall collapse into one outline (see mergeRooms)
this._showToast(this._t('toast.merge_not_adjacent'));
this._mergeSel = null;
return;
}
this._mergeDialog = { aId: this._mergeSel, bId: hitId, poly: merged, pick: 'a' };
this._mergeSel = null;
}
private _commitMerge(): void {
const d = this._mergeDialog;
const sp = this._curSpaceCfg;
if (!d || !sp) return;
const H = this._spaceH;
const keepId = d.pick === 'a' ? d.aId : d.bId;
const dropId = d.pick === 'a' ? d.bId : d.aId;
const keep = sp.rooms.find((r: any) => r.id === keepId);
if (!keep) {
this._mergeDialog = null;
return;
}
// the kept room keeps its id, so its label position and devices stay put
keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]);
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
this._saveConfig();
this._mergeDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.rooms_merged', { name: keep.name || '' }));
}
/** Split: click the room, then two points on its walls. */
private _splitClick(raw: number[]): void {
const rooms = this._spaceModel().rooms;
if (!this._splitSel) {
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!hit?.id) return;
this._splitSel = { roomId: hit.id, a: null };
return;
}
const room = rooms.find((r) => r.id === this._splitSel!.roomId);
const poly = room ? roomPoly(room) : null;
if (!room || !poly) {
this._splitSel = null;
return;
}
// A split point lands on the room's nearest wall — the user aims at a wall,
// and rooms need not be grid-aligned (imported/legacy polygons), so snapping
// to the grid would miss the outline. The pull is capped: a click far from
// any wall (e.g. an accidental one in the middle of the room) is a miss and
// gets the toast, not a wall the user never meant. splitRoom() still rejects
// any cut that is not a clean wall-to-wall chord.
const eps = this._gridPitch * 0.02;
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
const near = closestPointOnBoundary(raw, poly);
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
if (!pt || !pointOnBoundary(pt, poly, eps)) {
this._showToast(this._t('toast.split_pick_wall'));
return;
}
if (!this._splitSel.a) {
this._splitSel = { ...this._splitSel, a: pt };
return;
}
const parts = splitRoom(poly, this._splitSel.a, pt, eps);
if (!parts) {
this._showToast(this._t('toast.split_bad_cut'));
return;
}
// the bigger part stays the room it was — name, area and devices go with it
const [p1, p2] = parts;
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
const fresh = main === p1 ? p2 : p1;
this._pendingSplit = { roomId: room.id!, mainPoly: main, newPoly: fresh };
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._roomDialog = true;
}
private get _contourClosed(): boolean {
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
}
private _markupMove(ev: MouseEvent): void {
if (!this._markup || this._tool !== 'draw' || !this._path.length || this._contourClosed) {
return;
}
if (!this._markup) return;
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
const cutting = this._tool === 'split' && !!this._splitSel?.a;
if (!drawing && !cutting) return;
this._cursorPt = this._snap(this._svgPoint(ev));
}
@@ -1113,11 +1431,26 @@ class HouseplanCard extends LitElement {
}
private _commitRoom(): void {
if (!this._contourClosed) return;
const sp = this._curSpaceCfg;
if (!sp) return;
const H = this._spaceH;
const verts = this._path.slice(0, -1); // without the duplicated closing vertex
let verts: number[][];
if (this._pendingSplit) {
// apply the cut now: the bigger part keeps the original room, this dialog names the rest
const main = sp.rooms.find((r: any) => r.id === this._pendingSplit!.roomId);
if (!main) {
this._pendingSplit = null;
this._splitSel = null;
this._roomDialog = false;
return;
}
main.poly = this._pendingSplit.mainPoly.map((p) => [p[0] / NORM_W, p[1] / H]);
delete main.x; delete main.y; delete main.w; delete main.h;
verts = this._pendingSplit.newPoly;
} else {
if (!this._contourClosed) return;
verts = this._path.slice(0, -1); // without the duplicated closing vertex
}
const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : '';
sp.rooms.push({
id: 'r' + Date.now().toString(36),
@@ -1127,6 +1460,8 @@ class HouseplanCard extends LitElement {
});
this._saveConfig();
this._path = [];
this._pendingSplit = null;
this._splitSel = null;
const boundArea = this._areaSel;
this._areaSel = '';
this._nameSel = '';
@@ -1164,11 +1499,21 @@ class HouseplanCard extends LitElement {
this._path = [];
this._cursorPt = null;
this._roomDialog = false;
this._pendingSplit = null;
this._splitSel = null;
this._mergeSel = null;
this._mergeDialog = null;
}
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
private _roomDialogCancel(): void {
this._roomDialog = false;
if (this._pendingSplit) {
// nothing was applied yet — drop the cut entirely, the room stays whole
this._pendingSplit = null;
this._splitSel = null;
return;
}
this._undoPoint();
}
@@ -1196,6 +1541,11 @@ class HouseplanCard extends LitElement {
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '',
icon: d.marker?.icon || '',
display: d.marker?.display || 'badge',
rippleColor: d.marker?.ripple_color || '',
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '',
model: d.model || '',
link: d.link || '',
@@ -1206,7 +1556,9 @@ class HouseplanCard extends LitElement {
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '',
name: '', binding: 'virtual', bindingFilter: '', icon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
@@ -1335,7 +1687,7 @@ class HouseplanCard extends LitElement {
const json = await resp.json().catch(() => ({}));
if (!resp.ok || json.error) {
const map: Record<string, string> = {
too_large: this._t('err.too_large', { mb: json.max_mb || 25 }),
too_large: this._t('err.too_large', { mb: json.max_mb || 50 }),
bad_ext: this._t('err.bad_ext'),
unauthorized: this._t('err.unauthorized'),
};
@@ -1386,6 +1738,11 @@ class HouseplanCard extends LitElement {
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
display: dlg.display !== 'badge' ? dlg.display : null,
ripple_color: dlg.display !== 'badge' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display !== 'badge' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null,
model: dlg.model.trim() || null,
link: dlg.link.trim() || null,
@@ -1937,7 +2294,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup' : ''}"
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'}"
style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -1953,6 +2310,8 @@ class HouseplanCard extends LitElement {
: nothing}
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
cls += ' picked';
let style = '';
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
cls += ' styled';
@@ -1992,14 +2351,16 @@ class HouseplanCard extends LitElement {
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))}
${this._renderOpeningLocks(view)}
${disp.showNames && !this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
</div>
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed
${this._measureAnchor
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
: nothing}
</div>
@@ -2009,6 +2370,9 @@ class HouseplanCard extends LitElement {
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._mergeDialog ? this._renderMergeDialog() : nothing}
${this._openingDialog ? this._renderOpeningDialog() : nothing}
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
@@ -2039,9 +2403,22 @@ class HouseplanCard extends LitElement {
const temp = this._liveTemp(d);
const hum = this._liveHum(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
const m = d.marker;
const disp = m?.display || 'badge';
const ripple = disp === 'ripple' || disp === 'icon_ripple';
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
if (ripple) {
st.push(`--ripple-scale:${rScale}`);
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
}
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
style="left:${left}%;top:${top}%"
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name,
@@ -2052,7 +2429,12 @@ class HouseplanCard extends LitElement {
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
>
<ha-icon icon="${d.icon}"></ha-icon>
${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
: nothing}
${disp !== 'ripple'
? html`<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
@@ -2122,9 +2504,18 @@ class HouseplanCard extends LitElement {
>${r.name}</div>`;
}
/** Length badge that follows the cursor while drawing the current segment. */
/** Where the live measurement starts: the last outline point, or the first split point. */
private get _measureAnchor(): number[] | null {
if (!this._markup || !this._cursorPt) return null;
if (this._tool === 'draw' && this._path.length && !this._contourClosed)
return this._path[this._path.length - 1];
if (this._tool === 'split' && this._splitSel?.a) return this._splitSel.a;
return null;
}
/** Length badge that follows the cursor while drawing a segment or a cut. */
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const a = this._path[this._path.length - 1];
const a = this._measureAnchor!;
const b = this._cursorPt!;
const left = ((b[0] - view.x) / view.w) * 100;
const top = ((b[1] - view.y) / view.h) * 100;
@@ -2139,6 +2530,203 @@ class HouseplanCard extends LitElement {
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
}
/** Live state of an opening's contact, 0..1 drawn amount. */
private _openingAmt(o: OpeningCfg): number {
const st = o.contact ? this.hass.states[o.contact]?.state : null;
return openingAmount(o.type, st, !!o.invert);
}
/**
* Doors and windows, drawn in plan (SVG) coordinates so they scale and pan with
* the plan. Symbol geometry after easy-floorplan (MIT): jambs, a leaf that swings
* around its hinge, and a quarter-circle arc that "draws on" via stroke-dashoffset.
*/
private _renderOpenings(disp: SpaceDisplay): TemplateResult {
const items = this._openingsR;
if (!items.length) return svg``;
const base = disp.color;
return svg`${items.map((o) => {
const half = o.rlen / 2;
const amt = this._openingAmt(o);
const active = amt > 0 && !!o.contact;
const tone = active ? 'var(--hp-open)' : base;
const jamb = 8;
const sx = o.flip_h ? -1 : 1;
const sy = o.flip_v ? -1 : 1;
let body;
if (o.type === 'window') {
// two casement leaves hinged at the jambs, meeting in the middle
const arcLen = (Math.PI / 2) * half;
body = svg`
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 0 ${-half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 1 ${half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>
<g transform="translate(${half} 0)">
<g class="op-leaf" style="transform:rotate(${90 * amt}deg)">
<rect x="${-half}" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>`;
} else {
// door leaf hinged at the left jamb, swinging up; arc from tip to tip
const L = o.rlen;
const arcLen = (Math.PI / 2) * L;
body = svg`
<path class="op-arc" d="M ${half} 0 A ${L} ${L} 0 0 0 ${-half} ${-L}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.75" width="${L}" height="3.5" fill="${tone}"></rect>
</g>
</g>`;
}
return svg`<g class="opening" transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
<g transform="scale(${sx} ${sy})">
<line x1="${-half}" y1="${-jamb / 2}" x2="${-half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${half}" y1="${-jamb / 2}" x2="${half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
${body}
</g>
<rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40"
@click=${(e: MouseEvent) => this._opClick(e, o)}
@dblclick=${(e: MouseEvent) => this._opDblClick(e, o)}
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
@pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)}
@pointercancel=${(e: PointerEvent) => this._opPointerUp(e, o)}></rect>
</g>`;
})}`;
}
/** Padlock badges for doors with a lock entity (HTML, so ha-icon just works). */
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const items = this._openingsR.filter((o) => o.type === 'door' && o.lock);
if (!items.length) return html``;
return html`${items.map((o) => {
const st = this.hass.states[o.lock!]?.state;
const locked = st === 'locked';
const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st));
// perpendicular offset from the opening center, away from the swing side
const rad = ((o.angle + 90) * Math.PI) / 180;
const off = 16 * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const left = ((px - view.x) / view.w) * 100;
const top = ((py - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</div>`;
})}`;
}
private _renderOpeningInfoCard(): TemplateResult {
const o = this._openingInfo!;
const cSt = o.contact ? this.hass.states[o.contact]?.state : null;
const amt = this._openingAmt(o);
const lSt = o.lock ? this.hass.states[o.lock]?.state : null;
const row = (icon: string, label: string, value: string, cls = '') =>
html`<div class="oprow ${cls}"><ha-icon icon=${icon}></ha-icon><span>${label}</span><b>${value}</b></div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._openingInfo = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${o.type === 'door' ? 'mdi:door' : 'mdi:window-closed-variant'}></ha-icon>
${this._t(o.type === 'door' ? 'opening.door' : 'opening.window')}</div>
<div class="body">
${o.contact
? row(amt > 0 ? 'mdi:door-open' : 'mdi:door-closed',
this._t('opening.contact_label'),
cSt === 'unavailable' || cSt == null
? this._t('opening.state_unknown')
: this._t(amt > 0 ? 'opening.open' : 'opening.closed'),
amt > 0 ? 'warn' : 'ok')
: nothing}
${o.lock
? row(lSt === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant',
this._t('opening.lock_label'),
lSt === 'locked' ? this._t('opening.locked')
: ['unlocked', 'open'].includes(String(lSt)) ? this._t('opening.unlocked')
: this._t('opening.state_unknown'),
lSt === 'locked' ? 'ok' : 'warn')
: nothing}
${!o.contact && !o.lock ? html`<p class="muted">${this._t('opening.no_entities')}</p>` : nothing}
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingInfo = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
private _renderOpeningDialog(): TemplateResult {
const d = this._openingDialog!;
const opt = (list: { value: string; label: string }[], cur: string, set: (v: string) => void) =>
html`<select class="areasel" @change=${(e: Event) => set((e.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!cur}>${this._t('opening.none')}</option>
${list.map((c) => html`<option value=${c.value} ?selected=${c.value === cur}>${c.label}</option>`)}
</select>`;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:door"></ha-icon>
${d.id ? this._t('opening.edit') : this._t('opening.new')}</div>
<div class="body">
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
@change=${() => (this._openingDialog = { ...d, type: 'door', lengthCm: d.id ? d.lengthCm : 90 })} />
<span>${this._t('opening.door')}</span></label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
@change=${() => (this._openingDialog = { ...d, type: 'window', lengthCm: d.id ? d.lengthCm : 120 })} />
<span>${this._t('opening.window')}</span></label>
<label>${this._t('opening.length_label')}</label>
<input class="namein tempin" type="number" min="20" max="600" step="5" .value=${String(d.lengthCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._openingDialog = { ...d, lengthCm: n };
}} />
<label>${this._t('opening.contact_label')}</label>
${opt(this._contactCandidates(), d.contact, (v) => (this._openingDialog = { ...d, contact: v }))}
${d.contact
? html`<label class="srcrow"><input type="checkbox" .checked=${d.invert}
@change=${(e: Event) => (this._openingDialog = { ...d, invert: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.invert')}</span></label>`
: nothing}
${d.type === 'door'
? html`<label>${this._t('opening.lock_label')}</label>
${opt(this._lockCandidates(), d.lock, (v) => (this._openingDialog = { ...d, lock: v }))}`
: nothing}
<label class="srcrow"><input type="checkbox" .checked=${d.flipH}
@change=${(e: Event) => (this._openingDialog = { ...d, flipH: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.flip_h')}</span></label>
<label class="srcrow"><input type="checkbox" .checked=${d.flipV}
@change=${(e: Event) => (this._openingDialog = { ...d, flipV: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.flip_v')}</span></label>
</div>
<div class="row">
${d.id
? html`<button class="btn danger" @click=${this._deleteOpening}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveOpening}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
private _renderMarkupDefs(_vb: number[]): TemplateResult {
const g = this._gridPitch;
const dotR = g * 0.14;
@@ -2167,6 +2755,13 @@ class HouseplanCard extends LitElement {
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${this._tool === 'split' && this._splitSel?.a
? svg`<circle class="vertex first" cx="${this._splitSel.a[0]}" cy="${this._splitSel.a[1]}" r="${g * 0.22}"></circle>
${this._cursorPt
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}`
: nothing}
`;
}
@@ -2177,6 +2772,21 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button>
<button class="btn ${this._tool === 'merge' ? 'on' : ''}"
@click=${() => { this._tool = 'merge'; this._cancelPath(); this._tool = 'merge'; }}
title=${this._t('title.markup_merge')}>
<ha-icon icon="mdi:vector-union"></ha-icon>${this._t('markup.merge')}
</button>
<button class="btn ${this._tool === 'split' ? 'on' : ''}"
@click=${() => { this._tool = 'split'; this._cancelPath(); this._tool = 'split'; }}
title=${this._t('title.markup_split')}>
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
</button>
<button class="btn ${this._tool === 'opening' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'opening'; }}
title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
@@ -2304,6 +2914,35 @@ class HouseplanCard extends LitElement {
.value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple']].map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.display !== 'badge'
? html`<div class="colorrow">
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('marker.ripple_size')}</span>
<input type="range" min="2" max="8" step="0.5" .value=${String(d.rippleSize)}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleSize: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">×${d.rippleSize}</span>
</div>`
: nothing}
<label>${this._t('marker.size_label')}</label>
<div class="colorrow">
<input type="range" min="0.5" max="3" step="0.1" .value=${String(d.size)}
@input=${(e: Event) => (this._markerDialog = { ...d, size: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
<input type="range" min="0" max="350" step="10" .value=${String(d.angle)}
@input=${(e: Event) => (this._markerDialog = { ...d, angle: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">${d.angle}°</span>
</div>
<label>${this._t('marker.model_label')}</label>
<input class="namein" type="text" placeholder=${this._t('marker.model_ph')}
.value=${d.model}
@@ -2477,6 +3116,38 @@ class HouseplanCard extends LitElement {
</div>`;
}
private _renderMergeDialog(): TemplateResult {
const d = this._mergeDialog!;
const rooms = this._spaceModel().rooms;
const opt = (id: string, key: 'a' | 'b') => {
const r = rooms.find((x) => x.id === id);
const area = r?.area ? this.hass.areas[r.area]?.name : null;
return html`<label class="srcrow">
<input type="radio" name="mergekeep" .checked=${d.pick === key}
@change=${() => (this._mergeDialog = { ...d, pick: key })} />
<span>${r?.name || ''} <span class="muted">· ${area || this._t('merge.no_area')}</span></span>
</label>`;
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:vector-union"></ha-icon>${this._t('merge.header')}</div>
<div class="body">
<p class="muted">${this._t('merge.hint')}</p>
<label>${this._t('merge.keep')}</label>
${opt(d.aId, 'a')}
${opt(d.bId, 'b')}
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._mergeDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._commitMerge}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
private _renderRoomDialog(): TemplateResult {
const areas = this._freeAreas;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
+41
View File
@@ -27,11 +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_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.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",
@@ -56,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 +126,15 @@
"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",
+41
View File
@@ -27,11 +27,36 @@
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"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": "кликните точку сетки, чтобы начать контур",
@@ -56,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 +126,15 @@
"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": "Редактирование устройств доступно после переноса конфига на сервер",
+166
View File
@@ -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 {
@@ -73,6 +74,66 @@ export function roomEdges(rooms: any[]): number[][] {
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;
// normalize to [-90, 90): two rooms sharing a wall yield the same edge in
// OPPOSITE directions — without this, dragging an opening across segment
// boundaries would flip its hinge side back and forth
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
best = { x: q[0], y: q[1], angle };
}
}
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. */
export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
@@ -104,6 +165,29 @@ function distToSeg(p: number[], a: number[], b: number[]): number {
* 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++)
@@ -170,6 +254,88 @@ export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean
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().
+131 -6
View File
@@ -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,121 @@ 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;
}
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
.op-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
g.opening:hover .op-outline {
opacity: 0.9;
}
.op-hit {
fill: transparent;
cursor: grab;
pointer-events: auto;
touch-action: none; /* drags, not scrolls, on touch */
}
.op-hit:active {
cursor: grabbing;
}
.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,7 +350,6 @@ 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; }
@@ -274,6 +391,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);
@@ -331,10 +453,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);
@@ -395,7 +519,7 @@ 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);
@@ -412,7 +536,7 @@ export const cardStyles = css`
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(--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);
@@ -497,6 +621,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; }
+20
View File
@@ -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 {
+106
View File
@@ -4,6 +4,7 @@ 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,
@@ -338,3 +339,108 @@ test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
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);
});
test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directions cannot flip a dragged opening', () => {
// the same wall as seen from two neighbouring rooms (opposite winding)
const a = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
const b = [{ poly: [[10, 0], [0, 0], [0, 10], [10, 10]] }]; // reversed
const sa = snapToWall([5, 0.4], a, 1);
const sb = snapToWall([5, 0.4], b, 1);
assert.equal(sa.angle, sb.angle);
assert.ok(sa.angle >= -90 && sa.angle < 90);
const v = snapToWall([10.3, 5], a, 1); // vertical wall
assert.ok(v.angle >= -90 && v.angle < 90);
});