mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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).
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.22.0"
|
VERSION = "1.23.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.22.0"
|
"version": "1.23.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("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("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
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
|
# 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;
|
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||||
# the card strips the field on every write.
|
# the card strips the field on every write.
|
||||||
|
|||||||
Vendored
+248
-94
File diff suppressed because one or more lines are too long
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.23.0 — 2026-07-17 (doors & windows with live open/lock state)
|
||||||
|
Visual language after easy-floorplan (MIT); the placement model is ours.
|
||||||
|
- **New markup tool "Opening"**: click next to a wall → the opening snaps onto the nearest
|
||||||
|
DERIVED room wall (walls have no independent existence — v1.19.0) and takes its angle, then a
|
||||||
|
dialog asks for type (door/window), **length in real cm** (defaults: door 90, window 120 —
|
||||||
|
the per-space scale makes this honest), an open/close sensor (door/window-class
|
||||||
|
`binary_sensor`/`cover`, invertible) and, for doors, a **lock entity**. The opening keeps
|
||||||
|
absolute coordinates, so editing/merging/deleting rooms never breaks it. Click an existing
|
||||||
|
opening with the tool to edit or delete it.
|
||||||
|
- **Live rendering**: a door is a leaf hinged at the jamb with a quarter-circle swing arc that
|
||||||
|
"draws on" (stroke-dashoffset) as it opens; a window is two casement leaves meeting in the
|
||||||
|
middle. Открыто → the moving parts take the accent colour and animate (CSS transitions,
|
||||||
|
`prefers-reduced-motion` honoured). No sensor → the classic static plan: doors drawn open,
|
||||||
|
windows closed. `unavailable`/`unknown` freeze that default — an outage must not fake motion
|
||||||
|
(pure `openingAmount`, unit-tested). Hinge side / swing side via flip toggles.
|
||||||
|
- **Locks**: a padlock badge beside the door — green closed when `locked`, orange open when
|
||||||
|
unlocked, grey question when unknown. The lock is NEVER toggled from the plan (the card's
|
||||||
|
standing security rule); clicking the opening or the badge shows an **info card** with both
|
||||||
|
states.
|
||||||
|
- New pure helpers `snapToWall` (projection + wall angle over derived edges) and
|
||||||
|
`openingAmount`; `space.openings[]` validated server-side. (+2 tests: 74 → 76.)
|
||||||
|
|
||||||
## v1.22.0 — 2026-07-17 (presence ripples, per-device icon size/rotation, one-click install)
|
## 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,
|
Ideas borrowed from [easy-floorplan](https://github.com/nicosandller/easy-floorplan) — the visuals,
|
||||||
not the model.
|
not the model.
|
||||||
|
|||||||
+2
-2
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.22.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
| Version | **v1.23.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
| 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 |
|
| 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 |
|
| 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.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
| 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 | 74 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
| Tests | 76 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||||
|
|
||||||
## Recent milestones (details in CHANGELOG.md)
|
## Recent milestones (details in CHANGELOG.md)
|
||||||
|
|
||||||
|
|||||||
@@ -209,3 +209,17 @@ require hands on real hardware — they remain for the human pass.
|
|||||||
- [ ] Unknown `space` → tidy error card [auto]
|
- [ ] Unknown `space` → tidy error card [auto]
|
||||||
- [ ] `show_button: false` hides the footer
|
- [ ] `show_button: false` hides the footer
|
||||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
||||||
|
|
||||||
|
## Doors & windows (v1.23.0)
|
||||||
|
|
||||||
|
- [ ] Markup → "Opening": a click away from any wall shows a toast; near a wall — the dialog
|
||||||
|
- [ ] A door placed on a wall renders jambs + leaf + swing arc at the wall's angle; length in cm
|
||||||
|
matches the ruler/scale of the space
|
||||||
|
- [ ] Bind a contact sensor: open → leaf swings and the arc draws on in the accent colour;
|
||||||
|
closed → leaf lies along the wall, arc hidden; invert flips this
|
||||||
|
- [ ] Sensor unavailable → the opening freezes at its static default (door open / window closed)
|
||||||
|
- [ ] A door with a lock shows the padlock badge: green locked / orange unlocked / grey unknown
|
||||||
|
- [ ] Clicking an opening (or the padlock) in view mode opens the info card with both states;
|
||||||
|
the lock can NOT be toggled from the plan
|
||||||
|
- [ ] Flip toggles mirror the hinge side and the swing side
|
||||||
|
- [ ] Click an existing opening with the tool → edit dialog; Delete removes it
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+330
-3
@@ -15,12 +15,14 @@ import {
|
|||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||||
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||||
} from './logic';
|
} from './logic';
|
||||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
|
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
|
||||||
import type {
|
import type {
|
||||||
|
OpeningCfg,
|
||||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
import './editor';
|
import './editor';
|
||||||
@@ -28,14 +30,14 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.22.0';
|
const CARD_VERSION = '1.23.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
const NORM_W = 1000; // width of the render space for normalized configs
|
const NORM_W = 1000; // width of the render space for normalized configs
|
||||||
|
|
||||||
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
|
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
|
||||||
type MarkupTool = 'draw' | 'merge' | 'split' | 'delroom';
|
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom';
|
||||||
|
|
||||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||||
@@ -82,7 +84,19 @@ class HouseplanCard extends LitElement {
|
|||||||
private _tool: MarkupTool = 'draw';
|
private _tool: MarkupTool = 'draw';
|
||||||
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
|
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
|
||||||
private _cursorPt: number[] | null = null;
|
private _cursorPt: number[] | null = null;
|
||||||
private _mergeSel: string | null = null; // first room picked for a merge
|
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; // first room picked for a merge
|
||||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
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
|
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
|
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||||
@@ -182,6 +196,8 @@ class HouseplanCard extends LitElement {
|
|||||||
_path: { state: true },
|
_path: { state: true },
|
||||||
_cursorPt: { state: true },
|
_cursorPt: { state: true },
|
||||||
_mergeSel: { state: true },
|
_mergeSel: { state: true },
|
||||||
|
_openingDialog: { state: true },
|
||||||
|
_openingInfo: { state: true },
|
||||||
_mergeDialog: { state: true },
|
_mergeDialog: { state: true },
|
||||||
_splitSel: { state: true },
|
_splitSel: { state: true },
|
||||||
_areaSel: { state: true },
|
_areaSel: { state: true },
|
||||||
@@ -1070,6 +1086,10 @@ class HouseplanCard extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this._tool === 'opening') {
|
||||||
|
this._openingClick(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this._tool === 'merge') {
|
if (this._tool === 'merge') {
|
||||||
this._mergeClick(raw);
|
this._mergeClick(raw);
|
||||||
return;
|
return;
|
||||||
@@ -1114,6 +1134,113 @@ class HouseplanCard extends LitElement {
|
|||||||
this._path = [...this._path, pt];
|
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._openingDialog = {
|
||||||
|
id: hit.id,
|
||||||
|
type: hit.type,
|
||||||
|
lengthCm: Math.round((hit.rlen / this._gridPitch) * this._cellCm),
|
||||||
|
contact: hit.contact || '',
|
||||||
|
lock: hit.lock || '',
|
||||||
|
invert: !!hit.invert,
|
||||||
|
flipH: !!hit.flip_h,
|
||||||
|
flipV: !!hit.flip_v,
|
||||||
|
x: hit.rx, y: hit.ry, angle: hit.angle,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Merge: first click picks a room, second picks the room to merge it with. */
|
||||||
private _mergeClick(raw: number[]): void {
|
private _mergeClick(raw: number[]): void {
|
||||||
const rooms = this._spaceModel().rooms;
|
const rooms = this._spaceModel().rooms;
|
||||||
@@ -2156,9 +2283,11 @@ class HouseplanCard extends LitElement {
|
|||||||
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
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._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||||
|
${this._renderOpenings(disp)}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
||||||
${devs.map((d) => this._renderDevice(d, view))}
|
${devs.map((d) => this._renderDevice(d, view))}
|
||||||
|
${this._renderOpeningLocks(view)}
|
||||||
${disp.showNames && !this._markup
|
${disp.showNames && !this._markup
|
||||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -2174,6 +2303,8 @@ class HouseplanCard extends LitElement {
|
|||||||
|
|
||||||
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
||||||
${this._mergeDialog ? this._renderMergeDialog() : nothing}
|
${this._mergeDialog ? this._renderMergeDialog() : nothing}
|
||||||
|
${this._openingDialog ? this._renderOpeningDialog() : nothing}
|
||||||
|
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
|
||||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||||
@@ -2331,6 +2462,197 @@ class HouseplanCard extends LitElement {
|
|||||||
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
|
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 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-hit" x="${-half - 6}" y="${-o.rlen - 8}" width="${o.rlen + 12}" height="${o.rlen * 2 + 16}"
|
||||||
|
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = 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 {
|
private _renderMarkupDefs(_vb: number[]): TemplateResult {
|
||||||
const g = this._gridPitch;
|
const g = this._gridPitch;
|
||||||
const dotR = g * 0.14;
|
const dotR = g * 0.14;
|
||||||
@@ -2386,6 +2708,11 @@ class HouseplanCard extends LitElement {
|
|||||||
title=${this._t('title.markup_split')}>
|
title=${this._t('title.markup_split')}>
|
||||||
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
|
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
|
||||||
</button>
|
</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')}
|
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
|
||||||
title=${this._t('title.markup_delroom')}>
|
title=${this._t('title.markup_delroom')}>
|
||||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
|
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
|
||||||
|
|||||||
@@ -36,6 +36,27 @@
|
|||||||
"markup.add": "Add",
|
"markup.add": "Add",
|
||||||
"markup.merge": "Merge",
|
"markup.merge": "Merge",
|
||||||
"markup.split": "Split",
|
"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.delete": "Delete",
|
||||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
"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",
|
"markup.hint_start": "click a grid dot to start the outline",
|
||||||
|
|||||||
@@ -36,6 +36,27 @@
|
|||||||
"markup.add": "Добавить",
|
"markup.add": "Добавить",
|
||||||
"markup.merge": "Объединить",
|
"markup.merge": "Объединить",
|
||||||
"markup.split": "Разделить",
|
"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.delete": "Удалить",
|
||||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||||
|
|||||||
@@ -74,6 +74,50 @@ export function roomEdges(rooms: any[]): number[][] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a point onto the nearest wall of any room, returning the snapped point and
|
||||||
|
* the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
|
||||||
|
* DERIVED room edges (a line has no independent existence on the plan), so an opening
|
||||||
|
* placed with this stays valid however rooms are later edited — it keeps absolute
|
||||||
|
* coordinates and is not tied to a room id or edge index.
|
||||||
|
*/
|
||||||
|
export function snapToWall(
|
||||||
|
p: number[], rooms: any[], maxDist: number,
|
||||||
|
): { x: number; y: number; angle: number } | null {
|
||||||
|
let best: { x: number; y: number; angle: number } | null = null;
|
||||||
|
let bestD = maxDist;
|
||||||
|
for (const e of roomEdges(rooms)) {
|
||||||
|
const [x1, y1, x2, y2] = e;
|
||||||
|
const dx = x2 - x1, dy = y2 - y1;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
if (!len2) continue;
|
||||||
|
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const q = [x1 + t * dx, y1 + t * dy];
|
||||||
|
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = { x: q[0], y: q[1], angle: (Math.atan2(dy, dx) * 180) / Math.PI };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far open an opening is drawn, 0..1, from its contact sensor state.
|
||||||
|
* No sensor bound → doors default to open (the familiar swing symbol), windows to
|
||||||
|
* closed (intact glass) — same convention as a static architectural plan.
|
||||||
|
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
|
||||||
|
*/
|
||||||
|
export function openingAmount(
|
||||||
|
type: 'door' | 'window', state: string | null | undefined, invert = false,
|
||||||
|
): number {
|
||||||
|
if (state == null || state === 'unavailable' || state === 'unknown')
|
||||||
|
return type === 'door' ? 1 : 0;
|
||||||
|
const open = isActiveState(state) !== !!invert;
|
||||||
|
return open ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
|
* 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.
|
* device and therefore must not depend on the card-wide live_states toggle.
|
||||||
|
|||||||
@@ -225,6 +225,57 @@ export const cardStyles = css`
|
|||||||
fill-opacity: 0.22;
|
fill-opacity: 0.22;
|
||||||
stroke-opacity: 1;
|
stroke-opacity: 1;
|
||||||
}
|
}
|
||||||
|
/* doors & windows */
|
||||||
|
.op-leaf {
|
||||||
|
transition: transform 0.6s ease;
|
||||||
|
}
|
||||||
|
.op-arc {
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transition: stroke-dashoffset 0.6s ease;
|
||||||
|
}
|
||||||
|
.op-hit {
|
||||||
|
fill: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.stage.markup .op-hit {
|
||||||
|
pointer-events: none; /* markup clicks go to the stage tools */
|
||||||
|
}
|
||||||
|
.oplock {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||||
|
height: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--hp-bg);
|
||||||
|
border: 1px solid var(--hp-line);
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.oplock ha-icon {
|
||||||
|
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
|
||||||
|
display: flex;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.oplock.locked { color: #66d17a; border-color: #66d17a; }
|
||||||
|
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
|
||||||
|
.oplock.unknown { color: var(--hp-muted); }
|
||||||
|
.oprow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.oprow b { margin-left: auto; }
|
||||||
|
.oprow.ok b { color: #66d17a; }
|
||||||
|
.oprow.warn b { color: var(--hp-open); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.op-leaf, .op-arc { transition: none; }
|
||||||
|
}
|
||||||
/* presence ripples: opted into per device, drawn around the anchor point */
|
/* presence ripples: opted into per device, drawn around the anchor point */
|
||||||
.dev.noicon {
|
.dev.noicon {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ export interface Marker {
|
|||||||
angle?: number | null; // icon rotation, degrees
|
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 {
|
export interface ServerConfig {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
markers: Marker[];
|
markers: Marker[];
|
||||||
|
|||||||
+26
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||||
@@ -407,3 +407,28 @@ test('isActiveState: a sensor outage calms the plan down, it never pulses foreve
|
|||||||
assert.ok(!isActiveState(undefined));
|
assert.ok(!isActiveState(undefined));
|
||||||
assert.ok(!isActiveState(null));
|
assert.ok(!isActiveState(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('snapToWall: projects onto the nearest derived wall with its angle; misses return null', () => {
|
||||||
|
const rooms = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
|
||||||
|
const s = snapToWall([4, 0.6], rooms, 1); // near the top wall
|
||||||
|
assert.deepEqual([s.x, s.y], [4, 0]);
|
||||||
|
assert.equal(Math.abs(s.angle) % 180, 0); // horizontal wall
|
||||||
|
const v = snapToWall([10.4, 7], rooms, 1); // near the right wall
|
||||||
|
assert.deepEqual([v.x, v.y], [10, 7]);
|
||||||
|
assert.equal(Math.abs(v.angle), 90);
|
||||||
|
assert.equal(snapToWall([5, 5], rooms, 1), null); // middle of the room: no wall within reach
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openingAmount: doors default open, windows closed; outages freeze the default', () => {
|
||||||
|
assert.equal(openingAmount('door', null), 1);
|
||||||
|
assert.equal(openingAmount('window', null), 0);
|
||||||
|
assert.equal(openingAmount('door', 'unavailable'), 1);
|
||||||
|
assert.equal(openingAmount('window', 'unknown'), 0);
|
||||||
|
assert.equal(openingAmount('door', 'on'), 1);
|
||||||
|
assert.equal(openingAmount('door', 'off'), 0);
|
||||||
|
assert.equal(openingAmount('window', 'open'), 1);
|
||||||
|
// invert flips on/off but never the outage default
|
||||||
|
assert.equal(openingAmount('door', 'on', true), 0);
|
||||||
|
assert.equal(openingAmount('door', 'off', true), 1);
|
||||||
|
assert.equal(openingAmount('door', 'unavailable', true), 1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user