fix: keep large-plan interactions off the full render path

Issue: #451
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 22:03:45 +03:00
parent 137e160f1a
commit c0d61ca3d2
74 changed files with 21454 additions and 623 deletions
+122 -124
View File
@@ -305,6 +305,7 @@ import {
createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions,
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { RenderLifecycle, intakeHass } from './houseplan-render-lifecycle';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry'; import { renderZigbeeTopologyOverlay } from './zigbee-topology-overlay-bridge';
import {
@@ -377,12 +378,15 @@ import {
import {
openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry,
resolveOpeningPlacementResult, sameOpeningPlacementInput,
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import {
buildOpeningDimensionContext, resolveOpeningDimensions,
type OpeningDimension, type OpeningDimensionContext,
type OpeningDimensionContext,
} from './opening-dimensions';
import type {
DeviceDragState, OpeningPlacementCandidate, OpMeasure, RenderOpening,
} from './interaction-types';
import { safeStoredColor } from './color';
import {
gridCellFieldToCm, gridCellFieldValue, gridVisualScale, gridVisualUnits,
@@ -626,32 +630,6 @@ const capturePointer = (ev: PointerEvent): void => {
}
};
/** Ruler labels + the centre-magnet tick. Existing-opening DRAG carries its
* legacy two labels; a new-opening PLACEMENT may additionally carry physical
* line/tick geometry (#238). */
interface OpMeasureLabel {
x: number;
y: number;
text: string;
dimension?: OpeningDimension;
}
interface OpMeasure {
labels: OpMeasureLabel[];
guide: { x: number; y: number; angle: number } | null;
}
type OpeningPlacementCandidate = Omit<OpeningPlacementCore, 'measure'> & {
face: OpeningFaceOffset;
measure: OpMeasure;
};
type RenderOpening = OpeningCfg & {
rx: number; ry: number; rlen: number;
partitionHost?: ResolvedPartitionOpening;
orphanReason?: PartitionOpeningOrphanReason;
};
interface DeviceInboxDialogState {
tab: DeviceInboxCategory;
search: string;
@@ -678,23 +656,23 @@ type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown }
*/
const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;
interface DeviceDragState {
id: string;
spaceId: string;
displayName: string;
pointerId: number;
source: Element | null;
sx: number;
sy: number;
ox: number;
oy: number;
moved: boolean;
before: DevicePlacement | null;
start: DevicePlacement;
}
export class HouseplanCard extends LitElement {
public requestUpdate(name?: PropertyKey, oldValue?: unknown, options?: any): void {
if (name === 'hass' && this.hass && this._liveRt) {
const snapshot = this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
const render = this._liveRt.hass(
oldValue, this.hass, snapshot ? { entityIds: snapshot.entityIds } : null,
() => intakeHass(this),
);
if (!render) return;
}
if (this._editorRuntime?._routeLiveEditorUpdate(name, oldValue)) return;
this._liveRt?.clear();
super.requestUpdate(name, oldValue, options);
}
public _editorRuntime: import('./houseplan-editor-runtime').HouseplanEditorRuntime | null = null;
private _liveRt: import('./live-interaction-runtime').LiveRuntime | null = null;
public _onboardingRuntime: import('./houseplan-onboarding-runtime').HouseplanOnboardingRuntime | null = null;
private _editorRuntimeLoadingVisible = false;
/** #39: pending large-backdrop decision; set only by the lazy pick flow. */
@@ -1114,7 +1092,7 @@ export class HouseplanCard extends LitElement {
state.presented = presented;
this._zoom = presented.zoom;
this._view = { ...presented.viewBox };
this.requestUpdate();
this._scheduleLiveViewport();
}
private _settleCameraTransition(state: CameraTransitionState): void {
@@ -1952,6 +1930,7 @@ export class HouseplanCard extends LitElement {
private _panLock: 'pan' | 'swipe' | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false;
private _viewportGestureDirty = false;
/**
* Pointer events from an interactive child may stop before the stage sees
* them. Track touch contacts on the card in capture phase, so the second
@@ -2482,8 +2461,6 @@ export class HouseplanCard extends LitElement {
_space: { state: true },
_layout: { state: true },
_devices: { state: true },
_tip: { state: true },
_hoverRoom: { state: true },
_selId: { state: true },
_toast: { state: true },
_serverCfg: { state: true },
@@ -2554,8 +2531,6 @@ export class HouseplanCard extends LitElement {
_backupImportDialog: { state: true },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
_view: { state: true },
};
public connectedCallback(): void {
@@ -2580,6 +2555,7 @@ export class HouseplanCard extends LitElement {
this._showToast(this._t('toast.locale_load_failed'));
});
super.connectedCallback();
void this._ensureLiveRuntime().catch(() => this.requestUpdate());
this._pointerModality.connect(this.ownerDocument.defaultView);
const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver;
if (PointerHoverObserver) {
@@ -2644,6 +2620,8 @@ export class HouseplanCard extends LitElement {
}
public disconnectedCallback(): void {
this._liveRt?.dispose();
this._editorRuntime?._disposeLiveEditor();
this._clearRoomFocus(true);
this._cancelDangerConfirm();
// HA normally changes the route before removing the old Lovelace tree.
@@ -4052,6 +4030,7 @@ export class HouseplanCard extends LitElement {
const preserveGeometry = this._cfgEpochPreservedConfig === this._serverCfg;
this._cfgEpochPreservedConfig = null;
if (!preserveGeometry) this._cfgEpoch++;
this._renderLife.invalidate();
}
this._syncEmptySpaceState();
// #417: losing the only renderable space while a decision is pending must
@@ -4062,28 +4041,18 @@ export class HouseplanCard extends LitElement {
this._cancelDangerConfirm();
}
if (changed.has('hass') && this.hass) {
this._hassSequence++;
this._renderSnapshotAt = Date.now();
this._continuity.note('hass-snapshot');
this._ensureHaRegistryAuthority();
this._planHassMemo = null;
this._hookConnection();
if (!this._loadOk && !this._loading && this._loadTries < 8) {
this._loadFromServer();
}
// Devices must exist before their first state snapshot is classified.
// Otherwise the first real off->on edge after mount becomes the baseline.
this._maybeRebuildDevices();
this._vacTick();
this._activityTick();
const snapshot = this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
this._renderLife.observe(changed.get('hass'), this.hass,
snapshot ? { entityIds: snapshot.entityIds } : null, () => intakeHass(this));
}
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
this._continuity.refreshCompleteFrame(this._visualFrameFingerprint());
}
this._captureRenderDeviceSnapshot();
}
protected updated(): void {
this._liveRt?.commit();
this._editorRuntime?._commitLiveEditor();
this._pruneDevicePressFeedback();
this._syncDayCycleClock();
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
@@ -4532,10 +4501,25 @@ export class HouseplanCard extends LitElement {
private _haRegistryRev = -1;
private _haBindingCacheKey = '';
private _planHassMemo: { hass: any; sig: string; active: any; full: any } | null = null;
private _renderLife = new RenderLifecycle();
private _liveEditorPaintCount = 0;
private async _ensureLiveRuntime(): Promise<void> {
if (this._liveRt) return;
const module = await import('./live-interaction-runtime');
this._liveRt = new module.LiveRuntime(this);
if (this.isConnected) this.requestUpdate();
}
private _scheduleLiveViewport(): void { this._liveRt ? this._liveRt.viewport() : this.requestUpdate(); }
private _syncLiveHover(): void { this._liveRt ? this._liveRt.hover() : this.requestUpdate(); }
/** Bypass live routing so a gesture terminal publishes the last HA frame. */
private _flushHa(): void { if (this._liveRt?.take()) super.requestUpdate(); }
private _onHaRegistryUpdate = (): void => {
const snapshot = haRegistrySnapshot(this.hass);
if (snapshot.revision === this._haRegistryRev && this._devices.length) return;
this._haRegistryRev = snapshot.revision;
this._renderLife.invalidate();
this._planHassMemo = null;
this._regSignature = '';
this._maybeRebuildDevices();
@@ -4549,6 +4533,7 @@ export class HouseplanCard extends LitElement {
this._haRegistryConnection = connection;
this._haRegistryRev = -1;
this._haBindingCacheKey = '';
this._renderLife.invalidate();
this._areaSnapshotCleanupCandidates.clear();
this._planHassMemo = null;
this._haRegistryRelease = acquireHaRegistries(this.hass, this._onHaRegistryUpdate);
@@ -4749,17 +4734,9 @@ export class HouseplanCard extends LitElement {
registry: ReturnType<typeof haRegistryDiagnostics> & { lastSuccessAgeMs: number | null };
bindings: Record<HaBindingStatus['kind'], number>;
} {
const registry = haRegistryDiagnostics(this.hass);
const bindings: Record<HaBindingStatus['kind'], number> = {
active: 0,
ha_disabled: 0,
orphaned: 0,
unverified: 0,
};
for (const marker of this._markers) {
if (marker.removed || marker.binding === 'virtual') continue;
bindings[this._bindingStatus(marker.binding).kind]++;
}
const { registry, bindings } = this._renderLife.diagnostics(
this.hass, this._markers, (binding) => this._bindingStatus(binding),
);
return {
registry: {
...registry,
@@ -5420,6 +5397,7 @@ export class HouseplanCard extends LitElement {
private _cancelDeviceDrag(): boolean {
const drag = this._deviceDrag;
if (!drag) return false;
this._editorRuntime?._cancelPointerMove('device');
this._deviceDrag = null;
try {
if (drag.source?.hasPointerCapture?.(drag.pointerId)) {
@@ -6444,6 +6422,7 @@ export class HouseplanCard extends LitElement {
}
this._zoom = z;
this._view = next;
this._scheduleLiveViewport();
return true;
}
@@ -6489,8 +6468,7 @@ export class HouseplanCard extends LitElement {
private _finishBootSettled(): void {
if (!this._booting) return;
// `_applyView()` is reactive: wait one paint opportunity, refit once more,
// and only then remove the veil. This prevents a default-view reveal.
// Refit once more before removing the veil. This prevents a default-view reveal.
this._refitView();
this._booting = false;
this._bootSettling = false;
@@ -6639,6 +6617,7 @@ export class HouseplanCard extends LitElement {
}
this._zoom = result.target.zoom;
this._view = { ...result.target.viewBox };
this._scheduleLiveViewport();
}
private _onWheel(ev: WheelEvent): void {
@@ -6883,6 +6862,7 @@ export class HouseplanCard extends LitElement {
const cx = (pts[0].x + pts[1].x) / 2 - r.left;
const cy = (pts[0].y + pts[1].y) / 2 - r.top;
this._zoomAt(cx, cy, this._pinchStart.zoom * scale);
this._viewportGestureDirty = true;
this._suppressClick = true;
this._saveZoom();
} else if (this._panStart) {
@@ -6925,11 +6905,14 @@ export class HouseplanCard extends LitElement {
},
fit,
);
this._viewportGestureDirty = true;
this._scheduleLiveViewport();
}
}
}
private _stagePointerLeave(ev: PointerEvent): void {
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._mode === 'decor') this._editorRuntime?._furnPointerLeave(ev);
if (!this._markup) return;
if (this._tool === 'opening') {
@@ -6941,6 +6924,8 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerUp(ev: PointerEvent): void {
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
const acceptedRoom = acceptedRoomFitGesture(
this._roomPointer,
ev.pointerId,
@@ -7005,6 +6990,7 @@ export class HouseplanCard extends LitElement {
return;
}
if (this._decorMove?.pid === ev.pointerId) {
this._editorRuntimeOrThrow()._flushPointerMove('decor-move');
if (this._decorMove.moved) {
this._recordGeometry(this._t('history.decor_move'), this._decorMove.before);
this._saveConfig();
@@ -7013,7 +6999,6 @@ export class HouseplanCard extends LitElement {
return;
}
if (this._editorRuntime?._furnPointerUp(ev)) return;
const viewportGestureEnded = !!this._pinchStart || this._panLock === 'pan';
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
@@ -7022,7 +7007,10 @@ export class HouseplanCard extends LitElement {
// reset click suppression on the next tick (so that a click right after a pan does not fire)
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (viewportGestureEnded && this._pointers.size === 0 && !acceptedRoom) this.requestUpdate();
if (this._viewportGestureDirty && this._pointers.size === 0 && !acceptedRoom) {
this._viewportGestureDirty = false;
this.requestUpdate();
}
if (acceptedRoom) this._fitRoom(acceptedRoom);
}
@@ -7082,9 +7070,17 @@ export class HouseplanCard extends LitElement {
};
capturePointer(ev);
this._tip = null;
this._syncLiveHover();
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
if (this._mode !== 'devices') return;
const drag = this._deviceDrag;
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
this._editorRuntimeOrThrow()._queuePointerMove('device', () => this._pointerMoveNow(ev, d));
}
private _pointerMoveNow(ev: PointerEvent, d: DevItem): void {
if (this._mode !== 'devices') return;
const drag = this._deviceDrag;
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
@@ -7110,12 +7106,12 @@ export class HouseplanCard extends LitElement {
const ny = clampCanvasR(drag.oy + dy);
this._previewDevicePlacement(d.id, this._devicePlacementForCanvas(d, nx, ny));
}
private _pointerUp(ev: PointerEvent, d: DevItem): void {
clearTimeout(this._holdTimer);
if (this._mode !== 'devices') return;
const drag = this._deviceDrag;
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
this._editorRuntimeOrThrow()._flushPointerMove('device');
this._deviceDrag = null;
const after = devicePlacement(this._layout, d.id);
if (!drag.moved || after === null || sameDevicePlacement(after, drag.start)) {
@@ -7150,7 +7146,6 @@ export class HouseplanCard extends LitElement {
this.requestUpdate();
});
}
private _pointerCancel(ev: PointerEvent, d: DevItem): void {
if (this._deviceDrag?.id !== d.id || this._deviceDrag.pointerId !== ev.pointerId) return;
this._cancelDeviceDrag();
@@ -7198,6 +7193,7 @@ export class HouseplanCard extends LitElement {
if (suspend) this._pointerModality.suspend();
if (this._tip) this._tip = null;
if (this._hoverRoom) this._hoverRoom = null;
this._syncLiveHover();
}
/** The latest real pointer event, not a page-global first-touch latch, owns hover. */
@@ -7321,6 +7317,7 @@ export class HouseplanCard extends LitElement {
if (!this._pointerModality.hoverEnabled) return;
if (this._drag || this._deviceDrag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum, room };
this._syncLiveHover();
}
/** Room pointer preflight stays authoritative even when its tooltip is off. */
@@ -7328,7 +7325,7 @@ export class HouseplanCard extends LitElement {
if (this._mode !== 'view') return false;
this._notePointer(ev);
if (showRoomTooltipOf(this._settings)) return true;
if (this._tip?.room) this._tip = null;
if (this._tip?.room) { this._tip = null; this._syncLiveHover(); }
return false;
}
@@ -7861,16 +7858,21 @@ export class HouseplanCard extends LitElement {
/** Browser/OS cancellation is an aborted transaction, never a commit. */
private _stagePointerCancel(ev: PointerEvent): void {
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
const viewportGestureEnded = !!this._pinchStart || !!this._panStart; this._pointers.delete(ev.pointerId);
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
this._panStart = null;
this._panLock = null;
this._swipeStart = null;
}
if (viewportGestureEnded && this._pointers.size === 0) this.requestUpdate();
if (this._viewportGestureDirty && this._pointers.size === 0) {
this._viewportGestureDirty = false;
this.requestUpdate();
}
}
private _applyGeometryState(
@@ -8448,7 +8450,8 @@ export class HouseplanCard extends LitElement {
}
private _decorMoveUpdate(ev: PointerEvent): void {
return this._editorRuntimeOrThrow()._decorMoveUpdate(ev);
this._editorRuntimeOrThrow()._queuePointerMove('decor-move', () =>
this._editorRuntimeOrThrow()._decorMoveUpdate(ev));
}
private _decorNudge(renderDx: number, renderDy: number): boolean {
@@ -8538,10 +8541,12 @@ export class HouseplanCard extends LitElement {
}
private _dtMove(ev: PointerEvent): void {
return this._editorRuntimeOrThrow()._dtMove(ev);
this._editorRuntimeOrThrow()._queuePointerMove('decor-transform', () =>
this._editorRuntimeOrThrow()._dtMove(ev));
}
private _dtUp(): void {
this._editorRuntimeOrThrow()._flushPointerMove('decor-transform');
return this._editorRuntimeOrThrow()._dtUp();
}
@@ -8709,8 +8714,10 @@ export class HouseplanCard extends LitElement {
*/
private _renderTextFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
const sh = this._dtSel;
const b = this._dtBox;
if (!sh || !b || b.id !== sh.id) return nothing;
if (!sh) return nothing;
if (sh.kind === 'text' && this._dtBox?.id !== sh.id) return nothing;
const b = sh.kind === 'text' ? this._dtBox : this._decorBoxOf(sh);
if (sh.kind !== 'line' && !b) return nothing;
// Two radii, one gesture (owner 2026-08-05: «уменьшить в 4 раза»). `hr`
// is the HIT radius — unchanged, still 1.8 % of the visible view, still
// finger-sized at any zoom — carried by an invisible circle. `kr` is what
@@ -8729,6 +8736,7 @@ export class HouseplanCard extends LitElement {
<circle class="dtknob" cx="${p[0]}" cy="${p[1]}" r="${kr.toFixed(2)}"></circle>`)}
</g>` as unknown as TemplateResult;
}
if (!b) return nothing;
const [ax, ay] = this._dtPivot(sh);
const ang = Number(sh.angle) || 0;
const corners: [number, number, string][] = [
@@ -9724,19 +9732,20 @@ export class HouseplanCard extends LitElement {
private _renderRoomHoverFill(
paths: { fillD: string; outlineD: string } | null,
): TemplateResult {
if (!paths) return svg`` as unknown as TemplateResult;
return svg`<g class="room-hover room-hover-fill-layer" pointer-events="none">
<path class="room-hover-fill" d="${paths.fillD}" fill-rule="evenodd"></path>
<path class="room-hover-fill" data-hp-live-room-hover="fill"
d="${paths?.fillD || ''}" ?hidden=${!paths} fill-rule="evenodd"></path>
</g>` as unknown as TemplateResult;
}
private _renderRoomHoverOutline(
paths: { fillD: string; outlineD: string } | null,
): TemplateResult {
if (!paths) return svg`` as unknown as TemplateResult;
return svg`<g class="room-hover room-hover-outline-layer" pointer-events="none">
<path class="room-hover-halo" d="${paths.outlineD}"></path>
<path class="room-hover-outline" d="${paths.outlineD}"></path>
<path class="room-hover-halo" data-hp-live-room-hover="halo"
d="${paths?.outlineD || ''}" ?hidden=${!paths}></path>
<path class="room-hover-outline" data-hp-live-room-hover="outline"
d="${paths?.outlineD || ''}" ?hidden=${!paths}></path>
</g>` as unknown as TemplateResult;
}
@@ -9883,7 +9892,7 @@ export class HouseplanCard extends LitElement {
private _markupMove(ev: MouseEvent): void {
if (!this._markup || !this._editorRuntime) return;
return this._editorRuntime._markupMove(ev);
this._editorRuntimeOrThrow()._queuePointerMove('markup-hover', () => this._editorRuntime!._markupMove(ev));
}
/** One resolved architectural candidate shared by hover and click. */
@@ -11312,7 +11321,9 @@ export class HouseplanCard extends LitElement {
}
if (editorRuntimeRequested && !this._editorRuntime) void this._ensureEditorRuntime();
const model = this._model;
const diagnostics = this.houseplanDiagnostics();
const diagnostics = this._renderLife.diagnostics(
this.hass, this._markers, (binding) => this._bindingStatus(binding),
);
const fixed = this._fixedFloorState(model);
if (fixed.kind === 'pending') {
return html`<ha-card data-fixed-floor-state="pending">
@@ -11560,12 +11571,13 @@ export class HouseplanCard extends LitElement {
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg"
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg" data-hp-live-viewbox="camera"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoUnderlay(isoLayers)}
</svg>` : nothing}
<svg class=${isoLayers?.structural ? 'plan-svg' : nothing}
data-hp-live-viewbox=${isoLayers?.structural ? 'camera' : 'floor'}
viewBox=${isoLayers?.structural
? `${view.x} ${view.y} ${view.w} ${view.h}`
: `${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}`}
@@ -11662,6 +11674,7 @@ export class HouseplanCard extends LitElement {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
this._syncLiveHover();
}
};
const myPoly = polyOf(r);
@@ -11803,14 +11816,15 @@ export class HouseplanCard extends LitElement {
backdrop editor, where rooms and devices are pointer-inert. */}
${this._renderBackdropFrame(view)}
${this._renderTextFrame(view)}
<g data-hp-live-editor></g>
</g>
</svg>
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg"
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg" data-hp-live-viewbox="camera"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoShadows(isoLayers, isoPanels)}
</svg>
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
<svg class="iso-walls-svg" data-hp-live-viewbox="camera" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
</svg>` : nothing}${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, viewKey: view, view: this._mode === 'view', kiosk: this._kiosk })}
@@ -11825,7 +11839,7 @@ export class HouseplanCard extends LitElement {
space-card, so the two renderers agree. The compatibility
base is resolved before `iconCqw`; only the per-device and
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
<div class="devlayer" data-hp-live-layer="camera" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderVacuums(this._renderVacuumDevices, view, space.id)}
${this._renderVacFit(view)}
@@ -11836,10 +11850,10 @@ export class HouseplanCard extends LitElement {
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
</div>
${this._measureAnchor
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
? html`<div class="measurelayer" data-hp-live-layer="camera">${this._renderMeasureLabel(view)}</div>`
: nothing}
${this._resize?.liveLabels
? html`<div class="measurelayer">${this._resize?.liveLabels?.map((l) => html`<div
? html`<div class="measurelayer" data-hp-live-layer="camera">${this._resize?.liveLabels?.map((l) => html`<div
class="measurelabel ${l.kind === 'area' ? 'rszarea' : 'rszlength'}"
data-hp=${l.kind === 'area' ? 'resize-area-label' : 'resize-length-label'}
data-room=${l.kind === 'area' ? l.roomId : nothing}
@@ -11849,7 +11863,7 @@ export class HouseplanCard extends LitElement {
: ''}">${l.text}</div>`)}</div>`
: nothing}
${opMeasure
? html`<div class="measurelayer">${opMeasure.labels.map((l) => html`<div
? html`<div class="measurelayer" data-hp-live-layer="camera">${opMeasure.labels.map((l) => html`<div
class="measurelabel opshoulder ${l.dimension ? 'opdimension' : ''}"
data-dimension-source=${l.dimension?.source || nothing}
data-dimension-room=${l.dimension?.roomId || nothing}
@@ -11858,27 +11872,25 @@ export class HouseplanCard extends LitElement {
: ''}">${l.text}</div>`)}</div>`
: nothing}
${this._wallDialog
? html`<div class="measurelayer">${this._renderWallThickDialog()}</div>`
? html`<div class="measurelayer" data-hp-live-layer="camera">${this._renderWallThickDialog()}</div>`
: nothing}
${decorMeasure
? html`<div class="measurelayer"><div
? html`<div class="measurelayer" data-hp-live-layer="camera"><div
class="measurelabel dmeasure ${decorMeasure.on45 ? 'on45' : ''}"
style="left:${(((decorMeasure.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((decorMeasure.y - view.y) / view.h) * 100).toFixed(2)}%">${decorMeasure.text}</div></div>`
: nothing}
${furnLive
? html`<div class="measurelayer">${furnLive.map((l) => html`<div
? html`<div class="measurelayer" data-hp-live-layer="camera">${furnLive.map((l) => html`<div
class="measurelabel furnmeasure"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
${bdLive
? html`<div class="measurelayer"><div
? html`<div class="measurelayer" data-hp-live-layer="camera"><div
class="measurelabel bdmeasure"
style="left:${(((bdLive.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((bdLive.y - view.y) / view.h) * 100).toFixed(2)}%">${bdLive.text}</div></div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
: nothing}
<div class="zoombadge" data-hp-live-zoom hidden><span data-hp-live-zoom-value></span></div>
${this._renderFarHint()}
${this._renderHomeArrow()}
${this._renderEditorRuntimeLoading()}
@@ -11938,21 +11950,7 @@ export class HouseplanCard extends LitElement {
${this._backupExportDialog ? this._editorRuntime ? this._renderBackupExportDialog() : nothing : nothing}
${this._backupImportDialog ? this._editorRuntime ? this._renderBackupImportDialog() : nothing : nothing}
${this._importDialog ? this._onboardingRuntime ? this._renderImportDialog() : nothing : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.temp != null
? html`<span class="m">${this._t('tip.temp_avg')} <b>${this._tip.temp}°</b></span>`
: nothing}
${this._tip.hum != null
? html`<span class="m">${this._t('tip.hum_avg')} <b>${this._tip.hum}%</b></span>`
: nothing}
${this._tip.lqi != null
? html`<span class="m">${this._t('tip.lqi')}
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
: nothing}
</div>`
: nothing}
<div class="tip" data-hp-live-tip hidden></div>
${this._kiosk && !this._hasFixedFloor && this._kioskDots && this._model.length > 1
? html`<div class="kioskdots">
${this._model.map((m) => html`<span class="kdot ${m.id === this._space ? 'on' : ''}"></span>`)}
@@ -12493,7 +12491,7 @@ export class HouseplanCard extends LitElement {
if (pucks.length && !this._vacRaf) this._vacRafLoop();
if (!pucks.length && !trails.length) return nothing;
return html`
${trails.length ? svg`<svg class="vactrail" viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none">${trails}</svg>` : nothing}
${trails.length ? svg`<svg class="vactrail" data-hp-live-viewbox="camera" viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none">${trails}</svg>` : nothing}
${pucks}`;
}
+63 -63
View File
@@ -11,6 +11,13 @@ import { guard } from 'lit/directives/guard.js';
import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
import { calibrationTarget, planVacuumFit } from './vacuum-route-edit';
import { repeat } from 'lit/directives/repeat.js';
import {
cancelHouseplanPointerMove, flushHouseplanPointerMove, queueHouseplanPointerMove,
} from './pointer-move-queue';
import {
commitHouseplanEditor, disposeHouseplanEditor, measureHouseplanDecorText,
routeHouseplanEditorUpdate,
} from './live-editor';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
import type { HpConfirmRequest } from './danger-confirm';
@@ -347,12 +354,13 @@ import {
import {
openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry,
resolveOpeningPlacementResult, sameOpeningPlacementInput,
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import {
buildOpeningDimensionContext, resolveOpeningDimensions,
type OpeningDimension, type OpeningDimensionContext,
type OpeningDimensionContext,
} from './opening-dimensions';
import type { OpeningPlacementCandidate, OpMeasure, RenderOpening } from './interaction-types';
import { safeStoredColor } from './color';
import {
gridCellFieldToCm, gridCellFieldValue, gridVisualScale, gridVisualUnits,
@@ -794,32 +802,6 @@ const capturePointer = (ev: PointerEvent): void => {
}
};
/** Ruler labels + the centre-magnet tick. Existing-opening DRAG carries its
* legacy two labels; a new-opening PLACEMENT may additionally carry physical
* line/tick geometry (#238). */
interface OpMeasureLabel {
x: number;
y: number;
text: string;
dimension?: OpeningDimension;
}
interface OpMeasure {
labels: OpMeasureLabel[];
guide: { x: number; y: number; angle: number } | null;
}
type OpeningPlacementCandidate = Omit<OpeningPlacementCore, 'measure'> & {
face: OpeningFaceOffset;
measure: OpMeasure;
};
type RenderOpening = OpeningCfg & {
rx: number; ry: number; rlen: number;
partitionHost?: ResolvedPartitionOpening;
orphanReason?: PartitionOpeningOrphanReason;
};
interface DeviceInboxDialogState {
tab: DeviceInboxCategory;
search: string;
@@ -1163,6 +1145,7 @@ export interface HouseplanEditorHostPort {
_vacSourceResolution: (d: DevItem, includeAllCameras?: boolean, planHass?: any) => VacSourceResolution;
_vacSrvTrails: Record<string, any>;
_view: { x: number; y: number; w: number; h: number; } | null;
_viewportGestureDirty: boolean;
_viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number; } | null;
_viewOr: (vb: number[]) => { x: number; y: number; w: number; h: number; };
_viewPreference: Record<string, "flat" | "iso">;
@@ -1236,6 +1219,18 @@ export class HouseplanEditorRuntime {
>();
}
public _routeLiveEditorUpdate(name?: PropertyKey, oldValue?: unknown): boolean {
return routeHouseplanEditorUpdate(this.host, name, oldValue);
}
public _commitLiveEditor(): void { commitHouseplanEditor(this.host); }
public _disposeLiveEditor(): void { disposeHouseplanEditor(this.host); }
public _queuePointerMove(key: string, run: () => void): void { queueHouseplanPointerMove(this.host, key, run); }
public _flushPointerMove(key: string): void { flushHouseplanPointerMove(this.host, key); }
public _cancelPointerMove(key?: string): void { cancelHouseplanPointerMove(this.host, key); }
public _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
const ariaKey = `${key}.aria` as I18nKey;
const lang = langOf(this.host.hass, this.host._config?.language);
@@ -2336,14 +2331,16 @@ public _stagePointerCancel(ev: PointerEvent): void {
} else if (this.host._tool === 'draw') {
this._clearPlanSnapHover();
}
const viewportGestureEnded = !!this.host._pinchStart || !!this.host._panStart;
this.host._pointers.delete(ev.pointerId);
if (this.host._pointers.size < 2) this.host._pinchStart = null;
if (this.host._pointers.size === 0) {
this.host._panStart = null;
this.host._panLock = null;
}
if (viewportGestureEnded && this.host._pointers.size === 0) this.host.requestUpdate();
if (this.host._viewportGestureDirty && this.host._pointers.size === 0) {
this.host._viewportGestureDirty = false;
this.host.requestUpdate();
}
}
public _applyGeometryState(
@@ -3296,6 +3293,11 @@ public _clampPhysicalDelta(
}
public _physicalMove(ev: PointerEvent): void {
ev.stopPropagation();
queueHouseplanPointerMove(this.host, 'physical', () => this._physicalMoveNow(ev));
}
private _physicalMoveNow(ev: PointerEvent): void {
const drag = this.host._physicalDrag;
if (!drag || drag.pid !== ev.pointerId) return;
ev.stopPropagation();
@@ -3317,6 +3319,7 @@ public _physicalMove(ev: PointerEvent): void {
}
public _physicalUp(ev: PointerEvent): void {
flushHouseplanPointerMove(this.host, 'physical');
const drag = this.host._physicalDrag;
if (!drag || drag.pid !== ev.pointerId) return;
ev.stopPropagation();
@@ -3385,6 +3388,8 @@ public _registerPhysicalTap(
}
public _cancelPhysicalGesture(): void {
cancelHouseplanPointerMove(this.host, 'physical');
cancelHouseplanPointerMove(this.host, 'physical-rotate');
this.host._physicalDrag = null;
this.host._physicalRotate = null;
this.host.requestUpdate();
@@ -3406,6 +3411,12 @@ public _physicalRotateDown(ev: PointerEvent, c: WallColumnCfg): void {
}
public _physicalRotateMove(ev: PointerEvent): void {
ev.preventDefault();
ev.stopPropagation();
queueHouseplanPointerMove(this.host, 'physical-rotate', () => this._physicalRotateMoveNow(ev));
}
private _physicalRotateMoveNow(ev: PointerEvent): void {
const drag = this.host._physicalRotate;
if (!drag || drag.pid !== ev.pointerId) return;
ev.preventDefault();
@@ -3418,6 +3429,7 @@ public _physicalRotateMove(ev: PointerEvent): void {
}
public _physicalRotateUp(ev: PointerEvent): void {
flushHouseplanPointerMove(this.host, 'physical-rotate');
const drag = this.host._physicalRotate;
if (!drag || drag.pid !== ev.pointerId) return;
ev.preventDefault();
@@ -3801,6 +3813,11 @@ public _rszDisabledKey(ev: KeyboardEvent, reason: SafeResizeReason): void {
}
public _rszMove(ev: PointerEvent): void {
ev.stopPropagation();
queueHouseplanPointerMove(this.host, 'resize', () => this._rszMoveNow(ev));
}
private _rszMoveNow(ev: PointerEvent): void {
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
ev.stopPropagation();
const p = this._svgPoint(ev);
@@ -3838,6 +3855,7 @@ public _rszMove(ev: PointerEvent): void {
}
public _rszUp(ev: PointerEvent): void {
flushHouseplanPointerMove(this.host, 'resize');
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
ev.stopPropagation();
const result = this.host._resize.finish({
@@ -3878,6 +3896,7 @@ public _rszUp(ev: PointerEvent): void {
}
public _rszCancelDrag(pointerId?: number): void {
cancelHouseplanPointerMove(this.host, 'resize');
const result = this.host._resize.cancel(this._rszSnapshot(), pointerId);
if (result.kind === 'no-op') return;
// HP-1550-01/-03: a cancel just drops the overlay — the real config was
@@ -4158,6 +4177,9 @@ public _replaceDecor(id: string, patch: Partial<DecorShape>): void {
}
public _cancelDecorGesture(): void {
cancelHouseplanPointerMove(this.host, 'decor-transform');
cancelHouseplanPointerMove(this.host, 'decor-move');
cancelHouseplanPointerMove(this.host, 'backdrop');
const before = this.host._decorMove?.before || this.host._dtDrag?.before || this.host._bdDrag?.before;
const sp = before && this.host._serverCfg?.spaces.find((space) => space.id === before.spaceId);
if (before && sp) {
@@ -4808,39 +4830,7 @@ public _dtUp(): void {
}
public _dtMeasure(): void {
const sh = this.host._dtSel;
if (!sh) {
if (this.host._dtBox) { this.host._dtBox = null; this.host.requestUpdate(); }
return;
}
let box: { id: string; x: number; y: number; w: number; h: number };
if (sh.kind === 'line') {
const x1 = sh.x1 * NORM_W, y1 = sh.y1 * this.host._decorH;
const x2 = sh.x2 * NORM_W, y2 = sh.y2 * this.host._decorH;
box = { id: sh.id, x: Math.min(x1, x2), y: Math.min(y1, y2),
w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
} else if (sh.kind === 'furniture' || sh.kind === 'image'
|| sh.kind === 'rect' || sh.kind === 'ellipse') {
// …and furniture needs no measuring at all: its box IS the config. The
// frame therefore appears in the SAME frame as the selection, not one
// after it, and a resize can never be a render behind the shape.
box = { id: sh.id, x: sh.x * NORM_W, y: sh.y * this.host._decorH,
w: sh.w * NORM_W, h: sh.h * this.host._decorH };
} else {
const el = this.host.renderRoot.querySelector(`text.dtext[data-id="${sh.id}"]`) as SVGGraphicsElement | null;
if (!el || typeof (el as any).getBBox !== 'function') return;
let b: DOMRect;
try { b = el.getBBox(); } catch { return; } // not rendered yet (hidden card)
if (!b || (!b.width && !b.height)) return;
box = { id: sh.id, x: b.x, y: b.y, w: b.width, h: b.height };
}
const cur = this.host._dtBox;
const same = cur && cur.id === box.id && Math.abs(cur.x - box.x) < 0.01
&& Math.abs(cur.y - box.y) < 0.01 && Math.abs(cur.w - box.w) < 0.01
&& Math.abs(cur.h - box.h) < 0.01;
if (same) return;
this.host._dtBox = box;
this.host.requestUpdate();
measureHouseplanDecorText(this.host);
}
public _deleteDecor(id: string): void {
@@ -5273,6 +5263,10 @@ public _bdStart(ev: PointerEvent, corner?: number[], rotate = false): boolean {
}
public _bdMove(ev: PointerEvent): void {
queueHouseplanPointerMove(this.host, 'backdrop', () => this._bdMoveNow(ev));
}
private _bdMoveNow(ev: PointerEvent): void {
const d = this.host._bdDrag;
if (!d) return;
const p = this._svgPoint(ev);
@@ -5329,6 +5323,7 @@ public _bdReset(): void {
}
public _bdUp(): void {
flushHouseplanPointerMove(this.host, 'backdrop');
const d = this.host._bdDrag;
this.host._bdDrag = null;
if (d?.moved) {
@@ -6433,6 +6428,10 @@ public _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
}
public _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
queueHouseplanPointerMove(this.host, 'opening', () => this._opPointerMoveNow(ev, o));
}
private _opPointerMoveNow(ev: PointerEvent, o: OpeningCfg): void {
if (!this.host._opDrag || this.host._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
@@ -6552,6 +6551,7 @@ public _opRuler(
public _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this.host._opDrag || this.host._opDrag.id !== o.id) return;
flushHouseplanPointerMove(this.host, 'opening');
const drag = this.host._opDrag;
const moved = drag.moved;
this.host._opMeasure = null; // badges and the center tick live only through the drag
+80
View File
@@ -0,0 +1,80 @@
import {
haRegistryDiagnostics,
type HaBindingStatus,
} from './ha-binding-status';
import type { HassRenderDependencies } from './render-invalidation';
export interface HouseplanDiagnosticsCore {
registry: ReturnType<typeof haRegistryDiagnostics>;
bindings: Record<HaBindingStatus['kind'], number>;
}
type DiagnosticMarker = { removed?: boolean; binding: string };
/** State kept outside the already oversized card element (#34). */
export class RenderLifecycle {
private seen: any = null;
private diagnosticsCache: HouseplanDiagnosticsCore | null = null;
observe(
before: any,
after: any,
dependencies: HassRenderDependencies | null,
intake: () => void,
): void {
this.invalidateDiagnosticsForHass(before, after, dependencies);
this.intake(after, intake);
}
intake(snapshot: any, run: () => void): void {
if (!snapshot || this.seen === snapshot) return;
this.seen = snapshot;
run();
}
invalidate(): void {
this.diagnosticsCache = null;
}
private invalidateDiagnosticsForHass(
before: any, after: any, dependencies: HassRenderDependencies | null,
): void {
if (!this.diagnosticsCache) return;
if (before?.entities !== after?.entities || before?.devices !== after?.devices
|| [...(dependencies?.entityIds || [])].some((entityId) =>
!!before?.states?.[entityId] !== !!after?.states?.[entityId])) {
this.invalidate();
}
}
diagnostics(
hass: any,
markers: readonly DiagnosticMarker[],
resolve: (binding: string) => HaBindingStatus,
): HouseplanDiagnosticsCore {
if (this.diagnosticsCache) return this.diagnosticsCache;
const bindings: Record<HaBindingStatus['kind'], number> = {
active: 0, ha_disabled: 0, orphaned: 0, unverified: 0,
};
for (const marker of markers) {
if (marker.removed || marker.binding === 'virtual') continue;
bindings[resolve(marker.binding).kind]++;
}
this.diagnosticsCache = { registry: haRegistryDiagnostics(hass), bindings };
return this.diagnosticsCache;
}
}
/** Operational work that must survive a deliberately skipped visual update. */
export function intakeHass(host: any): void {
host._hassSequence++;
host._renderSnapshotAt = Date.now();
host._continuity.note('hass-snapshot');
host._ensureHaRegistryAuthority();
host._planHassMemo = null;
host._hookConnection();
if (!host._loadOk && !host._loading && host._loadTries < 8) host._loadFromServer();
host._maybeRebuildDevices();
host._vacTick();
host._activityTick();
}
+38
View File
@@ -0,0 +1,38 @@
import type { DevicePlacement } from './device-position-history';
import type { OpeningDimension } from './opening-dimensions';
import type { OpeningPlacementCore } from './opening-placement';
import type { OpeningFaceOffset } from './opening-symbol-placement';
import type { ResolvedPartitionOpening, PartitionOpeningOrphanReason } from './partition-openings';
import type { OpeningCfg } from './types';
/** Opening rulers shared by the card shell and lazy editor runtime. */
export interface OpMeasure {
labels: Array<{ x: number; y: number; text: string; dimension?: OpeningDimension }>;
guide: { x: number; y: number; angle: number } | null;
}
export type OpeningPlacementCandidate = Omit<OpeningPlacementCore, 'measure'> & {
face: OpeningFaceOffset;
measure: OpMeasure;
};
export type RenderOpening = OpeningCfg & {
rx: number; ry: number; rlen: number;
partitionHost?: ResolvedPartitionOpening;
orphanReason?: PartitionOpeningOrphanReason;
};
export interface DeviceDragState {
id: string;
spaceId: string;
displayName: string;
pointerId: number;
source: Element | null;
sx: number;
sy: number;
ox: number;
oy: number;
moved: boolean;
before: DevicePlacement | null;
start: DevicePlacement;
}
+202
View File
@@ -0,0 +1,202 @@
import { nothing, render, svg, type TemplateResult } from 'lit';
import { cancelHouseplanPointerMove } from './pointer-move-queue';
interface LiveEditorState {
raf: number;
hidden: HTMLElement[];
}
const states = new WeakMap<object, LiveEditorState>();
const liveProperties = new Set<PropertyKey>([
'_cursorPt', '_opMeasure', '_physicalDrag', '_physicalRotate',
'_decorDraft', '_dtBox', '_dtDrag', '_bdDrag', '_furnPreviewInput', '_layout',
]);
const hoverProperties = new Set<PropertyKey>(['_cursorPt', '_opMeasure', '_furnPreviewInput']);
const gestureProperties = new Set<PropertyKey>([
'_physicalDrag', '_physicalRotate', '_decorDraft', '_dtDrag', '_bdDrag',
]);
const stateOf = (host: object): LiveEditorState => {
let state = states.get(host);
if (!state) {
state = { raf: 0, hidden: [] };
states.set(host, state);
}
return state;
};
const activeEditorGesture = (host: any): boolean => host._mode !== 'view' && (
!!host._deviceDrag || !!host._physicalDrag || !!host._physicalRotate
|| !!host._decorDraft || !!host._decorMove || !!host._dtDrag || !!host._bdDrag
|| !!host._opDrag
|| !!host._resize?.dragging
);
/**
* ReactiveElement calls `requestUpdate(name, oldValue)` from generated state
* setters. Only pointer-owned values are diverted; selection, dialogs, toasts
* and terminal null assignments still receive a normal host update.
*/
export function routeHouseplanEditorUpdate(
host: any,
name?: PropertyKey,
oldValue?: unknown,
): boolean {
if (!host?.isConnected || host._mode === 'view') return false;
const namedLive = name !== undefined && liveProperties.has(name);
if (name === '_layout' && !host._deviceDrag) return false;
if (name === '_dtBox' && !host._dtDrag) return false;
if (name !== undefined && gestureProperties.has(name)
&& oldValue == null && host[name] != null && activeEditorGesture(host)) {
// Pointerdown may change selection/chrome once. Subsequent moves stay live.
return false;
}
if (name !== undefined && gestureProperties.has(name) && host[name] == null) return false;
const route = (namedLive && (hoverProperties.has(name!) || activeEditorGesture(host)))
|| (name === undefined && activeEditorGesture(host));
if (!route) return false;
scheduleHouseplanEditor(host);
return true;
}
const hide = (state: LiveEditorState, root: ParentNode, selector: string): void => {
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
if (element.closest('[data-hp-live-editor]')) continue;
if (element.style.visibility === 'hidden') continue;
element.style.visibility = 'hidden';
state.hidden.push(element);
}
};
const restore = (state: LiveEditorState): void => {
for (const element of state.hidden) element.style.removeProperty('visibility');
state.hidden.length = 0;
};
const planTemplate = (host: any): TemplateResult => {
const vb = host._baseVb();
const view = host._viewOr(vb);
const measure = host._opMeasureView;
if (host._resize?.dragging) {
const display = host._spaceDisplayForRender();
return svg`<g class="hp-live-resize" aria-hidden="true" pointer-events="none">
${host._renderWallBodies(display)}
${host._renderResizeMeasurements()}
${host._renderOpenings(display)}
${host._renderResizeLayer(view)}
</g>`;
}
return svg`<g class="hp-live-plan" aria-hidden="true" pointer-events="none">
${host._renderMarkupLayer(vb)}
${host._renderHiddenWallDiagnosticOverlay()}
${host._renderOpeningPlacementPreview()}
${measure ? host._renderOpeningDimensionGuides(measure) : nothing}
${measure?.guide ? host._renderOpeningCenterTick(measure.guide) : nothing}
${host._renderActiveChainInk()}
${host._renderPlanSnapOverlay()}
${host._renderWallThickUi()}
</g>`;
};
const editorTemplate = (host: any): TemplateResult | typeof nothing => {
if (host._mode === 'plan') return planTemplate(host);
if (host._mode === 'decor') {
const view = host._viewOr(host._baseVb());
return svg`<g class="hp-live-decor" aria-hidden="true" pointer-events="none">
${host._renderDecorLayer()}
${host._renderBackdropFrame(view)}
${host._renderTextFrame(view)}
</g>`;
}
return nothing;
};
const paintDevice = (host: any, root: ParentNode): void => {
const drag = host._deviceDrag;
if (!drag) return;
const device = host._devices.find((candidate: any) => candidate.id === drag.id);
const element = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((candidate) => candidate.dataset.id === drag.id);
if (!device || !element) return;
const position = host._livePos(device);
const point = host._scenePoint([position.x, position.y]);
const view = host._viewOr(host._baseVb());
element.style.left = `${((point[0] - view.x) / view.w) * 100}%`;
element.style.top = `${((point[1] - view.y) / view.h) * 100}%`;
};
/** One editor-only render root per card; the settled scene remains untouched. */
export function paintHouseplanEditor(host: any): void {
const root = host.renderRoot as ParentNode | undefined;
if (!root) return;
const state = stateOf(host);
restore(state);
paintDevice(host, root);
const target = root.querySelector<SVGElement>('[data-hp-live-editor]');
if (!target) return;
if (host._mode === 'plan') {
hide(state, root, '.hp-editor-only-layer');
if (host._resize?.dragging) hide(state, root, '.wallbodies');
} else if (host._mode === 'decor') {
hide(state, root, '.decorlayer');
hide(state, root, '.dtframe, .backdropframe');
}
render(editorTemplate(host), target);
host._liveEditorPaintCount = (host._liveEditorPaintCount || 0) + 1;
}
export function scheduleHouseplanEditor(host: any): void {
const state = stateOf(host);
if (state.raf || typeof requestAnimationFrame !== 'function') return;
state.raf = requestAnimationFrame(() => {
state.raf = 0;
paintHouseplanEditor(host);
});
}
/** Only text needs a DOM measurement; every box-like item owns its config box. */
export function measureHouseplanDecorText(host: any): void {
const shape = host._dtSel;
if (!shape) {
if (host._dtBox) { host._dtBox = null; host.requestUpdate(); }
return;
}
if (shape.kind !== 'text') return;
const element = host.renderRoot.querySelector(
`text.dtext[data-id="${shape.id}"]`,
) as SVGGraphicsElement | null;
if (!element || typeof element.getBBox !== 'function') return;
let measured: DOMRect;
try { measured = element.getBBox(); } catch { return; }
if (!measured || (!measured.width && !measured.height)) return;
const box = {
id: shape.id, x: measured.x, y: measured.y,
w: measured.width, h: measured.height,
};
const current = host._dtBox;
if (current?.id === box.id && Math.abs(current.x - box.x) < 0.01
&& Math.abs(current.y - box.y) < 0.01 && Math.abs(current.w - box.w) < 0.01
&& Math.abs(current.h - box.h) < 0.01) return;
host._dtBox = box;
host.requestUpdate();
}
export function commitHouseplanEditor(host: any): void {
const state = stateOf(host);
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
state.raf = 0;
restore(state);
const target = (host.renderRoot as ParentNode | undefined)
?.querySelector<SVGElement>('[data-hp-live-editor]');
if (target) render(nothing, target);
}
export function disposeHouseplanEditor(host: object): void {
cancelHouseplanPointerMove(host);
const state = states.get(host);
if (!state) return;
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
restore(state);
states.delete(host);
}
+86
View File
@@ -0,0 +1,86 @@
import { lqiColor } from './logic';
export interface LiveTip {
x: number;
y: number;
title: string;
meta: string;
lqi?: number | null;
temp?: number | null;
hum?: number | null;
room?: boolean;
}
interface HoverState {
room: unknown;
space: string;
}
const states = new WeakMap<object, HoverState>();
const appendMeta = (tip: HTMLElement, label: string, value?: string, color?: string): void => {
if (!value) return;
const row = document.createElement('span');
row.className = 'm';
if (!label) row.textContent = value;
else {
row.append(`${label} `);
const strong = document.createElement('b');
strong.textContent = value;
if (color) strong.style.color = color;
row.append(strong);
}
tip.append(row);
};
const syncTip = (host: any, root: ParentNode): void => {
const element = root.querySelector<HTMLElement>('[data-hp-live-tip]');
if (!element) return;
const tip = host._tip as LiveTip | null;
if (!tip) {
element.hidden = true;
element.replaceChildren();
return;
}
element.style.left = `${tip.x + 12}px`;
element.style.top = `${tip.y + 12}px`;
element.replaceChildren();
const title = document.createElement('b');
title.textContent = tip.title;
element.append(title);
appendMeta(element, '', tip.meta);
appendMeta(element, host._t('tip.temp_avg'), tip.temp == null ? '' : `${tip.temp}°`);
appendMeta(element, host._t('tip.hum_avg'), tip.hum == null ? '' : `${tip.hum}%`);
appendMeta(element, host._t('tip.lqi'), tip.lqi == null ? '' : String(tip.lqi),
tip.lqi == null ? undefined : lqiColor(tip.lqi));
element.hidden = false;
};
const setRoomPath = (root: ParentNode, selector: string, d: string): void => {
const path = root.querySelector<SVGPathElement>(selector);
if (!path) return;
path.setAttribute('d', d);
path.toggleAttribute('hidden', !d);
};
/** Update the ordinary mouse hover without scheduling a full card render. */
export function syncHouseplanHover(host: any): void {
const root = host.renderRoot as ParentNode | undefined;
if (!root) return;
syncTip(host, root);
const hover = host._hoverRoom;
const previous = states.get(host);
const space = hover?.space || '';
const room = hover?.room || null;
if (previous?.room === room && previous?.space === space) return;
states.set(host, { room, space });
const model = hover && host._spaceModel();
const paths = model ? host._roomHoverPaths(model) : null;
setRoomPath(root, '[data-hp-live-room-hover="fill"]', paths?.fillD || '');
setRoomPath(root, '[data-hp-live-room-hover="halo"]', paths?.outlineD || '');
setRoomPath(root, '[data-hp-live-room-hover="outline"]', paths?.outlineD || '');
}
export function resetHouseplanHover(host: object): void {
states.delete(host);
}
+46
View File
@@ -0,0 +1,46 @@
import {
commitHouseplanViewport, disposeHouseplanViewport, scheduleHouseplanViewport,
} from './live-viewport';
import { resetHouseplanHover, syncHouseplanHover } from './live-hover';
import {
classifyHassRenderChange, type HassRenderDependencies,
} from './render-invalidation';
/** Lazily loaded DOM-only fast paths; the card keeps a full-render fallback. */
export class LiveRuntime {
private deferredHass = false;
public constructor(private readonly host: any) {}
public hass(
before: any, after: any, dependencies: HassRenderDependencies | null, intake: () => void,
): boolean {
const change = classifyHassRenderChange(before, after, dependencies);
const defer = change === 'state' && this.active();
if (defer) this.deferredHass = true;
this.host._renderLife.observe(before, after, dependencies, intake);
return change !== 'none' && !defer;
}
public clear(): void { this.deferredHass = false; }
public take(): boolean {
const pending = this.deferredHass;
this.deferredHass = false;
return pending;
}
public viewport(): void { scheduleHouseplanViewport(this.host); }
public hover(): void { syncHouseplanHover(this.host); }
public active(): boolean {
const host = this.host;
return host._pointers.size > 0 || host._cameraTransition.active || !!host._deviceDrag
|| !!host._physicalDrag || !!host._physicalRotate || !!host._decorMove
|| !!host._decorDraft || !!host._dtDrag || !!host._bdDrag || !!host._opDrag
|| !!host._resize?.dragging;
}
public commit(): void {
commitHouseplanViewport(this.host);
resetHouseplanHover(this.host);
syncHouseplanHover(this.host);
}
public dispose(): void {
disposeHouseplanViewport(this.host);
resetHouseplanHover(this.host);
}
}
+138
View File
@@ -0,0 +1,138 @@
export interface LiveViewBox {
x: number;
y: number;
w: number;
h: number;
}
interface LiveViewportFrame {
view: LiveViewBox;
floor: LiveViewBox;
zoom: number;
}
interface LiveViewportState {
painted: LiveViewportFrame | null;
pending: LiveViewportFrame | null;
raf: number;
}
const states = new WeakMap<object, LiveViewportState>();
const stateOf = (host: object): LiveViewportState => {
let state = states.get(host);
if (!state) {
state = { painted: null, pending: null, raf: 0 };
states.set(host, state);
}
return state;
};
export const liveViewBoxText = (view: LiveViewBox): string =>
`${view.x} ${view.y} ${view.w} ${view.h}`;
export interface LiveLayerProjection {
translateXPercent: number;
translateYPercent: number;
scaleX: number;
scaleY: number;
}
/** Map an HTML percentage layer painted for `before` onto `after`. */
export function liveLayerProjection(
before: LiveViewBox,
after: LiveViewBox,
): LiveLayerProjection {
return {
translateXPercent: ((before.x - after.x) / after.w) * 100,
translateYPercent: ((before.y - after.y) / after.h) * 100,
scaleX: before.w / after.w,
scaleY: before.h / after.h,
};
}
const finiteView = (view: LiveViewBox): boolean =>
[view.x, view.y, view.w, view.h].every(Number.isFinite) && view.w > 0 && view.h > 0;
const setLayerProjection = (
layer: HTMLElement,
projection: LiveLayerProjection | null,
): void => {
if (!projection) {
layer.style.removeProperty('transform');
layer.style.removeProperty('transform-origin');
layer.style.removeProperty('will-change');
return;
}
layer.style.transformOrigin = '0 0';
layer.style.willChange = 'transform';
layer.style.transform = `translate(${projection.translateXPercent}%,${projection.translateYPercent}%) scale(${projection.scaleX},${projection.scaleY})`;
};
/** One atomic DOM-only viewport paint; no Lit host update is involved. */
export function paintLiveViewport(
root: ParentNode,
painted: LiveViewportFrame,
current: LiveViewportFrame,
): void {
if (!finiteView(painted.view) || !finiteView(current.view) || !finiteView(current.floor)) return;
const viewBox = liveViewBoxText(current.view);
const floorBox = liveViewBoxText(current.floor);
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="camera"]')) {
svg.setAttribute('viewBox', viewBox);
}
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="floor"]')) {
svg.setAttribute('viewBox', floorBox);
}
const projection = liveLayerProjection(painted.view, current.view);
for (const layer of root.querySelectorAll<HTMLElement>('[data-hp-live-layer="camera"]')) {
setLayerProjection(layer, projection);
}
const badge = root.querySelector<HTMLElement>('[data-hp-live-zoom]');
if (badge) {
badge.hidden = current.zoom <= 1;
const value = badge.querySelector<HTMLElement>('[data-hp-live-zoom-value]');
if (value) value.textContent = `${Math.round(current.zoom * 100)}%`;
}
}
const frameOf = (host: any): LiveViewportFrame => {
const view = host._viewOr(host._baseVb());
return { view: { ...view }, floor: { ...host._floorView(view) }, zoom: host._zoom };
};
/** Coalesce camera input into one lightweight paint per animation frame. */
export function scheduleHouseplanViewport(host: any): void {
const state = stateOf(host);
state.pending = frameOf(host);
if (state.raf || typeof requestAnimationFrame !== 'function') return;
state.raf = requestAnimationFrame(() => {
state.raf = 0;
const next = state.pending;
state.pending = null;
const root = host.renderRoot as ParentNode | undefined;
if (!next || !root) return;
if (!state.painted) state.painted = next;
paintLiveViewport(root, state.painted, next);
});
}
/** Record a complete Lit frame and remove any temporary HTML projection. */
export function commitHouseplanViewport(host: any): void {
const state = stateOf(host);
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
state.raf = 0;
state.pending = null;
state.painted = frameOf(host);
const root = host.renderRoot as ParentNode | undefined;
if (!root) return;
for (const layer of root.querySelectorAll<HTMLElement>('[data-hp-live-layer="camera"]')) {
setLayerProjection(layer, null);
}
paintLiveViewport(root, state.painted, state.painted);
}
export function disposeHouseplanViewport(host: object): void {
const state = states.get(host);
if (state?.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
states.delete(host);
}
+48
View File
@@ -0,0 +1,48 @@
interface QueuedMove { raf: number; run: () => void }
const moveQueues = new WeakMap<object, Map<string, QueuedMove>>();
/** Keep only the latest raw move of each active gesture between two frames. */
export function queueHouseplanPointerMove(host: object, key: string, run: () => void): void {
let queues = moveQueues.get(host);
if (!queues) {
queues = new Map();
moveQueues.set(host, queues);
}
const queued = queues.get(key);
if (queued) {
queued.run = run;
return;
}
const entry: QueuedMove = { raf: 0, run };
queues.set(key, entry);
if (typeof requestAnimationFrame !== 'function') {
queues.delete(key);
run();
return;
}
entry.raf = requestAnimationFrame(() => {
queues!.delete(key);
entry.run();
});
}
export function flushHouseplanPointerMove(host: object, key: string): void {
const queues = moveQueues.get(host);
const queued = queues?.get(key);
if (!queued) return;
if (queued.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(queued.raf);
queues!.delete(key);
queued.run();
}
export function cancelHouseplanPointerMove(host: object, key?: string): void {
const queues = moveQueues.get(host);
if (!queues) return;
const entries = key ? [...queues].filter(([name]) => name === key) : [...queues];
for (const [name, queued] of entries) {
if (queued.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(queued.raf);
queues.delete(name);
}
if (!queues.size) moveQueues.delete(host);
}
+3
View File
@@ -22,6 +22,8 @@ export interface RenderDeviceSnapshot {
readonly presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
/** Extra resolved render facts (for example a vacuum puck/trail frame). */
readonly facts: ReadonlyMap<string, unknown>;
/** Exact state rows whose identity may invalidate the painted frame. */
readonly entityIds: readonly string[];
}
/**
@@ -148,6 +150,7 @@ export function createRenderDeviceSnapshot(options: {
[key, cloneFact(presentation)] as const)),
facts: readonlyMap([...(options.facts || [])].map(([key, fact]) =>
[key, cloneFact(fact)] as const)),
entityIds: Object.freeze([...entityIds]),
});
}
+68
View File
@@ -0,0 +1,68 @@
/** HA data that can change the painted House Plan frame. */
export interface HassRenderDependencies {
readonly entityIds: Iterable<string>;
}
export type HassRenderChange = 'none' | 'state' | 'structural';
const scalarChanged = (before: any, after: any, path: readonly string[]): boolean => {
let a = before, b = after;
for (const part of path) {
a = a?.[part];
b = b?.[part];
}
return a !== b;
};
/**
* Classify one HA assignment without walking the complete `hass.states` map.
*
* Home Assistant keeps unchanged state rows by identity. Comparing only the
* dependency rows therefore catches state, attribute and availability changes
* while making an unrelated tick O(number of plan dependencies), not
* O(number of HA entities). Structural values deliberately fail open: they are
* rare and can alter formatting, registry resolution, permissions or actions.
*/
export function classifyHassRenderChange(
before: any,
after: any,
dependencies: HassRenderDependencies | null,
): HassRenderChange {
if (before === after) return 'none';
if (!before || !after || !dependencies) return 'structural';
if (before.connection !== after.connection
|| before.entities !== after.entities
|| before.devices !== after.devices
|| before.areas !== after.areas
|| before.themes !== after.themes
|| before.user !== after.user
|| before.config !== after.config
|| before.floors !== after.floors
|| before.services !== after.services
|| before.panels !== after.panels
|| before.language !== after.language
|| scalarChanged(before, after, ['locale', 'language'])
|| scalarChanged(before, after, ['locale', 'number_format'])
|| scalarChanged(before, after, ['locale', 'time_format'])
|| scalarChanged(before, after, ['locale', 'date_format'])) {
return 'structural';
}
// `states` is the only top-level collection for which this classifier can
// prove irrelevance from the dependency projection. A newly introduced HA
// capability must therefore fail open until it receives an explicit rule;
// otherwise a frontend release could silently leave that UI stale.
const compared = new Set([
'states', 'connection', 'entities', 'devices', 'areas', 'themes', 'user',
'config', 'floors', 'services', 'panels', 'language', 'locale',
]);
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (!compared.has(key) && before[key] !== after[key]) return 'structural';
}
for (const entityId of dependencies.entityIds) {
if (before.states?.[entityId] !== after.states?.[entityId]) return 'state';
}
return 'none';
}
+1 -1
View File
@@ -17,6 +17,6 @@ export function renderZigbeeTopologyOverlay(input: {
if (!input.view || input.kiosk || input.hass?.user?.is_admin !== true
|| !zigbeeTopologySettingsOf(input.settings).enabled) return nothing;
void import('./hp-zigbee-topology-overlay');
return html`<hp-zigbee-topology-overlay .hass=${input.hass} .devices=${input.devices}
return html`<hp-zigbee-topology-overlay data-hp-live-layer="camera" .hass=${input.hass} .devices=${input.devices}
.registry=${input.registry} .currentSpace=${input.currentSpace} .viewKey=${input.viewKey}></hp-zigbee-topology-overlay>`;
}