mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
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:
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user