Files
houseplan-card/src/stairs-view.ts
T
Claudeandclaude[bot] 6f17b6cd4c perf(card): a floor switch stops re-querying the same subtrees (#694)
A floor switch replaces the whole stage, so the card's pointer-hover
MutationObserver receives hundreds of records whose targets are the same
few containers. Each record re-ran `matches` and a `.devlayer` subtree
`querySelector` on its target, and kept doing so after the device layer
had already been found. The batch logic moves to `deviceLayerMutated` in
device-hit-owner.ts: a node is checked at most once per batch, the first
hit ends the checks, and every added node still goes through
`_syncPointerHoverSubtree` in record order. The card shrinks by 12 lines.

The View stair layer read the card's `_model` getter once more for every
navigable stair; the getter rebuilds the config fingerprint on each read.
`renderLayer` now reads it once.

`languageRenderGate` wrote `lang` on the host on every render. It now
writes it only when the value differs (language switch, English fallback,
a foreign value); an unchanged value is left alone.

No behaviour changes: DOM, tooltips and pixels are the same. Unit tests
count subtree queries per node, `_model` reads per render and `lang`
writes; one mutant per change restores the old behaviour.

Issue: #694
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 00:22:26 +00:00

123 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nothing, svg, type TemplateResult } from 'lit';
import {
cachedStairRenderGeometry, stairList, stairStyleVars, type Stair,
} from './stairs';
import { stairTargetState } from './stairs-editor-model';
import type { SpaceModel } from './types';
export interface StairViewHostPort {
_mode: 'view' | 'plan' | 'devices' | 'decor';
_curSpaceCfg: unknown;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
_cellCm: number;
_gridPitch: number;
_decorStyle: { color: string; opacity: number };
_tabClick(spaceId: string): void;
_t(key: 'markup.stairs' | 'stairs.tooltip_navigate', vars?: Record<string, string | number>): string;
/** The card's pointer tooltip: shown only for a hover-capable pointer. */
_showTip(event: PointerEvent, title: string, meta: string): void;
_clearPointerHover(): void;
}
type ViewPress = { pid: number; startedAt: number };
/** Eager, read-only stair surface. All editing stays in the lazy editor graph. */
export class StairViewRuntime {
private press: ViewPress | null = null;
private suppressClick = false;
public constructor(private readonly owner: StairViewHostPort) {}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
public pointerDown(event: PointerEvent): void {
this.suppressClick = false;
if (event.pointerType === 'touch') {
this.press = { pid: event.pointerId, startedAt: performance.now() };
}
}
public pointerUp(event: PointerEvent): void {
const press = this.press;
if (!press || press.pid !== event.pointerId) return;
this.press = null;
if (performance.now() - press.startedAt >= 600) this.suppressClick = true;
}
public pointerCancel(event: PointerEvent): void {
if (this.press?.pid !== event.pointerId) return;
this.press = null;
this.suppressClick = true;
}
public clearGesture(): void {
this.press = null;
this.suppressClick = false;
}
public renderLayer(): TemplateResult {
// #694: the card's `_model` getter fingerprints the whole config on every
// read; read it once per layer, not once more per navigable stair.
const model = this.owner._model;
const spaceIds = new Set(model.map((item) => item.id));
const interactive = this.owner._mode === 'view';
const items = this.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
const targetState = stairTargetState(
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
);
const active = interactive && targetState === 'active';
// #676 К8: the tooltip has exactly the link's condition — `active` — so a
// missing, self, deleted or fixed-floor target never announces a floor.
const targetTitle = active
? model.find((item) => item.id === stair.target_space_id)?.title ?? ''
: '';
const tip = (event: PointerEvent): void => {
if (!active) return;
this.owner._showTip(event, this.owner._t('stairs.tooltip_navigate', { title: targetTitle }), '');
};
const navigate = (event: Event): void => {
event.stopPropagation();
if (event.type === 'keydown') this.suppressClick = false;
if (!active || this.owner._suppressClick || this.suppressClick
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair hp-stair-view ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
style=${stairStyleVars(
stair, this.owner._cellCm, this.owner._gridPitch, this.owner._decorStyle,
)}
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
aria-label=${this.owner._t('markup.stairs')}
@click=${navigate}
@pointerenter=${tip}
@pointermove=${tip}
@pointerleave=${() => { if (active) this.owner._clearPointerHover(); }}
@keydown=${(event: KeyboardEvent) => {
if (active && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
navigate(event);
}
}}>
<polygon class="hp-stair-outline" points=${outline}></polygon>
<polygon class="hp-stair-hit" points=${outline}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></polygon>
${geometry.trapezoid.map((line) => svg`<line class="hp-stair-trapezoid"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
</g>`;
});
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
}
}