refactor(editor): диалог «Оптимизировать планы» — свой модуль с узким портом (#642)

Первый вынос из монолита по образцу live-*/RadarSetupController.
src/optimize-plans-dialog.ts: OptimizePlansDialogState, чистые
preflightDiagnostics/preflightVersionsDiffer и класс OptimizePlansDialog
(open/preview/toggleLivePositions/run/copyDiagnostics/
reportPreflightFailure/render, clipboardFallback) за портом из 18 членов.
Модуль не знает HouseplanEditorHostPort; импортирует его только рантайм,
карточка берёт тип.

Инлайн-фолбэк буфера обмена — WeakMap по объекту диалога
(CODE-REVIEW-295-r1 M2), дедуп dev-лога — поле класса: из карточки и порта
ушли _preflightClipboardFallback и _reportedPreflightFingerprint, пять
делегатов и две стрелки-заглушки. Харнесс (13 смоков, wall-draw-click,
golden) зовёт card._editorRuntime.optimizePlans.* — меняется только адрес.

11 текстовых утверждений i18n.test.mjs о разметке и тосте диалога и гварды
8 мутантов переехали в юнит test/optimize-plans-dialog.test.mjs
(test-build); новый мутант optimize-dialog-imports-host-port.

monolith-baseline: delegates 159→154, portMembers 350→348,
hostRefs 4948→4869, portPrivates 96→94, harnessPrivates 107→101,
bundleBytes 2499647→2499182. Потолки ядер опущены на выигрыш;
потолок lazy editor gzip пересчитан (+555 Б gzip при −465 Б сырых).

Issue: #642
User-Visible: no
This commit is contained in:
Claude
2026-09-24 08:00:58 +00:00
committed by claude[bot]
parent 79ee16546d
commit 66ae952c2a
70 changed files with 1833 additions and 1208 deletions
+1 -1
View File
@@ -245,7 +245,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
</div>` : nothing}
${subsection({ title: t('gs.grid_group'), help: this._help('gs.grid_group.help') })}
<div class="hpf-actions">
<button class="btn" @click=${() => this._openAlignDialog()}>
<button class="btn" @click=${() => this.optimizePlans.open()}>
<ha-icon icon="mdi:broom"></ha-icon>${t('gs.align_all')}
</button>
${host._canOptimizeUndo && host._undoKind !== 'import' ? html`
+4 -66
View File
@@ -12,6 +12,7 @@ import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
import type { OptimizePlansDialogState } from './optimize-plans-dialog';
import './hp-confirm';
import {
HpConfirmController,
@@ -137,7 +138,6 @@ import {
iconUnit, iconCqw, itemOf, snapPt, MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, GRID_PITCH, GRID_STEP_N,
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { type OptimizeReport } from './plan-optimizer';
import { resolveZeroWalls, zeroWallStyleOf } from './zero-walls';
import {
geometryOpenings, geometryPartitionOpeningCuts, geometryRoomOpeningInputs,
@@ -2003,9 +2003,6 @@ export class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
/** #295: diagnostics text shown inline when the clipboard is unavailable. */
private _preflightClipboardFallback: string | null = null;
/** #295: integration version from houseplan/config/get; null on old backends. */
private _haIntegrationVersion: string | null = null;
/** #462: eager full-card recovery; the static space card never owns one. */
@@ -2026,18 +2023,7 @@ export class HouseplanCard extends LitElement {
private _haRadarStage1Api: number | null = null;
private readonly _radarLive = new RadarLiveController(this);
private _decorAssetSyncToken = 0;
private _alignDialog: {
report: OptimizeReport; config: any; layout: Record<string, any>;
preflight: OptimizeGeometryPreflightResult | null;
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
cm: number;
/** the space that maximum belongs to, named only when there are several */
where: string;
changed: boolean;
busy: boolean;
/** False by default; true only after the secondary preview action. */
removeLiveMissingPositions: boolean;
} | null = null;
private _alignDialog: OptimizePlansDialogState | null = null;
private _settingsDialog: {
colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null;
@@ -2500,7 +2486,6 @@ export class HouseplanCard extends LitElement {
_pdfDialog: { state: true },
_supportDialog: { state: true },
_alignDialog: { state: true },
_preflightClipboardFallback: { state: true },
_backupExportDialog: { state: true },
_backupImportDialog: { state: true },
_importDialog: { state: true },
@@ -2789,7 +2774,7 @@ export class HouseplanCard extends LitElement {
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._closeInfoCard(); return; }
if (this._rulesDialog) { this._rulesDialog = null; return; }
if (this._alignDialog) { this._alignDialog = null; this._preflightClipboardFallback = null; return; }
if (this._alignDialog) { this._alignDialog = null; return; }
if (this._backupImportDialog) { this._backupImportDialog = null; return; }
if (this._backupExportDialog) { this._backupExportDialog = null; return; }
if (this._pdfDialog) { this._pdfDialog = false; return; }
@@ -7393,7 +7378,6 @@ export class HouseplanCard extends LitElement {
this._closeInfoCard();
this._rulesDialog = null;
this._alignDialog = null;
this._preflightClipboardFallback = null;
this._backupImportDialog = null;
this._backupExportDialog = null;
this._pdfDialog = false;
@@ -10197,24 +10181,6 @@ export class HouseplanCard extends LitElement {
});
}
/**
* Preview whole-plan maintenance. Nothing is written here: the pure run
* produces both the report and the exact config/layout pair to commit.
*/
/** #295: dev-log once per distinct failing preflight, not once per render. */
private _reportedPreflightFingerprint: string | null = null;
private _reportPreflightFailure(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
): void {
return this._editorRuntimeOrThrow()._reportPreflightFailure(preflight, candidate);
}
private async _copyPreflightDiagnostics(): Promise<void> {
return this._editorRuntimeOrThrow()._copyPreflightDiagnostics();
}
private _checkOptimizeGeometry(config: ServerConfig): OptimizeGeometryPreflightResult {
return this._editorRuntimeOrThrow()._checkOptimizeGeometryImpl(config);
}
@@ -10230,26 +10196,6 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._checkSpacePhysicalGeometryImpl(config, spaceId, captureWallGeometry);
}
private _previewAlignDialog(removeLiveMissingPositions: boolean): void {
return this._editorRuntimeOrThrow()._previewAlignDialog(removeLiveMissingPositions);
}
private _openAlignDialog = (): void => {
return this._editorRuntimeOrThrow()._openAlignDialog();
}
private _toggleOptimizeLivePositions = (): void => {
return this._editorRuntimeOrThrow()._toggleOptimizeLivePositions();
}
/**
* The backend persists an intent before either store changes, then keeps a
* one-deep snapshot that remains undoable until the next plan edit.
*/
private async _runAlignToGrid(): Promise<void> {
return this._editorRuntimeOrThrow()._runAlignToGrid();
}
/** Prevent a double click from sending two restores of the same snapshot. */
private _optimizeUndoBusy = false;
@@ -10533,14 +10479,6 @@ export class HouseplanCard extends LitElement {
});
}
/**
* The confirmation separates geometry movement from lossless maintenance,
* and promises the one-deep undo before either store is changed.
*/
private _renderAlignDialog(): TemplateResult {
return this._editorRuntimeOrThrow()._renderAlignDialog();
}
private _renderSettingsDialog(): TemplateResult {
return this._editorRuntimeOrThrow()._renderSettingsDialog();
}
@@ -11305,7 +11243,7 @@ export class HouseplanCard extends LitElement {
${this._settingsDialog ? this._editorRuntime ? this._renderSettingsDialog() : nothing : nothing}
${this._supportDialog ? this._editorRuntime ? this._renderSupportDialog() : nothing : nothing}
${this._pdfDialog ? this._renderPdfDialog() : nothing}
${this._alignDialog ? this._editorRuntime ? this._renderAlignDialog() : nothing : nothing}
${this._alignDialog && this._editorRuntime ? this._editorRuntime.optimizePlans.render() : nothing}
${this._backupExportDialog ? this._editorRuntime ? this._renderBackupExportDialog() : nothing : nothing}
${this._backupImportDialog ? this._editorRuntime ? this._renderBackupImportDialog() : nothing : nothing}
${this._importDialog ? this._onboardingRuntime ? this._renderImportDialog() : nothing : nothing}
+26 -388
View File
@@ -148,6 +148,7 @@ import {
import { finishMarkerDialogClose } from './marker-dialog-close';
import { renderRadarSection } from './editors/radar-section';
import { radarDiscardRequest, RadarSetupController } from './radar-setup';
import { OptimizePlansDialog, type OptimizePlansDialogState } from './optimize-plans-dialog';
import {
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody, floorMinusBodies, geometryArea,
partitionBody, pointInPhysicalBody, sameColumnPlacement, type PartitionOpeningCut,
@@ -182,7 +183,6 @@ import {
spaceModels, iconCqw, gridLevels, CANVAS_LIMIT, SANE_LIMIT, GRID_PITCH, GRID_STEP_N,
PLAN_SCALE_MIN, PLAN_SCALE_MAX, clampCanvasR, clampCanvasN, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
WALL_SEGMENT_MODEL_VERSION,
adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel,
@@ -199,7 +199,7 @@ import {
type OptimizeGeometryPreflightResult,
} from './plan-geometry-preflight';
import {
canonicalizeConfigGeometry, canonicalizePosition, formatLatticeShiftCm,
canonicalizeConfigGeometry, canonicalizePosition,
} from './coordinate-canonicalization';
import { enqueueSerializedWrite, type OptimisticAttempt } from './serialized-write-queue';
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
@@ -440,7 +440,7 @@ export interface HouseplanEditorHostPort {
_adoptAuthoritative: (input: GatedAdoptionInput) => Promise<GatedAdoptionResult>; // #500: the one adoption entry
_rollbackOptimistic: (attempt: OptimisticAttempt<ServerConfig>) => boolean;
readonly _adoption: ConfigAdoption; // #500: identity owner; _serverCfg/_cfgRev/_layout/_layoutRev delegate to it
_alignDialog: { report: OptimizeReport; config: any; layout: Record<string, any>; preflight: OptimizeGeometryPreflightResult | null; cm: number; where: string; changed: boolean; busy: boolean; removeLiveMissingPositions: boolean; } | null;
_alignDialog: OptimizePlansDialogState | null;
_alignPoint: number[] | null;
_allRoomsFlat: () => { value: string; label: string; }[];
_angleField: (value: unknown) => string;
@@ -668,7 +668,6 @@ export interface HouseplanEditorHostPort {
_pointerModality: PointerModalityController;
_pointers: Map<number, { x: number; y: number; }>;
_pos: (d: DevItem) => { x: number; y: number; };
_preflightClipboardFallback: string | null;
_prepareModeTransition: (request: number, from: ModeVisualState, targetMode: HouseplanMode, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void;
_reducedMotion: boolean;
_rawPhysicalBodiesR: () => number[][][];
@@ -678,7 +677,6 @@ export interface HouseplanEditorHostPort {
_renderCardPreview: (spaceScale: number, nameScale: number, labelScale: number) => TemplateResult;
_renderCompass: () => TemplateResult;
_renderPlanHass: any;
_reportedPreflightFingerprint: string | null;
_resign: () => void;
_resize: ResizeController<
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
@@ -810,6 +808,8 @@ export class HouseplanEditorRuntime {
// к контроллеру оттуда. Остальной класс использует `public _x` — поле
// следует той же договорённости, а не заводит обёртку ради одного слова.
public readonly _radarSetup: RadarSetupController;
/** #642: «Оптимизировать планы» — свой модуль с узким портом, не делегаты карточки. */
public readonly optimizePlans: OptimizePlansDialog;
public constructor(public readonly host: HouseplanEditorHostPort) {
this._decorImages = new DecorImageEditor(host, {
decorSnap: (raw, pointerType) => this._decorSnap(raw, pointerType),
@@ -836,6 +836,26 @@ export class HouseplanEditorRuntime {
},
confirmDiscard: () => host._confirmDanger(radarDiscardRequest((key) => host._t(key))),
});
this.optimizePlans = new OptimizePlansDialog({
dialog: () => host._alignDialog,
setDialog: (next) => { host._alignDialog = next; },
t: (key, vars) => host._t(key, vars),
hass: () => host.hass,
config: () => host._serverCfg,
planReady: () => !!host._norm,
layout: () => host._layout,
integrationVersion: () => host._haIntegrationVersion,
cardVersion: () => displayVersion(CARD_VERSION),
requestUpdate: () => host.requestUpdate(),
showToast: (message) => host._showToast(message),
errorText: (error) => host._errText(error),
checkGeometry: (config) => host._checkOptimizeGeometry(config),
referenceContext: (removeLive) => this._optimizeReferenceContext(removeLive),
showMigrationBlocked: (error) => this._showWallModelMigrationBlocked(error),
clearGeometryGesture: () => this._clearGeometryGesture(),
commit: (config, layout) => commitPlanOptimization(host, config, layout),
reloadAfterConflict: () => Promise.all([host._reloadConfigOnly(true), host._reloadLayoutOnly()]),
});
host._editorSecondary = new EditorSecondaryController({
root: () => host.renderRoot as ShadowRoot,
requestUpdate: () => host.requestUpdate(),
@@ -8334,7 +8354,7 @@ public _saveSpaceCopy(): Promise<void> {
return saveSpaceCopy(this.host, {
clearGeometryGesture: () => this._clearGeometryGesture(),
optimizeReferenceContext: () => this._optimizeReferenceContext(false),
reportPreflightFailure: (result, config) => this._reportPreflightFailure(result, config),
reportPreflightFailure: (result, config) => this.optimizePlans.reportPreflightFailure(result, config),
saveConfigNow: (attempt) => this._saveConfigNow(attempt),
setMode: () => this._setMode('plan'),
showWallModelMigrationBlocked: (error) => this._showWallModelMigrationBlocked(error),
@@ -8931,67 +8951,6 @@ public _renderSupportDialog(): TemplateResult {
</hp-dialog>`;
}
public _preflightDiagnostics(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
): object {
// CODE-REVIEW-295-r1 M1: hash the CANDIDATE spaces the preflight judged,
// not the already-saved config — a saved-config hash is exactly what a
// space export would reproduce, and the block promises what the export
// does not carry.
const spacesById = new Map(((candidate as any)?.spaces || [])
.map((space) => [String(space?.id || ''), space]));
return {
kind: 'houseplan-optimize-preflight',
origin: 'runtime',
cardVersion: displayVersion(CARD_VERSION),
checkedAt: new Date().toISOString(),
preflightFingerprint: preflight.fingerprint,
failures: preflight.failures.map((failure) => ({
spaceId: failure.spaceId,
displayName: failure.displayName,
reason: failure.reason,
detail: failure.detail ?? null,
spaceGeometryFingerprint: spacesById.has(failure.spaceId)
? spacePhysicalGeometryFingerprint(spacesById.get(failure.spaceId))
: null,
})),
};
}
public _reportPreflightFailure(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
): void {
if (preflight.ok || preflight.fingerprint === this.host._reportedPreflightFingerprint) return;
this.host._reportedPreflightFingerprint = preflight.fingerprint;
// eslint-disable-next-line no-console
console.warn('[houseplan] optimize preflight failed', this._preflightDiagnostics(preflight, candidate));
}
public _preflightVersionsDiffer(): boolean {
const integration = this.host._haIntegrationVersion;
return typeof integration === 'string' && integration.length > 0
&& integration !== displayVersion(CARD_VERSION);
}
public async _copyPreflightDiagnostics(): Promise<void> {
const preflight = this.host._alignDialog?.preflight;
if (!preflight || preflight.ok) return;
const text = JSON.stringify(
this._preflightDiagnostics(preflight, this.host._alignDialog?.config ?? null), null, 2,
);
try {
await navigator.clipboard.writeText(text);
this.host._preflightClipboardFallback = null;
this.host._showToast(this.host._t('gs.preflight_copied'));
} catch {
// Insecure context / embedded webview: surface the block inline so the
// owner can select and copy it by hand.
this.host._preflightClipboardFallback = text;
}
}
public _checkOptimizeGeometryImpl(config: ServerConfig): OptimizeGeometryPreflightResult {
return checkOptimizeGeometry(config, {
fallbackSpaceName: (index) => this.host._t('gs.align_preflight_space', {
@@ -9075,90 +9034,6 @@ public _optimizeReferenceContext(
};
}
public _previewAlignDialog(removeLiveMissingPositions: boolean): void {
if (!this.host._norm || !this.host._serverCfg) return;
const spaces = this.host._serverCfg.spaces || [];
let r;
try {
r = optimizePlans(
this.host._serverCfg,
this.host._layout || {},
this._optimizeReferenceContext(removeLiveMissingPositions),
);
} catch (error) {
this._showWallModelMigrationBlocked(error);
return;
}
const preflight = r.changed ? this.host._checkOptimizeGeometry(r.config) : null;
if (preflight) this._reportPreflightFailure(preflight, r.config);
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
// measured every element in the centimetres of ITS OWN space — converting
// one normalised maximum through the first space's `cell_cm` understated
// a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is
// rounded UP, so the promise can never be smaller than the deed.
const cm = Math.ceil(r.report.maxShiftCm * 10) / 10;
const sp = spaces.find((x: any) => x?.id != null && String(x.id) === r.report.maxSpace);
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
// CODE-REVIEW-295-r1 M2: the inline clipboard fallback belongs to one
// dialog showing — a reopened dialog must not display the previous
// refusal's JSON while the visible reasons already describe a new one.
this.host._preflightClipboardFallback = null;
this.host._alignDialog = {
report: r.report, config: r.config, layout: r.layout, cm, where,
preflight, changed: r.changed, busy: false, removeLiveMissingPositions,
};
}
public _openAlignDialog = (): void => this._previewAlignDialog(false);
public _toggleOptimizeLivePositions = (): void => {
const dialog = this.host._alignDialog;
if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return;
this._previewAlignDialog(!dialog.removeLiveMissingPositions);
};
public async _runAlignToGrid(): Promise<void> {
let d = this.host._alignDialog;
if (!d || d.busy || !this.host._serverCfg || !d.changed || !d.preflight?.ok) return;
const fingerprint = contentFingerprint(d.config);
if (d.preflight.fingerprint !== fingerprint) {
const preflight = this.host._checkOptimizeGeometry(d.config);
this._reportPreflightFailure(preflight, d.config);
d = { ...d, preflight };
this.host._alignDialog = d;
if (!preflight.ok) return;
}
this._clearGeometryGesture();
this.host._alignDialog = { ...d, busy: true };
try {
await commitPlanOptimization(this.host, d.config, d.layout);
this.host._alignDialog = null;
this.host._preflightClipboardFallback = null;
this.host._showToast(this.host._t('gs.align_done', {
n: String(d.report.moved),
m: String(d.report.migrated + d.report.canonicalized
+ d.report.coordsCanonicalized + d.report.latticeCoordinatesCanonicalized
+ d.report.wallsMerged + d.report.spansMerged
+ d.report.partitionsMerged + d.report.partitionsReconciled
+ d.report.openingsRehosted + d.report.wallsStraightened),
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
+ d.report.positionsRemapped + d.report.markersDetached
+ d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved
+ d.report.orphanGroupPositionsRemoved),
}));
} catch (e: any) {
if (this.host._alignDialog) this.host._alignDialog = { ...this.host._alignDialog, busy: false };
if (e?.code === 'wall_model_client_outdated') {
this.host._showToast(this.host._t('toast.wall_model_client_outdated'));
return;
}
if (e?.code === 'conflict') {
await Promise.all([this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly()]);
}
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
}
}
public async _undoPlanOptimization(): Promise<void> {
if (!this.host._canOptimizeUndo || this.host._optimizeUndoBusy) return;
const undoKind = this.host._undoKind;
@@ -9689,243 +9564,6 @@ public _rangeInput(
?disabled=${disabled} aria-label=${ariaLabel || nothing} @input=${h} />`;
}
public _renderAlignDialog(): TemplateResult {
const d = this.host._alignDialog!;
const r = d.report;
const failed = d.changed && !d.preflight?.ok;
const failures = d.preflight?.failures || [];
const visibleNames = failures.slice(0, 3).map((failure) => failure.displayName);
const spaces = visibleNames.length
? visibleNames.join(', ')
: this.host._t('gs.align_preflight_space', { n: '1' });
const remaining = Math.max(0, failures.length - visibleNames.length);
const more = remaining
? this.host._t('gs.align_preflight_more', { n: String(remaining) })
: '';
const repaired = r.spaceRefsRemapped + r.roomRefsRemapped
+ r.positionsRemapped + r.markersDetached;
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
+ r.wallSegmentsMigrated
+ r.roomDraftsMigrated + r.roomDraftSegmentsMigrated
+ r.wallsMerged + r.spansMerged + r.partitionsMerged
+ r.partitionsReconciled + r.openingsRehosted;
const gridWarning = r.moved + r.rotated + r.coordsCanonicalized + r.wallsStraightened;
const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10;
const straightenSpace = (this.host._serverCfg?.spaces || []).find(
(space) => String(space?.id || '') === r.maxStraightenSpace,
);
const straightenWhere = (this.host._serverCfg?.spaces || []).length > 1 && straightenSpace
? String(straightenSpace.title || straightenSpace.id) : '';
const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved
+ r.orphanGroupPositionsRemoved;
const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean);
const visibleLiveNames = liveNames.slice(0, 3).join(', ');
const remainingLiveNames = Math.max(0, liveNames.length - 3);
const liveNamesText = visibleLiveNames
? this.host._t('gs.optimize_live_names', {
names: visibleLiveNames,
more: remainingLiveNames
? this.host._t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '',
})
: '';
const registryLimited = r.unverifiedPositions.some(
(item) => item.reason === 'registry_unavailable',
);
const detailStatus = (item: typeof r.removedPositions[number]): string => {
if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) {
return this.host._t('gs.optimize_detail_removed');
}
if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) {
return this.host._t('gs.optimize_detail_live');
}
return this.host._t('gs.optimize_detail_unverified');
};
const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this.host._t(
kind === 'room_label' ? 'gs.optimize_detail_room_label'
: kind === 'group' ? 'gs.optimize_detail_group'
: kind === 'device' ? 'gs.optimize_detail_device'
: 'gs.optimize_detail_unknown',
);
const referenceDetails = [
...r.removedPositions,
...r.liveMissingPositions.filter((item) => (
!r.removedPositions.some((removedItem) => removedItem.id === item.id)
)),
...r.unverifiedPositions,
];
const visibleDetails = referenceDetails.slice(0, 10);
const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length);
return html`<hp-dialog .hass=${this.host.hass} data-kind="settings" .title=${this.host._t('gs.align_title')} icon="mdi:broom"
dismiss-on-scrim @hp-close=${() => { this.host._alignDialog = null; this.host._preflightClipboardFallback = null; }}>
<div class="body">
${failed
? html`
<p class="alignmsg">${this.host._t('gs.align_preflight_failed', { spaces, more })}</p>
${failures.slice(0, 10).map((failure) => html`<p class="alignmsg">
${failure.displayName}: ${this.host._t(`gs.preflight_reason_${failure.reason}` as I18nKey)}
</p>`)}
${failures.length > 10 ? html`<p class="alignmsg">
${this.host._t('gs.align_preflight_more', { n: String(failures.length - 10) })}
</p>` : nothing}
<div class="rhint">${this.host._t('gs.align_preflight_hint')}</div>
${this._preflightVersionsDiffer() ? html`
<div class="rhint">${this.host._t('gs.preflight_update_hint')}</div>` : nothing}
<div class="row">
<button class="btn ghost" @click=${() => this._copyPreflightDiagnostics()}>
<ha-icon icon="mdi:content-copy"></ha-icon>
${this.host._t('gs.preflight_copy')}
</button>
</div>
${this.host._preflightClipboardFallback ? html`<details open>
<summary>${this.host._t('gs.preflight_copy')}</summary>
<pre style="user-select:text;white-space:pre-wrap">${this.host._preflightClipboardFallback}</pre>
</details>` : nothing}`
: !d.changed
? html`<p class="alignmsg">${this.host._t(
r.liveMissingPositions.length || r.unverifiedPositions.length
|| r.nestedRefsUnresolved
? 'gs.optimize_no_automatic_changes' : 'gs.align_none',
)}</p>`
: html`
${r.moved ? html`<p class="alignmsg">${this.host._t('gs.align_count', {
n: String(r.moved), total: String(r.total), cm: String(d.cm),
})}</p>` : nothing}
${r.latticeCoordinatesCanonicalized ? html`
<p class="alignmsg">${this.host._t('gs.optimize_lattice_summary', {
n: String(r.latticeCoordinatesCanonicalized),
cm: formatLatticeShiftCm(r.latticeMaxShiftCm),
})}</p>
${r.latticeSpaces.map((space) => html`<p class="alignmsg">${this.host._t(
'gs.optimize_lattice_space', {
space: space.space,
n: String(space.canonicalized),
far: String(space.far),
},
)}</p>`)}
` : nothing}
${d.where
? html`<p class="alignmsg">${this.host._t('gs.align_where', { s: d.where })}</p>`
: nothing}
${r.rotated
? html`<p class="alignmsg">${this.host._t('gs.align_turned', { n: String(r.rotated) })}</p>`
: nothing}
${r.wallSegmentsMigrated ? html`<p class="alignmsg">${this.host._t(
'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) },
)}</p>` : nothing}
${r.roomDraftsMigrated || r.roomDraftSegmentsMigrated ? html`
<p class="alignmsg">${this.host._t('gs.room_drafts_migrated', {
drafts: String(r.roomDraftsMigrated),
segments: String(r.roomDraftSegmentsMigrated),
})}</p>` : nothing}
${r.legacyZeroWallsMigrated ? html`<p class="alignmsg">${this.host._t(
'gs.zero_walls_migrated', { n: String(r.legacyZeroWallsMigrated) },
)}</p>` : nothing}
${modelMaintenance ? html`<p class="alignmsg">${this.host._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
s: String(r.spansMerged), i: String(r.partitionsMerged),
})}</p>` : nothing}
${r.partitionsReconciled ? html`<p class="alignmsg">${this.host._t(
'gs.optimize_coincident_partitions', { n: String(r.partitionsReconciled) },
)}</p>` : nothing}
${r.openingsRehosted ? html`<p class="alignmsg">${this.host._t(
'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) },
)}</p>` : nothing}
${r.wallsStraightened ? html`<p class="alignmsg">${this.host._t(
'gs.optimize_walls_straightened', {
n: String(r.wallsStraightened), cm: String(straightenCm),
},
)}</p>` : nothing}
${straightenWhere ? html`<p class="alignmsg">${this.host._t(
'gs.optimize_walls_straightened_where', { s: straightenWhere },
)}</p>` : nothing}
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this.host._t('gs.optimize_glow_migration', {
spaces: String(r.glowSpacesMigrated),
rooms: String(r.glowRoomsMigrated),
})}</p>`
: nothing}
${gridWarning ? html`<div class="rhint">${this.host._t('gs.align_warn')}</div>` : nothing}`}
${!failed && r.wallsStraightenSkipped ? html`<p class="rhint">${this.host._t(
'gs.optimize_walls_straighten_skipped', {
n: String(r.wallsStraightenSkipped),
},
)}</p>` : nothing}
${repaired
? html`<p class="alignmsg">${this.host._t('gs.optimize_references', {
spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped),
positions: String(r.positionsRemapped), detached: String(r.markersDetached),
})}</p>`
: nothing}
${removed
? html`<p class="alignmsg">${this.host._t('gs.optimize_orphans_removed', {
total: String(removed),
rooms: String(r.orphanRoomLabelsRemoved),
devices: String(r.orphanDevicePositionsRemoved),
groups: String(r.orphanGroupPositionsRemoved),
})}</p>`
: nothing}
${r.liveMissingPositions.length
? html`<div class="optimize-live">
<p class="alignmsg">${this.host._t(d.removeLiveMissingPositions
? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', {
n: String(r.liveMissingPositions.length), names: liveNamesText,
})}</p>
<button class="btn ghost optimize-cleanup" type="button"
aria-pressed=${d.removeLiveMissingPositions ? 'true' : 'false'}
@click=${() => this._toggleOptimizeLivePositions()} ?disabled=${d.busy}>
<ha-icon icon=${d.removeLiveMissingPositions ? 'mdi:undo' : 'mdi:map-marker-remove-outline'}></ha-icon>
${this.host._t(d.removeLiveMissingPositions
? 'gs.optimize_live_keep' : 'gs.optimize_live_remove')}
</button>
${d.removeLiveMissingPositions
? html`<div class="rhint optimize-selected" role="status">
${this.host._t('gs.optimize_live_selected')}
</div>`
: nothing}
</div>`
: nothing}
${r.unverifiedPositions.length
? html`<div class="rhint" role="alert">
${this.host._t('gs.optimize_unverified', {
n: String(r.unverifiedPositions.length),
})}
${registryLimited ? ` ${this.host._t('gs.optimize_registry_limited')}` : ''}
</div>`
: nothing}
${r.nestedRefsUnresolved
? html`<div class="rhint" role="alert">${this.host._t('gs.optimize_vacuum_warning', {
n: String(r.nestedRefsUnresolved),
})}</div>`
: nothing}
${referenceDetails.length
? html`<details class="optimize-details">
<summary>${this.host._t('gs.optimize_details')}</summary>
<ul>
${visibleDetails.map((item) => html`<li>${this.host._t('gs.optimize_detail_item', {
status: detailStatus(item), kind: detailKind(item.kind),
id: item.id, space: item.spaceId,
})}</li>`)}
</ul>
${remainingDetails
? html`<div class="rhint">${this.host._t('gs.optimize_details_more', {
n: String(remainingDetails),
})}</div>`
: nothing}
</details>`
: nothing}
</div>
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => { this.host._alignDialog = null; this.host._preflightClipboardFallback = null; }}>${this.host._t('btn.cancel')}</button>
${!d.changed || !d.preflight?.ok ? nothing : html`
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._runAlignToGrid()} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this.host._t('gs.align_run')}
</button>`}
</div>
</hp-dialog>`;
}
public _renderSettingsDialog(): TemplateResult { return renderGeneralSettingsDialog.call(this); }
public _openRulesDialog = (): void => {
+492
View File
@@ -0,0 +1,492 @@
/**
* Диалог «Оптимизировать планы» (Общие настройки → Сетка), #642.
*
* Первый вынос из монолита по образцу `live-*` и `RadarSetupController`: узкий
* порт `OptimizePlansDialogPort` из 18 членов вместо `HouseplanEditorHostPort`
* на 350, без делегатов-заглушек в карточке. Модуль импортирует только
* редакторский рантайм, поэтому остаётся в его ленивом чанке; карточка берёт
* отсюда только тип состояния.
*
* Сам диалог (`_alignDialog`) остаётся `@state` карточки: харнесс и golden
* пишут его напрямую. Сюда переехали два куска состояния, которые карточке
* не принадлежали: инлайн-фолбэк буфера обмена и дедуп dev-лога.
*/
import { html, nothing, type TemplateResult } from 'lit';
import { formatLatticeShiftCm } from './coordinate-canonicalization';
import type { DeviceLayout } from './device-position-history';
import type { I18nKey } from './i18n';
import {
spacePhysicalGeometryFingerprint, type OptimizeGeometryPreflightResult,
} from './plan-geometry-preflight';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import type { SpaceReferenceRepairContext } from './space-reference-repair';
import type { ServerConfig } from './types';
import { contentFingerprint } from './visual-continuity';
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
export type OptimizePlansDialogState = {
report: OptimizeReport; config: any; layout: Record<string, any>;
preflight: OptimizeGeometryPreflightResult | null;
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
cm: number;
/** the space that maximum belongs to, named only when there are several */
where: string;
changed: boolean;
busy: boolean;
/** False by default; true only after the secondary preview action. */
removeLiveMissingPositions: boolean;
};
/**
* Всё, что диалогу нужно от редактора. Порт собирает рантайм в конструкторе;
* других зависимостей у модуля нет. Рост порта выше 20 членов — сигнал, что
* сюда протекает чужая подсистема (юнит `optimize-plans-dialog.test.mjs`).
*/
export interface OptimizePlansDialogPort {
dialog(): OptimizePlansDialogState | null;
setDialog(next: OptimizePlansDialogState | null): void;
t(key: I18nKey, vars?: Record<string, string | number>): string;
hass(): unknown;
config(): ServerConfig | null;
/** the normalised plan exists — the preview has something to optimise */
planReady(): boolean;
layout(): DeviceLayout | null | undefined;
integrationVersion(): string | null;
/** `displayVersion(CARD_VERSION)` of the runtime; no second copy here */
cardVersion(): string;
requestUpdate(): void;
showToast(message: string): void;
errorText(error: unknown): string;
checkGeometry(config: ServerConfig): OptimizeGeometryPreflightResult;
referenceContext(removeLiveMissingPositions: boolean): SpaceReferenceRepairContext;
showMigrationBlocked(error: unknown): void;
clearGeometryGesture(): void;
commit(config: ServerConfig, layout: DeviceLayout): Promise<void>;
/** a write conflict reloads both stores before the error toast */
reloadAfterConflict(): Promise<unknown>;
}
export function preflightDiagnostics(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
{ cardVersion, now }: { cardVersion: string; now: Date },
): object {
// CODE-REVIEW-295-r1 M1: hash the CANDIDATE spaces the preflight judged,
// not the already-saved config — a saved-config hash is exactly what a
// space export would reproduce, and the block promises what the export
// does not carry.
const spacesById = new Map(((candidate as any)?.spaces || []) // any-ok: кандидат приходит из untyped optimizePlans, форма spaces не гарантирована
.map((space) => [String(space?.id || ''), space]));
return {
kind: 'houseplan-optimize-preflight',
origin: 'runtime',
cardVersion,
checkedAt: now.toISOString(),
preflightFingerprint: preflight.fingerprint,
failures: preflight.failures.map((failure) => ({
spaceId: failure.spaceId,
displayName: failure.displayName,
reason: failure.reason,
detail: failure.detail ?? null,
spaceGeometryFingerprint: spacesById.has(failure.spaceId)
? spacePhysicalGeometryFingerprint(spacesById.get(failure.spaceId))
: null,
})),
};
}
export function preflightVersionsDiffer(integration: string | null, cardVersion: string): boolean {
return typeof integration === 'string' && integration.length > 0
&& integration !== cardVersion;
}
export class OptimizePlansDialog {
/**
* CODE-REVIEW-295-r1 M2: the inline clipboard fallback belongs to one
* dialog showing. Keyed by the dialog object, a reopened dialog — always a
* new object — can never display the previous refusal's JSON, and closing
* needs no manual reset.
*/
private readonly fallbacks = new WeakMap<object, string>();
/** #295: dev-log once per distinct failing preflight, not once per render. */
private reportedFingerprint: string | null = null;
public constructor(private readonly port: OptimizePlansDialogPort) {}
/** #295: diagnostics text shown inline when the clipboard is unavailable. */
public get clipboardFallback(): string | null {
const dialog = this.port.dialog();
return dialog ? this.fallbacks.get(dialog) ?? null : null;
}
private diagnostics(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
): object {
return preflightDiagnostics(preflight, candidate, {
cardVersion: this.port.cardVersion(), now: new Date(),
});
}
public reportPreflightFailure(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
): void {
if (preflight.ok || preflight.fingerprint === this.reportedFingerprint) return;
this.reportedFingerprint = preflight.fingerprint;
// eslint-disable-next-line no-console
console.warn('[houseplan] optimize preflight failed', this.diagnostics(preflight, candidate));
}
public async copyDiagnostics(): Promise<void> {
const dialog = this.port.dialog();
const preflight = dialog?.preflight;
if (!dialog || !preflight || preflight.ok) return;
const text = JSON.stringify(
this.diagnostics(preflight, dialog.config ?? null), null, 2,
);
try {
await navigator.clipboard.writeText(text);
if (this.fallbacks.delete(dialog)) this.port.requestUpdate();
this.port.showToast(this.port.t('gs.preflight_copied'));
} catch {
// Insecure context / embedded webview: surface the block inline so the
// owner can select and copy it by hand.
this.fallbacks.set(dialog, text);
this.port.requestUpdate();
}
}
/**
* Preview whole-plan maintenance. Nothing is written here: the pure run
* produces both the report and the exact config/layout pair to commit.
*/
public preview(removeLiveMissingPositions: boolean): void {
const config = this.port.config();
if (!this.port.planReady() || !config) return;
const spaces = config.spaces || [];
let r;
try {
r = optimizePlans(
config,
this.port.layout() || {},
this.port.referenceContext(removeLiveMissingPositions),
);
} catch (error) {
this.port.showMigrationBlocked(error);
return;
}
const preflight = r.changed ? this.port.checkGeometry(r.config) : null;
if (preflight) this.reportPreflightFailure(preflight, r.config);
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
// measured every element in the centimetres of ITS OWN space — converting
// one normalised maximum through the first space's `cell_cm` understated
// a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is
// rounded UP, so the promise can never be smaller than the deed.
const cm = Math.ceil(r.report.maxShiftCm * 10) / 10;
const sp = spaces.find((x) => x?.id != null && String(x.id) === r.report.maxSpace);
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
// A new dialog object also starts without an inline clipboard fallback.
this.port.setDialog({
report: r.report, config: r.config, layout: r.layout, cm, where,
preflight, changed: r.changed, busy: false, removeLiveMissingPositions,
});
}
public open(): void { this.preview(false); }
public toggleLivePositions(): void {
const dialog = this.port.dialog();
if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return;
this.preview(!dialog.removeLiveMissingPositions);
}
/**
* The backend persists an intent before either store changes, then keeps a
* one-deep snapshot that remains undoable until the next plan edit.
*/
public async run(): Promise<void> {
let d = this.port.dialog();
if (!d || d.busy || !this.port.config() || !d.changed || !d.preflight?.ok) return;
const fingerprint = contentFingerprint(d.config);
if (d.preflight.fingerprint !== fingerprint) {
const preflight = this.port.checkGeometry(d.config);
this.reportPreflightFailure(preflight, d.config);
d = { ...d, preflight };
this.port.setDialog(d);
if (!preflight.ok) return;
}
this.port.clearGeometryGesture();
this.port.setDialog({ ...d, busy: true });
try {
await this.port.commit(d.config, d.layout);
this.port.setDialog(null);
this.port.showToast(this.port.t('gs.align_done', {
n: String(d.report.moved),
m: String(d.report.migrated + d.report.canonicalized
+ d.report.coordsCanonicalized + d.report.latticeCoordinatesCanonicalized
+ d.report.wallsMerged + d.report.spansMerged
+ d.report.partitionsMerged + d.report.partitionsReconciled
+ d.report.openingsRehosted + d.report.wallsStraightened),
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
+ d.report.positionsRemapped + d.report.markersDetached
+ d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved
+ d.report.orphanGroupPositionsRemoved),
}));
} catch (e: any) { // any-ok: ответ WS HA не типизирован, code читается как у всех писателей конфига
const current = this.port.dialog();
if (current) this.port.setDialog({ ...current, busy: false });
if (e?.code === 'wall_model_client_outdated') {
this.port.showToast(this.port.t('toast.wall_model_client_outdated'));
return;
}
if (e?.code === 'conflict') {
await this.port.reloadAfterConflict();
}
this.port.showToast(this.port.t('toast.error', { err: this.port.errorText(e) }));
}
}
/**
* The confirmation separates geometry movement from lossless maintenance,
* and promises the one-deep undo before either store is changed.
*/
render(): TemplateResult {
const d = this.port.dialog()!;
const r = d.report;
const failed = d.changed && !d.preflight?.ok;
const failures = d.preflight?.failures || [];
const visibleNames = failures.slice(0, 3).map((failure) => failure.displayName);
const spaces = visibleNames.length
? visibleNames.join(', ')
: this.port.t('gs.align_preflight_space', { n: '1' });
const remaining = Math.max(0, failures.length - visibleNames.length);
const more = remaining
? this.port.t('gs.align_preflight_more', { n: String(remaining) })
: '';
const repaired = r.spaceRefsRemapped + r.roomRefsRemapped
+ r.positionsRemapped + r.markersDetached;
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
+ r.wallSegmentsMigrated
+ r.roomDraftsMigrated + r.roomDraftSegmentsMigrated
+ r.wallsMerged + r.spansMerged + r.partitionsMerged
+ r.partitionsReconciled + r.openingsRehosted;
const gridWarning = r.moved + r.rotated + r.coordsCanonicalized + r.wallsStraightened;
const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10;
const straightenSpace = (this.port.config()?.spaces || []).find(
(space) => String(space?.id || '') === r.maxStraightenSpace,
);
const straightenWhere = (this.port.config()?.spaces || []).length > 1 && straightenSpace
? String(straightenSpace.title || straightenSpace.id) : '';
const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved
+ r.orphanGroupPositionsRemoved;
const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean);
const visibleLiveNames = liveNames.slice(0, 3).join(', ');
const remainingLiveNames = Math.max(0, liveNames.length - 3);
const liveNamesText = visibleLiveNames
? this.port.t('gs.optimize_live_names', {
names: visibleLiveNames,
more: remainingLiveNames
? this.port.t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '',
})
: '';
const registryLimited = r.unverifiedPositions.some(
(item) => item.reason === 'registry_unavailable',
);
const detailStatus = (item: typeof r.removedPositions[number]): string => {
if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) {
return this.port.t('gs.optimize_detail_removed');
}
if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) {
return this.port.t('gs.optimize_detail_live');
}
return this.port.t('gs.optimize_detail_unverified');
};
const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this.port.t(
kind === 'room_label' ? 'gs.optimize_detail_room_label'
: kind === 'group' ? 'gs.optimize_detail_group'
: kind === 'device' ? 'gs.optimize_detail_device'
: 'gs.optimize_detail_unknown',
);
const referenceDetails = [
...r.removedPositions,
...r.liveMissingPositions.filter((item) => (
!r.removedPositions.some((removedItem) => removedItem.id === item.id)
)),
...r.unverifiedPositions,
];
const visibleDetails = referenceDetails.slice(0, 10);
const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length);
const fallback = this.clipboardFallback;
return html`<hp-dialog .hass=${this.port.hass()} data-kind="settings" .title=${this.port.t('gs.align_title')} icon="mdi:broom"
dismiss-on-scrim @hp-close=${() => this.port.setDialog(null)}>
<div class="body">
${failed
? html`
<p class="alignmsg">${this.port.t('gs.align_preflight_failed', { spaces, more })}</p>
${failures.slice(0, 10).map((failure) => html`<p class="alignmsg">
${failure.displayName}: ${this.port.t(`gs.preflight_reason_${failure.reason}` as I18nKey)}
</p>`)}
${failures.length > 10 ? html`<p class="alignmsg">
${this.port.t('gs.align_preflight_more', { n: String(failures.length - 10) })}
</p>` : nothing}
<div class="rhint">${this.port.t('gs.align_preflight_hint')}</div>
${preflightVersionsDiffer(this.port.integrationVersion(), this.port.cardVersion()) ? html`
<div class="rhint">${this.port.t('gs.preflight_update_hint')}</div>` : nothing}
<div class="row">
<button class="btn ghost" @click=${() => this.copyDiagnostics()}>
<ha-icon icon="mdi:content-copy"></ha-icon>
${this.port.t('gs.preflight_copy')}
</button>
</div>
${fallback ? html`<details open>
<summary>${this.port.t('gs.preflight_copy')}</summary>
<pre style="user-select:text;white-space:pre-wrap">${fallback}</pre>
</details>` : nothing}`
: !d.changed
? html`<p class="alignmsg">${this.port.t(
r.liveMissingPositions.length || r.unverifiedPositions.length
|| r.nestedRefsUnresolved
? 'gs.optimize_no_automatic_changes' : 'gs.align_none',
)}</p>`
: html`
${r.moved ? html`<p class="alignmsg">${this.port.t('gs.align_count', {
n: String(r.moved), total: String(r.total), cm: String(d.cm),
})}</p>` : nothing}
${r.latticeCoordinatesCanonicalized ? html`
<p class="alignmsg">${this.port.t('gs.optimize_lattice_summary', {
n: String(r.latticeCoordinatesCanonicalized),
cm: formatLatticeShiftCm(r.latticeMaxShiftCm),
})}</p>
${r.latticeSpaces.map((space) => html`<p class="alignmsg">${this.port.t(
'gs.optimize_lattice_space', {
space: space.space,
n: String(space.canonicalized),
far: String(space.far),
},
)}</p>`)}
` : nothing}
${d.where
? html`<p class="alignmsg">${this.port.t('gs.align_where', { s: d.where })}</p>`
: nothing}
${r.rotated
? html`<p class="alignmsg">${this.port.t('gs.align_turned', { n: String(r.rotated) })}</p>`
: nothing}
${r.wallSegmentsMigrated ? html`<p class="alignmsg">${this.port.t(
'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) },
)}</p>` : nothing}
${r.roomDraftsMigrated || r.roomDraftSegmentsMigrated ? html`
<p class="alignmsg">${this.port.t('gs.room_drafts_migrated', {
drafts: String(r.roomDraftsMigrated),
segments: String(r.roomDraftSegmentsMigrated),
})}</p>` : nothing}
${r.legacyZeroWallsMigrated ? html`<p class="alignmsg">${this.port.t(
'gs.zero_walls_migrated', { n: String(r.legacyZeroWallsMigrated) },
)}</p>` : nothing}
${modelMaintenance ? html`<p class="alignmsg">${this.port.t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
s: String(r.spansMerged), i: String(r.partitionsMerged),
})}</p>` : nothing}
${r.partitionsReconciled ? html`<p class="alignmsg">${this.port.t(
'gs.optimize_coincident_partitions', { n: String(r.partitionsReconciled) },
)}</p>` : nothing}
${r.openingsRehosted ? html`<p class="alignmsg">${this.port.t(
'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) },
)}</p>` : nothing}
${r.wallsStraightened ? html`<p class="alignmsg">${this.port.t(
'gs.optimize_walls_straightened', {
n: String(r.wallsStraightened), cm: String(straightenCm),
},
)}</p>` : nothing}
${straightenWhere ? html`<p class="alignmsg">${this.port.t(
'gs.optimize_walls_straightened_where', { s: straightenWhere },
)}</p>` : nothing}
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this.port.t('gs.optimize_glow_migration', {
spaces: String(r.glowSpacesMigrated),
rooms: String(r.glowRoomsMigrated),
})}</p>`
: nothing}
${gridWarning ? html`<div class="rhint">${this.port.t('gs.align_warn')}</div>` : nothing}`}
${!failed && r.wallsStraightenSkipped ? html`<p class="rhint">${this.port.t(
'gs.optimize_walls_straighten_skipped', {
n: String(r.wallsStraightenSkipped),
},
)}</p>` : nothing}
${repaired
? html`<p class="alignmsg">${this.port.t('gs.optimize_references', {
spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped),
positions: String(r.positionsRemapped), detached: String(r.markersDetached),
})}</p>`
: nothing}
${removed
? html`<p class="alignmsg">${this.port.t('gs.optimize_orphans_removed', {
total: String(removed),
rooms: String(r.orphanRoomLabelsRemoved),
devices: String(r.orphanDevicePositionsRemoved),
groups: String(r.orphanGroupPositionsRemoved),
})}</p>`
: nothing}
${r.liveMissingPositions.length
? html`<div class="optimize-live">
<p class="alignmsg">${this.port.t(d.removeLiveMissingPositions
? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', {
n: String(r.liveMissingPositions.length), names: liveNamesText,
})}</p>
<button class="btn ghost optimize-cleanup" type="button"
aria-pressed=${d.removeLiveMissingPositions ? 'true' : 'false'}
@click=${() => this.toggleLivePositions()} ?disabled=${d.busy}>
<ha-icon icon=${d.removeLiveMissingPositions ? 'mdi:undo' : 'mdi:map-marker-remove-outline'}></ha-icon>
${this.port.t(d.removeLiveMissingPositions
? 'gs.optimize_live_keep' : 'gs.optimize_live_remove')}
</button>
${d.removeLiveMissingPositions
? html`<div class="rhint optimize-selected" role="status">
${this.port.t('gs.optimize_live_selected')}
</div>`
: nothing}
</div>`
: nothing}
${r.unverifiedPositions.length
? html`<div class="rhint" role="alert">
${this.port.t('gs.optimize_unverified', {
n: String(r.unverifiedPositions.length),
})}
${registryLimited ? ` ${this.port.t('gs.optimize_registry_limited')}` : ''}
</div>`
: nothing}
${r.nestedRefsUnresolved
? html`<div class="rhint" role="alert">${this.port.t('gs.optimize_vacuum_warning', {
n: String(r.nestedRefsUnresolved),
})}</div>`
: nothing}
${referenceDetails.length
? html`<details class="optimize-details">
<summary>${this.port.t('gs.optimize_details')}</summary>
<ul>
${visibleDetails.map((item) => html`<li>${this.port.t('gs.optimize_detail_item', {
status: detailStatus(item), kind: detailKind(item.kind),
id: item.id, space: item.spaceId,
})}</li>`)}
</ul>
${remainingDetails
? html`<div class="rhint">${this.port.t('gs.optimize_details_more', {
n: String(remainingDetails),
})}</div>`
: nothing}
</details>`
: nothing}
</div>
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => this.port.setDialog(null)}>${this.port.t('btn.cancel')}</button>
${!d.changed || !d.preflight?.ok ? nothing : html`
<button class="btn on" data-hp="dialog-confirm" @click=${() => this.run()} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this.port.t('gs.align_run')}
</button>`}
</div>
</hp-dialog>`;
}
}