mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65268a7985 | ||
|
|
4593d96955 | ||
|
|
18a0d279a7 |
@@ -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.17.2"
|
||||
VERSION = "1.19.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"segments": len(s.get("segments", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.17.2"
|
||||
"version": "1.19.0"
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ 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],
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
|
||||
Vendored
+99
-76
File diff suppressed because one or more lines are too long
+13
-3
@@ -111,12 +111,22 @@ If the server config is empty, the card falls back to the legacy bundle (the dac
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
|
||||
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
|
||||
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
|
||||
outline you never close leaves no trace. Walls are *derived* from the room outlines by
|
||||
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
|
||||
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
|
||||
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
|
||||
still tolerates it on read; see CHANGELOG v1.19.0).
|
||||
|
||||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||||
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
|
||||
|
||||
## Integration WS API
|
||||
|
||||
| Command | Parameters | Response |
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## v1.19.0 — 2026-07-16 (a line is never a thing of its own)
|
||||
**Model change.** A wall can only exist as an edge of a closed room. Consequences:
|
||||
- **Walls are derived from room outlines** (`roomEdges` in logic.ts), not stored. A wall
|
||||
shared by two rooms is emitted once, so **deleting a room keeps the borders its neighbours
|
||||
still contribute** — and drops the walls nobody else uses. This falls out of the model
|
||||
instead of needing bookkeeping.
|
||||
- **An abandoned outline leaves nothing behind.** Previously every click pair was written to
|
||||
`space.segments` immediately, so a contour you never closed left orphan lines on the plan.
|
||||
Now nothing is persisted until the room is saved.
|
||||
- **The "Erase line" tool is gone** — there is no standalone line to erase. Mis-clicks are
|
||||
undone with Esc / Ctrl+Z as before.
|
||||
- **`space.segments` is dropped on every save** (legacy configs shed it on first write).
|
||||
Validation still tolerates the field so a stale browser tab cannot fail a save; diagnostics
|
||||
no longer reports it. Lines that belonged to no room disappear on upgrade — by design.
|
||||
- Dead code removed: `_addSegment`, `_removeSegmentByKey`, `_distToSeg`, `_pathSegs`, `_segKey`.
|
||||
`segKey(a, b, prec)` gained a precision argument (normalized coords need more than render
|
||||
units). (+2 tests: 61 → 63.)
|
||||
|
||||
## v1.18.1 — 2026-07-16 (fix: the drawing ruler was invisible)
|
||||
- Fix on top of v1.18.0: the length badge never showed up while drawing. It was rendered
|
||||
inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
|
||||
in markup mode (so icons do not get in the way) — the badge was in the DOM but invisible.
|
||||
It now lives in its own `.measurelayer` (absolute, `pointer-events: none`), which markup
|
||||
mode does not hide. Verified visually on a real drawn segment ("3.60 m" on screen).
|
||||
- Testing lesson (see docs/TESTING.md): asserting on `textContent` is not enough — a DOM
|
||||
query passes on elements hidden by an ancestor. Check `offsetParent`/rect or look at a
|
||||
screenshot.
|
||||
|
||||
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
|
||||
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
|
||||
length of the current segment (last placed vertex → cursor). Units come from the HA unit
|
||||
system: metric → metres ("1.25 m"), imperial → feet+inches ("4′ 1″").
|
||||
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
|
||||
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
|
||||
plan can have its own real-world size.
|
||||
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
|
||||
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
|
||||
(+3 tests: 58 → 61 frontend.)
|
||||
|
||||
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
|
||||
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
|
||||
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
|
||||
|
||||
+2
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.17.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.19.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 58 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 | 63 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)
|
||||
|
||||
|
||||
+8
-1
@@ -63,7 +63,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Room markup editor ★
|
||||
|
||||
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
|
||||
- [ ] Grid appears; dots snap; the outline draws pair-by-pair; shared walls reused
|
||||
- [ ] Ruler: while drawing, the length of the current segment follows the cursor
|
||||
(metres, or feet+inches on an imperial HA); scale = space "cm per cell" (default 5)
|
||||
- [ ] A line cannot exist on its own: start an outline, do NOT close it, leave markup —
|
||||
no lines are left behind (nothing was written to the config)
|
||||
- [ ] Deleting a room removes its walls, EXCEPT those shared with a neighbouring room
|
||||
(the neighbour still yields them); deleting the neighbour too removes them as well
|
||||
- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0)
|
||||
- [ ] 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.17.2",
|
||||
"version": "1.19.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+60
-87
@@ -12,7 +12,8 @@ import {
|
||||
type IconRule, type CompiledIconRule,
|
||||
} from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
@@ -26,14 +27,14 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.17.2';
|
||||
const CARD_VERSION = '1.19.0';
|
||||
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' | 'erase' | 'delroom';
|
||||
type MarkupTool = 'draw' | 'delroom';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||
@@ -79,7 +80,6 @@ class HouseplanCard extends LitElement {
|
||||
private _markup = false;
|
||||
private _tool: MarkupTool = 'draw';
|
||||
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
|
||||
private _pathSegs: (string | null)[] = []; // keys of the segments added by outline steps
|
||||
private _cursorPt: number[] | null = null;
|
||||
private _areaSel = '';
|
||||
private _nameSel = '';
|
||||
@@ -132,6 +132,7 @@ class HouseplanCard extends LitElement {
|
||||
fillMode: 'none' | 'lqi' | 'light' | 'temp';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
cellCm: number; // real-world cm represented by one grid cell
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||||
@@ -219,32 +220,12 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the last placed point (and its line, if it was added by that step). */
|
||||
/** Remove the last placed point. An unfinished outline is never persisted. */
|
||||
private _undoPoint(): void {
|
||||
if (!this._path.length) return;
|
||||
if (this._path.length === 1) {
|
||||
this._path = [];
|
||||
this._pathSegs = [];
|
||||
return;
|
||||
}
|
||||
const segKey = this._pathSegs[this._pathSegs.length - 1];
|
||||
this._pathSegs = this._pathSegs.slice(0, -1);
|
||||
if (segKey) this._removeSegmentByKey(segKey);
|
||||
this._path = this._path.slice(0, -1);
|
||||
}
|
||||
|
||||
private _removeSegmentByKey(key: string): void {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp?.segments) return;
|
||||
const idx = this._segments.findIndex(
|
||||
(s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
sp.segments.splice(idx, 1);
|
||||
this._saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public static getConfigElement() {
|
||||
return document.createElement('houseplan-card-editor');
|
||||
}
|
||||
@@ -944,6 +925,18 @@ class HouseplanCard extends LitElement {
|
||||
return NORM_W / GRID_N;
|
||||
}
|
||||
|
||||
/** cm represented by one grid cell for the current space (default 5). */
|
||||
private get _cellCm(): number {
|
||||
const v = Number(this._curSpaceCfg?.cell_cm);
|
||||
return Number.isFinite(v) && v > 0 ? v : 5;
|
||||
}
|
||||
|
||||
/** Human-readable length of a segment (render units) using the HA unit system. */
|
||||
private _fmtLen(a: number[], b: number[]): string {
|
||||
const cm = segmentCm(a, b, this._gridPitch, this._cellCm);
|
||||
return formatLength(cm, this.hass?.config?.unit_system?.length === 'mi');
|
||||
}
|
||||
|
||||
private get _curSpaceCfg(): any {
|
||||
return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
|
||||
}
|
||||
@@ -953,11 +946,15 @@ class HouseplanCard extends LitElement {
|
||||
return sp ? NORM_W / sp.aspect : NORM_W;
|
||||
}
|
||||
|
||||
/** Segments of the current space in render units. */
|
||||
/**
|
||||
* Walls of the current space in render units — DERIVED from the room outlines.
|
||||
* There is no standalone "line" entity: every wall belongs to a closed room, and a
|
||||
* wall shared with a neighbour survives deleting either room (the other still yields it).
|
||||
*/
|
||||
private get _segments(): number[][] {
|
||||
const sp = this._curSpaceCfg;
|
||||
const H = this._spaceH;
|
||||
return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
|
||||
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
|
||||
}
|
||||
|
||||
private _toggleMarkup(): void {
|
||||
@@ -986,12 +983,17 @@ class HouseplanCard extends LitElement {
|
||||
return samePoint(a, b);
|
||||
}
|
||||
|
||||
private _segKey(a: number[], b: number[]): string {
|
||||
return segKeyOf(a, b);
|
||||
/**
|
||||
* Walls are derived from rooms, so the legacy per-space `segments` array is dead
|
||||
* weight: drop it on every save. Configs written before v1.19.0 shed it on first write.
|
||||
*/
|
||||
private _dropLegacySegments(): void {
|
||||
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
||||
}
|
||||
|
||||
private _saveConfig = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
||||
.then((r: any) => {
|
||||
@@ -1008,33 +1010,6 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
}, 500);
|
||||
|
||||
/** Add a segment (render units) to the space skeleton (no duplicates). true = new. */
|
||||
private _addSegment(a: number[], b: number[]): boolean {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp) return false;
|
||||
const H = this._spaceH;
|
||||
const key = this._segKey(a, b);
|
||||
const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key);
|
||||
if (exists) return false;
|
||||
sp.segments = sp.segments || [];
|
||||
sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]);
|
||||
this._saveConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _distToSeg(p: number[], s: number[]): number {
|
||||
const [x, y] = p;
|
||||
const [x1, y1, x2, y2] = s;
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy || 1;
|
||||
let t = ((x - x1) * dx + (y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const px = x1 + t * dx;
|
||||
const py = y1 + t * dy;
|
||||
return Math.hypot(x - px, y - py);
|
||||
}
|
||||
|
||||
private _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||||
if (r.poly) return pointInPolygon(p, r.poly);
|
||||
return (
|
||||
@@ -1045,26 +1020,6 @@ class HouseplanCard extends LitElement {
|
||||
private _markupClick(ev: MouseEvent): void {
|
||||
if (!this._markup) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
if (this._tool === 'erase') {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp?.segments?.length) return;
|
||||
const segs = this._segments;
|
||||
let best = -1;
|
||||
let bestD = this._gridPitch * 0.5;
|
||||
segs.forEach((s, i) => {
|
||||
const d = this._distToSeg(raw, s);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
});
|
||||
if (best >= 0) {
|
||||
sp.segments.splice(best, 1);
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this._tool === 'delroom') {
|
||||
const space = this._spaceModel();
|
||||
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||
@@ -1078,17 +1033,15 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
// draw: clicks on grid points, pairs of points get connected with a line
|
||||
// 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);
|
||||
if (!this._path.length) {
|
||||
this._path = [pt];
|
||||
this._pathSegs = [];
|
||||
return;
|
||||
}
|
||||
const last = this._path[this._path.length - 1];
|
||||
if (this._samePt(pt, last)) return; // repeated click on the same point
|
||||
const added = this._addSegment(last, pt);
|
||||
this._pathSegs = [...this._pathSegs, added ? this._segKey(last, pt) : null];
|
||||
this._path = [...this._path, pt];
|
||||
// closing the outline: a click on the first vertex → the save dialog
|
||||
if (this._path.length >= 4 && this._samePt(pt, this._path[0])) {
|
||||
@@ -1138,7 +1091,6 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
this._saveConfig();
|
||||
this._path = [];
|
||||
this._pathSegs = [];
|
||||
const boundArea = this._areaSel;
|
||||
this._areaSel = '';
|
||||
this._nameSel = '';
|
||||
@@ -1174,7 +1126,6 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _cancelPath(): void {
|
||||
this._path = [];
|
||||
this._pathSegs = [];
|
||||
this._cursorPt = null;
|
||||
this._roomDialog = false;
|
||||
}
|
||||
@@ -1509,6 +1460,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||||
busy: false,
|
||||
};
|
||||
} else {
|
||||
@@ -1518,6 +1470,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
cellCm: 5,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
@@ -1573,7 +1526,6 @@ class HouseplanCard extends LitElement {
|
||||
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: [],
|
||||
segments: [],
|
||||
};
|
||||
cfg.spaces.push(sp);
|
||||
} else {
|
||||
@@ -1602,6 +1554,7 @@ class HouseplanCard extends LitElement {
|
||||
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
||||
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
await this._saveConfigNow();
|
||||
this._spaceDialog = null;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
@@ -1652,6 +1605,7 @@ class HouseplanCard extends LitElement {
|
||||
user's retry starts from the fresh config instead of hitting the same
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
@@ -1690,6 +1644,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
cellCm: 5,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
@@ -2008,6 +1963,9 @@ class HouseplanCard extends LitElement {
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
: nothing}
|
||||
</div>
|
||||
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed
|
||||
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._zoom > 1
|
||||
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
|
||||
@@ -2128,6 +2086,15 @@ class HouseplanCard extends LitElement {
|
||||
>${r.name}</div>`;
|
||||
}
|
||||
|
||||
/** Length badge that follows the cursor while drawing the current segment. */
|
||||
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
const a = this._path[this._path.length - 1];
|
||||
const b = this._cursorPt!;
|
||||
const left = ((b[0] - view.x) / view.w) * 100;
|
||||
const top = ((b[1] - view.y) / view.h) * 100;
|
||||
return html`<div class="measurelabel" style="left:${left}%;top:${top}%">${this._fmtLen(a, b)}</div>`;
|
||||
}
|
||||
|
||||
private _roomCenter(r: RoomCfg): number[] {
|
||||
if (r.poly) {
|
||||
const n = r.poly.length;
|
||||
@@ -2174,10 +2141,6 @@ 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 === 'erase' ? 'on' : ''}" @click=${() => (this._tool = 'erase')}
|
||||
title=${this._t('title.markup_erase')}>
|
||||
<ha-icon icon="mdi:eraser"></ha-icon>${this._t('markup.erase')}
|
||||
</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')}
|
||||
@@ -2402,6 +2365,16 @@ class HouseplanCard extends LitElement {
|
||||
</select>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number" min="0.1" step="0.1" .value=${String(d.cellCm)}
|
||||
@input=${(e: Event) => {
|
||||
const n = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._spaceDialog = { ...d, cellCm: Number.isFinite(n) && n > 0 ? n : d.cellCm };
|
||||
}} />
|
||||
<span class="opl">${this._t('space.scale_unit')}</span>
|
||||
</div>
|
||||
|
||||
<label class="dispsection">${this._t('space.display_section')}</label>
|
||||
<label class="srcrow">
|
||||
<input type="checkbox" .checked=${d.showBorders}
|
||||
|
||||
+2
-2
@@ -27,13 +27,11 @@
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
|
||||
"title.markup_erase": "Erase a line: click the line",
|
||||
"title.markup_delroom": "Delete a room: click inside the room",
|
||||
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
|
||||
"title.choose_area": "Select a Home Assistant area",
|
||||
"title.need_plan": "Upload a floor-plan image",
|
||||
"markup.add": "Add",
|
||||
"markup.erase": "Erase",
|
||||
"markup.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",
|
||||
@@ -152,6 +150,8 @@
|
||||
"import.progress": "Floor {i} of {n}",
|
||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
||||
"btn.skip": "Skip",
|
||||
"space.scale_label": "Scale (grid cell size)",
|
||||
"space.scale_unit": "cm per cell",
|
||||
"space.display_section": "Display",
|
||||
"space.show_borders": "Always show room borders",
|
||||
"space.show_names": "Show room names (drag to move)",
|
||||
|
||||
+2
-2
@@ -27,13 +27,11 @@
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||
"title.markup_erase": "Стереть линию: клик по линии",
|
||||
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
|
||||
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
|
||||
"title.choose_area": "Выберите зону Home Assistant",
|
||||
"title.need_plan": "Загрузите подложку (план этажа)",
|
||||
"markup.add": "Добавить",
|
||||
"markup.erase": "Стереть",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
@@ -152,6 +150,8 @@
|
||||
"import.progress": "Этаж {i} из {n}",
|
||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"btn.skip": "Пропустить",
|
||||
"space.scale_label": "Масштаб (размер клетки сетки)",
|
||||
"space.scale_unit": "см на клетку",
|
||||
"space.display_section": "Отображение",
|
||||
"space.show_borders": "Всегда отображать границы комнат",
|
||||
"space.show_names": "Отображать названия комнат (перетаскиваются)",
|
||||
|
||||
+53
-3
@@ -13,10 +13,60 @@ export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
}
|
||||
|
||||
/** Canonical key of a segment (independent of direction). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
|
||||
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
|
||||
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
|
||||
return cells * cellCm;
|
||||
}
|
||||
|
||||
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
|
||||
export function formatLength(cm: number, imperial: boolean): string {
|
||||
if (imperial) {
|
||||
const totalIn = cm / 2.54;
|
||||
let ft = Math.floor(totalIn / 12);
|
||||
let inch = Math.round(totalIn - ft * 12);
|
||||
if (inch === 12) { ft += 1; inch = 0; }
|
||||
return `${ft}′ ${inch}″`;
|
||||
}
|
||||
return `${(cm / 100).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical key of a segment (independent of direction).
|
||||
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
|
||||
* normalized (0..1) coordinates need more (see roomEdges).
|
||||
*/
|
||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall segments derived from room outlines (normalized coordinates in and out).
|
||||
*
|
||||
* A line has no independent existence on the plan: it can only be an edge of a closed
|
||||
* room. Shared walls are emitted once, which is what makes deleting a room keep the
|
||||
* borders its neighbours still contribute — the neighbour's polygon still yields them.
|
||||
*/
|
||||
export function roomEdges(rooms: any[]): number[][] {
|
||||
const out: number[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of rooms || []) {
|
||||
let pts: number[][] | null = null;
|
||||
if (r?.poly?.length) pts = r.poly;
|
||||
else if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
pts = [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
if (!pts || pts.length < 3) continue;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const k = segKey(a, b, 5);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push([a[0], a[1], b[0], b[1]]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Point equality within a tolerance. */
|
||||
|
||||
@@ -236,6 +236,25 @@ export const cardStyles = css`
|
||||
z-index: 1;
|
||||
}
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.measurelabel {
|
||||
position: absolute;
|
||||
transform: translate(12px, -150%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -258,3 +259,41 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
|
||||
assert.equal(o.tempMin, 18.5);
|
||||
assert.equal(o.tempMax, 23);
|
||||
});
|
||||
|
||||
test('segmentCm: cells scaled by cm-per-cell', () => {
|
||||
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
|
||||
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
|
||||
});
|
||||
|
||||
test('formatLength: metric metres with 2 decimals', () => {
|
||||
assert.equal(formatLength(25, false), '0.25 m');
|
||||
assert.equal(formatLength(125, false), '1.25 m');
|
||||
assert.equal(formatLength(0, false), '0.00 m');
|
||||
});
|
||||
|
||||
test('formatLength: imperial feet + inches, with inch rollover', () => {
|
||||
assert.equal(formatLength(124.46, true), '4′ 1″');
|
||||
assert.equal(formatLength(30.48, true), '1′ 0″');
|
||||
assert.equal(formatLength(29.464, true), '1′ 0″');
|
||||
});
|
||||
|
||||
test('roomEdges: a line exists only as a room edge; polygons and rects both yield walls', () => {
|
||||
const sq = { poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
|
||||
assert.equal(roomEdges([sq]).length, 4); // closed outline → 4 walls
|
||||
assert.equal(roomEdges([{ x: 0, y: 0, w: 1, h: 1 }]).length, 4); // legacy rect room
|
||||
assert.equal(roomEdges([]).length, 0); // no rooms → no lines at all
|
||||
assert.equal(roomEdges([{ poly: [[0, 0], [1, 1]] }]).length, 0); // not a closed room → nothing
|
||||
});
|
||||
|
||||
test('roomEdges: a wall shared by two rooms is emitted once, and survives deleting either room', () => {
|
||||
const left = { id: 'a', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] };
|
||||
const right = { id: 'b', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }; // shares x=0.5 wall
|
||||
const both = roomEdges([left, right]);
|
||||
assert.equal(both.length, 7); // 4 + 4 - 1 shared, deduped regardless of direction
|
||||
const shared = (segs) => segs.some((s) => s[0] === 0.5 && s[2] === 0.5);
|
||||
assert.ok(shared(both));
|
||||
// deleting 'left' → the shared wall stays, because 'right' still contributes it
|
||||
assert.ok(shared(roomEdges([right])));
|
||||
// deleting both → no lines remain
|
||||
assert.equal(roomEdges([]).length, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user