mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
perf/fix v1.43.1: external audit P1 — render cost, drag threshold, geometry, backend
L1: memoized space model + open pairs (structural fingerprint key, epoch bumped synchronously at mutation time, not inside the debounce); hoisted per-room geometry out of the render loop; smoke asserts zero recomputation across state pushes. L4: openings get the 3 px drag threshold used by every other pipeline and only write when the geometry actually changed — taps open the dialog again. G2: interiorPoint() replaces the vertex mean, so island rooms inside concave (U/L) parents are accepted and their evenodd holes render; traced duplicates still are not containment. G3: segKey rounds before ordering — one shared wall, one key. B2: _check_write fails closed when the entry is unavailable. B3: layout/set honours expected_rev and returns the new rev. B4: config/set without expected_rev over a non-empty store logs a warning. B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped. +2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs same-commit
This commit is contained in:
+97
-18
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.43.0';
|
||||
const CARD_VERSION = '1.43.1';
|
||||
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';
|
||||
@@ -177,7 +177,7 @@ class HouseplanCard extends LitElement {
|
||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||
} | null = null;
|
||||
private _openingInfo: OpeningCfg | null = null;
|
||||
private _opDrag: { id: string; moved: boolean } | null = null;
|
||||
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
|
||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
||||
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||
@@ -360,7 +360,7 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._saveConfig.flush(); // never leave an edit unsent on teardown
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
this._roViewport?.disconnect();
|
||||
@@ -480,6 +480,7 @@ class HouseplanCard extends LitElement {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||
this._serverCfg = c.config;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = c.rev || 0;
|
||||
this._layout = c.layout || {};
|
||||
this._serverStorage = true;
|
||||
@@ -520,7 +521,36 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||
private _cfgEpoch = 0;
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
|
||||
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||
private _cfgFingerprint(): string {
|
||||
const sp = this._serverCfg?.spaces || [];
|
||||
let s = sp.length + ':';
|
||||
for (const x of sp as any[]) {
|
||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||
for (const r of x.rooms || [])
|
||||
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private get _model(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
// same reasoning as _openPairs: mutations in place mean the epoch can lag,
|
||||
// so the key also carries the config's structural fingerprint
|
||||
const key = this._cfgEpoch + '|' + this._cfgFingerprint();
|
||||
if (this._modelCache && this._modelCache.key === key) return this._modelCache.model;
|
||||
const built = this._buildModel();
|
||||
this._modelCache = { key, model: built };
|
||||
return built;
|
||||
}
|
||||
|
||||
private _buildModel(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
@@ -645,6 +675,7 @@ class HouseplanCard extends LitElement {
|
||||
this._serverStorage = true;
|
||||
const cfg = cfgResp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = cfgResp?.rev || 0;
|
||||
this._layout = layResp?.layout || {};
|
||||
// live sync: the config was changed in another window → re-read it
|
||||
@@ -698,7 +729,7 @@ class HouseplanCard extends LitElement {
|
||||
*/
|
||||
private async _reloadConfigOnly(force = false): Promise<void> {
|
||||
if (!force) {
|
||||
if (this._saveConfig.pending()) this._saveConfig.flush();
|
||||
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||
if (this._cfgWriting) {
|
||||
// retry once the in-flight write settles
|
||||
clearTimeout(this._reloadRetry);
|
||||
@@ -710,6 +741,7 @@ class HouseplanCard extends LitElement {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||
const cfg = resp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = resp?.rev || 0;
|
||||
this._cacheSnapshot();
|
||||
this._regSignature = '';
|
||||
@@ -1437,7 +1469,18 @@ class HouseplanCard extends LitElement {
|
||||
/** A config write is in flight — the card must not adopt a server revision. */
|
||||
private _cfgWriting = false;
|
||||
|
||||
private _saveConfig = debounce(() => {
|
||||
/**
|
||||
* Every mutation path ends here, so this is the one place that can invalidate
|
||||
* the geometry caches synchronously. The WRITE stays debounced; the epoch bump
|
||||
* must not be (audit L1: a bump inside the debounce arrives 500 ms after the
|
||||
* render that needed it, and the plan renders stale geometry).
|
||||
*/
|
||||
private _saveConfig(): void {
|
||||
this._cfgEpoch++;
|
||||
this._saveConfigDebounced();
|
||||
}
|
||||
|
||||
private _saveConfigDebounced = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
this._cfgWriting = true;
|
||||
@@ -1844,7 +1887,25 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** All open-boundary pairs of the current space with their shared segments. */
|
||||
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||
|
||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||
// collinear-overlap math on every HA state push). Memoized on the config
|
||||
// epoch + current space.
|
||||
// The key includes the open_to links themselves: _serverCfg is mutated in
|
||||
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
||||
// trustworthy cache key — a missed bump would render a stale plan, which is
|
||||
// far worse than recomputing a short string here.
|
||||
const sp = this._spaceModel();
|
||||
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
||||
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
||||
const pairs = this._computeOpenPairs();
|
||||
this._openPairsCache = { key, pairs };
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private _computeOpenPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
||||
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
||||
for (let i = 0; i < rooms.length; i++)
|
||||
@@ -1955,11 +2016,16 @@ class HouseplanCard extends LitElement {
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
this._opDrag = { id: o.id, moved: false };
|
||||
this._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false };
|
||||
}
|
||||
|
||||
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
// audit L4: the other drag pipelines require 3 px before calling it a drag.
|
||||
// Without it every tap counted as a drag: the properties dialog never
|
||||
// opened and an unchanged config was written (which then broadcast the
|
||||
// event behind the L2 data loss).
|
||||
if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||
@@ -1967,8 +2033,11 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._curSpaceCfg;
|
||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||
if (!cfg) return;
|
||||
cfg.x = snap.x / NORM_W;
|
||||
cfg.y = snap.y / this._spaceH;
|
||||
const nx = snap.x / NORM_W;
|
||||
const ny = snap.y / this._spaceH;
|
||||
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
|
||||
cfg.x = nx;
|
||||
cfg.y = ny;
|
||||
cfg.angle = snap.angle;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -1976,7 +2045,8 @@ class HouseplanCard extends LitElement {
|
||||
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
const moved = this._opDrag.moved;
|
||||
if (moved) this._saveConfig();
|
||||
// only write when the geometry actually changed (audit L4)
|
||||
if (moved && this._opDrag.dirty) this._saveConfig();
|
||||
// keep the flag until the click event that follows pointerup, then let it go
|
||||
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
||||
else this._opDrag = null;
|
||||
@@ -2867,6 +2937,7 @@ class HouseplanCard extends LitElement {
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
this._cfgEpoch++;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
@@ -3418,7 +3489,18 @@ class HouseplanCard extends LitElement {
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${this._renderDecorLayer()}
|
||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
${(() => {
|
||||
// audit L1: hoisted out of the per-room map — these depend on the
|
||||
// config, not on entity state, and were recomputed per room.
|
||||
const allPairs = this._openPairs();
|
||||
const polyCache = new Map<any, number[][] | null>();
|
||||
const polyOf = (rr: any) => {
|
||||
if (!polyCache.has(rr)) polyCache.set(rr, roomPoly(rr));
|
||||
return polyCache.get(rr)!;
|
||||
};
|
||||
const otherPolys = (rr: any) =>
|
||||
space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][];
|
||||
return space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||
cls += ' picked';
|
||||
@@ -3465,16 +3547,12 @@ class HouseplanCard extends LitElement {
|
||||
// amber highlight — the merge/split selection must stay visible).
|
||||
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
||||
const openCuts = r.id && !isPicked
|
||||
? this._openPairs()
|
||||
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
||||
.flatMap((pp) => pp.segs)
|
||||
? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
|
||||
: [];
|
||||
if (openCuts.length) cls += ' noedge';
|
||||
// island rooms punch holes in their parent's fill (evenodd)
|
||||
const myPoly = roomPoly(r);
|
||||
const holes = myPoly
|
||||
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
|
||||
: [];
|
||||
const myPoly = polyOf(r);
|
||||
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
|
||||
const pathD = (pts: number[][]) =>
|
||||
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
||||
const shape = holes.length && myPoly
|
||||
@@ -3499,7 +3577,8 @@ class HouseplanCard extends LitElement {
|
||||
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
||||
: nothing;
|
||||
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${this._editing ? this._renderAlignGuides() : nothing}
|
||||
|
||||
+44
-13
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
|
||||
* 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(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
|
||||
// rounded ones let one wall produce two different keys, so shared walls were
|
||||
// emitted twice and the dedup invariant quietly broke.
|
||||
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
|
||||
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
|
||||
const first = ax < bx || (ax === bx && ay <= by);
|
||||
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
|
||||
return `${px},${py}-${qx},${qy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
/**
|
||||
* A point guaranteed to lie strictly inside the polygon (audit G2).
|
||||
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
|
||||
* are common in hand-drawn plans), which made containment tests misfire.
|
||||
* Strategy: try the midpoints of the diagonals from each vertex, then a
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
const mean = [
|
||||
poly.reduce((s, p) => s + p[0], 0) / n,
|
||||
poly.reduce((s, p) => s + p[1], 0) / n,
|
||||
];
|
||||
if (pointStrictlyInside(mean, poly, eps)) return mean;
|
||||
for (let i = 0; i < n; i++) {
|
||||
// centroid of the ear at vertex i
|
||||
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
|
||||
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 2; j < n; j++) {
|
||||
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
@@ -232,11 +268,8 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
|
||||
return !!c && pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -250,12 +283,10 @@ export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-
|
||||
return false;
|
||||
for (const v of inner)
|
||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
||||
const c = [
|
||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
||||
];
|
||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
// identical/traced outlines are NOT containment — probe a real interior point
|
||||
// (audit G2: the vertex mean lies outside concave rooms)
|
||||
const c = interiorPoint(inner, eps);
|
||||
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user