Files
houseplan-card/src/summary-panel-runtime-loaded.ts
T
Claudeandclaude[bot] 235f60e693 perf(card): a floor switch stops forcing layout and re-walking the config (#725)
Profiling #694 found three costs on every View pass, paid even with the
summary panel hidden.

The summary panel read the safe-area probe's computed style in layout(),
which the card reaches up to five times per render (renderControls,
menuItems, renderPanel twice, the clock check), and its updated()
measured the stage, probe and kiosk buttons after every DOM commit. The
insets now live in the measured state: measureLayout is the only method
that reads style or layout, and updated() calls it only when an input of
the measurement changed (probe, kiosk buttons or stage element, title,
language, mode, kiosk, kiosk scale, narrow, HA theme), after connect()
or an identity change, on visibility, once after document.fonts.ready,
and from resized() as before. A floor switch or an HA tick no longer
measures.

The _model getter rebuilt the config fingerprint (a walk over every
space and room with JSON.stringify of room settings) on each of its
dozens of reads per render. ConfigFingerprintPass remembers the whole
cache key (epoch and fingerprint) from the start of willUpdate() to the
end of render() while the epoch, the config object and its spaces array
are unchanged. Remembering only the fingerprint and concatenating the key
on every read was tried first: in 2.5D on the large house the switch cycle
measured slower than without any memo, and CPU profiles showed several
times more garbage collection on load and on the first visit of a floor;
one remembered key per pass has neither. Outside the pass (handlers, updated(),
timers) every read still builds the key, so an in-place edit without an
epoch bump stays visible (HP-1454-04). No write to the fingerprinted
fields is reachable from willUpdate() or render().

_isoScene read the stage box during render only to feed an aspect into
the overlay fit, whose frame has not depended on the aspect since #713.
It now uses the frame's own aspect and passes stageSize: null.

render-layout-read.mjs now also judges _isoScene and the whole summary
runtime except measureLayout, forbids layout property reads
(clientWidth, offsetTop, ...) besides the two calls, and reports every
violation. Two registered mutants restore the old reads.

No visible change: panel caps, side, offsets and kiosk clearance are
computed from the same values; the 2.5D frame is the same.

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

971 lines
43 KiB
TypeScript

import { html, nothing, type TemplateResult } from 'lit';
import { langOf, type I18nKey } from './i18n';
import type {
ServerConfig, SummaryPanelConfig, SummaryPanelSource, SummaryPanelSystemKey,
SummaryPanelValue,
} from './types';
import {
SUMMARY_PANEL_LEGACY_SCALE_KEY,
type SummaryDraftProblem, type SummaryPanelLocalPreferences,
cloneSummaryPanel, confirmedSummaryPanelWriteRecovery, defaultSummaryPanel, effectiveSummaryVisible,
moveSummaryItem, normalizeSummaryDraft, parseSummaryLocal,
normalizeSummaryScale, resolveSummaryLayout, sameSummaryPanel,
summaryLocalKey, summaryPanelEntityIds, summaryPanelOf, validateSummaryDraft, visibleSummaryBlocks,
} from './summary-panel';
import { SUMMARY_PANEL_API_VERSION } from './summary-panel-api';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { canonicalizeConfigGeometry } from './coordinate-canonicalization';
import type { SummaryPanelEditorRenderer } from './summary-panel-editor';
import { summaryPanelCss } from './summary-panel-style';
import { summaryPanelDialogCss } from './summary-panel-dialog-style';
import { summaryIcon } from './summary-panel-icons';
import { SummaryPanelPresentation } from './summary-panel-presentation';
import { summaryPanelText } from './summary-panel-i18n';
import type { SummaryPanelHost } from './summary-panel-host';
import type { HeaderMenuItem } from './header-menu';
import { stableSummaryPlacementSlot } from './summary-panel-identity';
import {
refreshSummaryEntityIndex, type SummaryEntityIndex,
} from './summary-panel-picker';
const isSummarySystemKey = (value: string): value is SummaryPanelSystemKey =>
value === 'device_count' || value === 'total_area' || value === 'datetime';
export type SummaryPanelDialogState = {
draft: SummaryPanelConfig;
base: SummaryPanelConfig;
baseRevision: number;
localShow: boolean;
baseLocalShow: boolean;
localOnly: boolean;
localOnlyHint: 'summary.local_only' | 'summary.backend_required' | 'summary.unsupported_schema';
busy: boolean;
attempted: boolean;
entityFilter: string;
activeSource: { blockId: string; valueId: string } | null;
error: string;
conflict: boolean;
};
type SummaryLayoutState = {
width: number;
height: number;
minimumHeight: number;
controlTop: number;
safeLeft: number;
safeRight: number;
safeTop: number;
safeBottom: number;
};
/**
* Eager, small View runtime for #437. The much larger settings form is loaded
* only after an explicit press on the gear, keeping the ordinary plan path
* out of the editor chunk.
*
* The host is deliberately structural. HousePlanCard owns integration state;
* this controller owns only summary-panel local/UI state and asks the host to
* repaint after mutations.
*/
/** Бюджет одной порции фонового расчёта агрегатов: короче кадра (#509). */
const SUMMARY_METRICS_FRAME_BUDGET_MS = 8;
export class LoadedSummaryPanelRuntime {
private readonly host: SummaryPanelHost;
private dialog: SummaryPanelDialogState | null = null;
private local: SummaryPanelLocalPreferences = {
version: 1, show: false, icon_scale: 1, font_scale: 1,
};
private storageKey: string | null = null;
private stage: SummaryLayoutState = {
width: 0, height: 0, minimumHeight: 162, controlTop: 0,
safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0,
};
/** #725: what the last measurement saw; `null` measures on the next `updated()`. */
private measured: { key: string; elements: readonly (Element | null)[] } | null = null;
private clock = new Date();
private clockTimer = 0;
private deviceMemo: {
cfgEpoch: number; layoutRev: number; registryRev: number; value: number | null;
} | null = null;
private areaMemo: { cfgEpoch: number; value: number | null } | null = null;
private storageUnavailable = false;
private clockContext = '';
private editorRenderer: SummaryPanelEditorRenderer | null = null;
private editorLoad: Promise<SummaryPanelEditorRenderer> | null = null;
private metricsModule: typeof import('./summary-panel-metrics') | null = null;
private metricsLoad: Promise<typeof import('./summary-panel-metrics')> | null = null;
// Тяжёлые агрегаты считаются ПОСЛЕ кадра (#509): на большом плане обход
// геометрии занимал сотни миллисекунд прямо в render, и первый показ панели
// выглядел зависанием. Здесь — только флаг «пересчёт уже запланирован».
private metricsRefresh = 0;
private areaSteps: Generator<void, number | null, void> | null = null;
private areaStepsEpoch = -1;
private styleSheet: CSSStyleSheet | null = null;
private entityIndex: SummaryEntityIndex | null = null;
private lifecycleGeneration = 0;
private lifecycleIdentity = '';
private connected = false;
private readonly presentation = new SummaryPanelPresentation(() => this.host.requestUpdate());
private dialogStyleSheet: CSSStyleSheet | null = null;
private readonly styledDialogRoots = new WeakSet<ShadowRoot>();
public constructor(host: unknown) { this.host = host as SummaryPanelHost; }
public get dialogOpen(): boolean { return !!this.dialog; }
/** Entity rows that can affect any supported saved summary-panel frame. */
public entityIds(): readonly string[] { return summaryPanelEntityIds(this.config().config); }
public connect(): void {
this.ensureStyle();
this.connected = true;
this.syncLifecycle();
this.loadLocal();
this.measured = null;
this.measureAfterFonts();
}
public disconnect(): void {
this.connected = false;
this.resetLifecycle();
}
/** A real HA route departure is stronger than a responsive remount. */
public leaveRoute(): void { this.resetLifecycle(); }
/** Re-apply the per-card authority instead of the legacy kiosk seed. */
public applyLocalScaleForCurrentIdentity(): boolean {
const key = this.preferenceKey();
if (!key) return false;
if (key !== this.storageKey) this.loadLocal();
if (key !== this.storageKey) return false;
this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };
return true;
}
public visibility(kind: 'hidden' | 'visible' | 'pageshow'): void {
if (kind === 'hidden') {
if (this.clockTimer) clearTimeout(this.clockTimer);
this.clockTimer = 0;
this.syncPresentation(true);
this.host.requestUpdate();
return;
}
this.measured = null;
this.clock = new Date();
this.syncClock();
this.host.requestUpdate();
}
public updated(): void {
this.syncLifecycle();
this.syncNativeNarrow();
this.loadLocal();
this.measureIfInputsChanged();
this.syncClock();
this.ensureDialogStyle();
this.presentation.updated(this.host.renderRoot.querySelector<HTMLElement>('.summary-overlay'));
}
/** Reset an identity-changing draft before the host renders the new context. */
public willUpdate(): void { this.syncLifecycle(); }
/** Wake a filtered HA render only when an open picker composition changed. */
public observeHassComposition(): boolean {
this.syncLifecycle();
if (!this.dialog || this.dialog.localOnly) return false;
const previous = this.entityIndex;
this.refreshEntityIndex();
return this.entityIndex !== previous;
}
public resized(): void { this.measureLayout(); }
public closeDialogIfIdle(): boolean {
if (!this.dialog || this.dialog.busy) return false;
this.dialog = null;
this.host.requestUpdate();
return true;
}
public blocksOtherDialogs(): boolean { return !!this.dialog; }
public saveScale(patch: Partial<{ icon: number; font: number }>): void {
this.saveLocal({
icon_scale: patch.icon ?? this.local.icon_scale,
font_scale: patch.font ?? this.local.font_scale,
});
}
public renderMeasure(): TemplateResult | typeof nothing {
const title = this.config().config?.title;
if (!title || this.host._mode !== 'view') return nothing;
return html`<div class="summary-measure" aria-hidden="true" inert>
<h2>${title}</h2><div class="summary-scroll"><section class="summary-block"><h3>${this.t('summary.measure_block')}</h3>
<div class="summary-value"><span>${this.t('summary.measure_label')}</span>
<strong>${this.t('summary.unavailable')}</strong></div></section></div>
</div><div class="summary-safe-probe" aria-hidden="true" inert></div>`;
}
public renderPanel(): TemplateResult | typeof nothing {
const resolved = this.config();
const config = resolved.config;
this.syncPresentation();
if (!config) return nothing;
const layout = this.layout();
if (!this.presentation.mounted) return nothing;
this.ensureMetrics();
const blocks = visibleSummaryBlocks(config, this.host._space);
const stop = (event: Event) => event.stopPropagation();
return html`<aside class="summary-overlay ${layout.side}" aria-label=${config.title}
data-phase=${this.presentation.phase} ?inert=${!this.presentation.interactive}
aria-hidden=${this.presentation.interactive ? 'false' : 'true'}
style="--summary-height-cap:${Math.floor(layout.heightCap)}px;--summary-width-cap:${Math.floor(layout.availableWidth)}px;--summary-top:${layout.top}px;--summary-bottom:${layout.bottom}px"
@click=${stop} @dblclick=${stop} @pointerdown=${stop} @pointerup=${stop}
@pointermove=${stop} @wheel=${stop}>
<h2>${config.title}</h2>
<div class="summary-scroll">
${blocks.length ? blocks.map((block) => html`<section class="summary-block">
<h3>${block.title}</h3>
${block.values.length ? block.values.map((value) => {
const state = this.valueState(value);
// Скелет вместо текста ошибки, пока значения нет (#509): та же
// плашка и та же высота строки, пульсирующий прямоугольник на
// месте значения, плавная замена по готовности.
return html`<div class="summary-value">
<span>${value.label}</span>${state.kind === 'pending'
? html`<strong class="summary-value-pending" aria-hidden="true"><i></i></strong>`
: html`<strong>${state.kind === 'ready' ? state.text : this.t('summary.unavailable')}</strong>`}
</div>`;
}) : html`<div class="summary-empty">${this.t('summary.empty_block')}</div>`}
</section>`) : html`<div class="summary-empty">${this.t('summary.empty_space')}</div>`}
</div>
</aside>`;
}
/** #616: the same two actions as `renderControls`; the phone menu offers them only in View. */
public menuItems(): HeaderMenuItem[] {
const toggleTitle = this.toggleTitle();
return [
{ id: 'summary-settings', glyph: summaryIcon('settings'), label: this.t('summary.settings'), run: () => void this.openDialog() },
{ id: 'summary-toggle', glyph: summaryIcon('sidebar'), label: toggleTitle, pressed: this.local.show,
run: () => this.saveLocal({ show: !this.local.show }) },
];
}
private toggleTitle(): string {
const resolved = this.config();
const layout = this.layout();
const temporaryReason = this.local.show && (
resolved.unsupported ? this.t('summary.unsupported_schema')
: !resolved.config?.show_on_mobile && this.host.narrow === true
? this.t('summary.hidden_mobile')
: !resolved.config?.show_on_mobile && this.host.narrow === null
? this.t('summary.hidden_narrow_unknown')
: !layout.fits ? this.t('summary.hidden_small') : ''
);
return temporaryReason || this.t(this.local.show ? 'summary.hide' : 'summary.show');
}
public renderControls(kiosk = false): TemplateResult | typeof nothing {
if (kiosk && this.host._mode !== 'view') return nothing;
const toggleTitle = this.toggleTitle();
const stop = (event: Event) => event.stopPropagation();
return html`<div class="summary-control ${kiosk ? 'kiosk' : ''}" role="group"
aria-label=${this.t('summary.controls')} @click=${stop} @dblclick=${stop}
@pointerdown=${stop} @pointerup=${stop} @pointermove=${stop} @wheel=${stop}>
<button type="button" @click=${() => void this.openDialog()}
title=${this.t('summary.settings')} aria-label=${this.t('summary.settings')}>
${summaryIcon('settings')}
</button>
<button type="button" class=${this.local.show ? 'on' : ''}
aria-pressed=${this.local.show ? 'true' : 'false'}
title=${toggleTitle} aria-label=${toggleTitle}
@click=${() => this.saveLocal({ show: !this.local.show })}>
${summaryIcon('sidebar')}
</button>
</div>`;
}
public renderDialog(): TemplateResult | typeof nothing {
if (!this.dialog || !this.editorRenderer) return nothing;
if (!this.dialog.localOnly) this.refreshEntityIndex();
return this.editorRenderer({
host: this.host,
dialog: this.dialog,
local: this.local,
storageUnavailable: this.storageUnavailable,
problems: this.problems(this.dialog),
entityIndex: this.entityIndex || refreshSummaryEntityIndex({}, null),
setDialog: (dialog) => { this.dialog = dialog; this.host.requestUpdate(); },
saveLocal: (patch) => this.saveLocal(patch),
mutate: (mutate) => this.mutate(mutate),
deleteBlock: (index) => void this.deleteBlock(index),
dragStart: (event, token) => this.dragStart(event, token),
drop: (event, target) => this.drop(event, target),
sourceToken: (source) => this.sourceToken(source),
openSource: (blockId, valueId) => this.openSource(blockId, valueId),
closeSource: (returnFocus) => this.closeSource(returnFocus),
setSource: (blockId, valueId, token) => this.setSource(blockId, valueId, token),
save: () => void this.saveDialog(),
reload: () => void this.reloadDialog(),
close: () => this.closeDialogIfIdle(),
t: (key) => this.t(key),
});
}
private t(key: string): string {
return key.startsWith('summary.')
? summaryPanelText(langOf(this.host.hass, this.host._config?.language), key)
: this.host._t(key as I18nKey);
}
private translate = (key: string): string => this.t(key);
private ensureStyle(): void {
const root = this.host.renderRoot as ShadowRoot;
if (this.styleSheet && root.adoptedStyleSheets.includes(this.styleSheet)) return;
const Sheet = this.host.ownerDocument.defaultView?.CSSStyleSheet;
if (Sheet && 'adoptedStyleSheets' in root) {
const sheet = new Sheet(); sheet.replaceSync(summaryPanelCss);
this.styleSheet = sheet;
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
return;
}
if (root.querySelector('style[data-hp-summary]')) return;
const style = this.host.ownerDocument.createElement('style');
style.dataset.hpSummary = 'true'; style.textContent = summaryPanelCss;
root.insertBefore(style, root.firstChild);
}
private ensureDialogStyle(): void {
const root = this.host.renderRoot.querySelector('hp-dialog[data-kind="summary"]')?.shadowRoot;
if (!root || this.styledDialogRoots.has(root)) return;
const Sheet = this.host.ownerDocument.defaultView?.CSSStyleSheet;
if (Sheet && 'adoptedStyleSheets' in root) {
if (!this.dialogStyleSheet) {
this.dialogStyleSheet = new Sheet();
this.dialogStyleSheet.replaceSync(summaryPanelDialogCss);
}
root.adoptedStyleSheets = [...root.adoptedStyleSheets, this.dialogStyleSheet];
} else {
const style = this.host.ownerDocument.createElement('style');
style.dataset.hpSummaryShell = 'true';
style.textContent = summaryPanelDialogCss;
root.appendChild(style);
}
this.styledDialogRoots.add(root);
}
private placementSlot(): string | null {
return stableSummaryPlacementSlot(this.host);
}
private preferenceKey(): string | null {
return summaryLocalKey({
userId: this.host.hass?.user?.id || this.host.hass?.user?.name,
path: location.pathname,
host: this.host.panelHost ? 'panel' : 'lovelace',
slot: this.placementSlot(),
});
}
private identity(): string {
return JSON.stringify({
key: this.preferenceKey(),
user: this.host.hass?.user?.id || this.host.hass?.user?.name || '',
route: location.pathname,
host: this.host.panelHost ? 'panel' : 'lovelace',
slot: this.placementSlot(),
kiosk: this.host._kiosk,
canManage: this.host._canManageConfiguration,
});
}
private syncLifecycle(): void {
const next = this.identity();
if (this.lifecycleIdentity && next !== this.lifecycleIdentity) this.resetLifecycle();
this.lifecycleIdentity = next;
}
private resetLifecycle(): void {
this.presentation.reset();
this.lifecycleGeneration++;
this.dialog = null;
this.entityIndex = null;
this.deviceMemo = null;
this.areaMemo = null;
this.clockContext = '';
this.storageKey = null;
this.storageUnavailable = false;
this.local = { version: 1, show: false, icon_scale: 1, font_scale: 1 };
if (this.clockTimer) clearTimeout(this.clockTimer);
this.clockTimer = 0;
this.lifecycleIdentity = this.connected ? this.identity() : '';
this.measured = null;
if (this.connected) this.measureAfterFonts();
this.host.requestUpdate();
}
private current(generation: number): boolean {
return this.connected && generation === this.lifecycleGeneration
&& this.lifecycleIdentity === this.identity();
}
private refreshEntityIndex(): void {
this.entityIndex = refreshSummaryEntityIndex(this.host.hass?.states, this.entityIndex);
}
private syncNativeNarrow(): void {
if (this.host.panelHost) return;
let node: Node | null = this.host;
for (let depth = 0; node && depth < 12; depth++) {
const root = node.getRootNode() as Document | ShadowRoot;
node = node.parentNode || (root instanceof ShadowRoot ? root.host : null);
if (!node || node === this.host) continue;
const value = (node as Node & { narrow?: unknown }).narrow;
if (typeof value === 'boolean') {
if (this.host.narrow !== value) this.host.narrow = value;
return;
}
}
}
private loadLocal(): void {
const key = this.preferenceKey();
if (!key || key === this.storageKey) return;
let raw: unknown = null;
let legacy: unknown = null;
let storageAvailable = true;
try { raw = JSON.parse(localStorage.getItem(key) || 'null'); } catch { storageAvailable = false; }
try { legacy = JSON.parse(localStorage.getItem(SUMMARY_PANEL_LEGACY_SCALE_KEY) || 'null'); } catch { storageAvailable = false; }
this.storageKey = key;
this.storageUnavailable = !storageAvailable;
this.local = parseSummaryLocal(raw, legacy);
this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };
}
private saveLocal(patch: Partial<SummaryPanelLocalPreferences>): void {
this.local = {
...this.local, ...patch, version: 1,
icon_scale: normalizeSummaryScale(patch.icon_scale ?? this.local.icon_scale),
font_scale: normalizeSummaryScale(patch.font_scale ?? this.local.font_scale),
};
this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };
this.storageKey ||= this.preferenceKey();
let stored = false;
try {
if (this.storageKey) {
localStorage.setItem(this.storageKey, JSON.stringify(this.local));
stored = true;
}
} catch { /* local preferences remain effective for this session */ }
this.storageUnavailable = !stored;
if (!stored) this.host._showToast?.(this.t('summary.storage_unavailable'));
this.host.requestUpdate();
}
private config(): { config: SummaryPanelConfig | null; derived: boolean; unsupported: boolean } {
return summaryPanelOf(this.host._settings, this.translate);
}
private async openDialog(): Promise<void> {
const generation = this.lifecycleGeneration;
this.loadLocal();
try {
this.editorLoad ||= import('./summary-panel-editor').then((module) => module.renderSummaryPanelEditor);
this.editorRenderer = await this.editorLoad;
} catch (error) {
this.editorLoad = null;
if (!this.current(generation)) return;
this.host._showToast?.(`${this.t('summary.load_failed')} ${this.host._errText(error)}`);
return;
}
if (!this.current(generation)) return;
const resolved = this.config();
const base = resolved.config || defaultSummaryPanel(this.translate);
const backendUnsupported = this.host._haSummaryPanelApi !== SUMMARY_PANEL_API_VERSION;
const localOnlyHint = resolved.unsupported ? 'summary.unsupported_schema'
: backendUnsupported && this.host._canManageConfiguration
? 'summary.backend_required' : 'summary.local_only';
this.dialog = {
draft: cloneSummaryPanel(base), base: cloneSummaryPanel(base),
baseRevision: this.host._cfgRev,
localShow: this.local.show, baseLocalShow: this.local.show,
localOnly: this.host._kiosk || !this.host._canManageConfiguration
|| backendUnsupported || resolved.unsupported,
localOnlyHint,
busy: false, attempted: false, entityFilter: '', activeSource: null,
error: '', conflict: false,
};
if (!this.dialog.localOnly) this.refreshEntityIndex();
this.host.requestUpdate();
}
private problems(dialog = this.dialog): SummaryDraftProblem[] {
if (!dialog || dialog.localOnly) return [];
return validateSummaryDraft(
normalizeSummaryDraft(dialog.draft), dialog.base,
new Set(this.host._model.map((space) => space.id)),
new Set(Object.keys(this.host.hass?.states || {})),
);
}
private mutate(mutate: (draft: SummaryPanelConfig) => void): void {
if (!this.dialog || this.dialog.busy || this.dialog.localOnly) return;
const draft = cloneSummaryPanel(this.dialog.draft);
mutate(draft);
const owner = this.dialog.activeSource;
const ownerExists = !owner || draft.blocks.some((block) => block.id === owner.blockId
&& block.values.some((value) => value.id === owner.valueId));
this.dialog = {
...this.dialog, draft, error: '', conflict: false,
activeSource: ownerExists ? owner : null,
entityFilter: ownerExists ? this.dialog.entityFilter : '',
};
this.host.requestUpdate();
}
private async deleteBlock(index: number): Promise<void> {
const block = this.dialog?.draft.blocks[index];
if (!block) return;
const generation = this.lifecycleGeneration;
if (block.values.length) {
const accepted = await this.host._confirmDanger({
key: 'summary-block', kind: 'warning',
title: this.t('summary.delete_block_title'), message: this.t('summary.delete_block_body'),
objectName: block.title, confirmLabel: this.t('btn.delete'), cancelLabel: this.t('btn.cancel'),
});
if (!accepted || !this.current(generation)) return;
}
this.mutate((draft) => {
const current = draft.blocks.findIndex((candidate) => candidate.id === block.id);
if (current >= 0) draft.blocks.splice(current, 1);
});
}
private sourceToken(source: SummaryPanelSource): string {
return source.type === 'system' ? `system:${source.key}` : `entity:${source.entity_id}`;
}
private openSource(blockId: string, valueId: string): void {
if (!this.dialog || this.dialog.busy || this.dialog.localOnly) return;
this.refreshEntityIndex();
this.dialog = { ...this.dialog, activeSource: { blockId, valueId }, entityFilter: '' };
this.host.requestUpdate();
void this.host.updateComplete.then(() => {
if (this.dialog?.activeSource?.blockId === blockId
&& this.dialog.activeSource.valueId === valueId) {
(this.host.renderRoot.querySelector('[data-summary-picker-search]') as HTMLElement | null)?.focus();
}
});
}
private closeSource(returnFocus = false): void {
const owner = this.dialog?.activeSource;
if (!this.dialog || !owner) return;
this.dialog = { ...this.dialog, activeSource: null, entityFilter: '' };
this.host.requestUpdate();
if (returnFocus) void this.focusSource(owner.blockId, owner.valueId);
}
private async focusSource(blockId: string, valueId: string): Promise<void> {
await this.host.updateComplete;
const token = `${blockId}\n${valueId}`;
const buttons = this.host.renderRoot.querySelectorAll<HTMLElement>('[data-summary-source-owner]');
[...buttons].find((button) => button.dataset.summarySourceOwner === token)?.focus();
}
private setSource(blockId: string, valueId: string, token: string): void {
this.mutate((draft) => {
const value = draft.blocks.find((block) => block.id === blockId)
?.values.find((candidate) => candidate.id === valueId);
if (!value) return;
if (token.startsWith('system:')) {
const key = token.slice(7);
if (isSummarySystemKey(key)) value.source = { type: 'system', key };
} else value.source = { type: 'entity', entity_id: token.startsWith('entity:') ? token.slice(7) : token };
});
if (this.dialog) this.dialog = { ...this.dialog, activeSource: null, entityFilter: '' };
this.host.requestUpdate();
void this.focusSource(blockId, valueId);
}
private dragStart(event: DragEvent, token: string): void {
event.dataTransfer?.setData('text/x-houseplan-summary', token);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
private drop(event: DragEvent, target: string): void {
event.preventDefault();
event.stopPropagation();
const source = event.dataTransfer?.getData('text/x-houseplan-summary') || '';
const from = source.split(':').map(Number);
const to = target.split(':').map(Number);
if (source.startsWith('block:') && target.startsWith('block:')) {
this.mutate((draft) => { draft.blocks = moveSummaryItem(draft.blocks, from[1], to[1]); });
} else if (source.startsWith('value:') && target.startsWith('value:') && from[1] === to[1]) {
this.mutate((draft) => {
draft.blocks[from[1]].values = moveSummaryItem(draft.blocks[from[1]].values, from[2], to[2]);
});
}
}
private async saveDialog(): Promise<void> {
const dialog = this.dialog;
if (!dialog || dialog.busy) return;
const generation = this.lifecycleGeneration;
const draft = normalizeSummaryDraft(dialog.draft);
const problems = dialog.localOnly ? [] : validateSummaryDraft(
draft, dialog.base, new Set(this.host._model.map((space) => space.id)),
new Set(Object.keys(this.host.hass?.states || {})),
);
if (problems.some((problem) => problem.kind === 'error')) {
this.dialog = { ...dialog, draft, attempted: true };
this.host.requestUpdate();
await this.host.updateComplete;
(this.host.renderRoot.querySelector('[data-summary-error="true"]') as HTMLElement | null)?.focus?.();
return;
}
this.dialog = { ...dialog, draft, busy: true, attempted: false, error: '', conflict: false };
this.host.requestUpdate();
try {
if (!dialog.localOnly && !sameSummaryPanel(draft, dialog.base)) {
this.host._writesPending++;
const write = enqueueSerializedWrite(this.host._writeChain, async () => {
if (!this.current(generation)) return;
if (!this.host._serverCfg) throw new Error(this.t('summary.save_failed'));
if (this.host._cfgRev !== dialog.baseRevision) throw new Error(this.t('summary.conflict'));
const candidate = canonicalizeConfigGeometry({
...this.host._serverCfg,
settings: { ...(this.host._serverCfg.settings || {}), summary_panel: draft },
}) as ServerConfig;
let recovered = false;
try {
await this.host._sendConfigCandidate(candidate);
if (!this.current(generation)) return;
} catch (writeError) {
if (!this.current(generation)) throw writeError;
try {
const authoritative = await this.host._getAuthoritativeConfig();
if (!this.current(generation)) throw writeError;
const confirmed = confirmedSummaryPanelWriteRecovery(authoritative, draft);
if (!confirmed) throw writeError;
// #500: the same gated sequence as every other authoritative
// adoption — backdrop readiness, continuity candidate, adopt,
// reload tail. A bounded asset failure keeps the write error.
const adopted = await this.host._adoptAuthoritative({
cfgResp: authoritative, reason: 'summary-recovery', profile: 'reload',
});
if (adopted.status !== 'adopted') throw writeError;
if (adopted.spaceChanged) this.host._restoreZoom();
this.host._regSignature = '';
this.host._maybeRebuildDevices();
recovered = true;
} catch { throw writeError; }
}
if (!recovered) {
if (!this.current(generation)) return;
this.host._adoption.stageConfigCandidate(candidate);
this.host._cacheSnapshot();
}
});
this.host._writeChain = write;
await write.finally(() => { this.host._writesPending--; });
}
if (!this.current(generation)) return;
this.saveLocal({ show: dialog.localShow });
this.dialog = null;
} catch (error) {
if (!this.current(generation)) return;
const conflict = (error as { code?: unknown } | null)?.code === 'conflict'
|| this.host._cfgRev !== dialog.baseRevision;
this.dialog = {
...(this.dialog || dialog), busy: false, conflict,
error: conflict ? this.t('summary.conflict') : this.host._errText(error),
};
}
if (this.current(generation)) this.host.requestUpdate();
}
private async reloadDialog(): Promise<void> {
const dialog = this.dialog;
if (!dialog || dialog.busy) return;
const generation = this.lifecycleGeneration;
this.dialog = { ...dialog, busy: true, error: '', conflict: false };
this.host.requestUpdate();
try {
await this.host._reloadConfigOnly(true);
if (!this.current(generation)) return;
if (dialog.conflict && this.host._cfgRev === dialog.baseRevision) {
throw new Error(this.t('summary.load_failed'));
}
const resolved = this.config();
const base = resolved.config || defaultSummaryPanel(this.translate);
this.dialog = {
...dialog, draft: cloneSummaryPanel(base), base: cloneSummaryPanel(base),
baseRevision: this.host._cfgRev, busy: false, attempted: false,
error: '', conflict: false,
};
} catch (error) {
if (!this.current(generation)) return;
this.dialog = { ...dialog, busy: false, error: this.host._errText(error), conflict: true };
}
if (this.current(generation)) this.host.requestUpdate();
}
/** Свежи ли мемо-агрегаты для текущей конфигурации и реестра. */
/** @internal — читается рендером и тестами (#509). */
public metricsFresh(): boolean {
return !!this.deviceMemo && !!this.areaMemo
&& this.deviceMemo.cfgEpoch === this.host._cfgEpoch
&& this.deviceMemo.layoutRev === this.host._layoutRev
&& this.deviceMemo.registryRev === this.host._haRegistry.revision
&& this.areaMemo.cfgEpoch === this.host._cfgEpoch;
}
/**
* Пересчёт агрегатов ПОСЛЕ кадра (#509).
*
* Обход реестра и объединение чистого пола по всем пространствам стоят
* сотни миллисекунд на большом плане; в render это была заморозка первого
* показа. Кадр рисуется скелетами, работа идёт следом, и по её завершении
* панель перерисовывается уже числами. Устаревшее мемо при этом остаётся
* на экране до прихода нового — прыжка «значение → скелет → значение» нет.
*/
private scheduleMetrics(): void {
if (this.metricsRefresh || !this.metricsModule || this.metricsFresh()) return;
const generation = this.lifecycleGeneration;
const view = typeof window === 'undefined' ? undefined : window;
const run = () => {
this.metricsRefresh = 0;
if (!this.current(generation)) return;
// Порциями по комнате с бюджетом на кадр: полный обход большого дома —
// полторы секунды непрерывной работы, и вне кадра он всё равно
// «подвешивал» бы интерфейс (#509). Каждая порция короче кадра, между
// порциями браузер обрабатывает ввод и рисует.
const done = this.advanceMetrics(SUMMARY_METRICS_FRAME_BUDGET_MS);
this.host.requestUpdate();
if (!done) this.scheduleMetrics();
};
// Кадр сначала должен быть показан: rAF отдаёт нам момент ПОСЛЕ отрисовки,
// таймер — фолбэк для окружений без rAF (тесты, скрытая вкладка).
this.metricsRefresh = view?.setTimeout(() => {
if (typeof view.requestAnimationFrame === 'function') {
view.requestAnimationFrame(() => view.setTimeout(run, 0));
} else run();
}, 0) as unknown as number || 0;
}
/** @internal — фоновый пересчёт агрегатов; тесты вызывают напрямую (#509). */
/**
* Один шаг фонового расчёта: работает не дольше бюджета, возвращает `true`,
* когда считать больше нечего (#509).
*/
public advanceMetrics(budgetMs = SUMMARY_METRICS_FRAME_BUDGET_MS): boolean {
const module = this.metricsModule;
if (!module) return true;
this.computeDeviceCount();
if (!this.host._serverCfg) {
this.areaMemo = { cfgEpoch: this.host._cfgEpoch, value: null };
return true;
}
if (this.areaMemo && this.areaMemo.cfgEpoch === this.host._cfgEpoch) return true;
if (!this.areaSteps || this.areaStepsEpoch !== this.host._cfgEpoch) {
this.areaSteps = module.cleanFloorAreaSteps(this.host._serverCfg, this.host._model);
this.areaStepsEpoch = this.host._cfgEpoch;
}
const started = Date.now();
let step = this.areaSteps.next();
while (!step.done && Date.now() - started < budgetMs) step = this.areaSteps.next();
if (!step.done) return false;
this.areaMemo = { cfgEpoch: this.areaStepsEpoch, value: step.value ?? null };
this.areaSteps = null;
return true;
}
private computeDeviceCount(): void {
const module = this.metricsModule;
if (!module) return;
if (!this.deviceMemo || this.deviceMemo.cfgEpoch !== this.host._cfgEpoch
|| this.deviceMemo.layoutRev !== this.host._layoutRev
|| this.deviceMemo.registryRev !== this.host._haRegistry.revision) {
const represented = module.representedHaDeviceIds({
registry: this.host._haRegistry,
areaToSpace: Object.fromEntries(Object.entries(this.host._areaToSpace).map(
([area, value]) => [area, value.space],
)),
spaceIds: new Set(this.host._model.map((space) => space.id)),
firstSpaceId: this.host._model[0]?.id || '', markers: this.host._markers,
});
this.deviceMemo = {
cfgEpoch: this.host._cfgEpoch, layoutRev: this.host._layoutRev,
registryRev: this.host._haRegistry.revision, value: represented?.size ?? null,
};
}
}
/** Синхронный расчёт целиком — тесты и окружения без кадров (#509). */
public computeMetrics(): void {
while (!this.advanceMetrics(Number.POSITIVE_INFINITY)) { /* до конца */ }
}
private metrics(): { deviceCount: number | null; areaM2: number | null; now: Date } {
return {
deviceCount: this.deviceMemo ? this.deviceMemo.value : null,
areaM2: this.areaMemo ? this.areaMemo.value : null,
now: this.clock,
};
}
/**
* Состояние одного значения панели (#509).
*
* `pending` — значение ещё не посчитано: ленивый чанк метрик не пришёл либо
* агрегат считается после кадра. Это НЕ «источник недоступен»: до правки оба
* состояния рисовались текстом `summary.unavailable`, и первый показ панели
* выглядел как ошибка всех источников сразу.
*/
public valueState(value: SummaryPanelValue): { kind: 'ready'; text: string } | { kind: 'pending' } | { kind: 'unavailable' } {
const module = this.metricsModule;
if (!module) return { kind: 'pending' };
if (value.source.type === 'entity') {
const resolved = module.summaryEntityValue(this.host.hass, value.source.entity_id);
return resolved === null ? { kind: 'unavailable' } : { kind: 'ready', text: resolved };
}
const needsAggregate = value.source.key === 'device_count' || value.source.key === 'total_area';
const known = value.source.key === 'device_count' ? this.deviceMemo : this.areaMemo;
if (needsAggregate && !known) {
this.scheduleMetrics();
return { kind: 'pending' };
}
if (needsAggregate) this.scheduleMetrics();
const resolved = module.summarySystemValue(
value.source, this.metrics(), this.host.hass,
langOf(this.host.hass, this.host._config?.language),
);
return resolved === null ? { kind: 'unavailable' } : { kind: 'ready', text: resolved };
}
private ensureMetrics(): void {
if (this.metricsModule || this.metricsLoad) return;
const generation = this.lifecycleGeneration;
this.metricsLoad = import('./summary-panel-metrics');
void this.metricsLoad.then((module) => {
this.metricsModule = module;
if (this.current(generation)) this.host.requestUpdate();
}).catch(() => undefined).finally(() => { this.metricsLoad = null; });
}
/** Render-time layout from the last measurement: no style or layout read (#725). */
private layout() {
return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 });
}
private syncPresentation(immediate = false): void {
const config = this.config().config;
const layout = this.layout();
const eligible = !!config && this.host._mode === 'view' && layout.fits
&& effectiveSummaryVisible({
view: true, localShow: true, showOnMobile: config.show_on_mobile,
narrow: this.host.narrow, fits: true,
});
this.presentation.sync(eligible && this.local.show, layout.side,
immediate || !eligible || this.host.ownerDocument.visibilityState === 'hidden');
}
/**
* #725: the card calls `updated()` after every render — a floor switch, an HA
* tick — and measuring there forced a synchronous layout each time. Measure
* only when an input of the measurement changed: the probe and kiosk-button
* elements, the stage element, the title, language, mode, kiosk, its scale,
* `narrow` and the HA theme. The stage size arrives through `resized()`.
* Whatever is not listed and might matter resets `measured` instead.
*/
private measureIfInputsChanged(): void {
const { hass, renderRoot, _stageEl, _config, _mode, _kiosk, _kioskScale, narrow } = this.host;
const elements = [_stageEl, ...['.summary-measure', '.summary-safe-probe', '.summary-control.kiosk']
.map((selector) => renderRoot.querySelector(selector))];
const themes = hass?.themes as Record<string, unknown> | undefined;
const key = JSON.stringify([
this.config().config?.title ?? null, langOf(hass, _config?.language), _mode, _kiosk, _kioskScale, narrow,
hass?.selectedTheme ?? null,
...['theme', 'darkMode', 'default_theme', 'default_dark_theme'].map((name) => themes?.[name] ?? null),
]);
const last = this.measured;
if (last && last.key === key && last.elements.every((element, index) => element === elements[index])) return;
this.measured = { key, elements };
this.measureLayout();
}
/** #725: a late web font changes the probe height without resizing the stage. */
private measureAfterFonts(): void {
const generation = this.lifecycleGeneration;
void this.host.ownerDocument.fonts?.ready.then(() => { if (this.current(generation)) this.measureLayout(); });
}
/**
* The one method that reads style and layout (#725; `scripts/render-layout-read.mjs`
* names it): the stage, probe and kiosk-button boxes and the safe-area insets.
* Render paths use what it stored.
*/
private measureLayout(): void {
const stage = this.host._stageEl;
if (!stage) return;
const root = this.host.renderRoot;
const probe = root.querySelector('.summary-measure') as HTMLElement | null;
const controls = root.querySelector('.summary-control.kiosk') as HTMLElement | null;
const safeProbe = root.querySelector('.summary-safe-probe') as HTMLElement | null;
const stageBox = stage.getBoundingClientRect();
const controlBox = controls?.getBoundingClientRect();
const style = safeProbe ? this.host.ownerDocument.defaultView?.getComputedStyle(safeProbe) : undefined;
const px = (value: string | undefined): number => Number.parseFloat(value ?? '') || 0;
const next: SummaryLayoutState = {
width: Math.round(stage.clientWidth), height: Math.round(stage.clientHeight),
minimumHeight: Math.max(162, Math.ceil(probe?.getBoundingClientRect().height || 0)),
controlTop: controlBox ? Math.max(0, Math.ceil(controlBox.bottom - stageBox.top + 12)) : 0,
safeLeft: px(style?.paddingLeft), safeRight: px(style?.paddingRight),
safeTop: px(style?.paddingTop), safeBottom: px(style?.paddingBottom),
};
if ((Object.keys(next) as (keyof SummaryLayoutState)[]).some((name) => next[name] !== this.stage[name])) {
this.stage = next;
this.host.requestUpdate();
}
}
private hasVisibleClock(): boolean {
const config = this.config().config;
if (this.host.ownerDocument.visibilityState === 'hidden' || !config) return false;
if (!effectiveSummaryVisible({
view: this.host._mode === 'view', localShow: this.local.show,
showOnMobile: config.show_on_mobile, narrow: this.host.narrow, fits: this.layout().fits,
})) return false;
return visibleSummaryBlocks(config, this.host._space).some((block) => block.values.some(
(value) => value.source.type === 'system' && value.source.key === 'datetime',
));
}
private syncClock(): void {
const context = `${langOf(this.host.hass, this.host._config?.language)}\n${
this.host.hass?.config?.time_zone || ''}`;
if (context !== this.clockContext) {
this.clockContext = context;
this.clock = new Date();
}
const needed = this.hasVisibleClock();
if (needed && !this.clockTimer) {
this.clock = new Date();
const tick = () => {
this.clock = new Date();
this.clockTimer = 0;
this.host.requestUpdate();
this.syncClock();
};
this.clockTimer = window.setTimeout(tick, 60_000 - Date.now() % 60_000 + 25);
} else if (!needed && this.clockTimer) {
clearTimeout(this.clockTimer);
this.clockTimer = 0;
}
}
}