mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.61.0-beta.7 candidate
This commit is contained in:
+19
-2
@@ -8,12 +8,17 @@
|
||||
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
|
||||
* so embedded cards paint immediately, then refresh from the server in the background.
|
||||
*/
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
|
||||
const LS_CFG = 'houseplan_card_cfg_v1';
|
||||
|
||||
export interface HpConfigSnapshot {
|
||||
config: any | null;
|
||||
rev: number;
|
||||
configFingerprint: string;
|
||||
layout: Record<string, any>;
|
||||
layoutRev: number;
|
||||
layoutFingerprint: string;
|
||||
}
|
||||
|
||||
let cache: HpConfigSnapshot | null = null;
|
||||
@@ -27,7 +32,15 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
try {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
|
||||
const layout = c.layout || {};
|
||||
return {
|
||||
config: c.config,
|
||||
rev: c.rev || 0,
|
||||
configFingerprint: c.config_fingerprint || contentFingerprint(c.config),
|
||||
layout,
|
||||
layoutRev: c.layout_rev || 0,
|
||||
layoutFingerprint: c.layout_fingerprint || contentFingerprint(layout),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -43,7 +56,10 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
cache = {
|
||||
config: cfgResp?.config ?? null,
|
||||
rev: cfgResp?.rev ?? 0,
|
||||
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
|
||||
layout: layResp?.layout ?? {},
|
||||
layoutRev: layResp?.rev ?? 0,
|
||||
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
@@ -66,7 +82,8 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
}
|
||||
|
||||
/** Get the shared config snapshot (cached, deduped across cards). */
|
||||
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
|
||||
export function getConfig(hass: any, force = false): Promise<HpConfigSnapshot> {
|
||||
if (force) cache = null;
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetchFresh(hass).finally(() => {
|
||||
|
||||
+14
-5
@@ -1,6 +1,7 @@
|
||||
/** Runtime proof that this document can rasterize isolated SVG screen blend. */
|
||||
|
||||
const documentProbes = new WeakMap<Document, Promise<boolean>>();
|
||||
type ProbeState = { promise: Promise<boolean>; resolved?: boolean };
|
||||
const documentProbes = new WeakMap<Document, ProbeState>();
|
||||
|
||||
export type Rgb = readonly [number, number, number];
|
||||
|
||||
@@ -59,9 +60,17 @@ async function renderProbe(doc: Document): Promise<boolean> {
|
||||
/** One cached Promise per real Document; errors are a normal false fallback. */
|
||||
export function svgScreenBlendSupported(doc: Document): Promise<boolean> {
|
||||
const cached = documentProbes.get(doc);
|
||||
if (cached) return cached;
|
||||
const probe = renderProbe(doc).catch(() => false);
|
||||
documentProbes.set(doc, probe);
|
||||
return probe;
|
||||
if (cached) return cached.promise;
|
||||
const state: ProbeState = { promise: Promise.resolve(false) };
|
||||
state.promise = renderProbe(doc).catch(() => false).then((resolved) => {
|
||||
state.resolved = resolved;
|
||||
return resolved;
|
||||
});
|
||||
documentProbes.set(doc, state);
|
||||
return state.promise;
|
||||
}
|
||||
|
||||
/** Synchronous warm-remount path: undefined means the first cold probe is pending. */
|
||||
export function resolvedSvgScreenBlend(doc: Document): boolean | undefined {
|
||||
return documentProbes.get(doc)?.resolved;
|
||||
}
|
||||
|
||||
+563
-213
File diff suppressed because it is too large
Load Diff
@@ -342,6 +342,9 @@
|
||||
"space_card.button": "Open the space plan",
|
||||
"space_card.not_found": "Space “{id}” not found",
|
||||
"space_card.loading": "Loading…",
|
||||
"continuity.restore_plan": "Restoring the plan…",
|
||||
"continuity.restore_connection": "Restoring the connection to devices…",
|
||||
"continuity.retry": "Try again",
|
||||
"editor.space": "Space",
|
||||
"editor.show_button": "Show button",
|
||||
"editor.button_label": "Button label",
|
||||
|
||||
@@ -342,6 +342,9 @@
|
||||
"space_card.button": "Перейти к пространству",
|
||||
"space_card.not_found": "Пространство «{id}» не найдено",
|
||||
"space_card.loading": "Загрузка…",
|
||||
"continuity.restore_plan": "Восстанавливаем план…",
|
||||
"continuity.restore_connection": "Восстанавливаем подключение к устройствам…",
|
||||
"continuity.retry": "Повторить",
|
||||
"editor.space": "Пространство",
|
||||
"editor.show_button": "Показывать кнопку",
|
||||
"editor.button_label": "Текст кнопки",
|
||||
|
||||
+10
-4
@@ -115,9 +115,9 @@ const rayHit = (
|
||||
* Region lit by a point source, as a single ring in plan coordinates.
|
||||
*
|
||||
* `segments` may contain anything: only the ones that can reach into the
|
||||
* radius are considered. A source sitting exactly on an opaque edge would make
|
||||
* the sweep degenerate, so such edges are dropped — the caller is expected to
|
||||
* reject a lamp placed inside masonry before asking.
|
||||
* radius are considered. A source on an opaque edge is invalid and returns no
|
||||
* lit region. Dropping that edge would make the wall disappear precisely at a
|
||||
* grid-snapped placement and illuminate the room on its other side.
|
||||
*/
|
||||
export function visibilityPolygon(
|
||||
source: readonly number[],
|
||||
@@ -131,7 +131,8 @@ export function visibilityPolygon(
|
||||
if (!seg || seg.length < 4) continue;
|
||||
if (![seg[0], seg[1], seg[2], seg[3]].every(Number.isFinite)) continue;
|
||||
const distance = distanceToSegment(source, seg);
|
||||
if (distance > radius || distance < 1e-7) continue;
|
||||
if (distance < 1e-7) return [];
|
||||
if (distance > radius) continue;
|
||||
near.push(seg);
|
||||
}
|
||||
const angles: number[] = [];
|
||||
@@ -143,6 +144,11 @@ export function visibilityPolygon(
|
||||
angles.push(angle - CORNER_NUDGE, angle, angle + CORNER_NUDGE);
|
||||
}
|
||||
}
|
||||
// atan2's seam is a geometric non-event. Normalising every ray onto one
|
||||
// cyclic interval keeps the +nudge ray beside its corner instead of sorting
|
||||
// it to the opposite end and closing the fan with a long chord.
|
||||
const turn = Math.PI * 2;
|
||||
for (let i = 0; i < angles.length; i++) angles[i] = ((angles[i] % turn) + turn) % turn;
|
||||
angles.sort((left, right) => left - right);
|
||||
const ring: number[][] = [];
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
|
||||
-134
@@ -1716,117 +1716,6 @@ export function glowColorOf(state: any, fallback: string): ResolvedGlowValues |
|
||||
return resolveGlowAppearance(state, null, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound a doorway spill to the part of the source radius that remains after
|
||||
* the aperture. `reach` is deliberately aperture-relative: callers use it for
|
||||
* the spill gradient, while `outerRadius` remains source-relative for the
|
||||
* sight-line sector built by `doorSector`.
|
||||
*/
|
||||
export function doorSpillRange(
|
||||
sourceRadius: number,
|
||||
apertureEdgeDistance: number,
|
||||
requestedReach: number,
|
||||
): { reach: number; outerRadius: number } | null {
|
||||
if (!Number.isFinite(sourceRadius) || sourceRadius <= 0
|
||||
|| !Number.isFinite(apertureEdgeDistance) || apertureEdgeDistance < 0
|
||||
|| !Number.isFinite(requestedReach) || requestedReach <= 0
|
||||
|| apertureEdgeDistance >= sourceRadius) return null;
|
||||
const reach = Math.min(requestedReach, sourceRadius - apertureEdgeDistance);
|
||||
if (!(reach > 1e-6)) return null;
|
||||
return {
|
||||
reach,
|
||||
outerRadius: Math.min(sourceRadius, apertureEdgeDistance + reach),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Light spilling through a doorway: the sector of the glow circle between the
|
||||
* rays source→A and source→B (door edge points), out to radius r.
|
||||
*
|
||||
* With `tunnelDepth > 0`, A/B are the centreline opening corners and the wall
|
||||
* is an actual rectangular tunnel. A valid ray must cross BOTH its source-side
|
||||
* and far-side spans, so the outgoing angle is the intersection of the two
|
||||
* spans' angular intervals. That is the shadow cast by the two jamb returns;
|
||||
* an off-centre lamp no longer shines through the solid corner of a thick wall.
|
||||
* Zero depth is byte-for-byte the old centreline sector.
|
||||
*
|
||||
* This part of the circle is intentionally NOT clipped by the room (owner's
|
||||
* spec: no general shadow casting — only the opening tunnel constrains it).
|
||||
* Null when the door is out of reach/source is on an edge, or when the two
|
||||
* tunnel apertures have no common sight line. Sweep is clamped to maxDeg.
|
||||
*/
|
||||
export function doorSector(
|
||||
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
|
||||
tunnelDepth = 0,
|
||||
): number[][] | null {
|
||||
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
|
||||
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
|
||||
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
|
||||
let aa: number;
|
||||
let sweep: number;
|
||||
if (Number.isFinite(tunnelDepth) && tunnelDepth > 1e-6) {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-6) return null;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const nx = -uy, ny = ux;
|
||||
const cx = (a[0] + b[0]) / 2, cy = (a[1] + b[1]) / 2;
|
||||
const side = ((src[0] - cx) * nx + (src[1] - cy) * ny) >= 0 ? 1 : -1;
|
||||
const off = tunnelDepth / 2;
|
||||
const nearA = [a[0] + nx * off * side, a[1] + ny * off * side];
|
||||
const nearB = [b[0] + nx * off * side, b[1] + ny * off * side];
|
||||
const farA = [a[0] - nx * off * side, a[1] - ny * off * side];
|
||||
const farB = [b[0] - nx * off * side, b[1] - ny * off * side];
|
||||
const base = Math.atan2(cy - src[1], cx - src[0]);
|
||||
const unwrap = (p: number[]) => {
|
||||
let ang = Math.atan2(p[1] - src[1], p[0] - src[0]);
|
||||
while (ang - base > Math.PI) ang -= 2 * Math.PI;
|
||||
while (ang - base < -Math.PI) ang += 2 * Math.PI;
|
||||
return ang;
|
||||
};
|
||||
const na = unwrap(nearA), nb = unwrap(nearB);
|
||||
const fa = unwrap(farA), fb = unwrap(farB);
|
||||
const lo = Math.max(Math.min(na, nb), Math.min(fa, fb));
|
||||
const hi = Math.min(Math.max(na, nb), Math.max(fa, fb));
|
||||
if (!(hi - lo > 1e-9)) return null;
|
||||
aa = lo;
|
||||
sweep = hi - lo;
|
||||
} else {
|
||||
aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
|
||||
sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
|
||||
while (sweep > Math.PI) sweep -= 2 * Math.PI;
|
||||
while (sweep < -Math.PI) sweep += 2 * Math.PI;
|
||||
}
|
||||
const max = (maxDeg * Math.PI) / 180;
|
||||
if (Math.abs(sweep) > max) {
|
||||
const mid = aa + sweep / 2;
|
||||
sweep = max * Math.sign(sweep);
|
||||
aa = mid - sweep / 2;
|
||||
}
|
||||
const steps = 8;
|
||||
const pts: number[][] = [[src[0], src[1]]];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const ang = aa + (sweep * i) / steps;
|
||||
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a room on the far side of an opening (relative to the light source)?
|
||||
* Entrance doors lead outside — light must not spill there.
|
||||
*/
|
||||
export function hasRoomBehind(
|
||||
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
|
||||
): boolean {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const n = [-Math.sin(rad), Math.cos(rad)];
|
||||
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
|
||||
const sgn = toSrc > 0 ? -1 : 1;
|
||||
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
|
||||
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group toggle for a switch's controlled entities, HA-group semantics:
|
||||
* any target on -> turn everything off; all off -> turn everything on.
|
||||
@@ -1877,29 +1766,6 @@ export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected component of rooms joined by open (virtual) boundaries — the
|
||||
* "open zone" light flows through. The open_to link counts in either
|
||||
* direction. Returns a set of room ids including the start.
|
||||
*/
|
||||
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
|
||||
const zone = new Set<string>([roomId]);
|
||||
const linked = (x: any, y: any) =>
|
||||
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const r of rooms) {
|
||||
if (!r.id || zone.has(r.id)) continue;
|
||||
for (const z of rooms) {
|
||||
if (!z.id || !zone.has(z.id)) continue;
|
||||
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments with the given collinear stretches removed — the workhorse behind
|
||||
* TRUE dashed open boundaries (derived walls and room outlines alike).
|
||||
|
||||
+14
-79
@@ -114,15 +114,18 @@ const ringPath = (poly: number[][]): string =>
|
||||
`M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
|
||||
|
||||
/**
|
||||
* One `d` per resulting polygon (its outer ring plus its holes), never one
|
||||
* merged string. Two separate paths cannot cancel each other, while a single
|
||||
* path with the default nonzero rule silently erases the overlap of two
|
||||
* subpaths wound the opposite way — the way ten joined shadow quads once
|
||||
* collapsed into nothing.
|
||||
* One `d` fragment per resulting polygon (its outer ring plus its holes).
|
||||
* Callers may keep the fragments as separate paths or join them into one `d`
|
||||
* only with an explicit `evenodd` rule. Relying on default nonzero winding
|
||||
* can erase oppositely wound subpaths.
|
||||
*/
|
||||
export function geometryPolygonPaths(geom: any): string[] {
|
||||
const out: string[] = [];
|
||||
for (const poly of geom || []) {
|
||||
// Polyclip can leave zero-area needles when a visibility fan merely
|
||||
// touches a floor boundary. Rendering those makes an outside source leak
|
||||
// a few bright hairlines into the plan; they are not visible floor.
|
||||
if (geometryArea([poly]) <= 1e-6) continue;
|
||||
const parts: string[] = [];
|
||||
for (const ring of poly || []) {
|
||||
const pts = (ring || []).filter((p: any) => Array.isArray(p) && p.length >= 2);
|
||||
@@ -134,39 +137,20 @@ export function geometryPolygonPaths(geom: any): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Union of overlapping polygons as disjoint paths: no pixel is painted twice. */
|
||||
export function unionPaths(polygons: number[][][]): string[] {
|
||||
const usable = polygons.filter((poly) => poly.length >= 3);
|
||||
if (!usable.length) return [];
|
||||
const geom = unionBodies(usable);
|
||||
return geom ? geometryPolygonPaths(geom) : usable.map(ringPath);
|
||||
}
|
||||
|
||||
/** `polygons` clipped to `bounds`, as disjoint paths. Empty when they miss. */
|
||||
export function intersectionPaths(polygons: number[][][], bounds: number[][][]): string[] {
|
||||
const base = unionBodies(polygons.filter((poly) => poly.length >= 3));
|
||||
const limit = unionBodies(bounds.filter((poly) => poly.length >= 3));
|
||||
if (!base) return [];
|
||||
if (!limit) return geometryPolygonPaths(base);
|
||||
if (!limit) return [];
|
||||
try {
|
||||
return geometryPolygonPaths(intersection(base, limit));
|
||||
} catch {
|
||||
return geometryPolygonPaths(base);
|
||||
}
|
||||
}
|
||||
|
||||
/** `polygons` minus `subtract`, as disjoint paths. Falls back to the union. */
|
||||
export function differencePaths(polygons: number[][][], subtract: number[][][]): string[] {
|
||||
const usable = polygons.filter((poly) => poly.length >= 3);
|
||||
if (!usable.length) return [];
|
||||
const base = unionBodies(usable);
|
||||
if (!base) return usable.map(ringPath);
|
||||
const cut = unionBodies(subtract.filter((poly) => poly.length >= 3));
|
||||
if (!cut) return geometryPolygonPaths(base);
|
||||
try {
|
||||
return geometryPolygonPaths(difference(base, cut));
|
||||
} catch {
|
||||
return geometryPolygonPaths(base);
|
||||
// The un-clipped visibility fan may cover the backdrop and the area
|
||||
// outside the house. A boolean failure must therefore fail dark: returning
|
||||
// `base` here turns a numerical polyclip exception into a light leak and
|
||||
// then persists it in the per-source clip cache.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,55 +244,6 @@ export function directionalOccluders(
|
||||
])).filter((p) => p.length >= 3);
|
||||
}
|
||||
|
||||
/** Approximate the hard shadow cast by each body away from a point source. */
|
||||
export function radialOccluders(
|
||||
bodies: number[][][], source: number[], radius: number,
|
||||
): number[][][] {
|
||||
if (!(radius > 0)) return [];
|
||||
const boundaryEps = Math.max(1e-9, radius * 1e-9);
|
||||
const onBoundary = (body: number[][]): boolean => body.some((a, i) => {
|
||||
const b = body[(i + 1) % body.length];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!(len2 > 0)) return Math.hypot(source[0] - a[0], source[1] - a[1]) <= boundaryEps;
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((source[0] - a[0]) * dx + (source[1] - a[1]) * dy) / len2));
|
||||
return Math.hypot(source[0] - (a[0] + t * dx), source[1] - (a[1] + t * dy))
|
||||
<= boundaryEps;
|
||||
});
|
||||
// A misplaced source inside/on masonry is invalid input. Cover its entire
|
||||
// pool so it cannot illuminate through the body in any direction.
|
||||
if (bodies.some((body) => pointInPhysicalBody(source, body) || onBoundary(body))) {
|
||||
return [Array.from({ length: 128 }, (_, i) => {
|
||||
const a = (i / 128) * Math.PI * 2;
|
||||
return [source[0] + Math.cos(a) * radius * 1.01,
|
||||
source[1] + Math.sin(a) * radius * 1.01];
|
||||
})];
|
||||
}
|
||||
const out: number[][][] = [];
|
||||
for (const body of bodies) {
|
||||
if (body.length < 2) continue;
|
||||
out.push(body);
|
||||
// One quad per edge. Its far chord is deliberately pushed beyond the
|
||||
// glow circle even for an edge subtending almost 180°; a single hull with
|
||||
// a fixed projection distance lets that chord cut back through the pool.
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const a = body[i], b = body[(i + 1) % body.length];
|
||||
const ax = a[0] - source[0], ay = a[1] - source[1];
|
||||
const bx = b[0] - source[0], by = b[1] - source[1];
|
||||
const da = Math.hypot(ax, ay), db = Math.hypot(bx, by);
|
||||
if (!(da > boundaryEps) || !(db > boundaryEps)) continue;
|
||||
const cosTheta = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (da * db)));
|
||||
const halfCos = Math.sqrt(Math.max(0, (1 + cosTheta) / 2));
|
||||
const far = Math.max(radius * 2, (radius * 1.02) / Math.max(halfCos, 1e-3));
|
||||
const pa = [source[0] + (ax / da) * far, source[1] + (ay / da) * far];
|
||||
const pb = [source[0] + (bx / db) * far, source[1] + (by / db) * far];
|
||||
out.push([a, b, pb, pa]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pointInPhysicalBody(point: number[], body: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
|
||||
|
||||
+272
-34
@@ -6,6 +6,44 @@ export const SIGN_INFLIGHT_MS = 15000;
|
||||
export const SIGN_BACKOFF_MIN_MS = 2000;
|
||||
export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
|
||||
type SharedSignedEntry = {
|
||||
url: string;
|
||||
at: number;
|
||||
loaded: boolean;
|
||||
/** A refresh is decoded off-DOM before it may replace the visible url. */
|
||||
pending?: { url: string; at: number };
|
||||
preload?: Promise<boolean>;
|
||||
};
|
||||
type SharedSignerRuntime = {
|
||||
signed: Record<string, SharedSignedEntry>;
|
||||
queued: Set<string>;
|
||||
inFlight: Map<string, number>;
|
||||
retry: Map<string, { notBefore: number; delay: number }>;
|
||||
listeners: Set<() => void>;
|
||||
references: Map<object, Set<string>>;
|
||||
};
|
||||
|
||||
const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
|
||||
const SHARED_SIGNED_MAX = 96;
|
||||
|
||||
const signerAuthority = (hass: any, fallback: object): object => {
|
||||
const authority = hass?.connection || hass;
|
||||
return authority && (typeof authority === 'object' || typeof authority === 'function')
|
||||
? authority : fallback;
|
||||
};
|
||||
|
||||
const signerRuntime = (authority: object): SharedSignerRuntime => {
|
||||
let runtime = sharedSignerRuntimes.get(authority);
|
||||
if (!runtime) {
|
||||
runtime = {
|
||||
signed: {}, queued: new Set(), inFlight: new Map(), retry: new Map(),
|
||||
listeners: new Set(), references: new Map(),
|
||||
};
|
||||
sharedSignerRuntimes.set(authority, runtime);
|
||||
}
|
||||
return runtime;
|
||||
};
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
@@ -32,16 +70,19 @@ export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
* forever, and a failure backs off instead of retrying on the next frame.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
private readonly fallbackAuthority = {};
|
||||
private readonly referenceOwner = {};
|
||||
private shared?: SharedSignerRuntime;
|
||||
private authority?: object;
|
||||
private referenced = new Set<string>();
|
||||
/** Waiting for the batch timer to fire. */
|
||||
private queued = new Set<string>();
|
||||
/** Sent and not settled yet: url -> when the request went out. */
|
||||
private inFlight = new Map<string, number>();
|
||||
/** After a failure: when this url may be asked again, and the current delay. */
|
||||
private retry = new Map<string, { notBefore: number; delay: number }>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
private readonly sharedUpdate = () => {
|
||||
if (!this.disposed) this.onUpdate();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
@@ -49,9 +90,29 @@ export class ContentSigner {
|
||||
*/
|
||||
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
|
||||
|
||||
private bind(hass: any): SharedSignerRuntime {
|
||||
const authority = signerAuthority(hass, this.fallbackAuthority);
|
||||
if (this.shared && this.authority === authority) return this.shared;
|
||||
if (this.shared) {
|
||||
this.shared.listeners.delete(this.sharedUpdate);
|
||||
this.shared.references.delete(this.referenceOwner);
|
||||
}
|
||||
this.authority = authority;
|
||||
this.shared = signerRuntime(authority);
|
||||
if (!this.disposed) {
|
||||
this.shared.listeners.add(this.sharedUpdate);
|
||||
this.shared.references.set(this.referenceOwner, new Set(this.referenced));
|
||||
}
|
||||
return this.shared;
|
||||
}
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.disposed = false; // an element can be reconnected after a disconnect
|
||||
this.referenced = new Set(referenced());
|
||||
const shared = this.bind(hass());
|
||||
shared.listeners.add(this.sharedUpdate);
|
||||
shared.references.set(this.referenceOwner, new Set(this.referenced));
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
@@ -61,8 +122,12 @@ export class ContentSigner {
|
||||
this.disposed = true;
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
if (this.shared) {
|
||||
this.shared.listeners.delete(this.sharedUpdate);
|
||||
this.shared.references.delete(this.referenceOwner);
|
||||
for (const url of this.queued) this.shared.queued.delete(url);
|
||||
}
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
@@ -74,15 +139,18 @@ export class ContentSigner {
|
||||
display(hass: any, url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this.signed[u];
|
||||
const shared = this.bind(hass);
|
||||
this.referenced.add(u);
|
||||
shared.references.set(this.referenceOwner, new Set(this.referenced));
|
||||
const hit = shared.signed[u];
|
||||
const age = hit ? this.now() - hit.at : Infinity;
|
||||
if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
if (age < SIGN_TTL_MS) {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this.request(hass, u);
|
||||
if (!hit.pending) this.request(hass, u);
|
||||
return hit.url;
|
||||
}
|
||||
if (hit) delete this.signed[u];
|
||||
if (hit) delete shared.signed[u];
|
||||
this.request(hass, u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt".
|
||||
@@ -91,69 +159,211 @@ export class ContentSigner {
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (!hass?.callWS || this.queued.has(url)) return;
|
||||
const shared = this.bind(hass);
|
||||
const now = this.now();
|
||||
const sent = this.inFlight.get(url);
|
||||
const sent = shared.inFlight.get(url);
|
||||
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
|
||||
const back = this.retry.get(url);
|
||||
if (shared.queued.has(url)) return;
|
||||
const back = shared.retry.get(url);
|
||||
if (back && now < back.notBefore) return; // still backing off
|
||||
this.queued.add(url);
|
||||
shared.queued.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.queued];
|
||||
this.queued.clear();
|
||||
for (const path of paths) shared.queued.delete(path);
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
const shared = this.bind(hass);
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
const sentAt = this.now();
|
||||
for (const p of batch) this.inFlight.set(p, sentAt);
|
||||
const claimed = batch.filter((p) => {
|
||||
const sent = shared.inFlight.get(p);
|
||||
if (sent !== undefined && sentAt - sent < SIGN_INFLIGHT_MS) return false;
|
||||
shared.inFlight.set(p, sentAt);
|
||||
return true;
|
||||
});
|
||||
if (!claimed.length) continue;
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.callWS({ type: 'houseplan/content/sign', paths: claimed })
|
||||
.then((r: any) => {
|
||||
if (this.disposed) return;
|
||||
// A successful call does NOT mean every path was signed: the backend
|
||||
// skips a path it cannot sign, logs it and still answers `{urls: …}`
|
||||
// with the rest. Treating the whole batch as done then cleared the
|
||||
// backoff for the missing ones, so every later render asked again —
|
||||
// the very amplification the backoff exists to stop (review R5-1).
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
let accepted = 0;
|
||||
for (const p of batch) {
|
||||
let notifyNow = false;
|
||||
for (const p of claimed) {
|
||||
const url = r?.urls?.[p]; // only keys we asked for
|
||||
if (typeof url === 'string' && url) {
|
||||
next[p] = { url, at };
|
||||
this.retry.delete(p);
|
||||
const previous = shared.signed[p];
|
||||
if (previous?.loaded && previous.url !== url) {
|
||||
// Stale-while-decode: never swap an already painted backdrop
|
||||
// for a network/decode candidate. The old signed URL remains
|
||||
// in every card until the replacement is ready to paint.
|
||||
previous.pending = { url, at };
|
||||
this.preloadReplacement(shared, p, url, at);
|
||||
} else {
|
||||
shared.signed[p] = {
|
||||
url,
|
||||
at,
|
||||
loaded: previous?.url === url ? !!previous.loaded : false,
|
||||
};
|
||||
notifyNow = true;
|
||||
}
|
||||
shared.retry.delete(p);
|
||||
accepted++;
|
||||
} else {
|
||||
this.backOff(p);
|
||||
this.backOff(shared, p);
|
||||
}
|
||||
}
|
||||
if (!accepted) return;
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
this.trimShared(shared);
|
||||
if (notifyNow) for (const listener of [...shared.listeners]) listener();
|
||||
})
|
||||
.catch(() => {
|
||||
// back off rather than retry on the very next frame: a socket that
|
||||
// is refusing sign requests would otherwise be hammered per render
|
||||
for (const p of batch) this.backOff(p);
|
||||
for (const p of claimed) this.backOff(shared, p);
|
||||
})
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
|
||||
for (const p of claimed) if (shared.inFlight.get(p) === sentAt) shared.inFlight.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private preloadReplacement(
|
||||
shared: SharedSignerRuntime,
|
||||
path: string,
|
||||
url: string,
|
||||
at: number,
|
||||
): void {
|
||||
const promote = (): void => {
|
||||
const current = shared.signed[path];
|
||||
if (current?.pending?.url !== url) return;
|
||||
shared.signed[path] = { url, at, loaded: true };
|
||||
shared.retry.delete(path);
|
||||
this.trimShared(shared);
|
||||
for (const listener of [...shared.listeners]) listener();
|
||||
};
|
||||
const reject = (): void => {
|
||||
const current = shared.signed[path];
|
||||
if (current?.pending?.url !== url) return;
|
||||
delete current.pending;
|
||||
this.backOff(shared, path);
|
||||
};
|
||||
// Unit/non-DOM runtimes cannot preload; accepting synchronously preserves
|
||||
// the cache contract while browsers take the stricter decode path.
|
||||
if (typeof Image === 'undefined') {
|
||||
promote();
|
||||
return;
|
||||
}
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const decoded = typeof image.decode === 'function' ? image.decode() : Promise.resolve();
|
||||
decoded.then(promote).catch(reject);
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
}
|
||||
|
||||
private preloadCurrentImage(
|
||||
shared: SharedSignerRuntime,
|
||||
path: string,
|
||||
entry: SharedSignedEntry,
|
||||
): Promise<boolean> {
|
||||
if (entry.loaded) return Promise.resolve(true);
|
||||
if (entry.preload) return entry.preload;
|
||||
if (typeof Image === 'undefined') {
|
||||
entry.loaded = true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const url = entry.url;
|
||||
entry.preload = new Promise<boolean>((resolve) => {
|
||||
const image = new Image();
|
||||
const finish = (ready: boolean): void => {
|
||||
const current = shared.signed[path];
|
||||
if (current === entry) delete current.preload;
|
||||
if (ready && current === entry && current.url === url) {
|
||||
current.loaded = true;
|
||||
for (const listener of [...shared.listeners]) listener();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (!ready && current === entry) this.backOff(shared, path);
|
||||
resolve(false);
|
||||
};
|
||||
image.onload = () => {
|
||||
const decoded = typeof image.decode === 'function' ? image.decode() : Promise.resolve();
|
||||
decoded.then(() => finish(true)).catch(() => finish(false));
|
||||
};
|
||||
image.onerror = () => finish(false);
|
||||
image.src = url;
|
||||
});
|
||||
return entry.preload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and decode a protected image before a structural candidate adopts it.
|
||||
* The current frame can therefore keep its old backdrop for the whole wait.
|
||||
*/
|
||||
prepareImage(hass: any, url: string | null | undefined): Promise<boolean> {
|
||||
const path = contentUrl(url);
|
||||
if (!path.startsWith('/api/houseplan/content/')) return Promise.resolve(true);
|
||||
const shared = this.bind(hass);
|
||||
const attempt = (): Promise<boolean> | null => {
|
||||
const entry = shared.signed[path];
|
||||
if (entry && this.now() - entry.at < SIGN_TTL_MS) {
|
||||
return this.preloadCurrentImage(shared, path, entry);
|
||||
}
|
||||
this.display(hass, path);
|
||||
return null;
|
||||
};
|
||||
const immediate = attempt();
|
||||
if (immediate) return immediate;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
let active: Promise<boolean> | null = null;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
shared.listeners.delete(check);
|
||||
resolve(ready);
|
||||
};
|
||||
const check = (): void => {
|
||||
if (done || active) return;
|
||||
active = attempt();
|
||||
if (!active) return;
|
||||
active.then(finish).catch(() => finish(false));
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
|
||||
shared.listeners.add(check);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/** Next attempt for this url waits, and each failure waits twice as long. */
|
||||
private backOff(url: string): void {
|
||||
const prev = this.retry.get(url)?.delay || 0;
|
||||
private backOff(shared: SharedSignerRuntime, url: string): void {
|
||||
const prev = shared.retry.get(url)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(url, { notBefore: this.now() + delay, delay });
|
||||
shared.retry.set(url, { notBefore: this.now() + delay, delay });
|
||||
}
|
||||
|
||||
private trimShared(shared: SharedSignerRuntime): void {
|
||||
const entries = Object.entries(shared.signed);
|
||||
if (entries.length <= SHARED_SIGNED_MAX) return;
|
||||
entries.sort((left, right) => right[1].at - left[1].at);
|
||||
shared.signed = Object.fromEntries(entries.slice(0, SHARED_SIGNED_MAX));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,23 +372,51 @@ export class ContentSigner {
|
||||
* request — so prune to the urls the live config still references.
|
||||
*/
|
||||
resign(hass: any, referenced: Set<string>): void {
|
||||
const shared = this.bind(hass);
|
||||
this.referenced = new Set(referenced);
|
||||
shared.references.set(this.referenceOwner, new Set(this.referenced));
|
||||
const now = this.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this.signed)) {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
const allReferenced = new Set<string>();
|
||||
for (const refs of shared.references.values()) for (const path of refs) allReferenced.add(path);
|
||||
const kept: Record<string, SharedSignedEntry> = {};
|
||||
for (const [k, v] of Object.entries(shared.signed)) {
|
||||
if (allReferenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, Object.keys(kept));
|
||||
shared.signed = kept;
|
||||
shared.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, [...referenced].filter((path) => !!kept[path] && !kept[path].pending));
|
||||
}
|
||||
|
||||
/** Mark a protected image as decoded/paintable; shared warm mounts inherit it. */
|
||||
markLoaded(hass: any, url: string | null | undefined): void {
|
||||
const path = contentUrl(url);
|
||||
if (!path.startsWith('/api/houseplan/content/')) return;
|
||||
const entry = this.bind(hass).signed[path];
|
||||
if (entry && !entry.loaded) {
|
||||
entry.loaded = true;
|
||||
for (const listener of [...this.bind(hass).listeners]) listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Required protected assets are ready only after signing and image load. */
|
||||
isReady(hass: any, url: string | null | undefined): boolean {
|
||||
const path = contentUrl(url);
|
||||
if (!path.startsWith('/api/houseplan/content/')) return true;
|
||||
const entry = this.bind(hass).signed[path];
|
||||
return !!entry && this.now() - entry.at < SIGN_TTL_MS && entry.loaded;
|
||||
}
|
||||
|
||||
/** Test/debug view of the cache. */
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
const entries: Record<string, { url: string; at: number }> = {};
|
||||
for (const [path, entry] of Object.entries(this.shared?.signed || {})) {
|
||||
entries[path] = { url: entry.url, at: entry.at };
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Test/debug view of what is currently being asked for. */
|
||||
get inFlightUrls(): string[] {
|
||||
return [...this.inFlight.keys()];
|
||||
return [...(this.shared?.inFlight.keys() || [])];
|
||||
}
|
||||
}
|
||||
|
||||
+312
-28
@@ -19,6 +19,13 @@ import {
|
||||
type PresentationActivityRuntime,
|
||||
} from './device-presentation';
|
||||
import type { DevItem } from './types';
|
||||
import {
|
||||
VisualContinuityController,
|
||||
contentFingerprint,
|
||||
subscribePageVisibility,
|
||||
visualFrameFingerprint,
|
||||
type PageVisibilitySignal,
|
||||
} from './visual-continuity';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -55,14 +62,28 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _config?: SpaceCardConfig;
|
||||
private _snap: HpConfigSnapshot | null = null;
|
||||
private _loading = false;
|
||||
private _reloadQueued = false;
|
||||
private _forceReloadQueued = false;
|
||||
private _reloadRetryTimer = 0;
|
||||
private _unsub?: () => void;
|
||||
private _stageWidth = 0;
|
||||
private _pendingStageWidth = 0;
|
||||
private _stageWidthRaf = 0;
|
||||
private _stageObserver?: ResizeObserver;
|
||||
private _observedStage?: HTMLElement;
|
||||
private _haRegistryRelease?: () => void;
|
||||
private _haRegistryConnection: any = null;
|
||||
private _haRegistryRevision = -1;
|
||||
private _devices: DevItem[] = [];
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
private _continuityPaintToken = -1;
|
||||
private _continuityDisposed = false;
|
||||
private _renderSnapshotAt = Date.now();
|
||||
private _hassSequence = 0;
|
||||
private _connHooked: any = null;
|
||||
private _activityRuntime = new Map<string, StaticActivityRuntime>();
|
||||
private _onHaRegistryUpdate = () => {
|
||||
const revision = haRegistrySnapshot(this.hass).revision;
|
||||
@@ -85,8 +106,48 @@ class HouseplanSpaceCard extends LitElement {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_snap: { state: true },
|
||||
_continuityEpoch: { state: true },
|
||||
};
|
||||
|
||||
private _newContinuityController(): VisualContinuityController {
|
||||
return new VisualContinuityController(() => {
|
||||
this._continuityEpoch++;
|
||||
if (this.isConnected) this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private _pageVisibility = (signal: PageVisibilitySignal): void => {
|
||||
const token = this._continuity.visibility(signal);
|
||||
if (signal.kind === 'hidden' || !signal.long) return;
|
||||
if (Date.now() - this._renderSnapshotAt > 1000) this._continuity.note('device-snapshot-stale');
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) void this._load(true);
|
||||
};
|
||||
|
||||
private _onConnLost = (): void => {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.connectionLost();
|
||||
};
|
||||
|
||||
private _onConnReady = (): void => {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'connection');
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
private _hookConnection(): void {
|
||||
const connection = this.hass?.connection;
|
||||
if (!connection || connection === this._connHooked) return;
|
||||
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
|
||||
this._connHooked?.removeEventListener?.('disconnected', this._onConnLost);
|
||||
this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost);
|
||||
connection.addEventListener?.('ready', this._onConnReady);
|
||||
connection.addEventListener?.('disconnected', this._onConnLost);
|
||||
connection.addEventListener?.('reconnect-error', this._onConnLost);
|
||||
this._connHooked = connection;
|
||||
}
|
||||
|
||||
public static getConfigElement() {
|
||||
return document.createElement('houseplan-space-card-editor');
|
||||
}
|
||||
@@ -114,38 +175,67 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
if (this._continuityDisposed) {
|
||||
this._continuity = this._newContinuityController();
|
||||
this._continuityDisposed = false;
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
super.connectedCallback();
|
||||
if (this.hass) this._ensureHaRegistryAuthority();
|
||||
this._continuityUnsub?.();
|
||||
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
|
||||
this._unsub = onConfigChange(() => {
|
||||
this._loading = false;
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
this._beginContinuityCandidate('config-event', false);
|
||||
this._reloadQueued = true;
|
||||
void this._load();
|
||||
});
|
||||
// a dashboard on a wall tablet outlives a 24 h signature
|
||||
this._signer.start(() => this.hass, () => this._referenced());
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._continuityUnsub?.();
|
||||
this._continuityUnsub = undefined;
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
window.clearTimeout(this._reloadRetryTimer);
|
||||
this._reloadRetryTimer = 0;
|
||||
this._stageObserver?.disconnect();
|
||||
this._stageObserver = undefined;
|
||||
this._observedStage = undefined;
|
||||
if (this._stageWidthRaf) cancelAnimationFrame(this._stageWidthRaf);
|
||||
this._stageWidthRaf = 0;
|
||||
this._pendingStageWidth = 0;
|
||||
this._signer.dispose();
|
||||
this._haRegistryRelease?.();
|
||||
this._haRegistryRelease = undefined;
|
||||
this._haRegistryConnection = null;
|
||||
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
|
||||
this._connHooked?.removeEventListener?.('disconnected', this._onConnLost);
|
||||
this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost);
|
||||
this._connHooked = null;
|
||||
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
|
||||
this._activityRuntime.clear();
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
if (changed.has('hass') && this.hass) this._ensureHaRegistryAuthority();
|
||||
if (changed.has('hass') && this.hass) {
|
||||
this._hassSequence++;
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.note('hass-snapshot');
|
||||
this._ensureHaRegistryAuthority();
|
||||
this._hookConnection();
|
||||
}
|
||||
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
|
||||
if (!this._snap || !this._loadedOnce) this._load();
|
||||
}
|
||||
this._refreshDevices();
|
||||
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
this._continuity.refreshCompleteFrame(this._frameFingerprint());
|
||||
}
|
||||
}
|
||||
|
||||
private _stampActivity(runtime: StaticActivityRuntime, kind: 'event' | 'transition'): void {
|
||||
@@ -223,40 +313,188 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._devices = devices;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const stage = this.renderRoot.querySelector<HTMLElement>('.hp-static-stage') || undefined;
|
||||
if (stage === this._observedStage) return;
|
||||
this._stageObserver?.disconnect();
|
||||
this._observedStage = stage;
|
||||
if (!stage) {
|
||||
this._stageObserver = undefined;
|
||||
private _beginContinuityCandidate(
|
||||
reason: string,
|
||||
dataReady: boolean,
|
||||
recoveryReason: 'plan' | 'connection' | 'stage-size' | 'asset' = 'plan',
|
||||
): number {
|
||||
this._continuityDataReady = dataReady;
|
||||
this._continuityPaintToken = -1;
|
||||
return this._continuity.beginCandidate(reason, recoveryReason);
|
||||
}
|
||||
|
||||
private _backdropRaw(): string {
|
||||
if (!this._snap?.config || !this._config) return '';
|
||||
return spaceModels(this._snap.config).find((space) => space.id === this._config!.space)?.bg?.href || '';
|
||||
}
|
||||
|
||||
private _candidateBackdrop(config: any): string {
|
||||
if (!config || !this._config) return '';
|
||||
return spaceModels(config).find((space) => space.id === this._config!.space)?.bg?.href || '';
|
||||
}
|
||||
|
||||
private _assetsReady(): boolean {
|
||||
const raw = this._backdropRaw();
|
||||
return !raw || this._signer.isReady(this.hass, raw);
|
||||
}
|
||||
|
||||
private _frameFingerprint(): string {
|
||||
const snap = this._snap;
|
||||
return visualFrameFingerprint([
|
||||
snap?.rev || 0,
|
||||
snap?.configFingerprint || contentFingerprint(snap?.config),
|
||||
snap?.layoutRev || 0,
|
||||
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
|
||||
this._config?.space || '',
|
||||
this._stageWidth,
|
||||
this._hassSequence,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
private _stageValid(): boolean {
|
||||
const stage = this._observedStage;
|
||||
return !!stage && stage.clientWidth > 0 && stage.clientHeight > 0;
|
||||
}
|
||||
|
||||
private _settleContinuityFrame(): void {
|
||||
if (!this._stageValid()) return;
|
||||
if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
if (!this._assetsReady()) {
|
||||
this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
return;
|
||||
}
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._frameFingerprint());
|
||||
return;
|
||||
}
|
||||
const measure = () => {
|
||||
const width = stage.clientWidth;
|
||||
if (width > 0 && Math.abs(width - this._stageWidth) > 0.5) {
|
||||
this._stageWidth = width;
|
||||
this.requestUpdate();
|
||||
if (!this._continuityDataReady || !this._assetsReady()) return;
|
||||
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
|
||||
.includes(this._continuity.state)) return;
|
||||
const token = this._continuity.token;
|
||||
if (this._continuityPaintToken === token) return;
|
||||
this._continuityPaintToken = token;
|
||||
if (!this._continuity.candidateReady(token)) return;
|
||||
void this._continuity.commitAfterPaint(token, {
|
||||
updateComplete: () => this.updateComplete,
|
||||
stageValid: () => this.isConnected && this._stageValid(),
|
||||
assetsReady: () => this._assetsReady(),
|
||||
frameFingerprint: () => this._frameFingerprint(),
|
||||
}).then((committed) => {
|
||||
if (!committed || token !== this._continuity.token) {
|
||||
if (token === this._continuity.token) this._continuityPaintToken = -1;
|
||||
return;
|
||||
}
|
||||
};
|
||||
this._stageObserver = new ResizeObserver(measure);
|
||||
this._stageObserver.observe(stage);
|
||||
measure();
|
||||
this._renderSnapshotAt = Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
private _onAssetLoaded = (raw: string): void => {
|
||||
this._signer.markLoaded(this.hass, raw);
|
||||
this._continuity.note('asset-ready');
|
||||
this._continuityPaintToken = -1;
|
||||
if (this._continuity.state !== 'steady') this.requestUpdate();
|
||||
};
|
||||
|
||||
protected updated(): void {
|
||||
const stage = this.renderRoot.querySelector<HTMLElement>('.hp-static-stage') || undefined;
|
||||
if (stage !== this._observedStage) {
|
||||
this._stageObserver?.disconnect();
|
||||
this._observedStage = stage;
|
||||
if (!stage) {
|
||||
this._stageObserver = undefined;
|
||||
} else {
|
||||
const measure = () => {
|
||||
const width = stage.clientWidth;
|
||||
if (width <= 0 || Math.abs(width - this._stageWidth) <= 0.5) return;
|
||||
if (this._stageWidth <= 0) {
|
||||
this._stageWidth = width;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
this._pendingStageWidth = width;
|
||||
if (this._stageWidthRaf) return;
|
||||
this._stageWidthRaf = requestAnimationFrame(() => {
|
||||
this._stageWidthRaf = 0;
|
||||
const target = this._pendingStageWidth;
|
||||
this._pendingStageWidth = 0;
|
||||
if (!target || !this._observedStage || this._observedStage.clientWidth <= 0) return;
|
||||
if (Math.abs(this._observedStage.clientWidth - target) > 0.5) {
|
||||
measure();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(target - this._stageWidth) <= 0.5) return;
|
||||
if (this._continuity.hasCompleteFrame) {
|
||||
this._beginContinuityCandidate('stage-resize', true, 'stage-size');
|
||||
}
|
||||
this._stageWidth = target;
|
||||
this.requestUpdate();
|
||||
});
|
||||
};
|
||||
this._stageObserver = new ResizeObserver(measure);
|
||||
this._stageObserver.observe(stage);
|
||||
measure();
|
||||
}
|
||||
}
|
||||
this._settleContinuityFrame();
|
||||
}
|
||||
|
||||
private _loadedOnce = false;
|
||||
private async _load(): Promise<void> {
|
||||
if (!this.hass || this._loading) return;
|
||||
private async _load(force = false): Promise<void> {
|
||||
if (!this.hass) return;
|
||||
if (this._loading) {
|
||||
this._reloadQueued = true;
|
||||
this._forceReloadQueued ||= force;
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._reloadQueued = false;
|
||||
let loaded = false;
|
||||
try {
|
||||
const snap = await getConfig(this.hass);
|
||||
this._snap = snap;
|
||||
const snap = await getConfig(this.hass, force);
|
||||
const configChanged = !this._snap
|
||||
|| this._snap.configFingerprint !== snap.configFingerprint;
|
||||
const layoutChanged = !this._snap
|
||||
|| this._snap.layoutFingerprint !== snap.layoutFingerprint;
|
||||
if (configChanged && !await this._signer.prepareImage(
|
||||
this.hass, this._candidateBackdrop(snap.config),
|
||||
)) {
|
||||
this._continuity.note('asset-failed');
|
||||
window.clearTimeout(this._reloadRetryTimer);
|
||||
this._reloadRetryTimer = window.setTimeout(() => void this._load(true), 1000);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(this._reloadRetryTimer);
|
||||
this._reloadRetryTimer = 0;
|
||||
if ((configChanged || layoutChanged) && this._continuity.hasCompleteFrame
|
||||
&& this._continuity.state === 'steady') {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
if (configChanged || layoutChanged) {
|
||||
this._snap = snap;
|
||||
} else if (this._snap) {
|
||||
// Revision-only echoes are metadata, not a new visual candidate.
|
||||
this._snap.rev = snap.rev;
|
||||
this._snap.layoutRev = snap.layoutRev;
|
||||
}
|
||||
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
|
||||
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
|
||||
this._loadedOnce = true;
|
||||
loaded = true;
|
||||
this._continuityDataReady = true;
|
||||
this._refreshDevices();
|
||||
} catch {
|
||||
/* keep any localStorage snapshot */
|
||||
} finally {
|
||||
this._loading = false;
|
||||
if (!loaded && !this._continuity.hasCompleteFrame) this._continuityDataReady = true;
|
||||
this.requestUpdate();
|
||||
if (this._reloadQueued) {
|
||||
const forceAgain = this._forceReloadQueued;
|
||||
this._reloadQueued = false;
|
||||
this._forceReloadQueued = false;
|
||||
void this._load(forceAgain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +513,14 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
|
||||
private _errorCard(msg: string): TemplateResult {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
return html`<ha-card
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-recovery-reason=${(this._continuity.overlayVisible || this._continuity.state === 'recovery-error')
|
||||
? this._continuity.recoveryReason || nothing : nothing}>
|
||||
<div class="hp-static-error">${msg}</div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,12 +535,38 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return referencedContentUrls(this._snap?.config);
|
||||
}
|
||||
|
||||
private _retryContinuity = (): void => {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.retry(this._continuity.recoveryReason || 'plan');
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
private _renderRecoveryOverlay(): TemplateResult | typeof nothing {
|
||||
if (!this._continuity.overlayVisible && this._continuity.state !== 'recovery-error') return nothing;
|
||||
const connection = this._continuity.recoveryReason === 'connection';
|
||||
return html`<div class="recoveryoverlay phase-${this._continuity.overlayPhase}"
|
||||
role="status" aria-live="polite" aria-atomic="true">
|
||||
<ha-icon icon="mdi:home-sync-outline"></ha-icon>
|
||||
<span>${t(this._lang, connection
|
||||
? 'continuity.restore_connection' : 'continuity.restore_plan')}</span>
|
||||
${this._continuity.state === 'recovery-error'
|
||||
? html`<button class="btn on" @click=${this._retryContinuity}>${t(this._lang, 'continuity.retry')}</button>`
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Redacted lifecycle diagnostics shared with the full card's sampler. */
|
||||
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
|
||||
return this._continuity.trace;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
if (!cfg) {
|
||||
// still loading and no snapshot yet
|
||||
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
|
||||
return this._errorCard(t(this._lang, 'space_card.loading'));
|
||||
}
|
||||
const spaceId = this._config.space;
|
||||
const stage = renderSpaceStatic({
|
||||
@@ -308,6 +579,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
lang: this._lang,
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
assetLoaded: this._onAssetLoaded,
|
||||
registry: haRegistrySnapshot(this.hass),
|
||||
devices: this._devices,
|
||||
activityRuntime: this._activityRuntime,
|
||||
@@ -322,10 +594,19 @@ class HouseplanSpaceCard extends LitElement {
|
||||
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
|
||||
const showButton = this._config.show_button !== false;
|
||||
const label = this._config.button_label || t(this._lang, 'space_card.button');
|
||||
const recoveryReason = (this._continuity.overlayVisible || this._continuity.state === 'recovery-error')
|
||||
? this._continuity.recoveryReason : null;
|
||||
return html`
|
||||
<ha-card>
|
||||
<ha-card
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-recovery-reason=${recoveryReason || nothing}>
|
||||
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
|
||||
${stage}
|
||||
<div class="hp-static-body">
|
||||
${stage}
|
||||
${this._renderRecoveryOverlay()}
|
||||
</div>
|
||||
${showButton
|
||||
? html`<div class="hp-static-foot">
|
||||
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
|
||||
@@ -349,6 +630,9 @@ class HouseplanSpaceCard extends LitElement {
|
||||
font-size: 16px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.hp-static-body {
|
||||
position: relative;
|
||||
}
|
||||
.hp-static-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface StaticRenderOpts {
|
||||
* rather than an unsigned one, which would 401 (review R3-2).
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
/** Marks a protected backdrop as loaded/paintable for continuity barriers. */
|
||||
assetLoaded?: (raw: string) => void;
|
||||
}
|
||||
|
||||
export interface StaticDeviceBuildOpts {
|
||||
@@ -309,6 +311,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
)}
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}"
|
||||
@load=${() => o.assetLoaded?.(space.bg!.href)}
|
||||
transform=${space.bg!.angle
|
||||
? `rotate(${space.bg!.angle} ${space.bg!.x + space.bg!.w / 2} ${space.bg!.y + space.bg!.h / 2})`
|
||||
: nothing}
|
||||
|
||||
+50
-23
@@ -282,16 +282,6 @@ export const cardStyles = css`
|
||||
.stage.hpboot .zoombadge {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* A long-suspended normal View wakes through transient HA/viewport
|
||||
geometry. Keep those few frames behind the already-correct stage
|
||||
background; the final viewport and reveal are committed together.
|
||||
This class is never set in kiosk or an editor. */
|
||||
.stage.hpresume .zoomwrap,
|
||||
.stage.hpresume .zoombadge,
|
||||
.stage.hpresume .farhint,
|
||||
.stage.hpresume .homearrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
|
||||
the stage height smoothly; the viewport ResizeObserver refits the plan
|
||||
along the transition, so a late panel glides instead of jumping. */
|
||||
@@ -321,6 +311,38 @@ export const cardStyles = css`
|
||||
opacity: 0.85;
|
||||
animation: hp-boot-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.recoveryoverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Above the contextual editor tray (70): recovery is the one state in
|
||||
which every stage-editing surface must be inert. Dialogs live outside
|
||||
the stage and retain their own higher card-level stacking context. */
|
||||
z-index: 75;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-6);
|
||||
box-sizing: border-box;
|
||||
background: var(--ha-card-background, var(--card-background-color, #111));
|
||||
color: var(--primary-text-color, #fff);
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.recoveryoverlay.phase-entering,
|
||||
.recoveryoverlay.phase-leaving {
|
||||
opacity: 0;
|
||||
}
|
||||
.recoveryoverlay.phase-fading-in,
|
||||
.recoveryoverlay.phase-opaque {
|
||||
opacity: 1;
|
||||
}
|
||||
.recoveryoverlay ha-icon {
|
||||
--mdc-icon-size: 44px;
|
||||
color: var(--hp-accent);
|
||||
}
|
||||
@keyframes hp-boot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.94); }
|
||||
50% { opacity: 0.9; transform: scale(1); }
|
||||
@@ -330,6 +352,9 @@ export const cardStyles = css`
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.recoveryoverlay {
|
||||
transition: none;
|
||||
}
|
||||
.stage.hpsettle {
|
||||
transition: none;
|
||||
}
|
||||
@@ -498,8 +523,7 @@ export const cardStyles = css`
|
||||
.glow-base-tunnels,
|
||||
.glow-pools-frame,
|
||||
.glow-pools,
|
||||
.glow-spot,
|
||||
.glow-soft {
|
||||
.glow-spot {
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The parent isolates all source spots from the room data fill, Glow base,
|
||||
@@ -1344,12 +1368,14 @@ export const cardStyles = css`
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* View hover follows the clean-floor face above the unioned wall body.
|
||||
Plain fill/strokes deliberately replace CSS filters: GPU promotion of a
|
||||
filtered SVG sibling used to flash the screen-blended Glow layer. */
|
||||
/* The wash is rendered directly above the resolved room fill but below
|
||||
tunnels, Glow, sun and walls. Black at 22% reproduces the old
|
||||
brightness(.78) contract without filtering/promoting an SVG ancestor or
|
||||
changing the room's hue. The halo/outline are rendered again late, above
|
||||
the wall bodies. */
|
||||
.room-hover-fill {
|
||||
fill: var(--hp-accent);
|
||||
fill-opacity: 0.10;
|
||||
fill: #000;
|
||||
fill-opacity: 0.22;
|
||||
pointer-events: none;
|
||||
}
|
||||
.room-hover-halo {
|
||||
@@ -1681,22 +1707,23 @@ export const cardStyles = css`
|
||||
}
|
||||
.stage.mode-devices .dev { cursor: grab; }
|
||||
.stage.mode-devices .dev:active { cursor: grabbing; }
|
||||
.dev:hover {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
z-index: 5;
|
||||
}
|
||||
.dev.on {
|
||||
background: var(--hp-on);
|
||||
border-color: var(--hp-on);
|
||||
color: #503c00;
|
||||
box-shadow: 0 0 8px rgba(255, 212, 92, 0.7);
|
||||
}
|
||||
.dev.open {
|
||||
background: var(--hp-open);
|
||||
border-color: var(--hp-open);
|
||||
color: #4a2800;
|
||||
}
|
||||
/* Interaction wins ordinary state colours. Alarm keeps priority through
|
||||
the more-specific .dev.alarm:hover rule above. */
|
||||
.dev:hover {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
z-index: 5;
|
||||
}
|
||||
.dev.unavail {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Shared visual-continuity primitives for both House Plan cards (#73).
|
||||
*
|
||||
* The controller deliberately owns no House Plan model and never calls HA.
|
||||
* It only guards the transition from the last complete frame to a candidate,
|
||||
* keeps recovery-overlay timing deterministic, and exposes a small trace for
|
||||
* lifecycle diagnostics.
|
||||
*/
|
||||
|
||||
export const CONTINUITY_LONG_HIDDEN_MS = 15_000;
|
||||
export const RECOVERY_OVERLAY_DELAY_MS = 150;
|
||||
export const RECOVERY_OVERLAY_FADE_IN_MS = 150;
|
||||
export const RECOVERY_OVERLAY_MIN_OPAQUE_MS = 250;
|
||||
export const RECOVERY_OVERLAY_FADE_OUT_MS = 180;
|
||||
export const PAINT_BARRIER_MAX_MS = 2_000;
|
||||
|
||||
export type ContinuityState =
|
||||
| 'steady'
|
||||
| 'holding'
|
||||
| 'candidate-ready'
|
||||
| 'overlay-pending'
|
||||
| 'overlay-visible'
|
||||
| 'offline-stale'
|
||||
| 'recovery-error';
|
||||
|
||||
export type RecoveryReason = 'plan' | 'connection' | 'stage-size' | 'asset' | null;
|
||||
export type OverlayPhase = 'none' | 'entering' | 'fading-in' | 'opaque' | 'leaving';
|
||||
|
||||
export interface ContinuityTraceEvent {
|
||||
at: number;
|
||||
token: number;
|
||||
event: string;
|
||||
state: ContinuityState;
|
||||
reason?: string;
|
||||
stage?: [number, number];
|
||||
configRev?: number;
|
||||
layoutRev?: number;
|
||||
assetPending?: number;
|
||||
}
|
||||
|
||||
export interface PageVisibilitySignal {
|
||||
kind: 'hidden' | 'visible' | 'pageshow';
|
||||
token: number;
|
||||
at: number;
|
||||
hiddenFor: number;
|
||||
long: boolean;
|
||||
persisted?: boolean;
|
||||
}
|
||||
|
||||
interface PageVisibilityRuntime {
|
||||
hiddenAt: number;
|
||||
token: number;
|
||||
subscribers: Set<(signal: PageVisibilitySignal) => void>;
|
||||
onVisibility: () => void;
|
||||
onPageShow: (event: PageTransitionEvent) => void;
|
||||
}
|
||||
|
||||
const visibilityRuntimes = new WeakMap<Document, PageVisibilityRuntime>();
|
||||
|
||||
/** One visibility/pageshow listener per Document, shared by every card. */
|
||||
export function subscribePageVisibility(
|
||||
doc: Document,
|
||||
subscriber: (signal: PageVisibilitySignal) => void,
|
||||
): () => void {
|
||||
let runtime = visibilityRuntimes.get(doc);
|
||||
if (!runtime) {
|
||||
const subscribers = new Set<(signal: PageVisibilitySignal) => void>();
|
||||
runtime = {
|
||||
hiddenAt: doc.visibilityState === 'hidden' ? Date.now() : 0,
|
||||
token: 0,
|
||||
subscribers,
|
||||
onVisibility: () => {
|
||||
const current = visibilityRuntimes.get(doc);
|
||||
if (!current) return;
|
||||
const at = Date.now();
|
||||
if (doc.visibilityState === 'hidden') {
|
||||
if (!current.hiddenAt) current.hiddenAt = at;
|
||||
const signal: PageVisibilitySignal = {
|
||||
kind: 'hidden', token: current.token, at, hiddenFor: 0, long: false,
|
||||
};
|
||||
for (const listener of [...current.subscribers]) listener(signal);
|
||||
return;
|
||||
}
|
||||
const hiddenFor = current.hiddenAt ? Math.max(0, at - current.hiddenAt) : 0;
|
||||
current.hiddenAt = 0;
|
||||
current.token++;
|
||||
const signal: PageVisibilitySignal = {
|
||||
kind: 'visible', token: current.token, at, hiddenFor,
|
||||
long: hiddenFor >= CONTINUITY_LONG_HIDDEN_MS,
|
||||
};
|
||||
for (const listener of [...current.subscribers]) listener(signal);
|
||||
},
|
||||
onPageShow: (event: PageTransitionEvent) => {
|
||||
const current = visibilityRuntimes.get(doc);
|
||||
if (!current) return;
|
||||
const at = Date.now();
|
||||
current.token++;
|
||||
const signal: PageVisibilitySignal = {
|
||||
kind: 'pageshow', token: current.token, at, hiddenFor: 0,
|
||||
long: !!event.persisted, persisted: !!event.persisted,
|
||||
};
|
||||
for (const listener of [...current.subscribers]) listener(signal);
|
||||
},
|
||||
};
|
||||
visibilityRuntimes.set(doc, runtime);
|
||||
doc.addEventListener('visibilitychange', runtime.onVisibility);
|
||||
doc.defaultView?.addEventListener('pageshow', runtime.onPageShow);
|
||||
}
|
||||
runtime.subscribers.add(subscriber);
|
||||
return () => {
|
||||
const current = visibilityRuntimes.get(doc);
|
||||
if (!current) return;
|
||||
current.subscribers.delete(subscriber);
|
||||
if (current.subscribers.size) return;
|
||||
doc.removeEventListener('visibilitychange', current.onVisibility);
|
||||
doc.defaultView?.removeEventListener('pageshow', current.onPageShow);
|
||||
visibilityRuntimes.delete(doc);
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContinuityClock {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delay: number): number;
|
||||
clearTimeout(id: number): void;
|
||||
requestAnimationFrame(callback: FrameRequestCallback): number;
|
||||
cancelAnimationFrame(id: number): void;
|
||||
}
|
||||
|
||||
const browserClock: ContinuityClock = {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimeout: (id) => window.clearTimeout(id),
|
||||
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
|
||||
};
|
||||
|
||||
export interface PaintBarrierHooks {
|
||||
updateComplete(): Promise<unknown>;
|
||||
stageValid(): boolean;
|
||||
assetsReady(): boolean;
|
||||
frameFingerprint(): string;
|
||||
}
|
||||
|
||||
/** A bounded, placement-owned state machine; business data stays in the card. */
|
||||
export class VisualContinuityController {
|
||||
private _state: ContinuityState = 'steady';
|
||||
private _token = 0;
|
||||
private _frameFingerprint = '';
|
||||
private _hasCompleteFrame = false;
|
||||
private _overlayPhase: OverlayPhase = 'none';
|
||||
private _recoveryReason: RecoveryReason = null;
|
||||
private _overlayTimer = 0;
|
||||
private _overlayRaf = 0;
|
||||
private _overlayOpaqueAt = 0;
|
||||
private _barrierRafs = new Set<number>();
|
||||
private _trace: ContinuityTraceEvent[] = [];
|
||||
private _disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly onChange: () => void,
|
||||
private readonly clock: ContinuityClock = browserClock,
|
||||
) {}
|
||||
|
||||
get state(): ContinuityState { return this._state; }
|
||||
get token(): number { return this._token; }
|
||||
get frameFingerprint(): string { return this._frameFingerprint; }
|
||||
get hasCompleteFrame(): boolean { return this._hasCompleteFrame; }
|
||||
get overlayPhase(): OverlayPhase { return this._overlayPhase; }
|
||||
get overlayVisible(): boolean { return this._overlayPhase !== 'none'; }
|
||||
get overlayBlocksInteraction(): boolean { return this.overlayVisible; }
|
||||
get recoveryReason(): RecoveryReason { return this._recoveryReason; }
|
||||
get trace(): readonly ContinuityTraceEvent[] { return this._trace; }
|
||||
|
||||
/** Redacted card-owned lifecycle markers; never pass ids or urls here. */
|
||||
note(event: string, detail: Partial<ContinuityTraceEvent> = {}): void {
|
||||
this.record(event, undefined, detail);
|
||||
}
|
||||
|
||||
private record(event: string, reason?: string, detail: Partial<ContinuityTraceEvent> = {}): void {
|
||||
this._trace.push({
|
||||
at: this.clock.now(), token: this._token, event, state: this._state,
|
||||
...(reason ? { reason } : {}), ...detail,
|
||||
});
|
||||
if (this._trace.length > 80) this._trace.splice(0, this._trace.length - 80);
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
if (!this._disposed) this.onChange();
|
||||
}
|
||||
|
||||
/** Warm memo/static cache adoption happens before the first visible render. */
|
||||
adoptCompleteFrame(fingerprint: string): void {
|
||||
if (!fingerprint) return;
|
||||
this._hasCompleteFrame = true;
|
||||
this._frameFingerprint = fingerprint;
|
||||
this._state = 'steady';
|
||||
this._recoveryReason = null;
|
||||
this.clearOverlay();
|
||||
this.record('frame-adopted');
|
||||
this.changed();
|
||||
}
|
||||
|
||||
markCompleteFrame(fingerprint: string): void {
|
||||
if (!fingerprint) return;
|
||||
this._hasCompleteFrame = true;
|
||||
this._frameFingerprint = fingerprint;
|
||||
this._state = 'steady';
|
||||
this._recoveryReason = null;
|
||||
this.clearOverlay();
|
||||
this.record('frame-complete');
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* State-only renders are already one synchronous Lit commit and do not need
|
||||
* a structural candidate. Keep diagnostics/warm placement identity current
|
||||
* without scheduling the extra render that `markCompleteFrame()` uses for a
|
||||
* cold/adopted frame.
|
||||
*/
|
||||
refreshCompleteFrame(fingerprint: string): void {
|
||||
if (this._disposed || !this._hasCompleteFrame || this._state !== 'steady'
|
||||
|| !fingerprint || fingerprint === this._frameFingerprint) return;
|
||||
this._frameFingerprint = fingerprint;
|
||||
this.record('frame-refreshed');
|
||||
}
|
||||
|
||||
visibility(signal: PageVisibilitySignal): number {
|
||||
if (signal.kind === 'hidden') {
|
||||
this.record('visibility-hidden');
|
||||
return this._token;
|
||||
}
|
||||
if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {
|
||||
// A quick return with no changed inputs is a strict no-op: no Lit update.
|
||||
this.record(signal.kind === 'pageshow' ? 'pageshow-noop' : 'visibility-visible-quick');
|
||||
return this._token;
|
||||
}
|
||||
return this.beginCandidate(signal.kind === 'pageshow' ? 'pageshow' : 'long-resume');
|
||||
}
|
||||
|
||||
beginCandidate(reason: string, recoveryReason: RecoveryReason = 'plan'): number {
|
||||
this._token++;
|
||||
this._recoveryReason = recoveryReason;
|
||||
if (this._hasCompleteFrame) {
|
||||
this._state = recoveryReason === 'connection' ? 'offline-stale' : 'holding';
|
||||
this.clearOverlay();
|
||||
} else if (this.overlayVisible) {
|
||||
// Retry/late reconnect keeps an already opaque fallback in place; it may
|
||||
// only leave after the new token crosses the paint barrier.
|
||||
this._state = 'overlay-visible';
|
||||
} else {
|
||||
this._state = 'overlay-pending';
|
||||
this.scheduleOverlay(recoveryReason);
|
||||
}
|
||||
this.record('candidate-start', reason);
|
||||
this.changed();
|
||||
return this._token;
|
||||
}
|
||||
|
||||
connectionLost(): number {
|
||||
return this.beginCandidate('connection-lost', 'connection');
|
||||
}
|
||||
|
||||
candidateReady(token: number): boolean {
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
this._state = 'candidate-ready';
|
||||
this.record('candidate-ready');
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit completion + two animation opportunities form one cancellable paint
|
||||
* barrier. A stale token can never reveal an older candidate.
|
||||
*/
|
||||
async commitAfterPaint(token: number, hooks: PaintBarrierHooks): Promise<boolean> {
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
const barrier = (async () => {
|
||||
await hooks.updateComplete();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
|
||||
await this.nextFrame();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
|
||||
await this.nextFrame();
|
||||
return token === this._token && hooks.stageValid() && hooks.assetsReady();
|
||||
})();
|
||||
let timeout = 0;
|
||||
const timed = new Promise<{ ready: false; timedOut: true }>((resolve) => {
|
||||
timeout = this.clock.setTimeout(
|
||||
() => resolve({ ready: false, timedOut: true }), PAINT_BARRIER_MAX_MS,
|
||||
);
|
||||
});
|
||||
const outcome = await Promise.race([
|
||||
barrier.then((ready) => ({ ready, timedOut: false as const })),
|
||||
timed,
|
||||
]);
|
||||
this.clock.clearTimeout(timeout);
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
if (!outcome.ready && !outcome.timedOut) {
|
||||
// Stage/resource readiness can change between the two paint checks.
|
||||
// This is a recoverable rejection, not the two-second timeout: retain
|
||||
// the token and let the card retry when the missing readiness signal
|
||||
// arrives, without converting a transient image decode into an error.
|
||||
this.record('paint-barrier-rejected');
|
||||
if (this._hasCompleteFrame) {
|
||||
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'holding';
|
||||
} else {
|
||||
this._state = this.overlayVisible ? 'overlay-visible' : 'overlay-pending';
|
||||
}
|
||||
this.changed();
|
||||
return false;
|
||||
}
|
||||
if (!outcome.ready) {
|
||||
this.record('paint-barrier-timeout');
|
||||
if (this._hasCompleteFrame) {
|
||||
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'steady';
|
||||
this.clearOverlay();
|
||||
} else {
|
||||
this._state = 'recovery-error';
|
||||
if (this._overlayPhase === 'none') this.showOverlayNow();
|
||||
}
|
||||
this.changed();
|
||||
return false;
|
||||
}
|
||||
this._hasCompleteFrame = true;
|
||||
this._frameFingerprint = hooks.frameFingerprint();
|
||||
this.record('paint-barrier');
|
||||
this.finishOverlayAfterCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
retry(reason: RecoveryReason = this._recoveryReason || 'plan'): number {
|
||||
return this.beginCandidate('retry', reason);
|
||||
}
|
||||
|
||||
private nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const id = this.clock.requestAnimationFrame(() => {
|
||||
this._barrierRafs.delete(id);
|
||||
resolve();
|
||||
});
|
||||
this._barrierRafs.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleOverlay(reason: RecoveryReason): void {
|
||||
this.clearOverlayTimer();
|
||||
this._recoveryReason = reason;
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
if (this._hasCompleteFrame || this._state !== 'overlay-pending') return;
|
||||
this._state = 'overlay-visible';
|
||||
this._overlayPhase = 'entering';
|
||||
this.record('overlay-enter');
|
||||
this.changed();
|
||||
this._overlayRaf = this.clock.requestAnimationFrame(() => {
|
||||
this._overlayRaf = 0;
|
||||
if (this._overlayPhase !== 'entering') return;
|
||||
this._overlayPhase = 'fading-in';
|
||||
this.changed();
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
if (this._overlayPhase !== 'fading-in') return;
|
||||
this._overlayPhase = 'opaque';
|
||||
this._overlayOpaqueAt = this.clock.now();
|
||||
this.record('overlay-opaque');
|
||||
this.changed();
|
||||
}, RECOVERY_OVERLAY_FADE_IN_MS);
|
||||
});
|
||||
}, RECOVERY_OVERLAY_DELAY_MS);
|
||||
}
|
||||
|
||||
private showOverlayNow(): void {
|
||||
this.clearOverlayTimer();
|
||||
this._overlayPhase = 'opaque';
|
||||
this._overlayOpaqueAt = this.clock.now();
|
||||
this.record('overlay-error');
|
||||
}
|
||||
|
||||
private finishOverlayAfterCommit(): void {
|
||||
this.clearOverlayTimer();
|
||||
if (this._overlayPhase === 'none' || this._overlayPhase === 'entering'
|
||||
|| this._overlayPhase === 'fading-in') {
|
||||
// A candidate that wins during fade-in cancels it without a minimum hold.
|
||||
this.clearOverlay();
|
||||
this._state = 'steady';
|
||||
this._recoveryReason = null;
|
||||
this.record('candidate-committed');
|
||||
this.changed();
|
||||
return;
|
||||
}
|
||||
const wait = Math.max(0,
|
||||
this._overlayOpaqueAt + RECOVERY_OVERLAY_MIN_OPAQUE_MS - this.clock.now());
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
this._overlayPhase = 'leaving';
|
||||
this.record('overlay-leave');
|
||||
this.changed();
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
this._overlayPhase = 'none';
|
||||
this._state = 'steady';
|
||||
this._recoveryReason = null;
|
||||
this.record('candidate-committed');
|
||||
this.changed();
|
||||
}, RECOVERY_OVERLAY_FADE_OUT_MS);
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private clearOverlayTimer(): void {
|
||||
if (this._overlayTimer) this.clock.clearTimeout(this._overlayTimer);
|
||||
this._overlayTimer = 0;
|
||||
}
|
||||
|
||||
private clearOverlay(): void {
|
||||
this.clearOverlayTimer();
|
||||
if (this._overlayRaf) this.clock.cancelAnimationFrame(this._overlayRaf);
|
||||
this._overlayRaf = 0;
|
||||
this._overlayPhase = 'none';
|
||||
this._overlayOpaqueAt = 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposed = true;
|
||||
this._token++;
|
||||
this.clearOverlay();
|
||||
for (const id of this._barrierRafs) this.clock.cancelAnimationFrame(id);
|
||||
this._barrierRafs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable, inexpensive content identity for structural WS responses. */
|
||||
export function contentFingerprint(value: unknown): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x9e3779b9;
|
||||
const seen = new WeakSet<object>();
|
||||
const feed = (text: string): void => {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const code = text.charCodeAt(index);
|
||||
a ^= code;
|
||||
a = Math.imul(a, 0x01000193);
|
||||
b ^= code + ((b << 6) >>> 0) + (b >>> 2);
|
||||
}
|
||||
};
|
||||
const visit = (node: unknown): void => {
|
||||
if (node === null) { feed('n'); return; }
|
||||
const type = typeof node;
|
||||
if (type === 'string') { feed(`s${(node as string).length}:${node}`); return; }
|
||||
if (type === 'number') { feed(`d${Number.isNaN(node) ? 'NaN' : String(node)}`); return; }
|
||||
if (type === 'boolean') { feed(node ? 't' : 'f'); return; }
|
||||
if (type === 'undefined') { feed('u'); return; }
|
||||
if (type !== 'object') { feed(`${type}:${String(node)}`); return; }
|
||||
const object = node as object;
|
||||
if (seen.has(object)) { feed('[cycle]'); return; }
|
||||
seen.add(object);
|
||||
if (Array.isArray(node)) {
|
||||
feed('[');
|
||||
for (const item of node) visit(item);
|
||||
feed(']');
|
||||
return;
|
||||
}
|
||||
feed('{');
|
||||
for (const key of Object.keys(node as Record<string, unknown>).sort()) {
|
||||
feed(key);
|
||||
visit((node as Record<string, unknown>)[key]);
|
||||
}
|
||||
feed('}');
|
||||
};
|
||||
visit(value);
|
||||
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
export function visualFrameFingerprint(parts: readonly unknown[]): string {
|
||||
return contentFingerprint(parts);
|
||||
}
|
||||
@@ -1302,8 +1302,9 @@ export function wallBodyRings(
|
||||
* with opening slots cut through. Drawing uses it as one path; the light model
|
||||
* uses the same geometry as its occluders, so a wall blocks light exactly
|
||||
* where the plan shows a wall — with its real thickness, and with a doorway
|
||||
* that is a real gap between two jamb faces. Null when there is nothing solid,
|
||||
* or when the boolean pass fails (callers fall back to their own outlines).
|
||||
* that is a real gap between two jamb faces. A successful empty operation is
|
||||
* returned as an empty geometry; null means the boolean pass itself failed,
|
||||
* so drawing callers may distinguish it from "nothing solid" and fall back.
|
||||
*/
|
||||
export function wallBodiesGeometry(
|
||||
rooms: any[],
|
||||
@@ -1386,7 +1387,7 @@ export function wallBodiesGeometry(
|
||||
if (extra.length < 3) continue;
|
||||
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
|
||||
}
|
||||
return body ? { geom: body, depthUnits: maxDepth } : null;
|
||||
return { geom: body || [], depthUnits: maxDepth };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1411,11 +1412,14 @@ export function wallBodiesUnionPath(
|
||||
);
|
||||
const d = united ? polyclipToPathD(united.geom) : '';
|
||||
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
if (united) return null; // successful empty result: do not resurrect raw rings
|
||||
// fall back to evenodd rings concatenated
|
||||
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
|
||||
if (!rings.length && !extraD) return null;
|
||||
let maxDepth = united?.depthUnits || 0;
|
||||
// `united` is null on this branch: a successful-but-empty union returned
|
||||
// above and must not be resurrected by the raw-ring fallback.
|
||||
let maxDepth = 0;
|
||||
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
|
||||
// Each room ring already reverses its inset. `nonzero` therefore keeps
|
||||
// floors as holes while overlapping independent rings add instead of
|
||||
|
||||
Reference in New Issue
Block a user