mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.19.0: a line is never an entity of its own — walls are derived from rooms
A wall can only exist as an edge of a closed room: - roomEdges(rooms) derives walls from room outlines, deduping shared ones, so deleting a room keeps the borders its neighbours still contribute and drops the rest — the rule falls out of the model instead of needing bookkeeping. - Nothing is persisted while drawing: an outline you never close leaves no lines behind (previously every click pair was written to space.segments immediately). - The 'Erase line' tool is gone; space.segments is stripped on every save (validation still tolerates it on read so a stale tab cannot fail a save). - Dead code out: _addSegment/_removeSegmentByKey/_distToSeg/_pathSegs/_segKey. Docs (ARCHITECTURE/TESTING/CHANGELOG) updated in the same commit. +2 tests (63).
This commit is contained in:
+21
-88
@@ -12,8 +12,8 @@ import {
|
||||
type IconRule, type CompiledIconRule,
|
||||
} from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength,
|
||||
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,
|
||||
@@ -27,14 +27,14 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.18.1';
|
||||
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;
|
||||
@@ -80,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 = '';
|
||||
@@ -221,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');
|
||||
}
|
||||
@@ -967,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 {
|
||||
@@ -1000,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) => {
|
||||
@@ -1022,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 (
|
||||
@@ -1059,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));
|
||||
@@ -1092,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])) {
|
||||
@@ -1152,7 +1091,6 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
this._saveConfig();
|
||||
this._path = [];
|
||||
this._pathSegs = [];
|
||||
const boundArea = this._areaSel;
|
||||
this._areaSel = '';
|
||||
this._nameSel = '';
|
||||
@@ -1188,7 +1126,6 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _cancelPath(): void {
|
||||
this._path = [];
|
||||
this._pathSegs = [];
|
||||
this._cursorPt = null;
|
||||
this._roomDialog = false;
|
||||
}
|
||||
@@ -1589,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 {
|
||||
@@ -1669,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,
|
||||
@@ -2204,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')}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "кликните точку сетки, чтобы начать контур",
|
||||
|
||||
+35
-3
@@ -31,10 +31,42 @@ export function formatLength(cm: number, imperial: boolean): string {
|
||||
return `${(cm / 100).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
/** Canonical key of a segment (independent of direction). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
Reference in New Issue
Block a user