mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на пару «пространство × язык»), английский статически как слой отката; рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback, общий тост отказа), src/i18n/namespace-language.ts. - Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер только после того, как словарь текущего языка осел; гейт хоста учитывает словари загруженной на нём поверхности (смена языка на лету — ветка warm). Оверлей Zigbee ждёт topology до первой отрисовки. - bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500 (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки ленивости и владения по содержимому. - Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык синхронно, ждут гейт вместо ослабления проверок; пять мутантов. - monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro и export на каждый) и записи манифеста; gzip каждой поверхности при этом уменьшился. Issue: #627 User-Visible: no
11997 lines
558 KiB
TypeScript
11997 lines
558 KiB
TypeScript
/**
|
||
* House Plan Card — an interactive house plan as a native Lovelace card.
|
||
* Configuration sources:
|
||
* 1) SERVER (the houseplan integration, WS houseplan/config/get) — spaces, plans,
|
||
* rooms, device overrides, virtual devices. Coordinates are NORMALIZED (0..1).
|
||
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
|
||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||
*/
|
||
import { html, svg, nothing, TemplateResult } from 'lit';
|
||
import { displayVersion } from './card-version';
|
||
import { guard } from 'lit/directives/guard.js';
|
||
import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
|
||
import { renderMarkerDialog } from './editors/marker-dialog';
|
||
import {
|
||
CELL_CM_MAX, CELL_CM_MIN, renderSpaceSettingsDialog,
|
||
} from './editors/space-settings-dialog';
|
||
import { renderGeneralSettingsDialog } from './editors/general-settings-dialog';
|
||
import { renderRoomSettingsDialog } from './editors/room-settings-dialog';
|
||
import { calibrationTarget, planVacuumFit } from './vacuum-route-edit';
|
||
import {
|
||
cancelHouseplanPointerMove, flushHouseplanPointerMove, queueHouseplanPointerMove,
|
||
} from './pointer-move-queue';
|
||
import {
|
||
commitHouseplanEditor, disposeHouseplanEditor, measureHouseplanDecorText,
|
||
routeHouseplanEditorUpdate, whenHouseplanEditorSettled,
|
||
} from './live-editor';
|
||
import './hp-dialog';
|
||
import type { HpDialog } from './hp-dialog';
|
||
import type { HpConfirmRequest } from './danger-confirm';
|
||
import './hp-color-opacity';
|
||
import type { ColorPickerLabels } from './hp-color-opacity';
|
||
import './hp-help';
|
||
import type { AuthoritativeConfigResponse } from './version-recovery-card';
|
||
import type { ConfigAdoption, GatedAdoptionInput, GatedAdoptionResult } from './config-adoption';
|
||
import './hp-device-preview'; import './hp-zigbee-topology-settings';
|
||
import {
|
||
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
|
||
type IconRule, type CompiledIconRule,
|
||
} from './rules';
|
||
import {
|
||
snapToGrid, snapSegment45, samePoint, pointInPolygon, markerIdForBinding, formatLength, roomPoly,
|
||
pointStrictlyInside, roomsOverlap, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea,
|
||
closestPointOnBoundary, pointStrictlyInside as ptInside, distToSegment, cutSegments, alignGuides,
|
||
segmentAngle, type AlignGuide, clampScale, migratePdfUrls, snapToWall, snapPointAlongPoly,
|
||
openingAmount, openingShoulders, filterOpeningEntityCandidates, poleOfInaccessibility, subst,
|
||
type FloorInfo, parseRoomRef, resolveGlowValues, normalizeGlowColorOverride, isControllable,
|
||
spaceDisplayOf, DEFAULT_FILL_COLORS, customFillOf, DEFAULT_CUSTOM_FILL, type FillColors,
|
||
type FillColorEntry, RUN_TARGET_DOMAINS, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
|
||
showRoomTooltipOf, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, normalizeDeviceDisplay,
|
||
type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE,
|
||
} from './logic';
|
||
import {
|
||
resolveSafeResize,
|
||
coalesceResizeRooms,
|
||
polyIsSimple, areaM2, formatArea, MIN_ROOM_CM,
|
||
type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions,
|
||
type SafeResizePlan, type SafeResizeReason, type SafeResizeResolution,
|
||
} from './resize';
|
||
import {
|
||
ResizeController,
|
||
type ResizeProjectionResult,
|
||
} from './resize-controller';
|
||
import { resizeLiveCandidateSpace, resizeLiveJunctionRoomIds } from './resize-live-preflight';
|
||
import {
|
||
commitWallChainFinishGeometry, commitWallChainSegmentGeometry,
|
||
} from './draft-live-commit';
|
||
import { wallChainLiveSeed } from './draft-live-preflight';
|
||
import {
|
||
placeResizeAreaLabel, resizeMeasuredEdges,
|
||
type ResizeAreaPlacement,
|
||
} from './resize-labels';
|
||
import { northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf, type SunRayOrigin } from './sun';
|
||
import {
|
||
furnitureDefaultCm,
|
||
furnitureGraphic, furnitureCorners,
|
||
furnitureRenderTransform, furnitureStrokePx,
|
||
resizeFurnitureTransform, furnitureRotationAngle,
|
||
furnitureSignedFieldCm, furnitureSignedFieldValue,
|
||
clampFurnCm,
|
||
type FurnitureResizeResult,
|
||
} from './furniture';
|
||
import { FURN_WALL_CELLS, resolveFurniturePlacement, snapFurnitureToWall, type FurniturePlacement } from './furniture-placement';
|
||
import { FURNITURE_ART_RUNTIME } from './furniture-art-runtime';
|
||
import { FURNITURE_ART_FINGERPRINT, GENERATED_FURNITURE_ART } from './furniture-plan-art.generated';
|
||
import { furnitureWallSurfacesFor, type FurnitureWallSurface } from './furniture-wall-surface';
|
||
import {
|
||
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations, setWallThickness,
|
||
setWallThicknessForRoom, cmToField, wallCmToUnits, multiWallNodesForGeometry, wallBodiesGeometry,
|
||
wallBodiesGeometryPath, wallBodiesUnionPath, innerContourForRoom,
|
||
openingInnerFaceOffsetFromIndex, resolveOpeningWallAssociation, applyWallThicknessToNewRoom,
|
||
drawWallPreviewD, linearWallJoinPatches, DRAW_WALL_DEFAULT_CM, wallIntervals,
|
||
materializeWallIntervals, intervalCmAt, wallHatchStepUnits, HATCH_BASE_STEP_UNITS,
|
||
type OpeningWallIndex, type LinearWallSegment, type WallEntry, type WallInterval,
|
||
type MultiWallNodeMap, innerEdgeSpan, ownEdgeOffsets, thicknessCmAt,
|
||
} from './wall-thickness';
|
||
import {
|
||
junctionLimitViolations, increasedViolations,
|
||
type JunctionLimitViolation, type JunctionSharedGeometry, type LimitSegment,
|
||
} from './junction-limits';
|
||
import {
|
||
pointOnOpenCut, sanitizeOpenSpans,
|
||
type OpenSpanEntry,
|
||
} from './open-spans';
|
||
import { ContentSigner } from './signing';
|
||
import { type InitialSpaceSelection } from './initial-load';
|
||
import { selectSpaceModelById } from './space-model-selection';
|
||
import {
|
||
applyRoomTempThresholdDraft, createEmptySpaceConfig, initialSpaceDisplayDraft,
|
||
roomTempThresholdDraft, roomTempThresholdInputValues, strictNumber, type SpaceDialogState,
|
||
} from './space-dialog';
|
||
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
|
||
import { generalProblems, rememberGeneralBaseline } from './editors/general-form-state';
|
||
import { rememberRoomBaseline } from './editors/room-form-state';
|
||
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
|
||
import { commitPlanOptimization } from './plan-optimize-write';
|
||
import { saveSpaceCopy } from './space-copy-runtime';
|
||
import {
|
||
Affine, applyAffine, readVacTelemetry, autoCalibrate, vacTrailMode, areaCentroid,
|
||
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM, FitParams, fitMatrix,
|
||
reanchorFit, VacRoom, Pt as VacPt, type VacSourceCandidate, type VacSourceResolution,
|
||
} from './vacuum';
|
||
import {
|
||
buildDevices, deviceFromMarkerDraft, effectiveExcludedIntegrations, seedHiddenBindings,
|
||
resolvedLightSources, hasOwnSpatialSource, ownControllableEntities, selectSpatialGlowSource,
|
||
removedPlanBindings, isRemovedPlanEntity, deletePlanMarkerRecords, effectiveMarkerControls,
|
||
persistedExternalControls, removeMarkerControlReferences, rewriteMarkerControlReferences,
|
||
markerControlWouldCycle,
|
||
} from './devices';
|
||
import {
|
||
bindingCandidates, buildDeviceInbox, filterDeviceInbox,
|
||
type DeviceInboxCategory, type DeviceInboxReason, type DeviceInboxRow,
|
||
} from './device-inbox';
|
||
import {
|
||
formatToggleIntent, projectedTapAction, type ResolvedToggleIntent, type ResolvedToggleTarget,
|
||
type ToggleNextEffect, type ToggleNoneReason, type ToggleSkipReason,
|
||
} from './device-toggle';
|
||
import { toggleEntityWriteFields } from './marker-toggle-entity';
|
||
import { removeMarkerAreaSnapshots } from './device-area-relocation';
|
||
import { type VirtualLightSnapshot } from './virtual-light-state';
|
||
import type {
|
||
OpeningCfg, PartitionOpeningHost, RoomCfg, PartitionCfg, WallColumnCfg, SpaceModel, PdfRef,
|
||
Marker, ServerConfig, DevItem, CardConfig, MarkerValueBadge, ValueBadgePosition,
|
||
ValueBadgeSource,
|
||
} from './types';
|
||
import {
|
||
radarConfigFromDraft, radarDraft, recognizeRadar, type RadarEditorDraft,
|
||
} from './radar-editor';
|
||
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,
|
||
} from './physical-geometry';
|
||
import {
|
||
hostedOpeningIntervalsOverlap, materializePartitionOpening, partitionOpeningJambMargin,
|
||
partitionOpeningNeedsStrictValidation, partitionOpeningFace, partitionPlacementIntervals,
|
||
resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
|
||
} from './partition-openings';
|
||
import {
|
||
buildHiddenWallDiagnosticGeometry, buildPlanSnapGeometry,
|
||
resolvePlanSnapResult, resolveStrictPlanSnap,
|
||
type HiddenWallDiagnosticGeometry,
|
||
type PlanSnapCandidate, type PlanSnapEndpoint, type PlanSnapGeometry, type PlanSnapSegment,
|
||
} from './plan-snap-overlay';
|
||
import {
|
||
buildWallFaceGraph, findNewWallFacesInGraphs, findWallFaceAtPoint, chainSegmentCms,
|
||
type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment,
|
||
} from './wall-face-graph';
|
||
import { parameterOnPartition, planRoomDeletion } from './room-deletion';
|
||
import {
|
||
planWallFaceRepair, repairMovesHostedPartition, type WallFaceRepairProposal,
|
||
} from './wall-face-repair';
|
||
import './space-card';
|
||
import {
|
||
EditorSecondaryController,
|
||
type EditorSecondaryCopy,
|
||
type EditorSecondaryModel,
|
||
type EditorToolbarGroup,
|
||
} from './editor-secondary';
|
||
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 {
|
||
WALL_SEGMENT_MODEL_VERSION,
|
||
adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel,
|
||
fixedTopologyWallLineageHints,
|
||
resolveRoomOpeningHost, wallModelOffGridValueCount, WallSegmentModelError,
|
||
} from './wall-segment-model';
|
||
import { resolveZeroWalls, zeroWallHasOpening, zeroWallStyleOf } from './zero-walls';
|
||
import { snapNearAxisEndpoint } from './near-axis';
|
||
import type { SpaceReferenceRepairContext } from './space-reference-repair';
|
||
import { collectSpaceMarkerDependencies, spaceDeletionMessage } from './space-deletion';
|
||
import {
|
||
checkSpacePhysicalGeometry, checkOptimizeGeometry, geometryOpenCuts, geometryOpenings,
|
||
geometryPartitionOpeningCuts, spacePhysicalGeometryFingerprint,
|
||
type OptimizeGeometryPreflightResult,
|
||
} from './plan-geometry-preflight';
|
||
import {
|
||
canonicalizeConfigGeometry, canonicalizePosition,
|
||
} from './coordinate-canonicalization';
|
||
import { enqueueSerializedWrite, type OptimisticAttempt } from './serialized-write-queue';
|
||
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
|
||
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
|
||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||
import { supportT, type SupportI18nKey } from './i18n/support'; import { EDITOR_LANGUAGE_RUNTIME } from './i18n/editor-language'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings';
|
||
import {
|
||
newSupportDialogState,
|
||
supportApiCompatible,
|
||
supportCanSubmit,
|
||
supportDraftError,
|
||
supportErrorCode,
|
||
supportRuntimeFacts,
|
||
supportSizeKiB,
|
||
supportSubmissionIdentity,
|
||
type SupportDialogState,
|
||
type SupportPreview,
|
||
} from './support-feedback';
|
||
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
|
||
import { CommandStack } from './command-stack';
|
||
import type { DeviceLayout, DevicePositionState } from './device-position-history';
|
||
import { contentFingerprint } from './visual-continuity';
|
||
import { PointerModalityController } from './pointer-modality';
|
||
import { type ResolvedDevicePresentation } from './device-presentation';
|
||
import { type FiniteActivityRuntime } from './activity-runtime';
|
||
import {
|
||
recommendedValueBadgeSource, valueBadgeCandidates, valueBadgeWriteFields, valueSourceWriteFields,
|
||
type ValueBadgeCandidate,
|
||
} from './device-value-badge';
|
||
import { type RenderDeviceSnapshot } from './render-device-snapshot';
|
||
import { type HouseplanMode, type ModeVisualState } from './mode-transition';
|
||
import { projectPlanPoint } from './iso-projection';
|
||
import { type HaBindingStatus, type HaRegistrySnapshot } from './ha-binding-status';
|
||
import type { DecorShape, DecorStyle } from './editors/decor/types';
|
||
import {
|
||
DECOR_ASSETS_API_VERSION, type DecorAsset,
|
||
} from './decor-assets';
|
||
import { DecorImageEditor } from './decor-image-editor';
|
||
import {
|
||
boxAnchors, clamp01, decorCmToUnits, decorStylePatch, decorUnitsToCm, mergeSnapGeometry,
|
||
normalizeAngle, resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft,
|
||
type DecorBox, type SnapGeometry,
|
||
} from './editors/decor/geometry';
|
||
import { decorStyleToSettings } from './editors/decor/geometry';
|
||
import {
|
||
openingVisibleMetrics, renderOpeningVisibleGeometry,
|
||
type OpeningFaceOffset, type OpeningVisibleSpec,
|
||
} from './render/opening-symbol';
|
||
import {
|
||
openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry,
|
||
resolveOpeningPlacementResult, sameOpeningPlacementInput,
|
||
type OpeningPlacementPreset, type OpeningPlacementType,
|
||
} from './opening-placement';
|
||
import {
|
||
buildOpeningDimensionContext, resolveOpeningDimensions,
|
||
type OpeningDimensionContext,
|
||
} from './opening-dimensions';
|
||
import type { OpeningPlacementCandidate, OpMeasure, RenderOpening } from './interaction-types';
|
||
import { safeStoredColor } from './color';
|
||
import { gridCellFieldValue, gridVisualUnits, newSpaceCellCm } from './grid-scale';
|
||
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
|
||
import { reconcileCoincidentPartitions } from './coincident-partitions';
|
||
import { finalizeWallChainSpace } from './writer-fixed-point';
|
||
import {
|
||
captureMarkerRoomReferences, restoreMarkerRoomReferences,
|
||
rewriteMarkerRoomReferences, type MarkerRoomReferenceSnapshot,
|
||
} from './room-reference-transaction';
|
||
|
||
const CARD_VERSION = '1.78.0-beta.1';
|
||
|
||
// #474: the editor imports the furniture artwork statically and hands it to
|
||
// the page runtime the moment this chunk evaluates — before the loader's
|
||
// `install`, so the palette and the placement ghost never render `pending`.
|
||
FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT);
|
||
|
||
type ResizeLiveLabel = {
|
||
kind: 'length';
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
edge: { a: [number, number]; b: [number, number] };
|
||
} | {
|
||
kind: 'area';
|
||
roomId: string;
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
placement: ResizeAreaPlacement;
|
||
};
|
||
type ResizePreview = { space: string; sp: any };
|
||
type ResizeWallUnion = ReturnType<typeof wallBodiesUnionPath>;
|
||
type ResizeWallArtifact = ReturnType<typeof wallBodiesGeometry>;
|
||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||
const DECOR_TEXT_CM_MAX = 2000;
|
||
const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void => {
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
while (cache.size > limit) {
|
||
const oldest = cache.keys().next().value as K | undefined;
|
||
if (oldest === undefined) break;
|
||
cache.delete(oldest);
|
||
}
|
||
};
|
||
/** DEV-B703-01: warm re-mount memo — MODULE scope, so it lives with the loaded
|
||
* PAGE, not with any card instance. Lovelace re-creates card elements when
|
||
* the websocket reconnects after a long-backgrounded tab; the fresh instance
|
||
* used to run the whole first-open boot (veil + BOOT_MIN_MS + quiescence),
|
||
* which reads as «план перезагрузился», even though the page (and HA's
|
||
* chrome) never went anywhere. Within one loaded page the chrome IS already
|
||
* settled, so the geometry the previous instance settled at is still valid:
|
||
* remember it per (viewport size × card config) and let the next instance
|
||
* open instantly in the final geometry, no veil at all. A window resize
|
||
* between instances changes the key → miss → the full protective boot (the
|
||
* only case where the chrome may genuinely re-settle). The config part of
|
||
* the key keeps two DIFFERENT cards on one page from adopting each other's
|
||
* header height (same config twice on one view is indistinguishable — and
|
||
* then the heights match anyway). */
|
||
|
||
/** Rotation step of a decor text block — the same 5° a device icon turns in
|
||
* (marker dialog). Shift affects angle precision only, never position. */
|
||
const DT_ANGLE_STEP = 5;
|
||
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
|
||
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
|
||
/** Short semantic-event / direct-terminal-transition window. Event uses
|
||
three sequential 1.1 s waves; motion cool-down itself never animates. */
|
||
|
||
/** #313: one Thickness-tool hit — a room interval or independent masonry. */
|
||
type WallThickSource = { kind: 'room' }
|
||
| { kind: 'partition'; id: string };
|
||
type WallThickHit = {
|
||
a: number[]; b: number[]; roomId: string; segs: number[][];
|
||
open: boolean; cm: number; source: WallThickSource;
|
||
};
|
||
|
||
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'wallthick' | 'delroom';
|
||
type WallFaceCandidate = WallGraphFace & {
|
||
split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] };
|
||
consumeAllActive?: boolean;
|
||
/** Face existed before this click; rejecting it must be a true no-op. */
|
||
existing?: boolean;
|
||
repair?: WallFaceRepairProposal;
|
||
};
|
||
type WallFaceDecision = {
|
||
candidate: WallFaceCandidate;
|
||
create: boolean;
|
||
name?: string;
|
||
area?: string | null;
|
||
settings?: RoomCfg['settings'];
|
||
};
|
||
type WallFaceBatch = {
|
||
candidates: WallFaceCandidate[];
|
||
index: number;
|
||
decisions: WallFaceDecision[];
|
||
activePath: number[][];
|
||
activeCms: number[];
|
||
activePartitionIds: string[];
|
||
};
|
||
const MAX_WALL_CHAIN_POINTS = 500;
|
||
const MAX_PARTITIONS = 2000;
|
||
const MAX_ROOMS = 400;
|
||
const MAX_WALL_COLUMNS = 500;
|
||
/** Everything whose topology or wall association changes in the plan editor. */
|
||
interface SpaceGeometryState {
|
||
spaceId: string;
|
||
rooms: any[];
|
||
openings?: OpeningCfg[];
|
||
walls?: WallEntry[];
|
||
wall_segments?: any[];
|
||
open_spans?: OpenSpanEntry[];
|
||
partitions?: PartitionCfg[];
|
||
wall_columns?: WallColumnCfg[];
|
||
decor?: DecorShape[];
|
||
plan_transform: {
|
||
plan_x?: number; plan_y?: number; plan_scale?: number;
|
||
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
|
||
};
|
||
/** Session-only: current-writer normalisation scope for wall-chain history. */
|
||
wallChainSeedIds?: string[];
|
||
/** Session-only: exact room fields touched by Delete/Merge room. */
|
||
roomReferences?: MarkerRoomReferenceSnapshot[];
|
||
}
|
||
/** Tools of the decor (background) editor. `furniture` is the library
|
||
* (docs/FURNITURE.md): it opens a palette and places a symbol at real size. */
|
||
type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'image' | 'erase';
|
||
|
||
/**
|
||
* Debounce with `flush()` and `pending`. Both are load-bearing: a pending
|
||
* config write MUST be flushed before the card adopts a server revision,
|
||
* otherwise the edit is silently dropped (audit L2, 2026-07-27).
|
||
*/
|
||
interface Debounced<T extends (...a: any[]) => void> {
|
||
(...a: Parameters<T>): void;
|
||
flush(): void;
|
||
cancel(): void;
|
||
pending(): boolean;
|
||
}
|
||
|
||
/**
|
||
* Capture the pointer for a drag, tolerating an inactive pointerId.
|
||
*
|
||
* `setPointerCapture` throws for synthetic events and for pointers some
|
||
* browsers consider gone; that killed a drag outright. The opening pipeline
|
||
* was hardened for this, the device/label/resize ones were not (audit
|
||
* follow-up L4 sub-item) — now they all go through here.
|
||
*/
|
||
const capturePointer = (ev: PointerEvent): void => {
|
||
try {
|
||
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
|
||
} catch {
|
||
/* an inactive pointerId must never kill the drag */
|
||
}
|
||
};
|
||
|
||
interface DeviceInboxDialogState {
|
||
tab: DeviceInboxCategory;
|
||
search: string;
|
||
showEntities: boolean;
|
||
onlyNew: boolean;
|
||
limit: number;
|
||
/** #44: Discovery-filters drafts. `undefined` = untouched (mirror the
|
||
* stored settings); draftExcluded `null` = "recommended" (no stored key). */
|
||
filtersOpen?: boolean;
|
||
draftGroupLights?: boolean;
|
||
draftExcluded?: string[] | null;
|
||
/** Logical row restored after a nested marker dialog closes. */
|
||
anchor?: string;
|
||
busy?: string;
|
||
}
|
||
|
||
export interface HouseplanEditorHostPort {
|
||
_ackNewDevice: (id: string) => void;
|
||
_activeWallChainId: string | null;
|
||
_activeWallChainPartitionIds: string[];
|
||
_activePlanSnapCandidate: PlanSnapCandidate | null;
|
||
_activePlanSnapConflicts: PlanSnapEndpoint[];
|
||
_activityRt: Map<string, FiniteActivityRuntime>;
|
||
_adoptInitialSpace: (models: SpaceModel[], authoritative?: boolean) => InitialSpaceSelection;
|
||
_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: OptimizePlansDialogState | null;
|
||
_alignPoint: number[] | null;
|
||
_allRoomsFlat: () => { value: string; label: string; }[];
|
||
_angleField: (value: unknown) => string;
|
||
_applyView: (zoom: number, cx?: number, cy?: number) => boolean;
|
||
_areaSel: string;
|
||
_areaToSpace: Record<string, { space: string; room: RoomCfg; }>;
|
||
_aspectJob: Promise<number> | null;
|
||
_autoIconForBinding: (binding: string) => string;
|
||
_backdropGuard: import('./backdrop-pick').BackdropGuardState | null;
|
||
_backdropDialog: { widthCm: number; heightCm: number; angle: string; } | null;
|
||
_backupExportDialog: { kind: "full" | "space"; planOnly: boolean; busy: boolean; error: string; } | null;
|
||
_backupImportDialog: { filename: string; size: number; token: string; preview: any; expectedConfigRev: number; expectedLayoutRev: number; duplicatePolicy: "skip" | "virtual"; confirmMissing: boolean; busy: boolean; error: string; } | null;
|
||
_baseVb: () => number[];
|
||
_bdActive: boolean;
|
||
_bdBase: Rect | null;
|
||
_bdDrag: { kind: "move" | "scale" | "rotate"; pid: number; sx: number; sy: number; base: Rect; p0: { dx: number; dy: number; sx: number; sy: number; angle: number; }; fx: number; fy: number; sgx: number; sgy: number; rect0: DecorBox; before: SpaceGeometryState | null; moved: boolean; } | null;
|
||
_bdMovable: boolean;
|
||
_bdMoved: boolean;
|
||
_bdParams: { dx: number; dy: number; sx: number; sy: number; angle: number; };
|
||
_bdRect: (Rect & { angle?: number; }) | null;
|
||
_bindingEntities: (binding: string) => string[];
|
||
_bindingHasAlarm: (binding: string) => boolean;
|
||
_bindingHasClimate: (binding: string) => boolean;
|
||
_bindingHasHaPage: (binding: string) => boolean;
|
||
_bindingStatus: (binding: string) => HaBindingStatus;
|
||
_bootSoftCancel: () => void;
|
||
_cacheSnapshot: () => void;
|
||
_canCommitSpace: (id: string, authority?: boolean) => boolean;
|
||
_canEdit: boolean;
|
||
_canOptimizeUndo: boolean;
|
||
_cancelDevicePressFeedback: () => void;
|
||
_cancelDeviceDrag: () => boolean;
|
||
_cancelModeTransition: (commitTarget?: boolean) => void;
|
||
_candidateDeviceSnapshot: RenderDeviceSnapshot | null;
|
||
_capturedSnapshotConfigEpoch: number;
|
||
_cellCm: number;
|
||
_cfgEpoch: number;
|
||
readonly _cfgRev: number;
|
||
_clearTransientHover: (suspend?: boolean) => void;
|
||
_closeInfoCard: () => void;
|
||
_closingWallCm: number | null;
|
||
_cmToUnits: (cm: number) => number;
|
||
_colorPickerLabels: ColorPickerLabels;
|
||
_commitSpace: (id: string, authority?: boolean) => boolean;
|
||
_commitViewModeAtomic: (from: ModeVisualState | null, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void;
|
||
_confirmDanger: (request: HpConfirmRequest) => Promise<boolean>;
|
||
_cancelDangerConfirm: () => void;
|
||
_config: CardConfig | undefined;
|
||
_contourClosed: boolean;
|
||
_curSpaceCfg: any;
|
||
_currentModeVisual: (mode?: HouseplanMode) => ModeVisualState | null;
|
||
_cursorPt: number[] | null;
|
||
_decorBoxOf: (shape: DecorShape) => DecorBox | null;
|
||
_decorDraft: { kind: "line" | "rect" | "ellipse"; a: number[]; b: number[]; pid: number; } | null;
|
||
_decorEraseConfirm: { id: string; kind: DecorShape["kind"]; } | null;
|
||
_decorImagePalette: DecorAsset | null;
|
||
_decorAssetCatalog: DecorAsset[];
|
||
_decorAssets: Map<string, DecorAsset>;
|
||
_decorAssetBusy: boolean;
|
||
_decorH: number;
|
||
_decorLargeCm: (value: number) => number;
|
||
_decorLargeField: (cm: number) => number;
|
||
_decorList: DecorShape[];
|
||
_decorMove: { id: string; start: number[]; orig: DecorShape; pid: number; moved: boolean; before: SpaceGeometryState | null; } | null;
|
||
_decorResolvedStyle: (shape?: DecorShape | null) => DecorStyle;
|
||
_decorSel: string | null;
|
||
_decorShapeDialog: { id: string; kind: "line" | "rect" | "ellipse" | "furniture" | "image"; color: string; opacity: number; widthCm: number; lineStyle?: "solid" | "dashed"; fill?: boolean; fillColor?: string; fillOpacity?: number; lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string; symbol?: string; assetId?: string; sizeWField?: string; sizeHField?: string; flipH?: boolean; flipV?: boolean; } | null;
|
||
_decorSmallCm: (value: number) => number;
|
||
_decorSmallField: (cm: number) => number;
|
||
_decorSnapCache: { epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry; } | null;
|
||
_decorStyle: DecorStyle;
|
||
_decorTextCm: (value: number) => number;
|
||
_decorTextDialog: { id?: string; x: number; y: number; text: string; color: string; opacity: number; angle: string; sizeCm: number; pickerEntity?: string; preserveLegacy?: boolean; } | null;
|
||
_decorTextSelection: { start: number; end: number; };
|
||
_decorTextSizeCm: (shape?: DecorShape | null) => number;
|
||
_decorTool: DecorTool;
|
||
_defPos: Record<string, { x: number; y: number; }>;
|
||
_deviceInbox: DeviceInboxDialogState | null;
|
||
_deviceInboxMemo: { key: string; rows: DeviceInboxRow[]; } | null;
|
||
_deviceInboxReturn: DeviceInboxDialogState | null;
|
||
_devicePresentation: (d: DevItem, showLqi?: boolean, designPreview?: boolean) => ResolvedDevicePresentation;
|
||
_devicePositionBusy: boolean;
|
||
_devicePositionHistory: CommandStack<DevicePositionState>;
|
||
_devices: DevItem[];
|
||
_dirtyPos: Set<string>;
|
||
_display: (url: string | null | undefined) => string;
|
||
_wallChainSegmentCms: number[];
|
||
_wallChainRedo: Array<{ id: string; point: number[]; cm: number }>;
|
||
_drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean; } | null;
|
||
/** #400: device dragging has its own state since #74; `_drag` is null in
|
||
* the devices mode, so anything excluding "the thing being dragged" there
|
||
* must read this instead. Only the id is needed by the editor runtime. */
|
||
_deviceDrag: { id: string } | null;
|
||
_drawWallCm: number | null;
|
||
_drawWallField: string | null;
|
||
_drawWallFieldValue: string;
|
||
_drawWallMaxCm: number;
|
||
_dtBox: { id: string; x: number; y: number; w: number; h: number; } | null;
|
||
_dtDrag: { id: string; kind: "scale" | "rotate"; pid: number; ax: number; ay: number; r0: number; a0: number; textSizeCm0: number; angle0: number; sgx?: number; sgy?: number; orig?: DecorBox; origShape: DecorShape; before: SpaceGeometryState | null; lineEnd?: 0 | 1; moved: boolean; } | null;
|
||
_dtSel: DecorShape | null;
|
||
_duplicateColumnId: string | null;
|
||
_duplicateColumnTimer: number;
|
||
_editing: boolean;
|
||
_editorChromeMode: "plan" | "devices" | "decor";
|
||
_editorSecondary: EditorSecondaryController;
|
||
_editorSecondaryContextId: string;
|
||
_editorSecondaryCopy: EditorSecondaryCopy;
|
||
_editorSecondaryDialogBlocked: boolean;
|
||
_editorToolbarGroups: readonly EditorToolbarGroup[];
|
||
_effectiveProjection: () => "flat" | "iso";
|
||
_endTabDrag: () => void;
|
||
_errText: (e: any) => string;
|
||
_excluded: Set<string>;
|
||
_fillColors: FillColors;
|
||
_fmtLen: (a: number[], b: number[]) => string;
|
||
_frame: { id: string; model: SpaceModel; layout: unknown; devs: unknown; far: boolean; grow: boolean; rect: Rect; all: Rect; outliers: number; } | null;
|
||
_freeAreas: any[];
|
||
_fullRegistryHass: any;
|
||
_furnPalette: { symbol: string; w: number; h: number; } | null;
|
||
_furnCategory: string | null;
|
||
_furnPreviewInput: { raw: [number, number]; free: boolean; } | null;
|
||
_furnTouchPending: {
|
||
pid: number; sx: number; sy: number; pointerType: string; cancelled: boolean;
|
||
} | null;
|
||
_gearPtCache: WeakMap<number[][], number[]>;
|
||
_geometryHistory: CommandStack<SpaceGeometryState>;
|
||
_getAuthoritativeConfig: () => Promise<AuthoritativeConfigResponse>;
|
||
_glowRadiusCm: number;
|
||
_glowRadiusPlaceholder: string;
|
||
_gridPitch: number;
|
||
_haIntegrationVersion: string | null;
|
||
_haSupportApi: number | null;
|
||
_haDecorAssetsApi: number | null;
|
||
_haRegistry: HaRegistrySnapshot;
|
||
_hasFixedFloor: boolean;
|
||
_hiddenWallDiagnosticCache: { key: string; value: HiddenWallDiagnosticGeometry; } | null;
|
||
_hoverRoom: { space: string; room: RoomCfg; } | null;
|
||
_iconRules: CompiledIconRule[] | undefined;
|
||
_imperial: boolean;
|
||
_importDialog: { floors: (FloorInfo & { checked: boolean; })[]; } | null;
|
||
_importQueue: string[];
|
||
_importTotal: number;
|
||
_infoCard: DevItem | null;
|
||
_innerRoomContour: (space: SpaceModel, roomId: string, openCuts?: number[][], roomWalls?: ReturnType<typeof wallBodiesGeometry>['roomGeom'], multiWallNodes?: MultiWallNodeMap | null | undefined) => number[][] | null;
|
||
_junctionLimitViolations: (config: unknown, spaceId: string,
|
||
sharedGeometry?: JunctionSharedGeometry | null, roomIds?: ReadonlySet<string>) => JunctionLimitViolation[];
|
||
_isVacDev: (d: DevItem) => boolean;
|
||
_kiosk: boolean;
|
||
_kioskDialog: boolean;
|
||
_kioskHoldTimer: number | undefined;
|
||
_kioskScale: { icon: number; font: number; };
|
||
_labsIso: boolean;
|
||
_lastValidStageSize: [number, number] | null;
|
||
_layout: DeviceLayout;
|
||
readonly _layoutRev: number;
|
||
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
|
||
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
|
||
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
|
||
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
|
||
_markers: Marker[];
|
||
_markup: boolean;
|
||
_maybeRebuildDevices: () => void;
|
||
_mergeDialog: { aId: string; bId: string; poly: number[][]; pick: "a" | "b"; } | null;
|
||
_mergeSel: string | null;
|
||
_mode: "view" | "plan" | "devices" | "decor";
|
||
_modeTransitionBusy: boolean;
|
||
_modeTransitionEditorCamera: { zoom: number; centerX?: number; centerY?: number; } | null;
|
||
_modeTransitionForceAtomic: boolean;
|
||
_modeTransitionPreparing: boolean;
|
||
_modeTransitionRequest: number;
|
||
_modeTransitionTargetCenterX: number | undefined;
|
||
_modeTransitionTargetCenterY: number | undefined;
|
||
_modeTransitionTargetZoom: number;
|
||
_modeTransitionVisual: ModeVisualState | null;
|
||
_model: SpaceModel[];
|
||
_modelCache: { key: string; model: SpaceModel[]; } | null;
|
||
_nameSel: string;
|
||
_newIds: Set<string>;
|
||
_newSyncKey: string;
|
||
_norm: boolean;
|
||
_normPos: (space: string, x: number, y: number) => { s: string; x: number; y: number; };
|
||
_normalizeWalls: (walls: WallEntry[] | null | undefined, cuts: number[][]) => WallEntry[];
|
||
_noteLayoutRev: (r: any) => void;
|
||
_opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean; before: SpaceGeometryState | null; } | null;
|
||
_opMeasure: OpMeasure | null;
|
||
_openBindingInHa: (binding: string) => void;
|
||
_openCuts: () => number[][];
|
||
_openingAmt: (o: OpeningCfg) => number;
|
||
_openingDialog: { id?: string; type: "door" | "window" | "gate" | "passage"; lengthCm: number; lengthTouched?: boolean; contact: string; lock: string; contactOpen?: boolean; contactFilter?: string; lockOpen?: boolean; lockFilter?: string; invert: boolean; flipH: boolean; flipV: boolean; host?: PartitionOpeningHost; x: number; y: number; angle: number; } | null;
|
||
_openingDimensionContextCache: { key: string; value: OpeningDimensionContext; } | null;
|
||
_openingEntityAvailable: (eid: string | null | undefined) => boolean;
|
||
_openingFace: (opening: RenderOpening, index: OpeningWallIndex, flipV: boolean) => OpeningFaceOffset;
|
||
_openingHoverCandidate: OpeningPlacementCandidate | null;
|
||
_openingJambBlockCm: number | null;
|
||
_openingPlacementIntervalsCache: { key: string; value: WallInterval[]; } | null;
|
||
_openingPreset: OpeningPlacementPreset | null;
|
||
_openingPresetRevision: number;
|
||
_openingPreview: OpeningPlacementCandidate | null;
|
||
_openingRebindId: string | null;
|
||
_openingWallIndexFor: (space: SpaceModel, openCuts: number[][]) => { key: string; value: OpeningWallIndex; };
|
||
_openingsR: RenderOpening[];
|
||
_optimizeUndoBusy: boolean;
|
||
_panLock: "pan" | "swipe" | null;
|
||
_panStart: { sx: number; sy: number; vx: number; vy: number; } | null;
|
||
_partitionDeleteDialog: { id: string; openings: OpeningCfg[]; } | null;
|
||
_path: number[][];
|
||
_pendingNavMode: "plan" | "devices" | "decor" | null;
|
||
_pendingPhysicalWrites: Map<string, { fingerprint: string; before: SpaceGeometryState; }>;
|
||
_pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][]; } | null;
|
||
_persistLayout: Debounced<() => void>;
|
||
_physicalBodiesCache: { key: string; drafts: number[][][]; partitions: number[][][]; columns: number[][][]; patches: number[][][]; all: number[][][]; } | null;
|
||
_physicalBodiesR: (space?: SpaceModel | undefined) => number[][][];
|
||
_physicalDialog: { kind: "partition" | "column"; id: string; cm: string; shape?: "square" | "circle"; angle?: string; length?: string; } | null;
|
||
_physicalDrag: { pid: number; kind: "partition" | "column"; id: string; start: number[]; startClient: number[]; before: SpaceGeometryState | null; moved: boolean; base: PartitionCfg | WallColumnCfg; delta: number[]; } | null;
|
||
_physicalLastTap: { kind: "partition" | "column"; id: string; at: number; } | null;
|
||
_physicalPickCycle: { signature: string; index: number; x: number; y: number; at: number; } | null;
|
||
_physicalRotate: { pid: number; id: string; center: number[]; startAngle: number; baseAngle: number; angle: number; before: SpaceGeometryState | null; moved: boolean; } | null;
|
||
_physicalSel: { kind: "partition" | "column"; id: string; } | null;
|
||
_pinchStart: { dist: number; zoom: number; } | null;
|
||
_planEntityAvailable: (eid: string | null | undefined) => boolean;
|
||
_planHass: any;
|
||
_planSnapGeometryCache: { key: string; value: PlanSnapGeometry; } | null;
|
||
_planSnapHover: { contextKey: string; candidate: PlanSnapCandidate | null; conflicts: PlanSnapEndpoint[]; } | null;
|
||
_planStructuralGeometryCache: { key: string; value: PlanSnapGeometry; } | null;
|
||
_pointerModality: PointerModalityController;
|
||
_pointers: Map<number, { x: number; y: number; }>;
|
||
_pos: (d: DevItem) => { x: number; y: number; };
|
||
_prepareModeTransition: (request: number, from: ModeVisualState, targetMode: HouseplanMode, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void;
|
||
_reducedMotion: boolean;
|
||
_rawPhysicalBodiesR: () => number[][][];
|
||
_regSignature: string;
|
||
_reloadConfigOnly: (force?: boolean, observedRev?: number) => Promise<void>;
|
||
_reloadLayoutOnly: () => Promise<void>;
|
||
_renderCardPreview: (spaceScale: number, nameScale: number, labelScale: number) => TemplateResult;
|
||
_renderCompass: () => TemplateResult;
|
||
_renderPlanHass: any;
|
||
_resign: () => void;
|
||
_resize: ResizeController<
|
||
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
|
||
>;
|
||
_restoreZoom: () => void;
|
||
_redoDevicePosition: () => void;
|
||
_rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number; } | null;
|
||
_roomCenter: (r: RoomCfg) => number[];
|
||
_roomCustomFill: FillColorEntry | null;
|
||
_roomDeleteDialog: { roomId: string; name: string; } | null;
|
||
_roomDialog: boolean;
|
||
_roomEditId: string | null;
|
||
_roomFill: "" | "none" | "lqi" | "light" | "temp" | "custom";
|
||
_roomHumSrc: string;
|
||
_roomLabelScale: number;
|
||
_roomNameScale: number;
|
||
_roomTempMin: string; _roomTempMax: string; _roomTempSrc: string;
|
||
_roomSrcFilter: string;
|
||
_roomSrcOpen: "temp" | "hum" | null;
|
||
_roomWallOpeningInputs: (openings?: readonly RenderOpening[], space?: SpaceModel | undefined) => Array<{ x: number; y: number; angle: number; length: number; }>;
|
||
_rszLimitViolation: JunctionLimitViolation | null;
|
||
_rulesDialog: { rules: IconRule[]; test: string; busy: boolean; } | null;
|
||
_saveConfigDebounced: Debounced<() => void>;
|
||
_saveNav: () => void;
|
||
_savePos: (d: DevItem, x: number, y: number) => void;
|
||
_screenToVb: (sx: number, sy: number) => number[];
|
||
_segments: number[][];
|
||
_selId: string | null;
|
||
_sentPos: Map<string, DeviceLayout[string] | null>;
|
||
_serverCfg: ServerConfig | null;
|
||
_serverStorage: boolean;
|
||
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; };
|
||
_terminalFrame: 0 | 1 | 2;
|
||
_settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; busy: boolean; } | null;
|
||
_supportDialog: SupportDialogState | null;
|
||
_showAll: boolean;
|
||
_showHidden: boolean;
|
||
_showToast: (msg: string) => void;
|
||
_signer: ContentSigner;
|
||
_space: string;
|
||
_spaceDialog: SpaceDialogState | null;
|
||
_spaceH: number;
|
||
_spaceModel: () => SpaceModel | undefined;
|
||
_spaceModelById: (id: string | null | undefined) => SpaceModel | undefined;
|
||
_spaceWalls: WallEntry[];
|
||
_splitSel: { roomId: string; pts: number[][]; } | null;
|
||
_stageBgHex: () => string;
|
||
_stageEl: HTMLElement | null;
|
||
_stagedDeviceSnapshotToken: number;
|
||
_suppressClick: boolean;
|
||
_swipeStart: { x: number; y: number; id: number; } | null;
|
||
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
|
||
_undoDevicePosition: () => void;
|
||
_thickWallCuts: () => number[][];
|
||
_tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; room?: boolean; } | null;
|
||
_toggleConfirmationLines: (intent: ResolvedToggleIntent) => string[];
|
||
_toggleConfirmationStateText: (target: ResolvedToggleTarget) => string;
|
||
_toggleIntent: (device: DevItem, devices?: readonly DevItem[]) => ResolvedToggleIntent | null;
|
||
_toggleMarkerDialogVisibility: () => void;
|
||
_toggleStateText: (entityId: string, fallback: string) => string;
|
||
_tool: MarkupTool;
|
||
_undoKind: "optimize" | "import" | null;
|
||
_undoPoint: () => void;
|
||
_vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null;
|
||
_vacAllCamerasFor: string | null;
|
||
_vacCalConfirm: CalibrationProposal | null;
|
||
_vacEnsureMarker: (d: DevItem) => Marker | null;
|
||
_vacEntity: (d: DevItem) => string | null;
|
||
_vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string;
|
||
_vacFit: VacuumFit | null;
|
||
_vacOpenAllCameras: (d: DevItem) => void;
|
||
_vacRt: Map<string, { trail: VacPt[]; lastKey: string; lastTs: number; moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null; }>;
|
||
_vacSource: (d: DevItem, planHass?: any) => string | null;
|
||
_vacObservedMapId: (d: DevItem, source: string) => string | undefined;
|
||
_vacSourceResolution: (d: DevItem, includeAllCameras?: boolean, planHass?: any) => VacSourceResolution;
|
||
_vacSrvTrails: Record<string, any>;
|
||
_view: { x: number; y: number; w: number; h: number; } | null;
|
||
_viewportGestureDirty: boolean;
|
||
_viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number; w?: number; } | null;
|
||
_viewOr: (vb: number[]) => { x: number; y: number; w: number; h: number; };
|
||
_viewPreference: Record<string, "flat" | "iso">;
|
||
_virtualLights: VirtualLightSnapshot;
|
||
_visibleDeviceSnapshot: RenderDeviceSnapshot | null;
|
||
_wallDialog: { a: number[]; b: number[]; value: string; roomId: string | null; source: WallThickSource; sx: number; sy: number; } | null;
|
||
_wallFaceBatch: WallFaceBatch | null;
|
||
_wallFaceGraphCache: { key: string; value: WallFaceGraph; }[];
|
||
_wallKeyPitch: number;
|
||
_wallRepairDiagnostic: WallFaceRepairProposal | null;
|
||
_wallThickHover: { segs: number[][]; open: boolean; d: string; } | null;
|
||
_wallUnionCache: { key: string; value: ReturnType<typeof wallBodiesUnionPath>; } | null;
|
||
_wallUnionPool: Map<string, { key: string; value: ReturnType<typeof wallBodiesUnionPath>; }>;
|
||
_writeChain: Promise<void>;
|
||
_writesPending: number;
|
||
_sendConfigCandidate: (candidate: ServerConfig) => Promise<void>;
|
||
_writeConfig: () => Promise<void>;
|
||
_requestMode: (mode: 'view' | 'plan' | 'devices' | 'decor', animate?: boolean) => Promise<void>;
|
||
_checkOptimizeGeometry: (config: ServerConfig) => OptimizeGeometryPreflightResult;
|
||
_checkSpacePhysicalGeometry: (
|
||
config: ServerConfig,
|
||
spaceId: string,
|
||
captureWallGeometry?: (geometry: ReturnType<typeof wallBodiesGeometry>) => void,
|
||
) => ReturnType<typeof checkSpacePhysicalGeometry>;
|
||
_primeDrawWallField: () => void;
|
||
_reloadRejectedPhysicalWrite: () => Promise<void>;
|
||
_zoom: number;
|
||
_zoomBySpace: Record<string, number>;
|
||
hass: any;
|
||
isConnected: boolean;
|
||
renderRoot: HTMLElement | DocumentFragment;
|
||
requestUpdate: (name?: PropertyKey, oldValue?: unknown) => void;
|
||
updateComplete: Promise<boolean>;
|
||
}
|
||
|
||
/** Lazily loaded implementation of editor-only interaction and rendering. */
|
||
export const EDITOR_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||
|
||
export { EDITOR_LANGUAGE_RUNTIME }; // #627: see src/i18n/editor-language.ts
|
||
|
||
export class HouseplanEditorRuntime {
|
||
public readonly languageRuntime = EDITOR_LANGUAGE_RUNTIME;
|
||
private _junctionBaselineCache = new WeakMap<object, {
|
||
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
|
||
}>();
|
||
private _resizePreviewNodes: MultiWallNodeMap | null = null;
|
||
private _resizeBaselineLimits: JunctionLimitViolation[] = [];
|
||
private _resizeBaseFrameStable = true;
|
||
private _supportExpiryTimer?: number;
|
||
private _supportPreviewGeneration = 0;
|
||
private _decorAssetGuardReplace: boolean | null = null;
|
||
private readonly _decorImages: DecorImageEditor<SpaceGeometryState | null>;
|
||
// #592: маркерный диалог уехал в src/editors/marker-dialog.ts и обращается
|
||
// к контроллеру оттуда. Остальной класс использует `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),
|
||
geometrySnapshot: () => this._geometrySnapshot(),
|
||
clearFurniturePreview: () => this._clearFurniturePreview(),
|
||
recordGeometry: (name, before) => this._recordGeometry(name, before),
|
||
saveConfig: () => this._saveConfig(),
|
||
saveShape: () => this._decorSaveShape(),
|
||
setGuardReplace: (replace) => { this._decorAssetGuardReplace = replace; },
|
||
furnShiftDetach: () => this._furnShiftDetach(),
|
||
furnPick: (symbol) => this._furnPick(symbol),
|
||
furnFieldValue: (cm) => this._furnFieldValue(cm),
|
||
furnFieldToCm: (value) => this._furnFieldToCm(value),
|
||
});
|
||
this._radarSetup = new RadarSetupController({
|
||
hass: () => host.hass,
|
||
requestUpdate: () => host.requestUpdate(),
|
||
t: (key, vars) => host._t(key, vars),
|
||
configFromDraft: radarConfigFromDraft,
|
||
apply: (radar) => {
|
||
if (host._markerDialog) host._markerDialog = {
|
||
...host._markerDialog, radar, radarEligible: true, radarTouched: true, radarRemove: false,
|
||
};
|
||
},
|
||
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(),
|
||
updateComplete: () => host.updateComplete,
|
||
clearTip: () => { host._tip = null; },
|
||
});
|
||
host._resize = new ResizeController<
|
||
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
|
||
>();
|
||
}
|
||
public _routeLiveEditorUpdate(name?: PropertyKey, oldValue?: unknown): boolean {
|
||
const live = routeHouseplanEditorUpdate(this.host, name, oldValue);
|
||
if (!live && this.host._resize.preview) this._resizeBaseFrameStable = false;
|
||
return live;
|
||
}
|
||
public _commitLiveEditor(): void { commitHouseplanEditor(this.host); }
|
||
public _disposeLiveEditor(): void { this._radarSetup.reset(); disposeHouseplanEditor(this.host); }
|
||
public _cancelRadarSetup(): void { this._radarSetup.interrupt(); }
|
||
public async _whenLiveEditorSettled(): Promise<void> {
|
||
// An already queued pointer calculation runs before this continuation.
|
||
// A complete Lit render may also supersede the lightweight frame, and its
|
||
// updated() hook commits that same state before updateComplete resolves.
|
||
await this.host.updateComplete;
|
||
await whenHouseplanEditorSettled(this.host);
|
||
}
|
||
public _queuePointerMove(key: string, run: () => void): void { queueHouseplanPointerMove(this.host, key, run); }
|
||
public _flushPointerMove(key: string): void { flushHouseplanPointerMove(this.host, key); }
|
||
public _cancelPointerMove(key?: string): void { cancelHouseplanPointerMove(this.host, key); }
|
||
public _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
|
||
const ariaKey = `${key}.aria` as I18nKey;
|
||
const lang = langOf(this.host.hass, this.host._config?.language);
|
||
if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing;
|
||
return html`<hp-help data-help-key=${key}
|
||
.text=${t(lang, key)} .ariaLabel=${t(lang, ariaKey)}></hp-help>`;
|
||
}
|
||
|
||
public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
|
||
if (mode !== this.host._mode) this.host._cancelDangerConfirm();
|
||
this.host._endTabDrag();
|
||
this._clearFurniturePreview();
|
||
// A mode command is newer than the editor remembered by a same-route warm
|
||
// remount. Clear it before the same-mode early return: while can_write is
|
||
// pending, Lit may still be presenting the previous editor DOM even though
|
||
// the new instance already fails closed to `view`. Its close button must
|
||
// cancel the deferred editor in one press, not let the server response
|
||
// reopen it and force a second press (#95).
|
||
this.host._pendingNavMode = null;
|
||
const space = this.host._spaceModel();
|
||
if (!space) {
|
||
this.host._cancelModeTransition(false);
|
||
this.host._mode = 'view';
|
||
return;
|
||
}
|
||
if (this.host._kiosk && mode !== 'view') return; // wall devices never edit
|
||
if (this.host._mode === mode) {
|
||
if (!animate && mode === 'view' && this.host._modeTransitionBusy) {
|
||
const from = this.host._currentModeVisual(mode);
|
||
const targetZoom = this.host._modeTransitionTargetZoom;
|
||
const targetCenterX = this.host._modeTransitionTargetCenterX;
|
||
const targetCenterY = this.host._modeTransitionTargetCenterY;
|
||
this.host._cancelModeTransition(false);
|
||
this.host._commitViewModeAtomic(from, targetZoom, targetCenterX, targetCenterY);
|
||
}
|
||
return;
|
||
}
|
||
if (this.host._mode === 'devices') this.host._cancelDeviceDrag();
|
||
this.host._bootSoftCancel(); // navigation owns its own short, bounded transition
|
||
if ((mode === 'plan' || mode === 'decor') && !this.host._norm) {
|
||
this.host._showToast(this.host._t('toast.markup_needs_server'));
|
||
return;
|
||
}
|
||
if (this.host._wallFaceBatch) this._roomDialogCancel();
|
||
if (this.host._mode === 'plan' && this.host._tool === 'draw' && !this._finishWallChain()) return;
|
||
this.host._clearTransientHover(true);
|
||
this.host._cancelDevicePressFeedback();
|
||
const previousMode = this.host._mode;
|
||
const previousProjection = this.host._effectiveProjection();
|
||
const capturedVisual = this.host._currentModeVisual(previousMode);
|
||
const retargeting = this.host._modeTransitionBusy;
|
||
// The target toolbar replaces the old editor markup during the hidden
|
||
// measurement frame. Give that incoming content its own coordinate on the
|
||
// shared controller so editor-to-editor switches do not pop abruptly.
|
||
const fromVisual = capturedVisual && previousMode !== 'view' && mode !== 'view'
|
||
? { ...capturedVisual, toolbarContentOpacity: this.host._reducedMotion ? 1 : 0.35 }
|
||
: capturedVisual;
|
||
this.host._cancelModeTransition(false);
|
||
this.host._editorSecondary.closeForNavigation();
|
||
// A live decor transform is a transaction. Switching tabs must cancel and
|
||
// restore it, not merely forget its pointer record after the config has
|
||
// already been mutated by move/resize.
|
||
if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) this._cancelDecorGesture();
|
||
const baseChanges = !space.bg && (mode === 'view') !== (previousMode === 'view');
|
||
// A running/preparing transition has not committed its target to `_zoom`
|
||
// yet. Retarget from the painted frame, but preserve the latest camera
|
||
// intent; otherwise rapid View -> editor A -> editor B resurrects the old
|
||
// View zoom/center and settles somewhere a direct transition never would.
|
||
const returningToEditor = retargeting && previousMode === 'view' && mode !== 'view'
|
||
? this.host._modeTransitionEditorCamera : null;
|
||
let targetZoom = returningToEditor?.zoom
|
||
?? (retargeting ? this.host._modeTransitionTargetZoom : this.host._zoom);
|
||
let targetCenterX = returningToEditor
|
||
? returningToEditor.centerX
|
||
: retargeting ? this.host._modeTransitionTargetCenterX : fromVisual?.viewport.centerX;
|
||
let targetCenterY = returningToEditor
|
||
? returningToEditor.centerY
|
||
: retargeting ? this.host._modeTransitionTargetCenterY : fromVisual?.viewport.centerY;
|
||
if (previousMode === 'view' && mode !== 'view' && !retargeting) {
|
||
// remember the view-mode viewport: whatever zooming happens inside the
|
||
// editors is a working tool, not what the user wants to see afterwards
|
||
const v = this.host._view;
|
||
this.host._viewModeSnap = {
|
||
space: this.host._space,
|
||
zoom: this.host._zoom,
|
||
cx: v ? this.host._logicalViewCenter(previousProjection)?.x : undefined,
|
||
cy: v ? this.host._logicalViewCenter(previousProjection)?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined,
|
||
};
|
||
if (previousProjection === 'iso') {
|
||
const logical = this.host._logicalViewCenter('iso');
|
||
targetCenterX = logical?.x;
|
||
targetCenterY = logical?.y;
|
||
this.host._view = null;
|
||
this.host._mode = mode;
|
||
this.host._applyView(this.host._zoom, logical?.x, logical?.y);
|
||
}
|
||
if (baseChanges) {
|
||
targetZoom = 1;
|
||
targetCenterX = undefined;
|
||
targetCenterY = undefined;
|
||
}
|
||
}
|
||
this.host._mode = mode;
|
||
if (previousMode === 'devices' && mode !== 'devices') {
|
||
this.host._showHidden = false;
|
||
this.host._deviceInbox = null;
|
||
this.host._deviceInboxReturn = null;
|
||
this.host._deviceInboxMemo = null;
|
||
}
|
||
this.host._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode;
|
||
if (mode === 'view') {
|
||
if (previousMode !== 'view' && !retargeting) {
|
||
this.host._modeTransitionEditorCamera = {
|
||
zoom: this.host._zoom,
|
||
centerX: fromVisual?.viewport.centerX,
|
||
centerY: fromVisual?.viewport.centerY,
|
||
};
|
||
}
|
||
const snap = this.host._viewModeSnap;
|
||
// restore the snapshot only for the space it was taken in
|
||
if (snap && snap.space === this.host._space) {
|
||
targetZoom = snap.zoom;
|
||
const targetProjection = this.host._labsIso && this.host._viewPreference[this.host._space] === 'iso'
|
||
? 'iso' : 'flat';
|
||
const center = snap.cx != null && snap.cy != null && targetProjection === 'iso'
|
||
? projectPlanPoint([snap.cx, snap.cy], 0) : null;
|
||
targetCenterX = center?.[0] ?? snap.cx;
|
||
targetCenterY = center?.[1] ?? snap.cy;
|
||
} else if (snap) {
|
||
// HP-1543-01: the floor CHANGED inside the editor — the snapshot
|
||
// belongs to the floor the editor was entered from, while _zoom still
|
||
// carries the editor's working zoom for the floor we are exiting on.
|
||
// Dropping the snapshot alone left that editor zoom (say 500%) on
|
||
// screen in view mode. The per-space store was never polluted
|
||
// (_saveZoom is view-only), so the standard centred
|
||
// _restoreZoom() puts back this floor's saved VIEW viewport. Its rAF
|
||
// reads _zoomBySpace, not the snapshot, so the same-tick floor-tab
|
||
// race the view-only guard protects against stays closed.
|
||
targetZoom = this.host._zoomBySpace[this.host._space] || 1;
|
||
targetCenterX = undefined;
|
||
targetCenterY = undefined;
|
||
}
|
||
} else {
|
||
this.host._modeTransitionEditorCamera = {
|
||
zoom: targetZoom, centerX: targetCenterX, centerY: targetCenterY,
|
||
};
|
||
}
|
||
this.host._modeTransitionTargetZoom = targetZoom;
|
||
this.host._modeTransitionTargetCenterX = targetCenterX;
|
||
this.host._modeTransitionTargetCenterY = targetCenterY;
|
||
if (previousProjection === 'iso'
|
||
|| (mode === 'view' && this.host._labsIso && this.host._viewPreference[this.host._space] === 'iso')) {
|
||
this.host._modeTransitionForceAtomic = true;
|
||
}
|
||
const request = ++this.host._modeTransitionRequest;
|
||
if (!animate) {
|
||
// The only non-animated caller is route departure; keep the guard local
|
||
// so a future editor caller cannot accidentally use View geometry.
|
||
if (mode === 'view') {
|
||
this.host._commitViewModeAtomic(fromVisual, targetZoom, targetCenterX, targetCenterY);
|
||
}
|
||
} else if (fromVisual) {
|
||
this.host._modeTransitionPreparing = true;
|
||
this.host._modeTransitionVisual = fromVisual;
|
||
this.host._prepareModeTransition(
|
||
request, fromVisual, mode, targetZoom, targetCenterX, targetCenterY,
|
||
);
|
||
} else {
|
||
// A zero-sized stage cannot animate. Keep the functional fallback
|
||
// atomic and publish the exact target as soon as it is measurable.
|
||
this.host._modeTransitionPreparing = false;
|
||
this.host._modeTransitionVisual = null;
|
||
this.host._zoom = targetZoom;
|
||
this.host._view = null;
|
||
requestAnimationFrame(() => {
|
||
if (!this.host.isConnected || request !== this.host._modeTransitionRequest || this.host._mode !== mode) return;
|
||
this.host._applyView(targetZoom, targetCenterX, targetCenterY);
|
||
if (mode === 'view') {
|
||
this.host._viewModeSnap = null;
|
||
this.host._modeTransitionEditorCamera = null;
|
||
}
|
||
this.host.requestUpdate();
|
||
});
|
||
}
|
||
this.host._path = [];
|
||
this._clearPlanSnapHover();
|
||
this._clearOpeningPlacement(true);
|
||
this.host._tool = 'draw';
|
||
this.host._mergeSel = null;
|
||
this.host._mergeDialog = null;
|
||
this.host._splitSel = null;
|
||
this.host._pendingSplit = null;
|
||
this.host._selId = null;
|
||
this.host._physicalSel = null;
|
||
this.host._physicalDialog = null;
|
||
this.host._physicalDrag = null;
|
||
this._rszResetController();
|
||
this.host._tip = null;
|
||
this.host._hoverRoom = null;
|
||
this.host._decorDraft = null;
|
||
this.host._decorSel = null;
|
||
this.host._decorMove = null;
|
||
this.host._backdropDialog = null;
|
||
// Every Background session starts predictably on Select. The image has an
|
||
// explicit tool of its own; only that tool may claim its body or frame.
|
||
this.host._decorTool = 'select';
|
||
this.host._bdDrag = null;
|
||
this.host._dtDrag = null;
|
||
this.host._dtBox = null;
|
||
if (mode === 'plan') this._primeDrawWallField();
|
||
this.host._saveNav();
|
||
}
|
||
|
||
public _primeDrawWallField(): void {
|
||
if (this.host._drawWallField === null) {
|
||
this.host._drawWallField = cmToField(DRAW_WALL_DEFAULT_CM, this.host._imperial);
|
||
}
|
||
}
|
||
|
||
public _showPhysicalRange(max = this.host._drawWallMaxCm, min = 0): void {
|
||
this.host._showToast(this.host._t('toast.physical_range', {
|
||
min: cmToField(min, this.host._imperial),
|
||
max: cmToField(max, this.host._imperial),
|
||
unit: this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm'),
|
||
}));
|
||
}
|
||
|
||
public _mergeSpacePartitions(sp: any, seedIds?: string[]): number {
|
||
const partitions = (sp?.partitions || []) as PartitionCfg[];
|
||
if (partitions.length < 2) return 0;
|
||
const result = mergeCollinearPartitions(partitions, {
|
||
pitch: GRID_STEP_N,
|
||
seedIds,
|
||
geometry: spaceMergeGeometry(sp),
|
||
});
|
||
if (!result.merged) return 0;
|
||
sp.partitions = result.partitions;
|
||
applyOpeningMoves(sp.openings, sp.partitions, result.openingMoves, {
|
||
coordScale: NORM_W, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch,
|
||
});
|
||
return result.merged;
|
||
}
|
||
|
||
public _finalizeWallChainPartitions(seedIds: readonly string[]): boolean {
|
||
if (!seedIds.length) return true;
|
||
const sp = this.host._curSpaceCfg;
|
||
const model = this.host._spaceModel();
|
||
const before = this._geometrySnapshot();
|
||
if (!sp || !model || !before) return false;
|
||
const seed = seedIds.map((id) => wallChainLiveSeed(sp, id)).find(Boolean) || null;
|
||
const session = {
|
||
path: this.host._path.map((point) => [...point]),
|
||
chainId: this.host._activeWallChainId,
|
||
ids: [...this.host._activeWallChainPartitionIds],
|
||
cms: [...this.host._wallChainSegmentCms],
|
||
redo: this.host._wallChainRedo.map((item) => ({
|
||
...item, point: [...item.point],
|
||
})),
|
||
closingCm: this.host._closingWallCm,
|
||
};
|
||
let result;
|
||
try {
|
||
result = finalizeWallChainSpace(
|
||
sp, model, this.host._openCuts(), seedIds,
|
||
{
|
||
pitch: this.host._wallKeyPitch,
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
coordScale: NORM_W,
|
||
},
|
||
);
|
||
} catch {
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
return false;
|
||
}
|
||
if (!result.report.changed) return true;
|
||
const config = this.host._serverCfg;
|
||
const index = config?.spaces?.findIndex((space) => space.id === before.spaceId) ?? -1;
|
||
if (index < 0) return false;
|
||
config!.spaces[index] = result.space;
|
||
const committed = commitWallChainFinishGeometry(
|
||
this, this.host._t('history.wall_segment'), before, seed,
|
||
);
|
||
if (committed) return true;
|
||
// The common transaction clears gestures on refusal. A finish failure is
|
||
// fail-closed: keep the visible chain so the requested navigation can be
|
||
// retried after the underlying geometry is fixed.
|
||
this.host._path = session.path;
|
||
this.host._activeWallChainId = session.chainId;
|
||
this.host._activeWallChainPartitionIds = session.ids;
|
||
this.host._wallChainSegmentCms = session.cms;
|
||
this.host._wallChainRedo = session.redo;
|
||
this.host._closingWallCm = session.closingCm;
|
||
return false;
|
||
}
|
||
|
||
public _finishWallChain(): boolean {
|
||
if (this.host._tool !== 'draw' || this.host._wallFaceBatch || this.host._roomDialog) return true;
|
||
if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return false;
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this._clearPlanSnapHover();
|
||
return true;
|
||
}
|
||
|
||
public _activateMarkupTool(tool: MarkupTool): void {
|
||
if (tool === this.host._tool) return;
|
||
if (this.host._wallFaceBatch) this._roomDialogCancel();
|
||
if (this.host._tool === 'draw' && !this._finishWallChain()) return;
|
||
else this._cancelPath();
|
||
if (this.host._tool === 'resize') {
|
||
if (this.host._resize.dragging) this._rszCancelDrag();
|
||
else this.host._resize.reset();
|
||
}
|
||
this.host._tool = tool;
|
||
if (tool === 'resize') this.host._resize.selectRoom(null);
|
||
if (tool === 'wallthick') this.host._wallDialog = null;
|
||
}
|
||
|
||
public _limitReached(kind: 'partition' | 'column'): boolean {
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (!sp) return true;
|
||
const reached = kind === 'partition'
|
||
? (sp.partitions || []).length >= MAX_PARTITIONS
|
||
: (sp.wall_columns || []).length >= MAX_WALL_COLUMNS;
|
||
if (reached) this.host._showToast(this.host._t('toast.physical_limit'));
|
||
return reached;
|
||
}
|
||
|
||
public _svgPoint(ev: MouseEvent): number[] {
|
||
const stage = this.host.renderRoot.querySelector('.stage') as HTMLElement;
|
||
const r = stage.getBoundingClientRect();
|
||
return this.host._screenToVb(ev.clientX - r.left, ev.clientY - r.top);
|
||
}
|
||
|
||
public _snap(p: number[]): number[] {
|
||
const g = this.host._gridPitch;
|
||
return [clampCanvasR(snapToGrid(p[0], g)), clampCanvasR(snapToGrid(p[1], g))];
|
||
}
|
||
|
||
public _snapDrawPoint(p: number[], lock45 = false): number[] {
|
||
const anchor = this.host._path[this.host._path.length - 1];
|
||
const candidate = lock45 && anchor
|
||
? snapSegment45(anchor, p, this.host._gridPitch, SANE_LIMIT)
|
||
: p;
|
||
return this._snap(candidate);
|
||
}
|
||
|
||
public _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] {
|
||
if (!this.host._openingsR.length) return [];
|
||
const index = this.host._openingWallIndexFor(space, openCuts).value;
|
||
const cuts: number[][] = [];
|
||
for (const input of this.host._roomWallOpeningInputs(this.host._openingsR, space)) {
|
||
const association = resolveOpeningWallAssociation(index, input);
|
||
if (!association.negative && !association.positive) continue;
|
||
const rad = input.angle * Math.PI / 180;
|
||
const dx = Math.cos(rad) * input.length / 2;
|
||
const dy = Math.sin(rad) * input.length / 2;
|
||
cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]);
|
||
}
|
||
return cuts;
|
||
}
|
||
|
||
public _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } {
|
||
const space = this.host._spaceModel();
|
||
if (!space) {
|
||
return { key: `${this.host._space}|empty`, value: { segments: [], endpoints: [] } };
|
||
}
|
||
const key = [
|
||
this.host._space, this.host._cfgEpoch, space.rooms.length, space.partitions.length,
|
||
].join('|');
|
||
if (this.host._planSnapGeometryCache?.key === key) return this.host._planSnapGeometryCache;
|
||
const zeroCuts = this.host._openCuts();
|
||
const value = buildPlanSnapGeometry({
|
||
space,
|
||
// A zero-thickness wall is still a canonical wall axis and snap target.
|
||
// Only an actual opening removes the presentation interval (#306).
|
||
roomCuts: this._planSnapOpeningCuts(space, zeroCuts),
|
||
partitionCuts: this._partitionOpeningCuts(space),
|
||
epsilon: this.host._gridPitch * 0.0002,
|
||
});
|
||
this.host._planSnapGeometryCache = { key, value };
|
||
return this.host._planSnapGeometryCache;
|
||
}
|
||
|
||
public _hiddenWallDiagnosticSnapshot(): {
|
||
key: string; value: HiddenWallDiagnosticGeometry;
|
||
} {
|
||
const space = this.host._spaceModel();
|
||
if (!space) {
|
||
return { key: `${this.host._space}|hidden-empty`, value: { segments: [], endpoints: [] } };
|
||
}
|
||
const key = [
|
||
'hidden', this.host._space, this.host._cfgEpoch,
|
||
space.rooms.length, space.partitions.length,
|
||
].join('|');
|
||
if (this.host._hiddenWallDiagnosticCache?.key === key) {
|
||
return this.host._hiddenWallDiagnosticCache;
|
||
}
|
||
const value = buildHiddenWallDiagnosticGeometry({
|
||
space,
|
||
epsilon: this.host._gridPitch * 0.0002,
|
||
});
|
||
this.host._hiddenWallDiagnosticCache = { key, value };
|
||
return this.host._hiddenWallDiagnosticCache;
|
||
}
|
||
|
||
public _planStructuralGeometrySnapshot(): { key: string; value: PlanSnapGeometry } {
|
||
const space = this.host._spaceModel();
|
||
if (!space) {
|
||
return { key: `${this.host._space}|structural-empty`, value: { segments: [], endpoints: [] } };
|
||
}
|
||
const key = [
|
||
'structural', this.host._space, this.host._cfgEpoch,
|
||
space.rooms.length, space.partitions.length,
|
||
].join('|');
|
||
if (this.host._planStructuralGeometryCache?.key === key) {
|
||
return this.host._planStructuralGeometryCache;
|
||
}
|
||
const value = buildPlanSnapGeometry({
|
||
space,
|
||
epsilon: this.host._gridPitch * 0.0002,
|
||
});
|
||
this.host._planStructuralGeometryCache = { key, value };
|
||
return this.host._planStructuralGeometryCache;
|
||
}
|
||
|
||
public _planSnapContextKey(geometryKey: string): string {
|
||
const first = this.host._path[0];
|
||
const anchor = this.host._path[this.host._path.length - 1];
|
||
return [
|
||
geometryKey, this.host._tool, this.host._path.length,
|
||
first ? `${first[0]},${first[1]}` : '',
|
||
anchor ? `${anchor[0]},${anchor[1]}` : '',
|
||
].join('|');
|
||
}
|
||
|
||
public _resolvePlanDrawPoint(
|
||
raw: number[], lock45: boolean,
|
||
): {
|
||
point: number[]; candidate: PlanSnapCandidate | null;
|
||
conflicts: PlanSnapEndpoint[]; ambiguous: boolean; contextKey: string;
|
||
} {
|
||
const snapshot = this._planSnapGeometrySnapshot();
|
||
const anchor = this.host._path[this.host._path.length - 1];
|
||
const closure = this.host._tool === 'draw' && this.host._path.length >= 3
|
||
? [{ point: this.host._path[0], key: 'closure:first-point' }]
|
||
: [];
|
||
// Accepted segments are immediately part of the canonical partition graph.
|
||
// Keep their intermediate vertices out of ordinary snapping so the active
|
||
// chain cannot snap onto itself. The first vertex is deliberately omitted:
|
||
// once three points exist it remains the explicit contour-closure target.
|
||
const excludedActivePoints = this.host._path.length > 1
|
||
? this.host._path.slice(1)
|
||
: anchor ? [anchor] : [];
|
||
const options = {
|
||
tolerance: this._cssPxToRender(12),
|
||
distinguishTolerance: this._cssPxToRender(8),
|
||
gridStep: this.host._gridPitch,
|
||
excludePoints: excludedActivePoints,
|
||
extraEndpoints: closure,
|
||
epsilon: this.host._gridPitch * 0.0002,
|
||
};
|
||
const resolution = lock45 && anchor
|
||
? resolveStrictPlanSnap(snapshot.value, raw, { ...options, anchor })
|
||
: resolvePlanSnapResult(snapshot.value, raw, options);
|
||
const candidate = resolution.kind === 'resolved' ? resolution.candidate : null;
|
||
const snapped = candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45);
|
||
const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped;
|
||
// A nearby topology endpoint loses snap ownership when the drafting rule
|
||
// moves the actually persisted point away from it. Preview and click must
|
||
// describe the exact same geometry (#290).
|
||
const effectiveCandidate = candidate && samePoint(point, candidate.point) ? candidate : null;
|
||
return {
|
||
point,
|
||
candidate: effectiveCandidate,
|
||
conflicts: resolution.kind === 'ambiguous' ? resolution.conflicts : [],
|
||
ambiguous: resolution.kind === 'ambiguous',
|
||
contextKey: this._planSnapContextKey(snapshot.key),
|
||
};
|
||
}
|
||
|
||
public _clearPlanSnapHover(clearCursor = true): void {
|
||
this.host._planSnapHover = null;
|
||
this._syncPlanSnapActiveMarker(null);
|
||
this._syncPlanSnapConflictMarkers([]);
|
||
if (clearCursor) this.host._cursorPt = null;
|
||
}
|
||
|
||
public _samePt(a: readonly number[], b: readonly number[]): boolean {
|
||
return samePoint(a, b);
|
||
}
|
||
|
||
public _dropLegacySegments(config = this.host._serverCfg): void {
|
||
// «Ripple only» was removed from the UI: keep old configs readable, then
|
||
// materialise the recognisable icon+activity presentation on any write.
|
||
for (const marker of config?.markers || []) {
|
||
if (marker.display === 'ripple') marker.display = 'icon_ripple';
|
||
}
|
||
for (const sp of config?.spaces || []) {
|
||
delete (sp as any).segments;
|
||
const physicalIds = new Set<string>();
|
||
const validId = (id: any): id is string => typeof id === 'string'
|
||
&& id.length >= 1 && id.length <= 64 && !physicalIds.has(id);
|
||
const keepId = (id: string): boolean => { physicalIds.add(id); return true; };
|
||
const point = (p: any): p is number[] => Array.isArray(p) && p.length === 2
|
||
&& p.every((v: any) => Number.isFinite(Number(v)) && Math.abs(Number(v)) <= CANVAS_LIMIT);
|
||
if (Array.isArray((sp as any).partitions)) {
|
||
(sp as any).partitions = (sp as any).partitions.filter((p: any) =>
|
||
p && validId(p.id) && point(p.a) && point(p.b)
|
||
&& Math.hypot(p.a[0] - p.b[0], p.a[1] - p.b[1]) > 1e-9
|
||
&& keepId(p.id))
|
||
.map((p: any) => ({
|
||
...p,
|
||
cm: Number.isFinite(Number(p.cm)) ? Math.max(0, Math.min(100, Number(p.cm))) : 15,
|
||
}));
|
||
if (!(sp as any).partitions.length) delete (sp as any).partitions;
|
||
}
|
||
if (Array.isArray((sp as any).wall_columns)) {
|
||
(sp as any).wall_columns = (sp as any).wall_columns.filter((c: any) =>
|
||
c && validId(c.id) && point(c.center) && keepId(c.id))
|
||
.map((c: any) => ({
|
||
id: c.id, shape: c.shape === 'circle' ? 'circle' : 'square',
|
||
center: [Number(c.center[0]), Number(c.center[1])],
|
||
cm: clampColumnCm(Number(c.cm) || 15),
|
||
...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }),
|
||
}));
|
||
if (!(sp as any).wall_columns.length) delete (sp as any).wall_columns;
|
||
}
|
||
if (Array.isArray(sp.walls)) {
|
||
const model = selectSpaceModelById(spaceModels({ spaces: [sp] } as any), sp.id);
|
||
const cuts = model
|
||
? resolveZeroWalls(sp, model, NORM_W, GRID_PITCH * 0.02).contour
|
||
.map((cut) => cut.map((value) => value / NORM_W))
|
||
: sanitizeOpenSpans((sp as any).open_spans)
|
||
.map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]);
|
||
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, cuts);
|
||
if (!sp.walls.length) delete sp.walls;
|
||
}
|
||
}
|
||
}
|
||
|
||
public _rollbackRejectedPhysicalWrites(
|
||
entries: Array<[string, { fingerprint: string; before: SpaceGeometryState }]>,
|
||
): boolean {
|
||
if (!this.host._serverCfg || !entries.length) return false;
|
||
let restored = false;
|
||
for (const [spaceId] of entries) {
|
||
const pending = this.host._pendingPhysicalWrites.get(spaceId);
|
||
if (!pending) continue;
|
||
restored = this._restoreGeometryStateInConfig(this.host._serverCfg, pending.before) || restored;
|
||
this.host._pendingPhysicalWrites.delete(spaceId);
|
||
}
|
||
if (!restored) return false;
|
||
this._clearGeometryGesture();
|
||
this.host._geometryHistory.clear();
|
||
this.host._cfgEpoch++;
|
||
this.host._modelCache = null;
|
||
this.host._wallUnionCache = null;
|
||
this.host._physicalBodiesCache = null;
|
||
this.host._frame = null;
|
||
this.host._regSignature = '';
|
||
this.host._adoption.refreshConfigFingerprint();
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
return true;
|
||
}
|
||
|
||
public async _reloadRejectedPhysicalWrite(): Promise<void> {
|
||
const queued = this.host._writeChain;
|
||
await queued.catch(() => undefined);
|
||
await this.host._reloadConfigOnly(true);
|
||
}
|
||
|
||
public _prepareConfigCandidate(config: ServerConfig): ServerConfig { this._dropLegacySegments(config); return canonicalizeConfigGeometry(config); }
|
||
|
||
public _writeConfig(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
|
||
this.host._writesPending++;
|
||
this.host._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => {
|
||
if (!this.host._serverCfg) return;
|
||
const liveFingerprint = contentFingerprint(this.host._serverCfg);
|
||
const candidate = this._prepareConfigCandidate(this.host._serverCfg);
|
||
const candidateFingerprint = contentFingerprint(candidate);
|
||
if (attempt && liveFingerprint === attempt.attemptedFingerprint) Object.assign(attempt, { revision: this.host._cfgRev, attempted: candidate, attemptedFingerprint: candidateFingerprint });
|
||
const strictEntries = [...this.host._pendingPhysicalWrites.entries()];
|
||
for (const [spaceId, accepted] of strictEntries) {
|
||
const candidateSpace = candidate.spaces.find((space) => space.id === spaceId);
|
||
const exactFingerprint = spacePhysicalGeometryFingerprint(candidateSpace);
|
||
if (exactFingerprint === accepted.fingerprint) continue;
|
||
let safe = false;
|
||
try { safe = this.host._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { safe = false; }
|
||
if (!safe) {
|
||
this._restoreGeometryStateLocal(accepted.before);
|
||
this.host._pendingPhysicalWrites.delete(spaceId);
|
||
this.host._geometryHistory.clear();
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
throw Object.assign(new Error('unsafe wall geometry'), { code: 'geometry-unsafe' });
|
||
}
|
||
accepted.fingerprint = exactFingerprint;
|
||
}
|
||
// Do not replace the reactive root merely because the pure helper
|
||
// returned a clone. Besides an unnecessary render, that used to expose
|
||
// unrelated write-time cleanup as a visual change (#224 review H1).
|
||
// A real coordinate change still adopts the exact object sent below;
|
||
// willUpdate owns the corresponding geometry-epoch bump.
|
||
this.host._adoption.stageConfigCandidate(candidate);
|
||
try {
|
||
await this.host._sendConfigCandidate(candidate);
|
||
} catch (error) {
|
||
const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries);
|
||
if (!rolledBack) throw error;
|
||
// HA normally rejects with an Error-like object, but integrations and
|
||
// test doubles may reject with a string. Never lose the rollback tag:
|
||
// the caller still has to schedule an authoritative re-read after the
|
||
// synchronous, fail-closed restoration (#314).
|
||
const failure: any = error && typeof error === 'object'
|
||
? error : Object.assign(new Error(String(error)), { cause: error });
|
||
failure.physicalGeometryRolledBack = true;
|
||
throw failure;
|
||
}
|
||
for (const [spaceId, accepted] of strictEntries) {
|
||
if (this.host._pendingPhysicalWrites.get(spaceId)?.fingerprint === accepted.fingerprint)
|
||
this.host._pendingPhysicalWrites.delete(spaceId);
|
||
}
|
||
});
|
||
const mine = this.host._writeChain.finally(() => { this.host._writesPending--; });
|
||
// keep the chain itself unadorned so the next link waits for the write only
|
||
return mine;
|
||
}
|
||
|
||
public _saveConfig(): void {
|
||
this.host._cfgEpoch++;
|
||
this.host._saveConfigDebounced();
|
||
}
|
||
|
||
public _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryState | null {
|
||
const sp = config?.spaces?.find((s: any) => s.id === spaceId);
|
||
if (!sp) return null;
|
||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||
const plan_transform: SpaceGeometryState['plan_transform'] = {};
|
||
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) {
|
||
if (sp[key] !== undefined) plan_transform[key] = sp[key];
|
||
}
|
||
return {
|
||
spaceId,
|
||
rooms: copy(sp.rooms || []),
|
||
...(Array.isArray(sp.openings) ? { openings: copy(sp.openings) } : {}),
|
||
...(Array.isArray(sp.walls) ? { walls: copy(sp.walls) } : {}),
|
||
...(Array.isArray(sp.wall_segments) ? { wall_segments: copy(sp.wall_segments) } : {}),
|
||
...(Array.isArray((sp as any).open_spans)
|
||
? { open_spans: copy((sp as any).open_spans) }
|
||
: {}),
|
||
...(Array.isArray((sp as any).partitions)
|
||
? { partitions: copy((sp as any).partitions) }
|
||
: {}),
|
||
...(Array.isArray((sp as any).wall_columns)
|
||
? { wall_columns: copy((sp as any).wall_columns) }
|
||
: {}),
|
||
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}),
|
||
plan_transform,
|
||
};
|
||
}
|
||
|
||
public _geometrySnapshot(spaceId = this.host._space): SpaceGeometryState | null {
|
||
return this._geometrySnapshotFromConfig(this.host._serverCfg, spaceId);
|
||
}
|
||
|
||
public _recordGeometry(name: string, before: SpaceGeometryState | null): void {
|
||
if (!before) return;
|
||
const after = this._geometrySnapshot(before.spaceId);
|
||
if (!after || JSON.stringify(before) === JSON.stringify(after)) return;
|
||
if (before.wallChainSeedIds) {
|
||
after.wallChainSeedIds = [...this.host._activeWallChainPartitionIds];
|
||
}
|
||
if (before.roomReferences) {
|
||
after.roomReferences = captureMarkerRoomReferences(
|
||
this.host._serverCfg?.markers || [],
|
||
before.roomReferences.map((reference) => reference.markerId),
|
||
);
|
||
}
|
||
this.host._geometryHistory.push({ name, before, after });
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _restoreGeometryStateInConfig(
|
||
config: any, state: SpaceGeometryState, preserveIdentityHints = false,
|
||
): boolean {
|
||
const sp = config?.spaces?.find((space) => space.id === state.spaceId);
|
||
if (!sp) return false;
|
||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||
const oldRooms = new Map((sp.rooms || []).map((room) => [room.id, room]));
|
||
sp.rooms = copy(state.rooms);
|
||
if (preserveIdentityHints) {
|
||
for (const room of sp.rooms) {
|
||
const old: any = oldRooms.get(room.id);
|
||
if (!Array.isArray(room.wall_ids) && Array.isArray(old?.wall_ids)
|
||
&& old.wall_ids.length === room.poly?.length) room.wall_ids = copy(old.wall_ids);
|
||
}
|
||
}
|
||
const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans'
|
||
| 'partitions' | 'wall_columns' | 'decor', value: unknown): void => {
|
||
if (value !== undefined) (sp as any)[key] = copy(value);
|
||
else if (!(preserveIdentityHints && key === 'wall_segments')) delete (sp as any)[key];
|
||
};
|
||
const oldOpenings = new Map((sp.openings || []).map((opening: any) => [opening.id, opening]));
|
||
assign('openings', state.openings);
|
||
assign('walls', state.walls);
|
||
assign('wall_segments', state.wall_segments);
|
||
assign('open_spans', state.open_spans);
|
||
assign('partitions', state.partitions);
|
||
assign('wall_columns', state.wall_columns);
|
||
assign('decor', state.decor);
|
||
if (preserveIdentityHints) {
|
||
for (const opening of sp.openings || []) {
|
||
const old: any = oldOpenings.get(opening.id);
|
||
if (!opening.host && old?.host?.kind === 'wall') opening.host = copy(old.host);
|
||
}
|
||
}
|
||
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const)
|
||
delete (sp as any)[key];
|
||
Object.assign(sp, copy(state.plan_transform || {}));
|
||
if (state.roomReferences) {
|
||
config.markers ||= [];
|
||
restoreMarkerRoomReferences(config.markers, state.roomReferences);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public _canonicalWallChainHistoryState(state: SpaceGeometryState): SpaceGeometryState | null {
|
||
if (this.host._activeWallChainId || !state.wallChainSeedIds?.length) return state;
|
||
if (!this.host._serverCfg) return null;
|
||
const candidate = JSON.parse(JSON.stringify(this.host._serverCfg));
|
||
if (!this._restoreGeometryStateInConfig(candidate, state)) return null;
|
||
let committed: ServerConfig;
|
||
try { committed = commitWallSegmentModel(candidate).config; }
|
||
catch { return null; }
|
||
const raw = committed.spaces?.find((space) => space.id === state.spaceId);
|
||
const model = selectSpaceModelById(spaceModels(committed), state.spaceId);
|
||
if (!raw || !model) return null;
|
||
const result = finalizeWallChainSpace(
|
||
raw,
|
||
model,
|
||
geometryOpenCuts(raw, model, GRID_PITCH, NORM_W),
|
||
state.wallChainSeedIds,
|
||
{
|
||
pitch: GRID_STEP_N,
|
||
cellCm: Number(raw.cell_cm) > 0 ? Number(raw.cell_cm) : 5,
|
||
gridPitch: GRID_PITCH,
|
||
coordScale: NORM_W,
|
||
},
|
||
);
|
||
const index = committed.spaces.findIndex((space) => space.id === state.spaceId);
|
||
committed.spaces[index] = result.space;
|
||
// The finalizer can replace partitions with authoritative wall segments.
|
||
// Cross that model boundary once more before recording the history state,
|
||
// exactly as the production finish transaction does. Otherwise a Redo
|
||
// could restore the pre-commit carrier representation and Optimize would
|
||
// find work immediately afterwards.
|
||
let finalized: ServerConfig;
|
||
try { finalized = commitWallSegmentModel(committed).config; }
|
||
catch { return null; }
|
||
const normalized = this._geometrySnapshotFromConfig(finalized, state.spaceId);
|
||
if (!normalized) return null;
|
||
normalized.wallChainSeedIds = [...state.wallChainSeedIds];
|
||
if (state.roomReferences) normalized.roomReferences = JSON.parse(JSON.stringify(state.roomReferences));
|
||
return normalized;
|
||
}
|
||
|
||
public _restoreGeometryStateLocal(state: SpaceGeometryState): boolean {
|
||
if (!this._restoreGeometryStateInConfig(this.host._serverCfg, state)) return false;
|
||
this.host._cfgEpoch++;
|
||
this.host._modelCache = null;
|
||
this.host._wallUnionCache = null;
|
||
this.host._physicalBodiesCache = null;
|
||
this.host._frame = null;
|
||
this.host.requestUpdate();
|
||
return true;
|
||
}
|
||
|
||
public _wallModelBlockerLabel(error: unknown): string {
|
||
const reason = error instanceof WallSegmentModelError ? error.reason : 'invalid-room';
|
||
return this.host._t(`wall_model.reason.${reason}`);
|
||
}
|
||
|
||
public _hasLegacyZeroWallFields(config: any = this.host._serverCfg): boolean {
|
||
return (config?.spaces || []).some((space) => (
|
||
(Array.isArray(space?.open_spans) && space.open_spans.length > 0)
|
||
|| (space?.rooms || []).some((room) => (
|
||
Array.isArray(room?.open_to) && room.open_to.length > 0
|
||
))
|
||
));
|
||
}
|
||
|
||
public _showWallModelMigrationBlocked(error: unknown): void {
|
||
const key = this._hasLegacyZeroWallFields()
|
||
? 'toast.zero_wall_migration_blocked'
|
||
: 'toast.wall_model_migration_blocked';
|
||
this.host._showToast(this.host._t(key, { reason: this._wallModelBlockerLabel(error) }));
|
||
}
|
||
|
||
public _limitSegmentsOf(space: any): LimitSegment[] {
|
||
const segments: LimitSegment[] = [];
|
||
for (const segment of (space?.wall_segments || [])) {
|
||
if (segment?.a && segment?.b) {
|
||
segments.push({
|
||
id: String(segment.id || ''), a: segment.a, b: segment.b, cm: Number(segment.cm),
|
||
});
|
||
}
|
||
}
|
||
for (const partition of (space?.partitions || [])) {
|
||
if (partition?.a && partition?.b) {
|
||
segments.push({
|
||
id: String(partition.id || ''), a: partition.a, b: partition.b, cm: Number(partition.cm),
|
||
});
|
||
}
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
public _junctionLimitViolations(
|
||
config: unknown, spaceId: string,
|
||
/** #330 §4.7: an already computed masonry pass of THIS config (e.g. the
|
||
* resize preflight's artifact) — the union is never paid twice. */
|
||
sharedGeometry?: JunctionSharedGeometry | null,
|
||
roomIds?: ReadonlySet<string>,
|
||
): JunctionLimitViolation[] {
|
||
const space = ((config as { spaces?: Array<{ id?: unknown }> } | null)?.spaces || [])
|
||
.find((item) => item?.id === spaceId);
|
||
return space ? junctionLimitViolations(
|
||
config, spaceId, this._limitSegmentsOf(space), sharedGeometry, roomIds,
|
||
) : [];
|
||
}
|
||
|
||
public _junctionLimitLabel(violation: JunctionLimitViolation): string {
|
||
const round = (value: number) => String(Math.round(value * 10) / 10);
|
||
return this.host._t(`junction.limit_${violation.rule}` as I18nKey, {
|
||
actual: round(violation.actual), limit: round(violation.limit),
|
||
});
|
||
}
|
||
|
||
public _junctionLimitsIntroduced(
|
||
candidate: ServerConfig, previousConfig: ServerConfig, spaceId: string,
|
||
candidateGeometry?: JunctionSharedGeometry | null,
|
||
affectedRoomIds?: readonly string[],
|
||
): JunctionLimitViolation[] {
|
||
// The baseline must be the previous document AS THE CANDIDATE SEES IT: a
|
||
// legacy space carries no wall catalogue at all, so comparing it raw with
|
||
// a migrated candidate reported every inherited short segment as new and
|
||
// refused legitimate resizes of real plans. Both sides therefore cross
|
||
// the same identity barrier first. Current-version documents are already
|
||
// materialized, while legacy documents cross that barrier once.
|
||
const affected = affectedRoomIds?.length ? new Set(affectedRoomIds) : undefined;
|
||
let inherited: JunctionLimitViolation[] | undefined;
|
||
const previousSpace = (previousConfig.spaces || [])
|
||
.find((space) => space?.id === spaceId);
|
||
let fingerprint = '';
|
||
try { fingerprint = spacePhysicalGeometryFingerprint(previousSpace); }
|
||
catch { fingerprint = ''; }
|
||
const cached = !affected && previousConfig && typeof previousConfig === 'object'
|
||
? this._junctionBaselineCache.get(previousConfig) : undefined;
|
||
if (cached && fingerprint && cached.fingerprint === fingerprint
|
||
&& cached.spaceId === spaceId) {
|
||
inherited = cached.violations;
|
||
}
|
||
if (!inherited) {
|
||
try {
|
||
const baseline = Number(previousConfig?.model_version || 0) >= WALL_SEGMENT_MODEL_VERSION
|
||
? previousConfig
|
||
: commitWallSegmentModel(previousConfig).config;
|
||
inherited = this.host._junctionLimitViolations(
|
||
baseline, spaceId, affected ? null : undefined, affected,
|
||
);
|
||
} catch {
|
||
// An unmigratable baseline proves nothing about inheritance; never
|
||
// refuse the write on that basis.
|
||
return [];
|
||
}
|
||
if (!affected && previousConfig && typeof previousConfig === 'object' && fingerprint) {
|
||
this._junctionBaselineCache.set(previousConfig, {
|
||
spaceId, fingerprint, violations: inherited,
|
||
});
|
||
}
|
||
}
|
||
let next: JunctionLimitViolation[] = [];
|
||
let comparison = inherited;
|
||
try {
|
||
next = this.host._junctionLimitViolations(candidate, spaceId, candidateGeometry, affected);
|
||
if (affected) comparison = inherited.filter(
|
||
(item) => item.rule !== 'clearance' || affected.has(item.subject),
|
||
);
|
||
}
|
||
catch {
|
||
// #331 §2.5: candidate judgment fails closed; only an unprovable
|
||
// baseline above is allowed to fail open.
|
||
return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }];
|
||
}
|
||
return increasedViolations(next, comparison);
|
||
}
|
||
|
||
public _commitPhysicalGeometry(
|
||
name: string,
|
||
before: SpaceGeometryState | null,
|
||
additionalAuthoredPoints: readonly (readonly number[])[] = [],
|
||
recordHistory = true,
|
||
): boolean {
|
||
if (!before || !this.host._serverCfg) return false;
|
||
const liveCandidate = this.host._serverCfg;
|
||
// The public guard already selects one space internally. Give it the
|
||
// bounded carrier as well so a writer finish cannot accidentally make a
|
||
// future preflight implementation inspect unrelated floors (#477/#461).
|
||
const physicalCandidateFor = (config: ServerConfig): ServerConfig => {
|
||
const space = config?.spaces?.find((item) => item.id === before.spaceId);
|
||
return space ? { ...config, spaces: [space] } : config;
|
||
};
|
||
const editedState = this._geometrySnapshotFromConfig(liveCandidate, before.spaceId);
|
||
const liveSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);
|
||
if (!liveSpace || spacePhysicalGeometryFingerprint(before)
|
||
=== spacePhysicalGeometryFingerprint(liveSpace)) return false;
|
||
// Preserve #278's existing fail-closed guard. Identity materialisation may
|
||
// repair/atomise a degraded legacy projection, but it must never be used to
|
||
// make an otherwise rejected user edit look safe.
|
||
let legacySafe = false;
|
||
try {
|
||
legacySafe = this.host._checkSpacePhysicalGeometry(
|
||
physicalCandidateFor(liveCandidate), before.spaceId,
|
||
).ok;
|
||
}
|
||
catch { legacySafe = false; }
|
||
if (!legacySafe) {
|
||
this._clearGeometryGesture();
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
return false;
|
||
}
|
||
let committedCandidate: any;
|
||
let historyBefore = before;
|
||
try {
|
||
// ADR 282 Stage 1: every structural writer crosses the same atomic
|
||
// canonicalisation/identity barrier. The pure candidate is adopted only
|
||
// after every space migrated successfully, so a blocker leaves the live
|
||
// config byte-equivalent.
|
||
if (Number(liveCandidate.model_version || 0) < 9) {
|
||
// The first edit of a pre-v9 document must derive identity and legacy
|
||
// zero-wall projection from the pre-edit carrier, not from its already
|
||
// moved/split coordinates.
|
||
// Materialise that baseline locally, then replay the edited legacy
|
||
// projection over it so lineage and Undo share the same stable IDs.
|
||
const baselineSource = JSON.parse(JSON.stringify(liveCandidate));
|
||
if (!this._restoreGeometryStateInConfig(baselineSource, before))
|
||
throw new WallSegmentModelError('invalid-room', before.spaceId);
|
||
const baseline = commitWallSegmentModel(baselineSource).config;
|
||
historyBefore = this._geometrySnapshotFromConfig(baseline, before.spaceId) || before;
|
||
historyBefore.wallChainSeedIds = before.wallChainSeedIds;
|
||
historyBefore.roomReferences = before.roomReferences;
|
||
const editedWithIdentity = JSON.parse(JSON.stringify(baseline));
|
||
if (!editedState || !this._restoreGeometryStateInConfig(
|
||
editedWithIdentity, editedState, true,
|
||
)) throw new WallSegmentModelError('invalid-room', before.spaceId);
|
||
const baselineSpace = baseline.spaces.find((space) => space.id === before.spaceId);
|
||
const editedSpace = editedWithIdentity.spaces.find((space) => space.id === before.spaceId);
|
||
// A fixed-topology pre-catalogue edit expresses thickness through the
|
||
// legacy `walls[]` projection. Replay it onto the baseline catalogue
|
||
// before v9 treats `cm` as authoritative. Geometry-changing writers
|
||
// instead rely on lineage: projecting moved walls onto old baseline
|
||
// coordinates would incorrectly turn the moved atom into zero.
|
||
if (!Array.isArray(editedState.wall_segments)
|
||
&& JSON.stringify(before.rooms) === JSON.stringify(editedState.rooms)) {
|
||
for (const segment of editedSpace?.wall_segments || []) {
|
||
segment.cm = thicknessCmAt(
|
||
editedState.walls, segment.a, segment.b, GRID_STEP_N, 1,
|
||
);
|
||
}
|
||
}
|
||
const lineageHints = fixedTopologyWallLineageHints(
|
||
baselineSpace, before.rooms, editedSpace,
|
||
);
|
||
committedCandidate = commitWallSegmentModel(editedWithIdentity, {
|
||
lineageHints, lineageSpaceId: before.spaceId,
|
||
}).config;
|
||
} else committedCandidate = commitWallSegmentModel(liveCandidate).config;
|
||
} catch (error) {
|
||
this._clearGeometryGesture();
|
||
this._restoreGeometryStateLocal(before);
|
||
this._showWallModelMigrationBlocked(error);
|
||
return false;
|
||
}
|
||
let safe = false;
|
||
try {
|
||
const afterSpace = committedCandidate.spaces.find((space) => space.id === before.spaceId);
|
||
// A completed point of the active, grid-snapped chain is authored input.
|
||
// Include the session carrier in the growth baseline so later conversion
|
||
// from partitions to room masonry does not count it as newly derived.
|
||
const authoredPoints = this.host._path.length >= 2
|
||
? this.host._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W])
|
||
: [];
|
||
authoredPoints.push(...additionalAuthoredPoints.map((point) => [point[0], point[1]]));
|
||
safe = wallModelOffGridValueCount(afterSpace)
|
||
<= wallModelOffGridValueCount(historyBefore, authoredPoints)
|
||
&& this.host._checkSpacePhysicalGeometry(
|
||
physicalCandidateFor(committedCandidate), before.spaceId,
|
||
).ok;
|
||
} catch {
|
||
safe = false;
|
||
}
|
||
if (!safe) {
|
||
this._clearGeometryGesture();
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
return false;
|
||
}
|
||
// #329: the owner's junction limits refuse the WRITE. An existing plan's
|
||
// inherited violations are never re-judged (spec §3), so the candidate is
|
||
// compared against the pre-edit document.
|
||
const beforeConfig = JSON.parse(JSON.stringify(liveCandidate));
|
||
this._restoreGeometryStateInConfig(beforeConfig, before);
|
||
const introduced = this._junctionLimitsIntroduced(
|
||
committedCandidate, beforeConfig, before.spaceId,
|
||
);
|
||
if (introduced.length) {
|
||
this._clearGeometryGesture();
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this._junctionLimitLabel(introduced[0]));
|
||
return false;
|
||
}
|
||
adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);
|
||
if (recordHistory) this._recordGeometry(name, historyBefore);
|
||
const afterSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);
|
||
const pending = this.host._pendingPhysicalWrites.get(before.spaceId);
|
||
this.host._pendingPhysicalWrites.set(before.spaceId, {
|
||
before: pending?.before || historyBefore,
|
||
fingerprint: spacePhysicalGeometryFingerprint(afterSpace),
|
||
});
|
||
this._saveConfig();
|
||
return true;
|
||
}
|
||
|
||
public _clearGeometryGesture(): void {
|
||
this._clearFurniturePreview();
|
||
this.host._path = [];
|
||
this._clearPlanSnapHover();
|
||
this._clearOpeningPlacement(false);
|
||
this.host._mergeSel = null;
|
||
this.host._mergeDialog = null;
|
||
this.host._splitSel = null;
|
||
this.host._pendingSplit = null;
|
||
this.host._wallFaceBatch = null;
|
||
this.host._wallRepairDiagnostic = null;
|
||
this.host._roomDeleteDialog = null;
|
||
this.host._wallDialog = null;
|
||
this.host._physicalDialog = null;
|
||
this.host._physicalSel = null;
|
||
this.host._physicalDrag = null;
|
||
this.host._physicalRotate = null;
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this.host._openingDialog = null;
|
||
this._rszResetController();
|
||
this.host._decorDraft = null;
|
||
this.host._decorMove = null;
|
||
this.host._dtDrag = null;
|
||
this.host._bdDrag = null;
|
||
}
|
||
|
||
public _stagePointerCancel(ev: PointerEvent): void {
|
||
clearTimeout(this.host._kioskHoldTimer);
|
||
if (this.host._swipeStart?.id === ev.pointerId) this.host._swipeStart = null;
|
||
if (this.host._furnTouchPending?.pid === ev.pointerId) {
|
||
this._clearFurniturePreview();
|
||
this.host._pointers.delete(ev.pointerId);
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (this.host._physicalDrag?.pid === ev.pointerId || this.host._physicalRotate?.pid === ev.pointerId) {
|
||
this._cancelPhysicalGesture();
|
||
return;
|
||
}
|
||
if (this.host._decorDraft?.pid === ev.pointerId) {
|
||
this.host._decorDraft = null;
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (this.host._decorMove?.pid === ev.pointerId || this.host._dtDrag?.pid === ev.pointerId
|
||
|| this.host._bdDrag?.pid === ev.pointerId) {
|
||
this._cancelDecorGesture();
|
||
return;
|
||
}
|
||
if (this.host._tool === 'opening') {
|
||
this.host._cursorPt = null;
|
||
this._clearOpeningPlacement(false);
|
||
} else if (this.host._tool === 'draw') {
|
||
this._clearPlanSnapHover();
|
||
}
|
||
this.host._pointers.delete(ev.pointerId);
|
||
if (this.host._pointers.size < 2) this.host._pinchStart = null;
|
||
if (this.host._pointers.size === 0) {
|
||
this.host._panStart = null;
|
||
this.host._panLock = null;
|
||
}
|
||
if (this.host._viewportGestureDirty && this.host._pointers.size === 0) {
|
||
this.host._viewportGestureDirty = false;
|
||
this.host.requestUpdate();
|
||
}
|
||
}
|
||
|
||
public _applyGeometryState(
|
||
state: SpaceGeometryState, allowHistoryBoundaryRepair = false,
|
||
): boolean {
|
||
const target = this._canonicalWallChainHistoryState(state);
|
||
if (!target || !this.host._canCommitSpace(target.spaceId)) return false;
|
||
const before = this._geometrySnapshot(target.spaceId);
|
||
if (!before || !this._restoreGeometryStateLocal(target)) return false;
|
||
const restoredCandidate = this.host._serverCfg;
|
||
const physicalChanged = spacePhysicalGeometryFingerprint(before)
|
||
!== spacePhysicalGeometryFingerprint(target);
|
||
if (physicalChanged) {
|
||
let safe = false;
|
||
try {
|
||
const check = restoredCandidate
|
||
? this.host._checkSpacePhysicalGeometry(restoredCandidate, target.spaceId)
|
||
: null;
|
||
safe = !!check?.ok || !!(allowHistoryBoundaryRepair
|
||
&& check?.reason === 'wall-degraded-extra');
|
||
} catch { safe = false; }
|
||
if (!safe) {
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
return false;
|
||
}
|
||
}
|
||
let committedCandidate: any;
|
||
try {
|
||
committedCandidate = commitWallSegmentModel(restoredCandidate).config;
|
||
} catch (error) {
|
||
this._restoreGeometryStateLocal(before);
|
||
this._showWallModelMigrationBlocked(error);
|
||
return false;
|
||
}
|
||
if (physicalChanged) {
|
||
let safe = false;
|
||
try {
|
||
const check = committedCandidate
|
||
? this.host._checkSpacePhysicalGeometry(committedCandidate, target.spaceId)
|
||
: null;
|
||
// A history snapshot can predate the write-time wall degradation that
|
||
// canonicalized its command. Restore that one repairable baseline so
|
||
// Undo remains byte-exact immediately; _writeConfig still degrades and
|
||
// strictly validates the outbound candidate before it can leave the
|
||
// card. Every other preflight failure stays fail-closed.
|
||
safe = !!check?.ok || !!(allowHistoryBoundaryRepair
|
||
&& check?.reason === 'wall-degraded-extra');
|
||
} catch { safe = false; }
|
||
if (!safe) {
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
return false;
|
||
}
|
||
adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate);
|
||
const afterSpace = restoredCandidate?.spaces.find((space) => space.id === target.spaceId);
|
||
const pending = this.host._pendingPhysicalWrites.get(target.spaceId);
|
||
this.host._pendingPhysicalWrites.set(target.spaceId, {
|
||
before: pending?.before || before,
|
||
fingerprint: spacePhysicalGeometryFingerprint(afterSpace),
|
||
});
|
||
} else adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate);
|
||
this._clearGeometryGesture();
|
||
if (this.host._space !== target.spaceId) {
|
||
this.host._commitSpace(target.spaceId);
|
||
this.host._saveNav();
|
||
this.host._restoreZoom();
|
||
}
|
||
this.host._modelCache = null;
|
||
this.host._frame = null;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
return true;
|
||
}
|
||
|
||
public _undoGeometry = (): void => {
|
||
if (this.host._physicalDrag || this.host._physicalRotate) {
|
||
this._cancelPhysicalGesture();
|
||
return;
|
||
}
|
||
if (this.host._decorDraft) { this.host._decorDraft = null; this.host.requestUpdate(); return; }
|
||
if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) {
|
||
this._cancelDecorGesture();
|
||
return;
|
||
}
|
||
if (this.host._resize.dragging) { this._rszCancelDrag(); return; }
|
||
const command = this.host._geometryHistory.undo();
|
||
if (!command) return;
|
||
if (!this._applyGeometryState(command.before, true)) {
|
||
this.host._geometryHistory.clear();
|
||
return;
|
||
}
|
||
this.host._showToast(this.host._t('history.undone', { name: command.name }));
|
||
};
|
||
|
||
public _redoGeometry = (): void => {
|
||
// Redo and Undo share the same transaction boundary. The first invocation
|
||
// cancels an in-progress transform; only the next one navigates history.
|
||
if (this.host._physicalDrag || this.host._physicalRotate) {
|
||
this._cancelPhysicalGesture();
|
||
return;
|
||
}
|
||
if (this.host._decorDraft) { this.host._decorDraft = null; this.host.requestUpdate(); return; }
|
||
if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) {
|
||
this._cancelDecorGesture();
|
||
return;
|
||
}
|
||
if (this.host._resize.dragging) { this._rszCancelDrag(); return; }
|
||
if (this.host._activeWallChainId && this.host._wallChainRedo.length) {
|
||
const chainId = this.host._activeWallChainId;
|
||
const path = this.host._path.map((point) => [...point]);
|
||
const ids = [...this.host._activeWallChainPartitionIds];
|
||
const cms = [...this.host._wallChainSegmentCms];
|
||
const redo = [...this.host._wallChainRedo];
|
||
const terminal = redo[redo.length - 1];
|
||
const command = this.host._geometryHistory.redo();
|
||
if (!command) return;
|
||
if (!this._applyGeometryState(command.after, true)) {
|
||
this.host._geometryHistory.clear();
|
||
return;
|
||
}
|
||
this.host._activeWallChainId = chainId;
|
||
this.host._path = [...path, [...terminal.point]];
|
||
this.host._activeWallChainPartitionIds = [...ids, terminal.id];
|
||
this.host._wallChainSegmentCms = [...cms, terminal.cm];
|
||
this.host._wallChainRedo = redo.slice(0, -1);
|
||
this._clearPlanSnapHover();
|
||
this.host._showToast(this.host._t('history.redone', { name: command.name }));
|
||
return;
|
||
}
|
||
const command = this.host._geometryHistory.redo();
|
||
if (!command) return;
|
||
if (!this._applyGeometryState(command.after, true)) {
|
||
this.host._geometryHistory.clear();
|
||
return;
|
||
}
|
||
this.host._showToast(this.host._t('history.redone', { name: command.name }));
|
||
};
|
||
|
||
public _roomAt(p: number[]): RoomCfg | undefined {
|
||
return this.host._spaceModel()?.rooms.find((r) => {
|
||
const poly = roomPoly(r);
|
||
return !!poly && pointStrictlyInside(p, poly);
|
||
});
|
||
}
|
||
|
||
public _overlapRoom(verts: number[][]): RoomCfg | undefined {
|
||
return this.host._spaceModel()?.rooms.find((r) => {
|
||
const poly = roomPoly(r);
|
||
return !!poly && roomsOverlap(verts, poly);
|
||
});
|
||
}
|
||
|
||
public _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||
if (r.poly) return pointInPolygon(p, r.poly);
|
||
return (
|
||
r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h!
|
||
);
|
||
}
|
||
|
||
public _contourSelfIntersects(poly: number[][]): boolean {
|
||
if (!polyIsSimple(poly)) return true; // transverse crossings
|
||
const n = poly.length;
|
||
const eps = 0.001;
|
||
for (let i = 0; i < n; i++) {
|
||
const a = poly[i], b = poly[(i + 1) % n];
|
||
for (let j = i + 1; j < n; j++) {
|
||
// Neighbours deliberately share one endpoint, including first ↔ last.
|
||
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
|
||
const c = poly[j], d = poly[(j + 1) % n];
|
||
const cd = [c[0], c[1], d[0], d[1]];
|
||
const ab = [a[0], a[1], b[0], b[1]];
|
||
// This also catches a closing edge passing through an older vertex and
|
||
// collinear overlap, which a proper-crossing test intentionally omits.
|
||
if (distToSegment(a, cd) <= eps || distToSegment(b, cd) <= eps ||
|
||
distToSegment(c, ab) <= eps || distToSegment(d, ab) <= eps) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public _canAppendWallPoint(): boolean {
|
||
if (this.host._drawWallCm == null) { this._showPhysicalRange(100); return false; }
|
||
if (this.host._path.length >= MAX_WALL_CHAIN_POINTS) {
|
||
this.host._showToast(this.host._t('toast.physical_limit'));
|
||
return false;
|
||
}
|
||
const spCfg = this.host._curSpaceCfg as any;
|
||
if ((spCfg?.partitions || []).length >= MAX_PARTITIONS) {
|
||
this.host._showToast(this.host._t('toast.physical_limit'));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public _markupClick(ev: MouseEvent): void {
|
||
if (this.host._vacFit) return; // the fit overlay owns all pointer input
|
||
if (!this.host._markup) return;
|
||
const space = this.host._spaceModel();
|
||
if (!space) {
|
||
this._clearGeometryGesture();
|
||
return;
|
||
}
|
||
// a pan or pinch just happened — the synthesized click is not a draw
|
||
if (this.host._suppressClick) return;
|
||
// Room cards swallow markup clicks: dragging, resizing or just clicking a
|
||
// card must not feed the active tool (draw point, delete room, merge/split
|
||
// pick, opening placement). The drag itself already stops pointer events,
|
||
// but the synthesized `click` afterwards still bubbles to the stage.
|
||
if (this.host._drag || this.host._rlResize) return;
|
||
const path = (ev.composedPath?.() || []) as any[];
|
||
if (path.some((n) => n?.classList?.contains?.('roomlabel') || n?.classList?.contains?.('rlhandle'))) return;
|
||
if (path.some((n) => n?.classList?.contains?.('physical-hit'))) return;
|
||
const raw = this._svgPoint(ev);
|
||
if (this.host._tool === 'select') {
|
||
this.host._physicalSel = null;
|
||
return;
|
||
}
|
||
if (this.host._tool === 'resize') {
|
||
// a click picks the room for the scale frame; handle drags never get here
|
||
if (this.host._resize.dragging || path.some((n) => n?.classList?.contains?.('rszhandle'))) return;
|
||
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||
this.host._resize.selectRoom(room?.id || null);
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (this.host._tool === 'delroom') {
|
||
this._deleteRoomClick(raw);
|
||
return;
|
||
}
|
||
if (this.host._tool === 'opening') {
|
||
this._openingClick(raw);
|
||
return;
|
||
}
|
||
if (this.host._tool === 'merge') {
|
||
this._mergeClick(raw);
|
||
return;
|
||
}
|
||
if (this.host._tool === 'wallthick') {
|
||
this._wallThickClick(raw);
|
||
return;
|
||
}
|
||
if (this.host._tool === 'split') {
|
||
this._splitClick(raw);
|
||
return;
|
||
}
|
||
if (this.host._tool === 'column') {
|
||
this._columnClick(raw);
|
||
return;
|
||
}
|
||
// Walls: every completed segment is immediately an ordinary independent wall.
|
||
this.host._wallRepairDiagnostic = null;
|
||
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
|
||
if (resolved.ambiguous) {
|
||
this.host._planSnapHover = {
|
||
contextKey: resolved.contextKey, candidate: null, conflicts: resolved.conflicts,
|
||
};
|
||
this._syncPlanSnapActiveMarker(null);
|
||
this._syncPlanSnapConflictMarkers(resolved.conflicts);
|
||
this.host._showToast(this.host._t('toast.plan_snap_ambiguous'));
|
||
return;
|
||
}
|
||
let pt = resolved.point;
|
||
if (ev.ctrlKey || ev.metaKey) {
|
||
// The closure shortcut owns the gesture even before the chain has the
|
||
// two existing edges required to form a room. Falling through here
|
||
// would turn a refused Ctrl/Cmd+click into an ordinary drawing click.
|
||
ev.preventDefault();
|
||
if (this.host._path.length < 3) return;
|
||
// Preserve the established shortcut, but commit its closing wall through
|
||
// the same graph/draft path as an ordinary click on the first node.
|
||
pt = snapNearAxisEndpoint(
|
||
this.host._path[this.host._path.length - 1], this.host._path[0],
|
||
);
|
||
}
|
||
// Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
|
||
// contour may become a nested room (a column, an inner room). Partial
|
||
// overlaps are still rejected, but only at closing time, when the whole
|
||
// outline is known (roomsOverlap treats full nesting as legal).
|
||
if (!this.host._path.length) {
|
||
// A free idle click can name a room inside masonry that was completed in
|
||
// an earlier session. Shift and architectural snap hits retain the Walls
|
||
// gesture, so this offer never steals an intentional new chain.
|
||
if (!ev.shiftKey && !resolved.candidate && this._offerExistingWallFace(raw)) return;
|
||
this.host._activeWallChainId = `chain-${Date.now().toString(36)}`;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._path = [pt];
|
||
return;
|
||
}
|
||
const last = this.host._path[this.host._path.length - 1];
|
||
if (this._samePt(pt, last)) return; // repeated click on the same point
|
||
if (!this._canAppendWallPoint()) return;
|
||
const cm = this.host._drawWallCm;
|
||
if (cm == null) { this._showPhysicalRange(100); return; }
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (!sp) return;
|
||
const beforePath = this.host._path.map((point) => [...point]);
|
||
const beforeChainId = this.host._activeWallChainId;
|
||
const beforeIds = [...this.host._activeWallChainPartitionIds];
|
||
const beforeCms = [...this.host._wallChainSegmentCms];
|
||
const beforeGraphSources = this._wallGraphSources([]);
|
||
const before = this._geometrySnapshot();
|
||
if (before) before.wallChainSeedIds = [...beforeIds];
|
||
const id = `partition-${crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
|
||
sp.partitions ||= [];
|
||
sp.partitions.push({
|
||
id,
|
||
a: [last[0] / NORM_W, last[1] / NORM_W],
|
||
b: [pt[0] / NORM_W, pt[1] / NORM_W],
|
||
cm,
|
||
});
|
||
this.host._path = [...this.host._path, pt];
|
||
this.host._wallChainSegmentCms = [...this.host._wallChainSegmentCms, cm];
|
||
this.host._activeWallChainPartitionIds = [...beforeIds, id];
|
||
this.host._wallChainRedo = [];
|
||
if (!commitWallChainSegmentGeometry(this, this.host._t('history.wall_segment'), before)) {
|
||
this.host._activeWallChainId = beforeChainId;
|
||
this.host._activeWallChainPartitionIds = beforeIds;
|
||
this.host._path = beforePath;
|
||
this.host._wallChainSegmentCms = beforeCms;
|
||
return;
|
||
}
|
||
this._offerWallFaces(beforePath, beforePath.length - 1, beforeGraphSources);
|
||
}
|
||
|
||
|
||
public _activeWallSourceKey(index: number): string {
|
||
const id = this.host._activeWallChainPartitionIds[index];
|
||
const segment = id
|
||
? this._planStructuralGeometrySnapshot().value.segments.find((item) => (
|
||
item.sourceKind === 'partition' && item.sourceId === id
|
||
))
|
||
: null;
|
||
return segment ? `static:${segment.key}` : `active:${this.host._activeWallChainId || 'session'}:${index}`;
|
||
}
|
||
|
||
public _wallGraphSources(_path: readonly (readonly number[])[]): WallGraphSourceSegment[] {
|
||
const staticGeometry = this._planStructuralGeometrySnapshot().value;
|
||
return staticGeometry.segments.map((segment) => ({
|
||
a: segment.a, b: segment.b, key: `static:${segment.key}`,
|
||
}));
|
||
}
|
||
|
||
public _wallFaceGraph(
|
||
sources: readonly WallGraphSourceSegment[], epsilon: number,
|
||
): WallFaceGraph {
|
||
// Exact JSON identity avoids stale topology. Source keys include real
|
||
// open-span cuts/provenance; presentation opening cuts are intentionally
|
||
// absent from the structural graph (#185).
|
||
const key = `${this.host._space}|${epsilon}|${JSON.stringify(sources)}`;
|
||
const cachedIndex = this.host._wallFaceGraphCache.findIndex((entry) => entry.key === key);
|
||
if (cachedIndex >= 0) {
|
||
const [cached] = this.host._wallFaceGraphCache.splice(cachedIndex, 1);
|
||
this.host._wallFaceGraphCache.push(cached);
|
||
return cached.value;
|
||
}
|
||
const value = buildWallFaceGraph(sources, epsilon);
|
||
this.host._wallFaceGraphCache.push({ key, value });
|
||
if (this.host._wallFaceGraphCache.length > 4) this.host._wallFaceGraphCache.shift();
|
||
return value;
|
||
}
|
||
|
||
public _offerWallFaces(
|
||
beforePath: number[][],
|
||
addedSegmentIndex = this.host._path.length - 2,
|
||
beforeGraphSources?: WallGraphSourceSegment[],
|
||
): void {
|
||
if (this.host._path.length < 2 || this.host._wallFaceBatch || this.host._roomDialog) return;
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
const addedSourceKey = this._activeWallSourceKey(addedSegmentIndex);
|
||
const epsilon = this.host._gridPitch * 0.0002;
|
||
let faces: WallGraphFace[];
|
||
let after: WallGraphSourceSegment[];
|
||
try {
|
||
const before = beforeGraphSources || this._wallGraphSources(beforePath);
|
||
after = this._wallGraphSources(this.host._path);
|
||
faces = findNewWallFacesInGraphs(
|
||
this._wallFaceGraph(before, epsilon), this._wallFaceGraph(after, epsilon),
|
||
addedSourceKey,
|
||
);
|
||
} catch {
|
||
// Topology is advisory. The just-persisted draft remains the source of
|
||
// truth and drawing can continue when malformed legacy geometry exists.
|
||
return;
|
||
}
|
||
if (!faces.length) {
|
||
const repair = planWallFaceRepair(after, {
|
||
requiredSourceKey: addedSourceKey,
|
||
maxDistance: wallCmToUnits(2, this.host._cellCm, this.host._gridPitch),
|
||
gridStep: this.host._gridPitch,
|
||
epsilon,
|
||
});
|
||
if (repair.kind === 'ambiguous') {
|
||
this.host._wallRepairDiagnostic = repair.proposals[0] || null;
|
||
this.host._showToast(this.host._t(
|
||
this.host._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous',
|
||
));
|
||
return;
|
||
}
|
||
if (repair.kind === 'repair' && !this._overlapRoom(repair.face.ring)) {
|
||
this._beginWallFaceBatch([{ ...repair.face, repair: repair.proposal }]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A clean wall-to-wall cut is one product decision about the smaller part,
|
||
// even though the planar delta contains both resulting faces.
|
||
for (const room of space.rooms) {
|
||
const poly = roomPoly(room);
|
||
if (!room.id || !poly) continue;
|
||
const parts = splitRoomPath(poly, this.host._path, this.host._gridPitch * 0.02);
|
||
if (!parts) continue;
|
||
const [p1, p2] = parts;
|
||
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
||
const fresh = main === p1 ? p2 : p1;
|
||
const matched = [...faces].sort((left, right) =>
|
||
Math.abs(left.area - polygonArea(fresh)) - Math.abs(right.area - polygonArea(fresh))
|
||
|| left.key.localeCompare(right.key))[0];
|
||
if (!matched) return;
|
||
this._beginWallFaceBatch([{
|
||
...matched,
|
||
ring: fresh.map((point) => [point[0], point[1]] as [number, number]),
|
||
split: { roomId: room.id, mainPoly: main, newPoly: fresh },
|
||
consumeAllActive: true,
|
||
}]);
|
||
return;
|
||
}
|
||
|
||
const eligible = faces.filter((face) => {
|
||
if (face.ring.length < 3 || this._contourSelfIntersects(face.ring)) return false;
|
||
// roomsOverlap rejects exact duplicates and partial overlap, while full
|
||
// nesting in either direction deliberately remains eligible.
|
||
return !this._overlapRoom(face.ring);
|
||
});
|
||
if (eligible.length) this._beginWallFaceBatch(eligible);
|
||
}
|
||
|
||
public _beginWallFaceBatch(candidates: WallFaceCandidate[]): void {
|
||
this.host._wallRepairDiagnostic = null;
|
||
this.host._wallFaceBatch = {
|
||
candidates: [...candidates].sort((left, right) =>
|
||
left.area - right.area || left.key.localeCompare(right.key)),
|
||
index: 0,
|
||
decisions: [],
|
||
activePath: this.host._path.map((point) => [...point]),
|
||
activeCms: [...this.host._wallChainSegmentCms],
|
||
activePartitionIds: [...this.host._activeWallChainPartitionIds],
|
||
};
|
||
this._clearPlanSnapHover();
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this._resetRoomDialogFields();
|
||
this.host._roomDialog = true;
|
||
}
|
||
|
||
public _offerExistingWallFace(raw: number[]): boolean {
|
||
if (this.host._path.length || this.host._wallFaceBatch || this.host._roomDialog) return false;
|
||
const epsilon = this.host._gridPitch * 0.0002;
|
||
try {
|
||
const graph = this._wallFaceGraph(this._wallGraphSources([]), epsilon);
|
||
let face = findWallFaceAtPoint(graph, raw, epsilon);
|
||
let repair: WallFaceRepairProposal | undefined;
|
||
if (!face) {
|
||
const result = planWallFaceRepair(this._wallGraphSources([]), {
|
||
point: raw,
|
||
maxDistance: wallCmToUnits(2, this.host._cellCm, this.host._gridPitch),
|
||
gridStep: this.host._gridPitch,
|
||
epsilon,
|
||
});
|
||
if (result.kind === 'ambiguous') {
|
||
this.host._wallRepairDiagnostic = result.proposals[0] || null;
|
||
this.host._showToast(this.host._t(
|
||
this.host._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous',
|
||
));
|
||
return true;
|
||
}
|
||
if (result.kind === 'repair') {
|
||
face = result.face;
|
||
repair = result.proposal;
|
||
} else if (result.kind === 'none') {
|
||
const diagnostic = planWallFaceRepair(this._wallGraphSources([]), {
|
||
point: raw,
|
||
maxDistance: this._cssPxToRender(12),
|
||
gridStep: this.host._gridPitch,
|
||
epsilon,
|
||
});
|
||
if (diagnostic.kind === 'repair') {
|
||
this.host._wallRepairDiagnostic = diagnostic.proposal;
|
||
this.host._showToast(this.host._t('toast.wall_repair_too_large'));
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
if (!face || face.ring.length < 3 || this._contourSelfIntersects(face.ring)
|
||
|| this._overlapRoom(face.ring)) return false;
|
||
this._beginWallFaceBatch([{ ...face, existing: !repair, repair }]);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public _columnClick(raw: number[]): void {
|
||
const center = this._snap(raw);
|
||
const cm = this.host._drawWallCm;
|
||
if (cm == null) { this._showPhysicalRange(COLUMN_MAX_CM, 1); return; }
|
||
if (!this.host._curSpaceCfg || this._limitReached('column')) return;
|
||
const model = this.host._spaceModel();
|
||
if (!model) return;
|
||
const candidate: WallColumnCfg = {
|
||
id: 'column-' + Date.now().toString(36), shape: 'square', center, cm: clampColumnCm(cm), angle: 0,
|
||
};
|
||
const duplicate = (model.wall_columns || []).find((c) =>
|
||
sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02));
|
||
if (duplicate) {
|
||
clearTimeout(this.host._duplicateColumnTimer);
|
||
this.host._duplicateColumnId = duplicate.id;
|
||
this.host._duplicateColumnTimer = window.setTimeout(() => {
|
||
this.host._duplicateColumnId = null;
|
||
}, 900);
|
||
this.host._showToast(this.host._t('toast.column_duplicate'));
|
||
return;
|
||
}
|
||
const before = this._geometrySnapshot();
|
||
const sp = this.host._curSpaceCfg as any;
|
||
sp.wall_columns ||= [];
|
||
sp.wall_columns.push({ ...candidate, center: [center[0] / NORM_W, center[1] / NORM_W] });
|
||
this._commitPhysicalGeometry(this.host._t('history.column_add'), before);
|
||
}
|
||
|
||
public _openPhysicalDialog(
|
||
kind: 'partition' | 'column', id: string,
|
||
): void {
|
||
const model = this.host._spaceModel();
|
||
if (!model) return;
|
||
if (kind === 'partition') {
|
||
const p = model.partitions.find((x) => x.id === id);
|
||
if (p) this.host._physicalDialog = {
|
||
kind, id, cm: cmToField(p.cm, this.host._imperial), length: this.host._fmtLen(p.a, p.b),
|
||
};
|
||
} else if (kind === 'column') {
|
||
const c = model.wall_columns.find((x) => x.id === id);
|
||
if (c) this.host._physicalDialog = {
|
||
kind, id, cm: cmToField(c.cm, this.host._imperial), shape: c.shape,
|
||
angle: this.host._angleField(c.shape === 'square' ? canonicalColumnAngle(c.angle) : 0),
|
||
};
|
||
}
|
||
}
|
||
|
||
public _savePhysicalDialog = (): void => {
|
||
const d = this.host._physicalDialog;
|
||
const sp = this.host._curSpaceCfg as any;
|
||
const model = this.host._spaceModel();
|
||
if (!d || !sp || !model) return;
|
||
const raw = strictNumber(d.cm);
|
||
if (raw == null) {
|
||
this._showPhysicalRange(
|
||
d.kind === 'column' ? COLUMN_MAX_CM : 100, d.kind === 'column' ? 1 : 0,
|
||
);
|
||
return;
|
||
}
|
||
const cmRaw = this.host._imperial ? raw * 2.54 : raw;
|
||
const max = d.kind === 'column' ? COLUMN_MAX_CM : 100;
|
||
const min = d.kind === 'column' ? 1 : 0;
|
||
if (!Number.isFinite(cmRaw) || cmRaw < min || cmRaw > max) {
|
||
this._showPhysicalRange(max, min);
|
||
return;
|
||
}
|
||
if (d.kind === 'column') {
|
||
const current = model.wall_columns.find((x) => x.id === d.id);
|
||
if (!current) return;
|
||
const rawAngle = strictNumber(d.angle || '0');
|
||
if (d.shape !== 'circle'
|
||
&& (rawAngle == null || rawAngle < 0 || rawAngle >= 90)) {
|
||
this.host._showToast(this.host._t('toast.physical_angle'));
|
||
return;
|
||
}
|
||
const candidate: WallColumnCfg = d.shape === 'circle'
|
||
? { id: d.id, shape: 'circle', center: current.center, cm: cmRaw }
|
||
: { id: d.id, shape: 'square', center: current.center, cm: cmRaw,
|
||
angle: rawAngle! };
|
||
if (model.wall_columns.some((c) => c.id !== d.id
|
||
&& sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02))) {
|
||
this.host._showToast(this.host._t('toast.column_duplicate'));
|
||
return;
|
||
}
|
||
}
|
||
const before = this._geometrySnapshot();
|
||
if (d.kind === 'partition') {
|
||
const p = (sp.partitions || []).find((x: any) => x.id === d.id);
|
||
if (p && cmRaw === 0 && zeroWallHasOpening(sp.openings, {
|
||
kind: 'partition', id: d.id,
|
||
})) {
|
||
this.host._showToast(this.host._t('toast.zero_wall_opening_conflict'));
|
||
return;
|
||
}
|
||
if (p) p.cm = cmRaw;
|
||
} else if (d.kind === 'column') {
|
||
const c = (sp.wall_columns || []).find((x: any) => x.id === d.id);
|
||
if (c) {
|
||
c.cm = clampColumnCm(cmRaw);
|
||
c.shape = d.shape === 'circle' ? 'circle' : 'square';
|
||
if (c.shape === 'square') c.angle = strictNumber(d.angle || '0')!;
|
||
else delete c.angle;
|
||
}
|
||
}
|
||
const committed = this._commitPhysicalGeometry(this.host._t('history.physical_edit'), before);
|
||
this.host._physicalDialog = null;
|
||
if (!committed) this.host.requestUpdate();
|
||
};
|
||
|
||
public _deletePhysicalSelection = async (): Promise<void> => {
|
||
const sel = this.host._physicalSel;
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (!sel || !sp) return;
|
||
const before = this._geometrySnapshot();
|
||
if (sel.kind === 'partition') {
|
||
const hosted = (sp.openings || [])
|
||
.filter((opening: OpeningCfg) => opening.host?.kind === 'partition'
|
||
&& opening.host.id === sel.id)
|
||
.sort((a: OpeningCfg, b: OpeningCfg) => (a.host?.t || 0) - (b.host?.t || 0));
|
||
if (hosted.length) {
|
||
this.host._partitionDeleteDialog = {
|
||
id: sel.id,
|
||
openings: hosted.map((opening: OpeningCfg) => JSON.parse(JSON.stringify(opening))),
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
const key = sel.kind === 'partition' ? 'partitions' : 'wall_columns';
|
||
sp[key] = (sp[key] || []).filter((x: any) => x.id !== sel.id);
|
||
if (!sp[key].length) delete sp[key];
|
||
if (this.host._activeWallChainPartitionIds.includes(sel.id)) this._cancelPath();
|
||
this.host._physicalSel = null;
|
||
this.host._physicalDialog = null;
|
||
this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before);
|
||
};
|
||
|
||
public _confirmPartitionDelete = (): void => {
|
||
const dialog = this.host._partitionDeleteDialog;
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (!dialog || !sp) return;
|
||
const before = this._geometrySnapshot();
|
||
sp.partitions = (sp.partitions || []).filter((partition: PartitionCfg) =>
|
||
partition.id !== dialog.id);
|
||
if (!sp.partitions.length) delete sp.partitions;
|
||
sp.openings = (sp.openings || []).filter((opening: OpeningCfg) =>
|
||
opening.host?.kind !== 'partition' || opening.host.id !== dialog.id);
|
||
if (!sp.openings.length) delete sp.openings;
|
||
this.host._partitionDeleteDialog = null;
|
||
this.host._physicalSel = null;
|
||
this.host._physicalDialog = null;
|
||
this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before);
|
||
};
|
||
|
||
|
||
public _physicalDown(ev: PointerEvent, kind: 'partition' | 'column', id: string): void {
|
||
const model = this.host._spaceModel();
|
||
if (!model) return;
|
||
ev.stopPropagation();
|
||
capturePointer(ev);
|
||
const point = this._svgPoint(ev);
|
||
const candidates: Array<{ kind: 'partition' | 'column'; id: string }> = [];
|
||
for (const c of [...model.wall_columns].reverse()) {
|
||
if (pointInPhysicalBody(point, columnBody(c, this.host._cellCm, this.host._gridPitch)))
|
||
candidates.push({ kind: 'column', id: c.id });
|
||
}
|
||
for (const p of [...model.partitions].reverse()) {
|
||
const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch);
|
||
if (body && pointInPhysicalBody(point, body)) candidates.push({ kind: 'partition', id: p.id });
|
||
}
|
||
if (!candidates.some((x) => x.kind === kind && x.id === id)) candidates.unshift({ kind, id });
|
||
const signature = candidates.map((x) => `${x.kind}:${x.id}`).sort().join('|');
|
||
const now = performance.now();
|
||
const cycle = this.host._physicalPickCycle;
|
||
const repeat = candidates.length > 1 && cycle?.signature === signature
|
||
&& now - cycle.at > 380 && now - cycle.at <= 1200
|
||
&& Math.hypot(ev.clientX - cycle.x, ev.clientY - cycle.y) <= 10;
|
||
const index = repeat ? (cycle.index + 1) % candidates.length
|
||
: Math.max(0, candidates.findIndex((x) => x.kind === kind && x.id === id));
|
||
this.host._physicalPickCycle = { signature, index, x: ev.clientX, y: ev.clientY, at: now };
|
||
kind = candidates[index].kind;
|
||
id = candidates[index].id;
|
||
const base = kind === 'partition'
|
||
? model.partitions.find((x) => x.id === id)
|
||
: model.wall_columns.find((x) => x.id === id);
|
||
if (!base) return;
|
||
this.host._physicalSel = { kind, id };
|
||
this.host._physicalDrag = {
|
||
pid: ev.pointerId, kind, id, start: this._svgPoint(ev),
|
||
startClient: [ev.clientX, ev.clientY],
|
||
before: this._geometrySnapshot(), moved: false,
|
||
base: JSON.parse(JSON.stringify(base)), delta: [0, 0],
|
||
};
|
||
}
|
||
|
||
public _clampPhysicalDelta(
|
||
kind: 'partition' | 'column', base: PartitionCfg | WallColumnCfg, delta: number[],
|
||
): number[] {
|
||
const points = kind === 'partition'
|
||
? [(base as PartitionCfg).a, (base as PartitionCfg).b]
|
||
: [(base as WallColumnCfg).center];
|
||
const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]);
|
||
return [
|
||
Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...xs),
|
||
Math.min(CANVAS_LIMIT * NORM_W - Math.max(...xs), delta[0])),
|
||
Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...ys),
|
||
Math.min(CANVAS_LIMIT * NORM_W - Math.max(...ys), delta[1])),
|
||
];
|
||
}
|
||
|
||
public _physicalMove(ev: PointerEvent): void {
|
||
ev.stopPropagation();
|
||
queueHouseplanPointerMove(this.host, 'physical', () => this._physicalMoveNow(ev));
|
||
}
|
||
|
||
private _physicalMoveNow(ev: PointerEvent): void {
|
||
const drag = this.host._physicalDrag;
|
||
if (!drag || drag.pid !== ev.pointerId) return;
|
||
ev.stopPropagation();
|
||
const raw = this._svgPoint(ev);
|
||
const anchor = drag.kind === 'partition'
|
||
? (drag.base as PartitionCfg).a : (drag.base as WallColumnCfg).center;
|
||
const target = this._snap([
|
||
anchor[0] + raw[0] - drag.start[0], anchor[1] + raw[1] - drag.start[1],
|
||
]);
|
||
const delta = this._clampPhysicalDelta(
|
||
drag.kind, drag.base, [target[0] - anchor[0], target[1] - anchor[1]],
|
||
);
|
||
this.host._physicalDrag = {
|
||
...drag, delta,
|
||
moved: drag.moved || Math.hypot(
|
||
ev.clientX - drag.startClient[0], ev.clientY - drag.startClient[1],
|
||
) >= 5,
|
||
};
|
||
}
|
||
|
||
public _physicalUp(ev: PointerEvent): void {
|
||
flushHouseplanPointerMove(this.host, 'physical');
|
||
const drag = this.host._physicalDrag;
|
||
if (!drag || drag.pid !== ev.pointerId) return;
|
||
ev.stopPropagation();
|
||
this.host._physicalDrag = null;
|
||
if (!drag.moved) {
|
||
this._registerPhysicalTap(drag.kind, drag.id);
|
||
return;
|
||
}
|
||
const model = this.host._spaceModel();
|
||
if (!this.host._curSpaceCfg || !model) return;
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (drag.kind === 'partition') {
|
||
const p = (sp.partitions || []).find((x: any) => x.id === drag.id);
|
||
const base = drag.base as PartitionCfg;
|
||
if (p) {
|
||
const moved: PartitionCfg = {
|
||
...base,
|
||
a: [base.a[0] + drag.delta[0], base.a[1] + drag.delta[1]],
|
||
b: [base.b[0] + drag.delta[0], base.b[1] + drag.delta[1]],
|
||
};
|
||
p.a = [moved.a[0] / NORM_W, moved.a[1] / NORM_W];
|
||
p.b = [moved.b[0] / NORM_W, moved.b[1] / NORM_W];
|
||
for (const opening of sp.openings || []) {
|
||
if (opening.host?.kind !== 'partition' || opening.host.id !== drag.id) continue;
|
||
const resolved = resolvePartitionOpeningCompat(
|
||
opening, [moved], NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
).resolved;
|
||
if (resolved) Object.assign(
|
||
opening, materializePartitionOpening(opening, resolved, NORM_W),
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
const c = (sp.wall_columns || []).find((x: any) => x.id === drag.id);
|
||
const base = drag.base as WallColumnCfg;
|
||
const candidate: WallColumnCfg = {
|
||
...base,
|
||
center: [base.center[0] + drag.delta[0], base.center[1] + drag.delta[1]],
|
||
} as WallColumnCfg;
|
||
if (model.wall_columns.some((x) => x.id !== drag.id
|
||
&& sameColumnPlacement(x, candidate, this.host._gridPitch * 0.02))) {
|
||
this.host._showToast(this.host._t('toast.column_duplicate'));
|
||
return;
|
||
}
|
||
if (c) c.center = [
|
||
(base.center[0] + drag.delta[0]) / NORM_W,
|
||
(base.center[1] + drag.delta[1]) / NORM_W,
|
||
];
|
||
}
|
||
this._commitPhysicalGeometry(this.host._t('history.physical_move'), drag.before);
|
||
}
|
||
|
||
public _registerPhysicalTap(
|
||
kind: 'partition' | 'column', id: string,
|
||
): void {
|
||
const now = performance.now();
|
||
const twice = this.host._physicalLastTap?.kind === kind
|
||
&& this.host._physicalLastTap.id === id
|
||
&& now - this.host._physicalLastTap.at <= 360;
|
||
this.host._physicalLastTap = { kind, id, at: now };
|
||
if (twice) {
|
||
this.host._physicalLastTap = null;
|
||
this._openPhysicalDialog(kind, id);
|
||
}
|
||
}
|
||
|
||
public _cancelPhysicalGesture(): void {
|
||
cancelHouseplanPointerMove(this.host, 'physical');
|
||
cancelHouseplanPointerMove(this.host, 'physical-rotate');
|
||
this.host._physicalDrag = null;
|
||
this.host._physicalRotate = null;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _physicalRotateDown(ev: PointerEvent, c: WallColumnCfg): void {
|
||
if (c.shape !== 'square' || !this.host._spaceModel()) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
capturePointer(ev);
|
||
const p = this._svgPoint(ev);
|
||
this.host._physicalSel = { kind: 'column', id: c.id };
|
||
this.host._physicalRotate = {
|
||
pid: ev.pointerId, id: c.id, center: [...c.center],
|
||
startAngle: Math.atan2(p[1] - c.center[1], p[0] - c.center[0]) * 180 / Math.PI,
|
||
baseAngle: canonicalColumnAngle(c.angle), angle: canonicalColumnAngle(c.angle),
|
||
before: this._geometrySnapshot(), moved: false,
|
||
};
|
||
}
|
||
|
||
public _physicalRotateMove(ev: PointerEvent): void {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
queueHouseplanPointerMove(this.host, 'physical-rotate', () => this._physicalRotateMoveNow(ev));
|
||
}
|
||
|
||
private _physicalRotateMoveNow(ev: PointerEvent): void {
|
||
const drag = this.host._physicalRotate;
|
||
if (!drag || drag.pid !== ev.pointerId) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const p = this._svgPoint(ev);
|
||
const pointerAngle = Math.atan2(p[1] - drag.center[1], p[0] - drag.center[0]) * 180 / Math.PI;
|
||
const raw = drag.baseAngle + pointerAngle - drag.startAngle;
|
||
const angle = canonicalColumnAngle(ev.shiftKey ? raw : Math.round(raw / 5) * 5);
|
||
this.host._physicalRotate = { ...drag, angle, moved: drag.moved || Math.abs(raw - drag.baseAngle) >= 0.5 };
|
||
}
|
||
|
||
public _physicalRotateUp(ev: PointerEvent): void {
|
||
flushHouseplanPointerMove(this.host, 'physical-rotate');
|
||
const drag = this.host._physicalRotate;
|
||
if (!drag || drag.pid !== ev.pointerId) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
this.host._physicalRotate = null;
|
||
const model = this.host._spaceModel();
|
||
if (!drag.moved || !this.host._curSpaceCfg || !model) return;
|
||
const current = model.wall_columns.find((c) => c.id === drag.id);
|
||
if (!current || current.shape !== 'square') return;
|
||
const candidate: WallColumnCfg = { ...current, angle: drag.angle };
|
||
if (model.wall_columns.some((c) => c.id !== drag.id
|
||
&& sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02))) {
|
||
this.host._showToast(this.host._t('toast.column_duplicate'));
|
||
return;
|
||
}
|
||
const stored = (this.host._curSpaceCfg as any).wall_columns?.find((c: any) => c.id === drag.id);
|
||
if (!stored) return;
|
||
stored.angle = drag.angle;
|
||
this._commitPhysicalGeometry(this.host._t('history.physical_edit'), drag.before);
|
||
}
|
||
|
||
public _rszRooms(): { id: string; poly: number[][]; wall_ids?: string[] }[] {
|
||
const out: { id: string; poly: number[][]; wall_ids?: string[] }[] = [];
|
||
const space = this.host._spaceModel();
|
||
if (!space) return out;
|
||
for (const r of space.rooms) {
|
||
const poly = r.id ? roomPoly(r) : null;
|
||
if (poly) out.push({
|
||
id: r.id!, poly,
|
||
wall_ids: Array.isArray(r.wall_ids) ? [...r.wall_ids] : undefined,
|
||
});
|
||
}
|
||
return coalesceResizeRooms(out, Math.max(1e-12, this.host._gridPitch * 1e-12));
|
||
}
|
||
|
||
public _rszOpenings(): SafeOpeningIn[] {
|
||
return this.host._openingsR.map((o) => ({
|
||
id: o.id, x: o.rx, y: o.ry, length: o.rlen,
|
||
hosted: !!o.host, angle: o.angle, type: o.type,
|
||
}));
|
||
}
|
||
|
||
public _rszObstacles(): SafeResizeObstacle[] {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return [];
|
||
const obstacles: SafeResizeObstacle[] = [];
|
||
for (const partition of space.partitions || []) {
|
||
obstacles.push({
|
||
kind: 'segment', a: [...partition.a], b: [...partition.b],
|
||
half: wallCmToUnits(partition.cm, this.host._cellCm, this.host._gridPitch) / 2,
|
||
});
|
||
}
|
||
for (const column of space.wall_columns || []) {
|
||
const half = wallCmToUnits(column.cm, this.host._cellCm, this.host._gridPitch) / 2;
|
||
obstacles.push({
|
||
kind: 'circle', center: [...column.center],
|
||
radius: column.shape === 'square' ? half * Math.SQRT2 : half,
|
||
});
|
||
}
|
||
return obstacles;
|
||
}
|
||
|
||
public _rszOptsFor(a: number[], b: number[]): SafeResizeOptions {
|
||
const cm = thicknessCmAt(this.host._spaceWalls, a, b, this.host._wallKeyPitch, NORM_W);
|
||
const exact = this.host._spaceWalls.filter((wall) => Array.isArray(wall.a) && Array.isArray(wall.b)
|
||
&& wall.a.length >= 2 && wall.b.length >= 2).map((wall) => ({
|
||
wall,
|
||
a: [wall.a![0] * NORM_W, wall.a![1] * this.host._spaceH],
|
||
b: [wall.b![0] * NORM_W, wall.b![1] * this.host._spaceH],
|
||
}));
|
||
const axis = Math.abs(a[0] - b[0]) <= this.host._gridPitch * 0.05 ? 'v'
|
||
: Math.abs(a[1] - b[1]) <= this.host._gridPitch * 0.05 ? 'h' : null;
|
||
const overlaps = exact.filter((entry) => {
|
||
if (!axis) return false;
|
||
if (axis === 'h') {
|
||
if (Math.abs(entry.a[1] - a[1]) > this.host._gridPitch * 0.05
|
||
|| Math.abs(entry.b[1] - a[1]) > this.host._gridPitch * 0.05) return false;
|
||
return Math.min(Math.max(a[0], b[0]), Math.max(entry.a[0], entry.b[0]))
|
||
- Math.max(Math.min(a[0], b[0]), Math.min(entry.a[0], entry.b[0])) > this.host._gridPitch * 0.05;
|
||
}
|
||
if (Math.abs(entry.a[0] - a[0]) > this.host._gridPitch * 0.05
|
||
|| Math.abs(entry.b[0] - a[0]) > this.host._gridPitch * 0.05) return false;
|
||
return Math.min(Math.max(a[1], b[1]), Math.max(entry.a[1], entry.b[1]))
|
||
- Math.max(Math.min(a[1], b[1]), Math.min(entry.a[1], entry.b[1])) > this.host._gridPitch * 0.05;
|
||
});
|
||
const exactCms = new Set(overlaps.map((entry) => Number(entry.wall.cm)).filter((value) => value > 0));
|
||
return {
|
||
minDim: this.host._cmToUnits(MIN_ROOM_CM),
|
||
eps: this.host._gridPitch * 0.05,
|
||
step: this.host._gridPitch,
|
||
movingHalf: cm > 0 ? wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2 : 0,
|
||
obstacles: this._rszObstacles(),
|
||
thicknessConflict: exactCms.size > 1,
|
||
};
|
||
}
|
||
|
||
public _rszResolution(
|
||
roomId: string, edge: number, renderSnapshot?: string,
|
||
): SafeResizeResolution {
|
||
const snap = this.host._resize.snapshotIdentity || renderSnapshot || this._rszSnapshot();
|
||
const key = `${this.host._space}|${this.host._cellCm}|${this.host._gridPitch}|${snap}`;
|
||
const cacheKey = `${roomId}:${edge}`;
|
||
return this.host._resize.resolve(key, cacheKey, () => {
|
||
const rooms = [...(this.host._resize.rooms || this._rszRooms())];
|
||
const room = rooms.find((candidate) => candidate.id === roomId);
|
||
const a = room?.poly?.[edge] || [0, 0];
|
||
const b = room?.poly?.[(edge + 1) % (room?.poly?.length || 1)] || [0, 0];
|
||
return resolveSafeResize(
|
||
rooms, [...(this.host._resize.openings || this._rszOpenings())], roomId, edge,
|
||
this._rszOptsFor(a, b),
|
||
);
|
||
});
|
||
}
|
||
|
||
public _rszSnapshot(): string {
|
||
return JSON.stringify(this._geometrySnapshot() || {
|
||
spaceId: this.host._space, rooms: [], openings: [], walls: [], open_spans: [],
|
||
});
|
||
}
|
||
|
||
public _rszResetController(): void {
|
||
const hadPreview = this.host._resize.preview !== null;
|
||
this.host._resize.reset();
|
||
if (hadPreview) this.host._cfgEpoch++;
|
||
}
|
||
|
||
public _rszProjectPreview(
|
||
snapshot: string,
|
||
polys: Record<string, number[][]>,
|
||
ops: Record<string, [number, number]>,
|
||
changedRoomIds: readonly string[],
|
||
sourceRooms: readonly { id: string; poly: number[][]; wall_ids?: string[] }[],
|
||
): ResizeProjectionResult<ResizePreview, ResizeWallArtifact> {
|
||
// #329 AC7a: only the LAST projection may explain the refusal, so the
|
||
// previous verdict never leaks into a rejection of another kind.
|
||
this.host._rszLimitViolation = null;
|
||
const real = this.host._serverCfg?.spaces.find((s: any) => s.id === this.host._space);
|
||
if (!real || !this.host._serverCfg) return { ok: false, reason: 'missing-context' };
|
||
const s = JSON.parse(snapshot); // fresh deep copies every move — free to mutate
|
||
const sp: any = {
|
||
...real,
|
||
rooms: s.rooms,
|
||
openings: s.openings || [],
|
||
};
|
||
for (const key of ['walls', 'wall_segments', 'open_spans', 'partitions', 'wall_columns', 'decor'] as const) {
|
||
if (s[key] !== undefined) (sp as any)[key] = s[key];
|
||
else delete (sp as any)[key];
|
||
}
|
||
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) {
|
||
if (s.plan_transform?.[key] !== undefined) (sp as any)[key] = s.plan_transform[key];
|
||
else delete (sp as any)[key];
|
||
}
|
||
const H = this.host._spaceH;
|
||
for (const [id, poly] of Object.entries(polys)) {
|
||
const r = sp.rooms.find((x: any) => x.id === id);
|
||
if (!r) continue;
|
||
r.poly = poly.map((p) => [p[0] / NORM_W, p[1] / H]);
|
||
delete r.x; delete r.y; delete r.w; delete r.h; // a resized room is saved as a polygon
|
||
}
|
||
for (const [id, c] of Object.entries(ops)) {
|
||
const o = (sp.openings || []).find((x: any) => x.id === id);
|
||
if (!o) continue;
|
||
o.x = c[0] / NORM_W;
|
||
o.y = c[1] / H;
|
||
}
|
||
// Geometry that belongs to a wall must ride in the SAME live overlay as
|
||
// its room polygons. Map from the immutable snapshot on every move (never
|
||
// from the previous preview), so atomic identity and thickness cannot lag
|
||
// behind or accumulate rounding error during a long drag.
|
||
const oldSpans: [number[], number[]][] = [];
|
||
const newSpans: [number[], number[]][] = [];
|
||
for (const id of changedRoomIds) {
|
||
const oldR = sourceRooms.find((r) => r.id === id);
|
||
const nr = sp.rooms.find((x: any) => x.id === id);
|
||
if (!oldR || !nr?.poly) continue;
|
||
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
|
||
if (oldR.poly.length !== newPoly.length) continue;
|
||
const ids = Array.isArray((oldR as any).wall_ids) ? (oldR as any).wall_ids : [];
|
||
for (let i = 0; i < oldR.poly.length; i++) {
|
||
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
|
||
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
|
||
const id = ids[i];
|
||
const segment = typeof id === 'string'
|
||
? (sp.wall_segments || []).find((item) => item.id === id)
|
||
: null;
|
||
if (segment) {
|
||
segment.a = [newPoly[i][0] / NORM_W, newPoly[i][1] / NORM_W];
|
||
segment.b = [
|
||
newPoly[(i + 1) % newPoly.length][0] / NORM_W,
|
||
newPoly[(i + 1) % newPoly.length][1] / NORM_W,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
if (oldSpans.length) {
|
||
if (Array.isArray(sp.walls) && sp.walls.length) {
|
||
const rekeyed = rekeyWallsAfterMoveChecked(
|
||
sp.walls, oldSpans, newSpans, this.host._wallKeyPitch, NORM_W, 'fixed-topology',
|
||
);
|
||
if (rekeyed.rejected) return { ok: false, reason: 'wall-metadata' };
|
||
sp.walls = rekeyed.walls;
|
||
}
|
||
}
|
||
const wallCarriers: [number[], number[]][] = [];
|
||
for (const room of sp.rooms || []) {
|
||
const poly = roomPoly(room);
|
||
if (!poly || poly.length < 2) continue;
|
||
for (let index = 0; index < poly.length; index++) {
|
||
wallCarriers.push([
|
||
[poly[index][0] * NORM_W, poly[index][1] * NORM_W],
|
||
[poly[(index + 1) % poly.length][0] * NORM_W,
|
||
poly[(index + 1) % poly.length][1] * NORM_W],
|
||
]);
|
||
}
|
||
}
|
||
// Old plans may contain explicit historical debt (for example an authored
|
||
// off-grid thickness breakpoint). Safe Resize must not silently repair it,
|
||
// but it may not create a new invalid record either. Remove byte-identical
|
||
// old records as a multiset and prove only newly written/split records.
|
||
// That keeps pointermove bounded by the touched thickness profile instead
|
||
// of comparing every historical record with every carrier twice per frame.
|
||
const wallSignature = (wall: WallEntry): string =>
|
||
JSON.stringify([wall?.key, wall?.cm, wall?.a, wall?.b]);
|
||
const oldWallCounts = new Map<string, number>();
|
||
for (const wall of s.walls || []) {
|
||
const key = wallSignature(wall);
|
||
oldWallCounts.set(key, (oldWallCounts.get(key) || 0) + 1);
|
||
}
|
||
const changedWalls: WallEntry[] = [];
|
||
for (const wall of sp.walls || []) {
|
||
const key = wallSignature(wall);
|
||
const remaining = oldWallCounts.get(key) || 0;
|
||
if (remaining) oldWallCounts.set(key, remaining - 1);
|
||
else changedWalls.push(wall);
|
||
}
|
||
if (wallRecordCarrierViolations(changedWalls, wallCarriers, this.host._wallKeyPitch,
|
||
NORM_W, s.walls || []).length) return { ok: false, reason: 'wall-metadata' };
|
||
const liveRoomIds = resizeLiveJunctionRoomIds(sp.rooms || [], changedRoomIds);
|
||
const liveSpace = resizeLiveCandidateSpace(sp, liveRoomIds);
|
||
if (!liveSpace || !this._rszSpaceCandidateGeometry(this.host._space, liveSpace).ok)
|
||
return { ok: false, reason: 'physical-geometry' };
|
||
// #329 AC7a: a step that would ADD a junction-limit violation is never
|
||
// projected, so the drag stops at the last allowed position instead of
|
||
// committing an impossible plan.
|
||
const limitCandidate = {
|
||
...this.host._serverCfg,
|
||
spaces: (this.host._serverCfg?.spaces || []).map(
|
||
(space) => (space?.id === this.host._space ? sp : space),
|
||
),
|
||
};
|
||
try { this._resizePreviewNodes = multiWallNodesForGeometry(
|
||
liveSpace.rooms, liveSpace.walls as unknown as WallEntry[], [], GRID_STEP_N,
|
||
this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
} catch { this._resizePreviewNodes = null; }
|
||
let limited: JunctionLimitViolation[];
|
||
try {
|
||
const affected = new Set(changedRoomIds);
|
||
const next = this.host._junctionLimitViolations(limitCandidate, this.host._space,
|
||
{ status: 'lightweight', multiWallNodes: this._resizePreviewNodes }, affected);
|
||
const baseline = this._resizeBaselineLimits.filter(
|
||
(item) => item.rule !== 'clearance' || affected.has(item.subject));
|
||
limited = increasedViolations(next, baseline);
|
||
} catch {
|
||
limited = [{ rule: 'check_failed', subject: this.host._space, actual: 0, limit: 0 }];
|
||
}
|
||
if (limited.length) {
|
||
this.host._rszLimitViolation = limited[0];
|
||
return { ok: false, reason: 'junction-limit' };
|
||
}
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
preview: { space: this.host._space, sp },
|
||
beforeWalls: s.walls || [],
|
||
afterWalls: sp.walls || [], artifact: null,
|
||
},
|
||
};
|
||
}
|
||
public _rszAcceptPreview(
|
||
preview: ResizePreview | null, wallGeometry: ResizeWallArtifact | null,
|
||
): void {
|
||
this.host._cfgEpoch++;
|
||
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
|
||
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
|
||
if (!preview || !wallGeometry) return;
|
||
const projected = wallBodiesGeometryPath(wallGeometry);
|
||
if (!projected) return;
|
||
const key = `${this.host._space}|${this.host._cfgEpoch}|${preview.sp.rooms.length}`;
|
||
Object.defineProperty(projected, 'sourceFingerprint', {
|
||
value: contentFingerprint([preview.sp, this.host._cellCm, this.host._gridPitch]),
|
||
enumerable: false,
|
||
});
|
||
const entry = { key, value: projected };
|
||
lruWrite(this.host._wallUnionPool, key, entry, 8);
|
||
this.host._wallUnionCache = entry;
|
||
}
|
||
|
||
public _rszSpaceCandidateGeometry(spaceId: string, sp: any): {
|
||
ok: boolean;
|
||
wallGeometry: ReturnType<typeof wallBodiesGeometry> | null;
|
||
} {
|
||
if (!this.host._serverCfg) return { ok: false, wallGeometry: null };
|
||
const candidate = {
|
||
...this.host._serverCfg,
|
||
spaces: this.host._serverCfg.spaces.map((space) =>
|
||
space.id === spaceId ? sp : space),
|
||
} as ServerConfig;
|
||
let wallGeometry: ReturnType<typeof wallBodiesGeometry> | null = null;
|
||
try {
|
||
const check = this.host._checkSpacePhysicalGeometry(
|
||
candidate, spaceId, (geometry) => { wallGeometry = geometry; },
|
||
);
|
||
return { ok: check.ok, wallGeometry };
|
||
} catch {
|
||
return { ok: false, wallGeometry: null };
|
||
}
|
||
}
|
||
|
||
public _rszSpaceCandidateRenderable(spaceId: string, sp: any): boolean {
|
||
if (!this.host._serverCfg) return false;
|
||
try {
|
||
const candidate = {
|
||
...this.host._serverCfg,
|
||
spaces: this.host._serverCfg.spaces.map((space) =>
|
||
space.id === spaceId ? sp : space),
|
||
} as ServerConfig;
|
||
return this.host._checkSpacePhysicalGeometry(candidate, spaceId).ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean {
|
||
return !!preview && preview.space === this.host._space
|
||
&& this._rszSpaceCandidateRenderable(preview.space, preview.sp);
|
||
}
|
||
|
||
public _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
|
||
if (this.host._tool !== 'resize' || this.host._resize.dragging) return;
|
||
this._resizeBaseFrameStable = true;
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
const rooms = this._rszRooms();
|
||
const resolution = this._rszResolution(roomId, edge);
|
||
if (!resolution.enabled) {
|
||
this.host._showToast(this._rszReasonText(resolution.reason));
|
||
return;
|
||
}
|
||
capturePointer(ev);
|
||
const plan = resolution.plan;
|
||
try {
|
||
this._resizeBaselineLimits = this.host._junctionLimitViolations(
|
||
this.host._serverCfg, this.host._space, null, new Set(plan.roomIds));
|
||
} catch { this._resizeBaselineLimits = []; }
|
||
const start = this._svgPoint(ev);
|
||
const wallUnionKey = `${this.host._space}|${this.host._cfgEpoch}|${rooms.length}`;
|
||
const wallUnionBefore = this.host._wallUnionCache?.key === wallUnionKey
|
||
? this.host._wallUnionCache.value : null;
|
||
const snapshotIdentity = this._rszSnapshot();
|
||
this.host._resize.begin({
|
||
pointerId: ev.pointerId, start: [start[0], start[1]], roomId, plan,
|
||
options: this._rszOptsFor(plan.a, plan.b), rooms,
|
||
openings: this._rszOpenings(), snapshotIdentity,
|
||
before: JSON.parse(snapshotIdentity) as SpaceGeometryState,
|
||
wallUnionBefore,
|
||
epochBefore: this.host._cfgEpoch,
|
||
});
|
||
}
|
||
|
||
public _rszReasonText(reason: SafeResizeReason): string {
|
||
return this.host._t(`resize.disabled.${reason}` as I18nKey);
|
||
}
|
||
|
||
public _rszDisabledActivate(ev: Event, reason: SafeResizeReason): void {
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
this.host._showToast(this._rszReasonText(reason));
|
||
}
|
||
|
||
public _rszDisabledKey(ev: KeyboardEvent, reason: SafeResizeReason): void {
|
||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||
this._rszDisabledActivate(ev, reason);
|
||
}
|
||
|
||
public _rszMove(ev: PointerEvent): void {
|
||
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
|
||
ev.stopPropagation();
|
||
queueHouseplanPointerMove(this.host, 'resize', () => this._rszMoveNow(ev));
|
||
}
|
||
private _rszMoveNow(ev: PointerEvent): void {
|
||
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
|
||
ev.stopPropagation();
|
||
const p = this._svgPoint(ev);
|
||
const result = this.host._resize.move({
|
||
pointerId: ev.pointerId,
|
||
point: [p[0], p[1]],
|
||
step: this.host._gridPitch,
|
||
snap: (point) => {
|
||
const snapped = this._snap(point);
|
||
return [snapped[0], snapped[1]];
|
||
},
|
||
project: (snapshot, polys, openings, changedRoomIds, rooms) =>
|
||
this._rszProjectPreview(snapshot, polys, openings, changedRoomIds, rooms),
|
||
publish: (preview, artifact) => this._rszAcceptPreview(preview, artifact),
|
||
measure: (candidate, plan) => this._rszEdgeLabels(candidate, plan),
|
||
});
|
||
if (result.kind === 'rejected') {
|
||
// Persistence metadata is part of the geometry transaction. If wall or
|
||
// virtual-span rekeying would be lossy, keep the last complete preview:
|
||
// the pointer visibly stops there and pointerup can commit only that
|
||
// already-rendered safe candidate.
|
||
if (result.notify) {
|
||
// #329 AC7a: a step stopped by a junction limit names THAT rule — the
|
||
// generic "geometry cannot be saved" wording would hide which limit
|
||
// the wall ran into.
|
||
this.host._showToast(this.host._rszLimitViolation
|
||
? `${this.host._t('resize.limit_stopped')} — `
|
||
+ this._junctionLimitLabel(this.host._rszLimitViolation)
|
||
: this.host._t('resize.preview_failed'));
|
||
}
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (result.kind === 'accepted') this.host.requestUpdate();
|
||
}
|
||
|
||
public _rszUp(ev: PointerEvent): void {
|
||
flushHouseplanPointerMove(this.host, 'resize');
|
||
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
|
||
ev.stopPropagation();
|
||
const result = this.host._resize.finish({
|
||
pointerId: ev.pointerId,
|
||
currentSnapshotIdentity: this._rszSnapshot(),
|
||
validatePreview: (preview) => this._rszCandidateRenderable(preview),
|
||
});
|
||
if (result.kind === 'no-op') {
|
||
// HP-1550-01: nothing to restore — the preview never touched the config
|
||
this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (result.kind === 'rejected') {
|
||
this.host._cfgEpoch++;
|
||
this.host._showToast(this.host._t('resize.commit_failed'));
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
// Commit the exact preview in one step. No simplify/rebuild pass is allowed:
|
||
// preview and persistence share the same fixed-topology candidate.
|
||
const preview = result.preview;
|
||
const sp = this.host._serverCfg?.spaces.find((space) => space.id === preview.space);
|
||
if (sp) {
|
||
sp.rooms = preview.sp.rooms;
|
||
sp.openings = preview.sp.openings;
|
||
if (Array.isArray(preview.sp.walls)) {
|
||
if (preview.sp.walls.length) sp.walls = preview.sp.walls;
|
||
else delete sp.walls;
|
||
}
|
||
if (Array.isArray(preview.sp.wall_segments)) sp.wall_segments = preview.sp.wall_segments;
|
||
}
|
||
// the click synthesized after the drag must not re-pick the selection
|
||
this.host._suppressClick = true;
|
||
setTimeout(() => (this.host._suppressClick = false), 0);
|
||
this._commitPhysicalGeometry(this.host._t('history.resize_room'), result.before);
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _rszCancelDrag(pointerId?: number): void {
|
||
cancelHouseplanPointerMove(this.host, 'resize');
|
||
const result = this.host._resize.cancel(this._rszSnapshot(), pointerId);
|
||
if (result.kind === 'no-op') return;
|
||
// An identical cancel reuses the pre-drag structural caches and writes nothing.
|
||
if (result.restoreEpoch !== null) this.host._cfgEpoch = result.restoreEpoch;
|
||
else this.host._cfgEpoch++;
|
||
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
|
||
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
|
||
if (result.restoreWallUnion) {
|
||
// Alias the already-proved pre-drag union under the restored epoch.
|
||
const space = this.host._spaceModel();
|
||
if (space) {
|
||
const key = `${this.host._space}|${this.host._cfgEpoch}|${space.rooms.length}`;
|
||
const entry = { key, value: result.restoreWallUnion };
|
||
lruWrite(this.host._wallUnionPool, key, entry, 8);
|
||
this.host._wallUnionCache = entry;
|
||
}
|
||
}
|
||
// Retain the canonical frame only when the drag was actually painted in
|
||
// the isolated plan-editor layer. Programmatic/non-editor callers still
|
||
// need a full render to replace their preview DOM.
|
||
if (result.restoreEpoch !== null && this.host._mode === 'plan'
|
||
&& this._resizeBaseFrameStable) this.host._terminalFrame = 1;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _rszPointerCancel(ev: PointerEvent): void {
|
||
if (!this.host._resize.ownsPointer(ev.pointerId)) return;
|
||
ev.stopPropagation();
|
||
this._rszCancelDrag(ev.pointerId);
|
||
}
|
||
|
||
public _rszEdgeLabels(
|
||
res: { polys: Record<string, number[][]> }, plan: SafeResizePlan,
|
||
sourceRooms: readonly { id: string; poly: number[][] }[] | null = this.host._resize.rooms,
|
||
): ResizeLiveLabel[] {
|
||
const rooms = sourceRooms;
|
||
const labels: ResizeLiveLabel[] = [];
|
||
const own = res.polys[plan.roomId] || rooms?.find((r) => r.id === plan.roomId)!.poly;
|
||
if (!own || !rooms) return labels;
|
||
const n = own.length;
|
||
// Длины — между внутренними гранями, как и площадь ниже (#233). Раньше
|
||
// здесь считалась осевая длина, и одно облачко подписей несло две разные
|
||
// конвенции: «3.00 × 4.00» по центрам стен рядом с площадью по полу.
|
||
const spanCms = this._rszInnerSpanCms(plan.roomId, own, res.polys);
|
||
// The moving wall is obvious under the pointer. Only its two side walls
|
||
// remain useful measurements, and each one owns the matching highlight.
|
||
for (const edge of resizeMeasuredEdges(own, plan.edge)) {
|
||
const a = own[edge], b = own[(edge + 1) % n];
|
||
const cm = spanCms?.[edge];
|
||
labels.push({
|
||
kind: 'length',
|
||
x: (a[0] + b[0]) / 2,
|
||
y: (a[1] + b[1]) / 2,
|
||
text: cm == null ? this.host._fmtLen(a, b)
|
||
: formatLength(cm, this.host.hass?.config?.unit_system?.length === 'mi'),
|
||
edge: { a: [a[0], a[1]], b: [b[0], b[1]] },
|
||
});
|
||
}
|
||
// Live areas sit beside the moving wall, one on each owner side. Placement
|
||
// is pure screen-space math fed by the stage size cached by ResizeObserver;
|
||
// no pointermove DOM measurement is allowed.
|
||
const imperial = this.host.hass?.config?.unit_system?.length === 'mi';
|
||
const ids = plan.roomIds;
|
||
const walls = this.host._spaceWalls;
|
||
const physical = this.host._physicalBodiesR();
|
||
const base = this.host._baseVb();
|
||
const currentView = this.host._view && this.host._view.w > 0 && this.host._view.h > 0
|
||
? this.host._view
|
||
: { x: base[0], y: base[1], w: base[2], h: base[3] };
|
||
const [cachedW, cachedH] = this.host._lastValidStageSize || [currentView.w, currentView.h];
|
||
const view = {
|
||
...currentView,
|
||
stageWidth: Math.max(1, cachedW),
|
||
stageHeight: Math.max(1, cachedH),
|
||
};
|
||
const space = this.host._spaceModel();
|
||
const cfgSize = this.host._config?.icon_size ?? 2.5;
|
||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||
const iconPx = space
|
||
? iconCqw(iconPct, space, currentView.w, this.host._kiosk ? this.host._kioskScale.icon : 1)
|
||
* view.stageWidth / 100
|
||
: 24;
|
||
const gearHeightPx = Math.max(10, iconPx * 0.77);
|
||
const gearText = this.host._t('room.settings_short');
|
||
// Mirrors the CSS proportions conservatively: icon + gap + two paddings +
|
||
// localized text at the button's 0.42*height font size.
|
||
const gearWidthPx = gearHeightPx * (
|
||
0.55 + 0.35 * 0.42 + 0.76 + Math.max(1, gearText.length) * 0.66 * 0.42
|
||
);
|
||
for (const id of ids) {
|
||
const poly = res.polys[id] || rooms.find((r) => r.id === id)!.poly;
|
||
// The preview is already the active render model. Reuse the same shared
|
||
// masonry union + contour cache that the following render consumes;
|
||
// rebuilding both independently here doubled one Resize frame.
|
||
const floor = walls.length && space
|
||
? (innerContourForRoom(
|
||
space.rooms, id, walls, this.host._openCuts(), this.host._wallKeyPitch,
|
||
this.host._cellCm, this.host._gridPitch, NORM_W, null, this._resizePreviewNodes,
|
||
) || poly)
|
||
: poly;
|
||
const m2 = physical.length
|
||
? geometryArea(floorMinusBodies(floor, physical))
|
||
* Math.pow(this.host._cellCm / this.host._gridPitch, 2) / 1e4
|
||
: areaM2(floor, this.host._gridPitch, this.host._cellCm);
|
||
const text = formatArea(m2, imperial);
|
||
const placement = placeResizeAreaLabel({
|
||
poly,
|
||
edge: plan.edgeByRoom[id],
|
||
text,
|
||
view,
|
||
gearCenter: poleOfInaccessibility(poly),
|
||
gearWidthPx,
|
||
gearHeightPx,
|
||
});
|
||
labels.push({
|
||
kind: 'area', roomId: id,
|
||
x: placement.anchor[0], y: placement.anchor[1], text, placement,
|
||
});
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
public _rszInnerSpanCms(
|
||
roomId: string, own: number[][], polys: Record<string, number[][]>,
|
||
): number[] | null {
|
||
const rooms = Object.keys(polys).length
|
||
? Object.entries(polys).map(([id, poly]) => ({ id, poly }))
|
||
: this.host._spaceModel()?.rooms;
|
||
if (!rooms?.length) return null;
|
||
const walls = this.host._spaceWalls;
|
||
if (!walls.length) return null;
|
||
const openCuts = this.host._openCuts();
|
||
const offsets = ownEdgeOffsets(
|
||
rooms, roomId, walls, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
if (!offsets || offsets.length !== own.length) return null;
|
||
const perUnitCm = this.host._cellCm / this.host._gridPitch;
|
||
return own.map((_, edge) => innerEdgeSpan(own, edge, offsets) * perUnitCm);
|
||
}
|
||
|
||
public _renderResizeMeasurements(): TemplateResult | typeof nothing {
|
||
const live = this.host._resize.liveLabels;
|
||
if (!live?.length) return nothing;
|
||
const lengths = live.filter((label) => label.kind === 'length');
|
||
const areas = live.filter((label) => label.kind === 'area');
|
||
return svg`<g class="rszmeasurelayer" aria-hidden="true" pointer-events="none">
|
||
${lengths.map((label, index) => svg`<g class="rszmeasuredge"
|
||
data-hp="resize-measured-edge" data-edge-index=${index}>
|
||
<line class="rszmeasurehalo" x1=${label.edge.a[0]} y1=${label.edge.a[1]}
|
||
x2=${label.edge.b[0]} y2=${label.edge.b[1]}></line>
|
||
<line class="rszmeasureink" x1=${label.edge.a[0]} y1=${label.edge.a[1]}
|
||
x2=${label.edge.b[0]} y2=${label.edge.b[1]}></line>
|
||
</g>`)}
|
||
${areas.map((label) => svg`<line class="rszleader" data-hp="resize-area-leader"
|
||
data-room=${label.roomId}
|
||
x1=${label.placement.leader.a[0]} y1=${label.placement.leader.a[1]}
|
||
x2=${label.placement.leader.b[0]} y2=${label.placement.leader.b[1]}></line>`)}
|
||
</g>`;
|
||
}
|
||
|
||
public _renderResizeLayer(
|
||
view: { x: number; y: number; w: number; h: number }, roomIds?: readonly string[],
|
||
): TemplateResult {
|
||
const hr = Math.max(view.w * 0.013, 5); // finger-sized HIT radius on touch, like .vacfithandle
|
||
// Wall-handle glyph: half the old circle — a wall segment with two arrows
|
||
// pointing perpendicular to it (the directions the wall can be dragged).
|
||
// Drawn in local coords (wall along X), rotated per edge at render time.
|
||
// The invisible circle above keeps the full finger-sized hit area and the
|
||
// HP-1550-04 hit-test priority over openings.
|
||
const s = hr / 2, f = (v: number) => v.toFixed(1);
|
||
const iconD =
|
||
`M ${f(-0.7 * s)} 0 H ${f(0.7 * s)}` +
|
||
` M 0 ${f(-0.22 * s)} V ${f(-s)} M ${f(-0.32 * s)} ${f(-0.6 * s)} L 0 ${f(-s)} L ${f(0.32 * s)} ${f(-0.6 * s)}` +
|
||
` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`;
|
||
const parts: TemplateResult[] = [];
|
||
const rooms = this._rszRooms();
|
||
// A geometry snapshot is a deep clone/serialization. It is the cache key
|
||
// for every handle in this frame, so compute it once per layer render —
|
||
// never once per room edge. During a drag the immutable gesture snapshot
|
||
// remains authoritative inside _rszResolution.
|
||
const renderSnapshot = this.host._resize.snapshotIdentity || this._rszSnapshot();
|
||
const visibleRooms = roomIds?.length ? new Set(roomIds) : null;
|
||
for (const r of rooms) {
|
||
if (visibleRooms && !visibleRooms.has(r.id)) continue;
|
||
for (let i = 0; i < r.poly.length; i++) {
|
||
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
|
||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this.host._gridPitch) continue;
|
||
const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2);
|
||
const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI);
|
||
const resolution = this._rszResolution(r.id, i, renderSnapshot);
|
||
const disabled = !resolution.enabled;
|
||
const reason = disabled ? this._rszReasonText(resolution.reason) : this.host._t('title.markup_resize');
|
||
parts.push(svg`<circle class="rszhandle ${disabled ? 'disabled' : ''}"
|
||
cx="${mx}" cy="${my}" r="${f(hr)}" tabindex="0" role="button"
|
||
aria-disabled="${disabled ? 'true' : 'false'}" aria-label="${reason}"
|
||
@pointerdown=${(e: PointerEvent) => this._rszEdgeDown(e, r.id, i)}
|
||
@pointermove=${(e: PointerEvent) => this._rszMove(e)}
|
||
@pointerup=${(e: PointerEvent) => this._rszUp(e)}
|
||
@pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)}
|
||
@lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}
|
||
@click=${disabled ? (e: Event) => this._rszDisabledActivate(e, resolution.reason) : null}
|
||
@keydown=${disabled ? (e: KeyboardEvent) => this._rszDisabledKey(e, resolution.reason) : null}>
|
||
<title>${reason}</title>
|
||
</circle>`);
|
||
parts.push(svg`<g class="rszicon ${disabled ? 'disabled' : ''}" transform="translate(${mx} ${my}) rotate(${ang})"><path class="rszhalo" d="${iconD}"></path><path class="rszink" d="${iconD}"></path></g>`);
|
||
}
|
||
}
|
||
return svg`<g class="resize-layer">${parts}</g>`;
|
||
}
|
||
|
||
public _partitionOpeningCuts(
|
||
space: SpaceModel | undefined = this.host._spaceModel(),
|
||
accept: (opening: OpeningCfg) => boolean = () => true,
|
||
): PartitionOpeningCut[] {
|
||
if (!space) return [];
|
||
const config = this.host._curSpaceCfg?.id === space.id ? this.host._curSpaceCfg : null;
|
||
const openings = geometryOpenings(
|
||
config, space, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
return geometryPartitionOpeningCuts(openings, accept);
|
||
}
|
||
|
||
public _decorSnapGeometry(excludeId?: string): SnapGeometry {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return { points: [], segments: [] };
|
||
const cacheKey = excludeId || '';
|
||
const cached = this.host._decorSnapCache;
|
||
if (cached && cached.epoch === this.host._cfgEpoch && cached.space === this.host._space
|
||
&& cached.height === this.host._decorH && cached.exclude === cacheKey) {
|
||
return cached.geometry;
|
||
}
|
||
const parts: SnapGeometry[] = [];
|
||
for (const shape of this.host._decorList) {
|
||
if (shape.id === excludeId) continue;
|
||
if (shape.kind === 'line') {
|
||
const a = [shape.x1 * NORM_W, shape.y1 * this.host._decorH];
|
||
const b = [shape.x2 * NORM_W, shape.y2 * this.host._decorH];
|
||
parts.push({
|
||
points: [a, b, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]],
|
||
segments: [{ a, b }],
|
||
});
|
||
} else if (shape.kind === 'text') {
|
||
parts.push({ points: [[shape.x * NORM_W, shape.y * this.host._decorH]], segments: [] });
|
||
} else {
|
||
const box = this.host._decorBoxOf(shape);
|
||
if (box) parts.push(boxAnchors(box));
|
||
}
|
||
}
|
||
for (const room of space.rooms) {
|
||
const poly = roomPoly(room);
|
||
if (!poly?.length) continue;
|
||
parts.push({
|
||
points: poly.flatMap((p, i) => {
|
||
const q = poly[(i + 1) % poly.length];
|
||
return [p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]];
|
||
}),
|
||
segments: poly.map((p, i) => ({ a: p, b: poly[(i + 1) % poly.length] })),
|
||
});
|
||
}
|
||
const geometry = mergeSnapGeometry(parts);
|
||
this.host._decorSnapCache = {
|
||
epoch: this.host._cfgEpoch, space: this.host._space, height: this.host._decorH,
|
||
exclude: cacheKey, geometry,
|
||
};
|
||
return geometry;
|
||
}
|
||
|
||
public _decorSnap(raw: number[], pointerType = 'mouse', excludeId?: string): number[] {
|
||
const stage = this.host._stageEl;
|
||
const view = this.host._viewOr(this.host._baseVb());
|
||
const px = pointerType === 'touch' || pointerType === 'pen' ? 14 : 8;
|
||
const tolerance = stage ? (view.w / Math.max(1, stage.clientWidth)) * px : this.host._gridPitch;
|
||
return snapDecorPoint(
|
||
raw, this._decorSnapGeometry(excludeId), tolerance,
|
||
(point) => this._snap(point),
|
||
).point;
|
||
}
|
||
|
||
public _replaceDecor(id: string, patch: Partial<DecorShape>): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
sp.decor = this.host._decorList.map((shape) => shape.id === id ? { ...shape, ...patch } : shape);
|
||
// The edited shape is excluded from the gesture's magnet candidates, so
|
||
// keep the cached geometry of every *other* object across pointermoves.
|
||
// A different exclude id/cache key rebuilds it when the next gesture starts.
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _cancelDecorGesture(): void {
|
||
cancelHouseplanPointerMove(this.host, 'decor-transform');
|
||
cancelHouseplanPointerMove(this.host, 'decor-move');
|
||
cancelHouseplanPointerMove(this.host, 'backdrop');
|
||
const before = this.host._decorMove?.before || this.host._dtDrag?.before || this.host._bdDrag?.before;
|
||
const sp = before && this.host._serverCfg?.spaces.find((space) => space.id === before.spaceId);
|
||
if (before && sp) {
|
||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||
if (before.decor !== undefined) sp.decor = copy(before.decor);
|
||
else delete sp.decor;
|
||
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const)
|
||
delete sp[key];
|
||
Object.assign(sp, copy(before.plan_transform || {}));
|
||
// The restored transform is byte-identical; keep geometry caches.
|
||
this.host._decorSnapCache = null;
|
||
}
|
||
this.host._decorMove = null;
|
||
this.host._dtDrag = null;
|
||
this.host._bdDrag = null;
|
||
// Mark after named terminal setters; only the unnamed cancel frame may be retained.
|
||
if (before && sp && this.host._mode === 'decor') this.host._terminalFrame = 1;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorPointerDown(ev: PointerEvent): boolean {
|
||
const t = this.host._decorTool;
|
||
// #369(е): only the primary mouse button places objects — a right/middle
|
||
// click with an armed tool must not stamp furniture or start a shape.
|
||
// Touch/pen keep their own transactions (button is 0/-1 there).
|
||
if (ev.pointerType === 'mouse' && ev.button !== 0
|
||
&& t !== 'select' && t !== 'erase') return false;
|
||
// A DRAWING tool owns the whole canvas. Pressing on top of an existing
|
||
// shape must start a NEW figure at that very point — otherwise a line can
|
||
// never begin at the end of another line, because the old line grabs the
|
||
// press first (owner, 2026-08-04). Only select/erase talk to shapes, and
|
||
// only for them does the shape's own handler get to deal with the event.
|
||
const onShape = (t === 'select' || t === 'erase')
|
||
? ((ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null)
|
||
: null;
|
||
if (onShape) return true; // the shape's own handler deals with it
|
||
if (t === 'line' || t === 'rect' || t === 'ellipse') {
|
||
ev.preventDefault();
|
||
const p = this._decorSnap(this._svgPoint(ev), ev.pointerType);
|
||
this.host._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||
capturePointer(ev);
|
||
return true;
|
||
}
|
||
if (t === 'text') {
|
||
// …and the press did NOT land on an existing label: those are the one
|
||
// exception to the inertness above (see _decorShapeDown).
|
||
const p = this._decorSnap(this._svgPoint(ev), ev.pointerType);
|
||
this.host._decorTextDialog = {
|
||
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this.host._decorH),
|
||
text: '', color: this.host._decorStyle.color, opacity: this.host._decorStyle.opacity,
|
||
angle: '0', sizeCm: decorUnitsToCm(DECOR_TEXT_BASE, this.host._cellCm, this.host._gridPitch),
|
||
};
|
||
this.host._decorTextSelection = { start: 0, end: 0 };
|
||
return true;
|
||
}
|
||
if (t === 'furniture' || t === 'image') {
|
||
// The furniture tool is a STAMP: the palette arms a symbol, the press
|
||
// puts it down at its real size and the editor goes back to `select`
|
||
// with the new piece selected (owner: «сразу выделен»). Without an armed
|
||
// symbol the press does nothing but keep the pan — pressing the canvas
|
||
// must not silently place whatever was chosen last week.
|
||
if (t === 'furniture' ? !this.host._furnPalette : !this.host._decorImagePalette) return false;
|
||
ev.preventDefault();
|
||
// A mouse has already shown the exact result and keeps the traditional
|
||
// one-press stamp. Touch/pen has no hover, so commit only after a clean
|
||
// pointerup; pointercancel, movement and a second contact stay fail-dark.
|
||
const pointerType = ev.pointerType || 'mouse';
|
||
if (pointerType === 'mouse') {
|
||
if (t === 'furniture') this._furnPlace(this._svgPoint(ev), ev.shiftKey, pointerType);
|
||
else this._decorImagePlace(this._svgPoint(ev), pointerType);
|
||
return true;
|
||
}
|
||
const pending = this.host._furnTouchPending;
|
||
if (pending && pending.pid !== ev.pointerId) {
|
||
pending.cancelled = true;
|
||
this.host._furnPreviewInput = null;
|
||
return true;
|
||
}
|
||
this.host._furnTouchPending = {
|
||
pid: ev.pointerId,
|
||
sx: ev.clientX,
|
||
sy: ev.clientY,
|
||
pointerType,
|
||
cancelled: false,
|
||
};
|
||
this.host._furnPreviewInput = null;
|
||
capturePointer(ev);
|
||
return true;
|
||
}
|
||
// Empty-space selection belongs only to Select. Erase is an object
|
||
// command: a miss must be a true no-op. With SVG text its painted glyphs
|
||
// do not cover the whole logical label box, so clearing here made a click
|
||
// between letters appear to erase only the selection outline.
|
||
if (t === 'select') this.host._decorSel = null;
|
||
// …and under its own tool the picture is grabbable by its body
|
||
// (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the
|
||
// picture and the plane still pans with one finger.
|
||
if (this.host._bdMovable) {
|
||
const r = this.host._bdRect!;
|
||
const p = this._svgPoint(ev);
|
||
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
||
const a = (-normalizeAngle(r.angle) * Math.PI) / 180;
|
||
const dx = p[0] - cx, dy = p[1] - cy;
|
||
const local = [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
|
||
if (local[0] >= r.x && local[0] <= r.x + r.w && local[1] >= r.y && local[1] <= r.y + r.h) {
|
||
ev.preventDefault();
|
||
return this._bdStart(ev);
|
||
}
|
||
}
|
||
return false; // pan is allowed
|
||
}
|
||
|
||
public _decorCommitDraft(): void {
|
||
const d = this.host._decorDraft;
|
||
this.host._decorDraft = null;
|
||
if (!d) return;
|
||
const min = this.host._gridPitch * 0.5;
|
||
if (!validDecorDraft(d.kind, d.a, d.b, min)) return;
|
||
const W = NORM_W, H = this.host._decorH;
|
||
const st = this.host._decorStyle;
|
||
const before = this._geometrySnapshot();
|
||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||
// creation had no canvas guard at all while the MOVE did — a draft could be
|
||
// born outside the range the mover then refused to leave. One limit, both
|
||
// ends of the gesture (docs/CANVAS.md §9).
|
||
const cn = clampCanvasN;
|
||
let shape: DecorShape;
|
||
if (d.kind === 'line') {
|
||
shape = { id, kind: 'line', x1: cn(d.a[0] / W), y1: cn(d.a[1] / H),
|
||
x2: cn(d.b[0] / W), y2: cn(d.b[1] / H), ...decorStylePatch(st, false) } as DecorShape;
|
||
} else {
|
||
const x = cn(Math.min(d.a[0], d.b[0]) / W), y = cn(Math.min(d.a[1], d.b[1]) / H);
|
||
const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H;
|
||
shape = { id, kind: d.kind, x, y, w, h, ...decorStylePatch(st, true) } as DecorShape;
|
||
}
|
||
const sp = this.host._curSpaceCfg;
|
||
sp.decor = [...this.host._decorList, shape];
|
||
this.host._decorSel = id;
|
||
this._recordGeometry(this.host._t('history.decor_add'), before);
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorShapeDown(ev: PointerEvent, shape: DecorShape): void {
|
||
if (this.host._mode !== 'decor') return;
|
||
// Under any other tool the shape is not a target at all: the press has to
|
||
// reach the stage, where the drawing tool starts a new figure (or the
|
||
// backdrop tool grabs the picture). Swallowing it here was the bug — the
|
||
// click on a line end did nothing but keep the old selection alive.
|
||
const t = this.host._decorTool;
|
||
// ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
|
||
// is a target again, and pressing it opens its editor instead of starting
|
||
// a new label on top of it. Everything else stays inert — a press on a
|
||
// line or a rectangle under the text tool still reaches the stage and
|
||
// creates a new label there (the CSS keeps them pointer-inert, this is
|
||
// the belt to that pair of braces).
|
||
if (t === 'text') {
|
||
if (shape.kind !== 'text') return;
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
this._decorOpenText(shape);
|
||
return;
|
||
}
|
||
if (t !== 'select' && t !== 'erase') return;
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
if (t === 'erase') {
|
||
this.host._decorEraseConfirm = { id: shape.id, kind: shape.kind };
|
||
return;
|
||
}
|
||
this.host._decorSel = shape.id;
|
||
this.host._decorMove = {
|
||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||
pid: ev.pointerId, moved: false, before: this._geometrySnapshot(),
|
||
};
|
||
capturePointer(ev);
|
||
}
|
||
|
||
public _decorMoveUpdate(ev: PointerEvent): void {
|
||
const m = this.host._decorMove; if (!m) return;
|
||
if (m.orig?.kind === 'furniture') { this._furnMoveUpdate(ev); return; }
|
||
const p = this._svgPoint(ev);
|
||
const o0: any = m.orig;
|
||
// The delta used to be what got snapped, which preserves whatever off-grid
|
||
// offset the shape already had: a legacy shape at 0.3013 stayed at 0.3013
|
||
// for ever, one step at a time. Snap the RESULTING ANCHOR instead, so one
|
||
// drag is enough to put any shape on the grid (docs/CANVAS.md §9).
|
||
const ax0 = (o0.kind === 'line' ? o0.x1 : o0.x) * NORM_W;
|
||
const ay0 = (o0.kind === 'line' ? o0.y1 : o0.y) * this.host._decorH;
|
||
const anchor = this._decorSnap(
|
||
[ax0 + (p[0] - m.start[0]), ay0 + (p[1] - m.start[1])],
|
||
ev.pointerType, m.id,
|
||
);
|
||
let dx = (anchor[0] - ax0) / NORM_W;
|
||
let dy = (anchor[1] - ay0) / this.host._decorH;
|
||
// audit follow-up L4 gave decor a bounds clamp of -0.25..1.25 — the plan
|
||
// was a sheet with edges then. It is not any more (docs/CANVAS.md): the
|
||
// clamp is now the same garbage limit the backend enforces, so decor can
|
||
// follow a plan that lives at 2.7 and still cannot be flung to 1e100.
|
||
const o: any = m.orig;
|
||
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
|
||
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
|
||
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
|
||
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
|
||
const lim = CANVAS_LIMIT;
|
||
dx = Math.max(-lim - curX, Math.min(lim - curX - w, dx));
|
||
dy = Math.max(-lim - curY, Math.min(lim - curY - h, dy));
|
||
if (dx || dy) m.moved = true;
|
||
const sp = this.host._curSpaceCfg;
|
||
sp.decor = this.host._decorList.map((x) => {
|
||
if (x.id !== m.id) return x;
|
||
const o: any = m.orig;
|
||
if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy };
|
||
return { ...x, x: o.x + dx, y: o.y + dy };
|
||
});
|
||
this.host.requestUpdate();
|
||
}
|
||
public _dmUp(): void {
|
||
flushHouseplanPointerMove(this.host, 'decor-move'); const move = this.host._decorMove;
|
||
if (move?.moved) { this._recordGeometry(this.host._t('history.decor_move'), move.before); this._saveConfig(); }
|
||
this.host._decorMove = null; this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorShapeDbl(ev: MouseEvent, shape: DecorShape): void {
|
||
if (this.host._mode !== 'decor' || this.host._decorTool !== 'select') return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
this.host._decorMove = null;
|
||
this.host._decorSel = shape.id;
|
||
this._openDecorProperties(shape);
|
||
}
|
||
|
||
public _openDecorProperties(shape: DecorShape): void {
|
||
if (this.host._mode !== 'decor' || this.host._decorTool !== 'select') return;
|
||
if (shape.kind === 'text') {
|
||
this._decorOpenText(shape);
|
||
return;
|
||
}
|
||
if (!['line', 'rect', 'ellipse', 'furniture', 'image'].includes(shape.kind)) return;
|
||
if (shape.kind === 'image') void this._decorImageCatalogLoad();
|
||
const style = this.host._decorResolvedStyle(shape);
|
||
const line = shape.kind === 'line' ? shape : null;
|
||
const box = this.host._decorBoxOf(shape);
|
||
this.host._decorShapeDialog = {
|
||
id: shape.id,
|
||
kind: shape.kind as 'line' | 'rect' | 'ellipse' | 'furniture' | 'image',
|
||
color: style.color, opacity: style.opacity, widthCm: style.widthCm,
|
||
angle: this.host._angleField(line
|
||
? normalizeAngle(segmentAngle(
|
||
[line.x1 * NORM_W, line.y1 * this.host._decorH],
|
||
[line.x2 * NORM_W, line.y2 * this.host._decorH],
|
||
))
|
||
: normalizeAngle((shape as any).angle)),
|
||
...(line ? {
|
||
lengthCm: decorUnitsToCm(
|
||
Math.hypot((line.x2 - line.x1) * NORM_W, (line.y2 - line.y1) * this.host._decorH),
|
||
this.host._cellCm, this.host._gridPitch,
|
||
),
|
||
lineStyle: line.line_style === 'dashed' ? 'dashed' : 'solid',
|
||
} : {}),
|
||
...(box ? {
|
||
sizeWCm: decorUnitsToCm(box.w, this.host._cellCm, this.host._gridPitch),
|
||
sizeHCm: decorUnitsToCm(box.h, this.host._cellCm, this.host._gridPitch),
|
||
} : {}),
|
||
...(shape.kind === 'furniture' ? {
|
||
symbol: shape.symbol,
|
||
flipH: !!shape.flip_h,
|
||
flipV: !!shape.flip_v,
|
||
sizeWField: furnitureSignedFieldValue(
|
||
decorUnitsToCm(box!.w, this.host._cellCm, this.host._gridPitch),
|
||
!!shape.flip_h, this.host._imperial,
|
||
),
|
||
sizeHField: furnitureSignedFieldValue(
|
||
decorUnitsToCm(box!.h, this.host._cellCm, this.host._gridPitch),
|
||
!!shape.flip_v, this.host._imperial,
|
||
),
|
||
} : {}),
|
||
...(shape.kind === 'image' ? {
|
||
assetId: shape.asset_id,
|
||
flipH: !!shape.flip_h,
|
||
flipV: !!shape.flip_v,
|
||
sizeWField: furnitureSignedFieldValue(
|
||
decorUnitsToCm(box!.w, this.host._cellCm, this.host._gridPitch),
|
||
!!shape.flip_h, this.host._imperial,
|
||
),
|
||
sizeHField: furnitureSignedFieldValue(
|
||
decorUnitsToCm(box!.h, this.host._cellCm, this.host._gridPitch),
|
||
!!shape.flip_v, this.host._imperial,
|
||
),
|
||
} : {}),
|
||
...(shape.kind === 'rect' || shape.kind === 'ellipse' ? {
|
||
fill: style.fill, fillColor: style.fillColor, fillOpacity: style.fillOpacity,
|
||
} : {}),
|
||
};
|
||
}
|
||
|
||
public _decorOpenText(shape: DecorShape): void {
|
||
if (shape.kind !== 'text') return;
|
||
let text = String(shape.text ?? '');
|
||
const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)]
|
||
.some((m) => !!liveTextReference(m[1]));
|
||
const explicitUnit = String(shape.unit ?? '').trim();
|
||
// Old dialogs represented the entity state as attr="state"; inline text
|
||
// represents that same value with a bare `{entity}` token.
|
||
const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state'
|
||
? null
|
||
: shape.attr;
|
||
const legacyToken = !hasInline && !explicitUnit
|
||
? liveTextToken(shape.entity, legacyAttr)
|
||
: '';
|
||
const preserveLegacy = !!String(shape.entity ?? '').trim() && !hasInline
|
||
&& (!legacyToken || !!explicitUnit);
|
||
if (legacyToken && !preserveLegacy) {
|
||
const slot = text.indexOf('{}');
|
||
text = slot >= 0
|
||
? text.slice(0, slot) + legacyToken + text.slice(slot + 2)
|
||
: `${text}${text ? ' ' : ''}${legacyToken}`;
|
||
}
|
||
this.host._decorTextDialog = {
|
||
id: shape.id, x: shape.x, y: shape.y, text,
|
||
color: safeStoredColor(shape.color, this.host._decorStyle.color),
|
||
opacity: clamp01(shape.opacity, this.host._decorStyle.opacity),
|
||
angle: this.host._angleField(shape.angle),
|
||
sizeCm: this.host._decorTextSizeCm(shape),
|
||
pickerEntity: shape.entity || '',
|
||
preserveLegacy: preserveLegacy || undefined,
|
||
};
|
||
this.host._decorTextSelection = { start: text.length, end: text.length };
|
||
}
|
||
|
||
public _decorRememberTextSelection(el: HTMLTextAreaElement): void {
|
||
this.host._decorTextSelection = {
|
||
start: el.selectionStart ?? el.value.length,
|
||
end: el.selectionEnd ?? el.value.length,
|
||
};
|
||
}
|
||
|
||
public _decorInsertLiveVariable(attr: string | null): void {
|
||
const d = this.host._decorTextDialog;
|
||
if (!d) return;
|
||
const token = liveTextToken(d.pickerEntity, attr);
|
||
if (!token) return;
|
||
const old = d.text;
|
||
const start = Math.max(0, Math.min(old.length, this.host._decorTextSelection.start));
|
||
const end = Math.max(start, Math.min(old.length, this.host._decorTextSelection.end));
|
||
if (old.length - (end - start) + token.length > 200) return;
|
||
const text = old.slice(0, start) + token + old.slice(end);
|
||
const caret = start + token.length;
|
||
this.host._decorTextSelection = { start: caret, end: caret };
|
||
// Inserting a new inline reference is the explicit replacement of any
|
||
// unrepresentable legacy link; the stale side fields can now be dropped.
|
||
this.host._decorTextDialog = { ...d, text, preserveLegacy: undefined };
|
||
this.host.updateComplete.then(() => {
|
||
const el = this.host.renderRoot.querySelector<HTMLTextAreaElement>('textarea.dtarea');
|
||
if (!el) return;
|
||
el.focus();
|
||
el.setSelectionRange(caret, caret);
|
||
});
|
||
}
|
||
|
||
public _decorSaveText(): void {
|
||
const d = this.host._decorTextDialog;
|
||
// The user's own line breaks are kept; the surrounding whitespace is not.
|
||
const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim();
|
||
if (!d || !text) { this.host._decorTextDialog = null; return; }
|
||
const before = this._geometrySnapshot();
|
||
const sp = this.host._curSpaceCfg;
|
||
const textStyle = {
|
||
color: d.color, opacity: clamp01(d.opacity),
|
||
size_cm: Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.sizeCm)).toFixed(4)),
|
||
...(normalizeAngle(d.angle) ? { angle: normalizeAngle(d.angle) } : {}),
|
||
};
|
||
if (d.id) {
|
||
sp.decor = this.host._decorList.map((x) => {
|
||
if (x.id !== d.id) return x;
|
||
if (x.kind !== 'text') return x;
|
||
// A legacy unit or non-canonical attribute cannot be represented by
|
||
// the inline grammar. Keep those fields until the user explicitly
|
||
// replaces the binding; ordinary representable links still migrate.
|
||
if (d.preserveLegacy) {
|
||
const { angle: _angle, size: _size, scale: _scale, ...straight } = x;
|
||
return { ...straight, text, ...textStyle };
|
||
}
|
||
// beta.9 and earlier stored one live link beside the text. Saving in
|
||
// the new editor migrates it to inline tokens and drops the old fields.
|
||
const { entity, attr, unit, ...rest } = x;
|
||
const { angle: _angle, size: _size, scale: _scale, ...straight } = rest;
|
||
return { ...straight, text, ...textStyle };
|
||
});
|
||
} else {
|
||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||
sp.decor = [...this.host._decorList, { id, kind: 'text', x: d.x, y: d.y,
|
||
text, ...textStyle }];
|
||
this.host._decorSel = id;
|
||
}
|
||
this.host._decorTextDialog = null;
|
||
this._recordGeometry(this.host._t(d.id ? 'history.decor_edit' : 'history.decor_add'), before);
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorSaveShape(): void {
|
||
const d = this.host._decorShapeDialog;
|
||
if (!d) return;
|
||
const before = this._geometrySnapshot();
|
||
const style: DecorStyle = {
|
||
color: d.color, opacity: clamp01(d.opacity),
|
||
widthCm: Math.max(0.1, Math.min(100, Number(d.widthCm) || 0.1)),
|
||
fill: !!d.fill, fillColor: d.fillColor || d.color,
|
||
fillOpacity: clamp01(d.fillOpacity, 0.25),
|
||
};
|
||
const sp = this.host._curSpaceCfg;
|
||
const furnitureWcm = d.kind === 'furniture' || d.kind === 'image'
|
||
? furnitureSignedFieldCm(
|
||
d.sizeWField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
|
||
) : null;
|
||
const furnitureHcm = d.kind === 'furniture' || d.kind === 'image'
|
||
? furnitureSignedFieldCm(
|
||
d.sizeHField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
|
||
) : null;
|
||
if ((d.kind === 'furniture' || d.kind === 'image')
|
||
&& (furnitureWcm === null || furnitureHcm === null)) return;
|
||
sp.decor = this.host._decorList.map((shape) => {
|
||
if (shape.id !== d.id) return shape;
|
||
const fillable = d.kind === 'rect' || d.kind === 'ellipse';
|
||
const visual = decorStylePatch(style, fillable);
|
||
if (shape.kind === 'line') {
|
||
const cx = ((shape.x1 + shape.x2) / 2) * NORM_W;
|
||
const cy = ((shape.y1 + shape.y2) / 2) * this.host._decorH;
|
||
const length = decorCmToUnits(Math.max(0.1, Number(d.lengthCm) || 0.1), this.host._cellCm, this.host._gridPitch);
|
||
const rad = (normalizeAngle(d.angle) * Math.PI) / 180;
|
||
const dx = Math.cos(rad) * length / 2, dy = Math.sin(rad) * length / 2;
|
||
const a = this._snap([cx - dx, cy - dy]);
|
||
const b = this._snap([cx + dx, cy + dy]);
|
||
const { width: _legacyWidth, line_style: _oldLineStyle, ...rest } = shape;
|
||
return { ...rest, ...visual,
|
||
x1: clampCanvasN(a[0] / NORM_W), y1: clampCanvasN(a[1] / this.host._decorH),
|
||
x2: clampCanvasN(b[0] / NORM_W), y2: clampCanvasN(b[1] / this.host._decorH),
|
||
...(d.lineStyle === 'dashed' ? { line_style: 'dashed' as const } : {}),
|
||
} as DecorShape;
|
||
}
|
||
if (shape.kind === 'furniture') {
|
||
const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH;
|
||
const w = decorCmToUnits(Math.abs(furnitureWcm!), this.host._cellCm, this.host._gridPitch);
|
||
const h = decorCmToUnits(Math.abs(furnitureHcm!), this.host._cellCm, this.host._gridPitch);
|
||
const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2;
|
||
const angle = normalizeAngle(d.angle);
|
||
const { width: _legacyWidth, angle: _oldAngle, flip_h: _oldFlipH,
|
||
flip_v: _oldFlipV, ...rest } = shape;
|
||
return { ...rest, ...visual,
|
||
x: clampCanvasN((cx - w / 2) / NORM_W),
|
||
y: clampCanvasN((cy - h / 2) / this.host._decorH),
|
||
w: w / NORM_W, h: h / this.host._decorH,
|
||
...(d.symbol ? { symbol: d.symbol } : {}),
|
||
...(furnitureWcm! < 0 ? { flip_h: true } : {}),
|
||
...(furnitureHcm! < 0 ? { flip_v: true } : {}),
|
||
...(angle ? { angle } : {}),
|
||
} as DecorShape;
|
||
}
|
||
if (shape.kind === 'image') {
|
||
const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH;
|
||
const w = decorCmToUnits(Math.abs(furnitureWcm!), this.host._cellCm, this.host._gridPitch);
|
||
const h = decorCmToUnits(Math.abs(furnitureHcm!), this.host._cellCm, this.host._gridPitch);
|
||
const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2;
|
||
const angle = normalizeAngle(d.angle);
|
||
return {
|
||
id: shape.id, kind: 'image' as const,
|
||
asset_id: d.assetId || shape.asset_id,
|
||
x: clampCanvasN((cx - w / 2) / NORM_W),
|
||
y: clampCanvasN((cy - h / 2) / this.host._decorH),
|
||
w: w / NORM_W, h: h / this.host._decorH,
|
||
opacity: clamp01(d.opacity, 1),
|
||
...(furnitureWcm! < 0 ? { flip_h: true } : {}),
|
||
...(furnitureHcm! < 0 ? { flip_v: true } : {}),
|
||
...(angle ? { angle } : {}),
|
||
} as DecorShape;
|
||
}
|
||
if (shape.kind === 'rect' || shape.kind === 'ellipse') {
|
||
const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH;
|
||
const w = Math.max(this.host._gridPitch, snapToGrid(
|
||
decorCmToUnits(Number(d.sizeWCm), this.host._cellCm, this.host._gridPitch), this.host._gridPitch,
|
||
));
|
||
const h = Math.max(this.host._gridPitch, snapToGrid(
|
||
decorCmToUnits(Number(d.sizeHCm), this.host._cellCm, this.host._gridPitch), this.host._gridPitch,
|
||
));
|
||
const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2;
|
||
const angle = normalizeAngle(d.angle);
|
||
const topLeft = resizedBoxTopLeft(
|
||
{ x: cx - w / 2, y: cy - h / 2 }, angle,
|
||
(point) => this._snap(point),
|
||
);
|
||
const { width: _legacyWidth, angle: _oldAngle, ...rest } = shape;
|
||
return { ...rest, ...visual,
|
||
x: clampCanvasN(topLeft[0] / NORM_W), y: clampCanvasN(topLeft[1] / this.host._decorH),
|
||
w: w / NORM_W, h: h / this.host._decorH,
|
||
...(angle ? { angle } : {}),
|
||
} as DecorShape;
|
||
}
|
||
return shape;
|
||
});
|
||
if (d.kind !== 'image') this._updateDecorStyle({ ...style,
|
||
fill: d.kind === 'rect' || d.kind === 'ellipse' ? style.fill : this.host._decorStyle.fill,
|
||
fillColor: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillColor : this.host._decorStyle.fillColor,
|
||
fillOpacity: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillOpacity : this.host._decorStyle.fillOpacity,
|
||
});
|
||
this.host._decorShapeDialog = null;
|
||
this._recordGeometry(this.host._t('history.decor_edit'), before);
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _dtPivot(sh: DecorShape): number[] {
|
||
if (sh.kind === 'line') return [
|
||
((sh.x1 + sh.x2) / 2) * NORM_W,
|
||
((sh.y1 + sh.y2) / 2) * this.host._decorH,
|
||
];
|
||
if (sh.kind === 'furniture' || sh.kind === 'image'
|
||
|| sh.kind === 'rect' || sh.kind === 'ellipse')
|
||
return [(sh.x + sh.w / 2) * NORM_W, (sh.y + sh.h / 2) * this.host._decorH];
|
||
return [sh.x * NORM_W, sh.y * this.host._decorH];
|
||
}
|
||
|
||
public _dtApply(id: string, patch: { textSizeCm?: number; angle?: number }): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
sp.decor = this.host._decorList.map((x) => {
|
||
if (x.id !== id) return x;
|
||
const rest: any = { ...x };
|
||
if (x.kind === 'text' && patch.textSizeCm !== undefined) {
|
||
delete rest.size;
|
||
delete rest.scale;
|
||
}
|
||
const out: any = { ...rest };
|
||
if (patch.textSizeCm !== undefined)
|
||
out.size_cm = Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, patch.textSizeCm)).toFixed(4));
|
||
if (patch.angle !== undefined) {
|
||
if (patch.angle) out.angle = Number(patch.angle.toFixed(2));
|
||
else delete out.angle; // straight again = the field goes away
|
||
}
|
||
return out;
|
||
});
|
||
this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _dtStart(
|
||
ev: PointerEvent, kind: 'scale' | 'rotate', corner?: number[], lineEnd?: 0 | 1,
|
||
): void {
|
||
const sh = this.host._dtSel;
|
||
if (!sh) return;
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
const [ax, ay] = this._dtPivot(sh);
|
||
const p = this._svgPoint(ev);
|
||
const box = this.host._decorBoxOf(sh);
|
||
this.host._dtDrag = {
|
||
id: sh.id, kind, pid: ev.pointerId, ax, ay,
|
||
r0: Math.hypot(p[0] - ax, p[1] - ay),
|
||
a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI,
|
||
textSizeCm0: sh.kind === 'text' ? this.host._decorTextSizeCm(sh) : 1,
|
||
angle0: sh.kind === 'line' ? 0 : Number(sh.angle) || 0,
|
||
// which corner is being pulled, and the box it started from: a piece of
|
||
// furniture is resized about the OPPOSITE corner, so both have to be
|
||
// remembered — a label, scaled about its anchor, needs neither
|
||
sgx: corner?.[0], sgy: corner?.[1],
|
||
orig: box || undefined,
|
||
origShape: JSON.parse(JSON.stringify(sh)),
|
||
before: this._geometrySnapshot(),
|
||
lineEnd,
|
||
moved: false,
|
||
};
|
||
capturePointer(ev);
|
||
}
|
||
|
||
public _dtMove(ev: PointerEvent): void {
|
||
const d = this.host._dtDrag;
|
||
if (!d) return;
|
||
const p = this._svgPoint(ev);
|
||
if (d.lineEnd !== undefined && d.origShape.kind === 'line') {
|
||
const at = this._decorSnap(p, ev.pointerType, d.id);
|
||
const nx = clampCanvasN(at[0] / NORM_W), ny = clampCanvasN(at[1] / this.host._decorH);
|
||
const old = d.origShape;
|
||
const ox = d.lineEnd === 0 ? old.x1 : old.x2;
|
||
const oy = d.lineEnd === 0 ? old.y1 : old.y2;
|
||
if (Math.abs(nx - ox) > 1e-9 || Math.abs(ny - oy) > 1e-9) d.moved = true;
|
||
this._replaceDecor(d.id, d.lineEnd === 0 ? { x1: nx, y1: ny } : { x2: nx, y2: ny });
|
||
return;
|
||
}
|
||
if (d.kind === 'scale' && d.orig) {
|
||
if (d.origShape.kind === 'furniture' || d.origShape.kind === 'image') {
|
||
const box = resizeFurnitureTransform(
|
||
{ ...d.orig, flip_h: d.origShape.flip_h, flip_v: d.origShape.flip_v },
|
||
d.sgx ?? 1, d.sgy ?? 1, p[0], p[1],
|
||
!ev.shiftKey, decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch),
|
||
);
|
||
const changed = Math.abs(box.x - d.orig.x) > 1e-9 || Math.abs(box.y - d.orig.y) > 1e-9
|
||
|| Math.abs(box.w - d.orig.w) > 1e-9 || Math.abs(box.h - d.orig.h) > 1e-9
|
||
|| !!box.flip_h !== !!d.origShape.flip_h || !!box.flip_v !== !!d.origShape.flip_v;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._decorApplyFurnitureBox(d.id, box);
|
||
return;
|
||
}
|
||
// Box geometry preserves ratio by default and separates axes with Shift.
|
||
// Each dimension lands on a whole cell.
|
||
const box = resizeDecorBox(
|
||
d.orig, d.sgx ?? 1, d.sgy ?? 1, p[0], p[1],
|
||
!ev.shiftKey, this.host._gridPitch, this.host._gridPitch,
|
||
);
|
||
const changed = Math.abs(box.x - d.orig.x) > 1e-6 || Math.abs(box.y - d.orig.y) > 1e-6
|
||
|| Math.abs(box.w - d.orig.w) > 1e-6 || Math.abs(box.h - d.orig.h) > 1e-6;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._decorApplyBox(d.id, box);
|
||
return;
|
||
}
|
||
if (d.kind === 'scale') {
|
||
// uniform, about the anchor: the distance from the anchor is invariant
|
||
// under the block's own rotation, so a rotated block scales the same way
|
||
const r = Math.hypot(p[0] - d.ax, p[1] - d.ay);
|
||
if (d.r0 < 1e-6) return;
|
||
const sizeCm = Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.textSizeCm0 * (r / d.r0)));
|
||
const changed = Math.abs(sizeCm - d.textSizeCm0) > 1e-6;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._dtApply(d.id, { textSizeCm: sizeCm });
|
||
return;
|
||
}
|
||
const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI;
|
||
let ang: number;
|
||
if (d.origShape.kind === 'furniture' || d.origShape.kind === 'image') {
|
||
ang = furnitureRotationAngle(d.angle0, d.a0, a, ev.shiftKey);
|
||
} else {
|
||
ang = d.angle0 + (a - d.a0);
|
||
// Existing non-furniture contract: 5° normally, Shift = free.
|
||
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
|
||
ang = ((ang % 360) + 360) % 360;
|
||
if (ang > 180) ang -= 360;
|
||
}
|
||
const changed = Math.abs(ang - d.angle0) > 1e-6;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._dtApply(d.id, { angle: ang });
|
||
}
|
||
|
||
public _dtUp(): void {
|
||
const d = this.host._dtDrag;
|
||
this.host._dtDrag = null;
|
||
if (d?.moved) {
|
||
this._recordGeometry(this.host._t('history.decor_transform'), d.before);
|
||
this._saveConfig();
|
||
}
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _dtMeasure(): void {
|
||
measureHouseplanDecorText(this.host);
|
||
}
|
||
|
||
public _deleteDecor(id: string): void {
|
||
if (!this.host._decorList.some((shape) => shape.id === id)) return;
|
||
const before = this._geometrySnapshot();
|
||
const sp = this.host._curSpaceCfg;
|
||
sp.decor = this.host._decorList.filter((shape) => shape.id !== id);
|
||
if (this.host._decorSel === id) this.host._decorSel = null;
|
||
this._recordGeometry(this.host._t('history.decor_delete'), before);
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorDeleteSel(): void {
|
||
if (this.host._decorSel) this._deleteDecor(this.host._decorSel);
|
||
}
|
||
|
||
public _confirmDecorErase(): void {
|
||
const pending = this.host._decorEraseConfirm;
|
||
this.host._decorEraseConfirm = null;
|
||
if (pending) this._deleteDecor(pending.id);
|
||
}
|
||
|
||
public _furnFieldValue(cm: number): number {
|
||
return Math.round((this.host._imperial ? cm / 30.48 : cm / 100) * 100) / 100;
|
||
}
|
||
|
||
public _furnFieldToCm(v: number): number {
|
||
return clampFurnCm(this.host._imperial ? v * 30.48 : v * 100);
|
||
}
|
||
|
||
/** #369(д): the preview must follow Shift even without mouse movement. */
|
||
private _furnShiftListener = (ev: KeyboardEvent): void => {
|
||
if (ev.key !== 'Shift') return;
|
||
const input = this.host._furnPreviewInput;
|
||
if (!input) return;
|
||
const free = ev.type === 'keydown';
|
||
if (input.free === free) return;
|
||
this.host._furnPreviewInput = { ...input, free };
|
||
this.host.requestUpdate();
|
||
};
|
||
|
||
private _furnShiftAttached = false;
|
||
|
||
private _furnShiftAttach(): void {
|
||
if (this._furnShiftAttached) return;
|
||
this._furnShiftAttached = true;
|
||
window.addEventListener('keydown', this._furnShiftListener);
|
||
window.addEventListener('keyup', this._furnShiftListener);
|
||
}
|
||
|
||
public _furnShiftDetach(): void {
|
||
if (!this._furnShiftAttached) return;
|
||
this._furnShiftAttached = false;
|
||
window.removeEventListener('keydown', this._furnShiftListener);
|
||
window.removeEventListener('keyup', this._furnShiftListener);
|
||
}
|
||
|
||
public _furnPick(symbol: string): void {
|
||
const d = furnitureDefaultCm(symbol);
|
||
this.host._furnPalette = { symbol, w: d.w, h: d.h };
|
||
this._furnShiftAttach();
|
||
}
|
||
|
||
/** Cached room-facing surfaces plus already-physical independent body faces. */
|
||
private get _furnWalls(): readonly FurnitureWallSurface[] {
|
||
return furnitureWallSurfacesFor(this.host);
|
||
}
|
||
|
||
public _resolveFurniturePlacement(
|
||
raw: number[], free = false, pointerType = 'mouse',
|
||
): FurniturePlacement | null {
|
||
const pal = this.host._furnPalette;
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!pal || !sp) return null;
|
||
const W = NORM_W, H = this.host._decorH;
|
||
const snapped = this._decorSnap(raw, pointerType);
|
||
return resolveFurniturePlacement({
|
||
symbol: pal.symbol,
|
||
widthCm: pal.w,
|
||
depthCm: pal.h,
|
||
point: [snapped[0], snapped[1]],
|
||
canvasW: W,
|
||
canvasH: H,
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
walls: this._furnWalls,
|
||
wallReach: this.host._gridPitch * FURN_WALL_CELLS,
|
||
intentPoint: [raw[0], raw[1]],
|
||
free,
|
||
});
|
||
}
|
||
|
||
public _furniturePreviewPlacement(): FurniturePlacement | null {
|
||
const input = this.host._furnPreviewInput;
|
||
if (!input || this.host._mode !== 'decor' || this.host._decorTool !== 'furniture'
|
||
|| !this.host._furnPalette || !this.host._pointerModality.hoverEnabled) return null;
|
||
return this._resolveFurniturePlacement(input.raw, input.free, 'mouse');
|
||
}
|
||
|
||
/** One real furniture path, in the same decor composition group as saved
|
||
* shapes. It paints after them and the whole decor layer remains below walls. */
|
||
public _renderFurniturePlacementPreview(
|
||
furnitureScreenScale: number,
|
||
): TemplateResult | typeof nothing {
|
||
const placement = this._furniturePreviewPlacement();
|
||
if (!placement) return nothing;
|
||
const art = furnitureGraphic(placement.symbol);
|
||
if (!art) return nothing;
|
||
const transform = furnitureRenderTransform(
|
||
placement, NORM_W, this.host._decorH, art.viewW, art.viewH,
|
||
);
|
||
const style = this.host._decorStyle;
|
||
const strokeWidth = furnitureStrokePx(
|
||
decorCmToUnits(style.widthCm, this.host._cellCm, this.host._gridPitch),
|
||
furnitureScreenScale,
|
||
);
|
||
return svg`<path class="furniture-placement-preview dfurn"
|
||
data-symbol=${placement.symbol} d=${art.d} transform=${transform}
|
||
stroke=${style.color} stroke-opacity=${style.opacity}
|
||
stroke-width=${strokeWidth}
|
||
fill="none" stroke-linecap="round" stroke-linejoin="round"
|
||
vector-effect="non-scaling-stroke" aria-hidden="true" pointer-events="none"></path>`;
|
||
}
|
||
|
||
/** Live width/depth labels for a furniture corner resize. */
|
||
public _furnLive(): { x: number; y: number; text: string }[] | null {
|
||
const drag = this.host._dtDrag;
|
||
if (!drag || drag.kind !== 'scale' || !drag.orig) return null;
|
||
const shape = this.host._decorList.find((item) => item.id === drag.id);
|
||
if (!shape || shape.kind !== 'furniture') return null;
|
||
const w = shape.w * NORM_W;
|
||
const h = shape.h * this.host._decorH;
|
||
const corners = furnitureCorners(
|
||
shape.x * NORM_W, shape.y * this.host._decorH, w, h, Number(shape.angle) || 0,
|
||
);
|
||
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
const top = mid(corners[0], corners[1]);
|
||
const left = mid(corners[0], corners[3]);
|
||
return [
|
||
{ x: top[0], y: top[1], text: this.host._fmtLen([0, 0], [w, 0]) },
|
||
{ x: left[0], y: left[1], text: this.host._fmtLen([0, 0], [0, h]) },
|
||
];
|
||
}
|
||
|
||
public _furnPlace(raw: number[], free = false, pointerType = 'mouse'): void {
|
||
const placement = this._resolveFurniturePlacement(raw, free, pointerType);
|
||
if (!placement) return;
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
const before = this._geometrySnapshot();
|
||
const id = 'df' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||
const shape: any = {
|
||
id, kind: 'furniture', symbol: placement.symbol,
|
||
x: placement.x, y: placement.y, w: placement.w, h: placement.h,
|
||
...decorStylePatch(this.host._decorStyle, false),
|
||
};
|
||
// a straight piece stores no angle at all, exactly as a straight label
|
||
// stores none (docs/LIVE-TEXT.md §3)
|
||
if (placement.angle) shape.angle = placement.angle;
|
||
sp.decor = [...this.host._decorList, shape];
|
||
this.host._decorSel = id;
|
||
// …and the editor goes back to the tool that can move what was just placed
|
||
this.host._decorTool = 'select';
|
||
this.host._furnPalette = null;
|
||
this.host._furnCategory = null;
|
||
this._furnShiftDetach();
|
||
this._clearFurniturePreview();
|
||
this._recordGeometry(this.host._t('history.decor_add'), before);
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _clearFurniturePreview(): void {
|
||
this.host._furnPreviewInput = null;
|
||
this.host._furnTouchPending = null;
|
||
}
|
||
|
||
/** Track only real fine-pointer hover. Touch/pen own a delayed one-tap
|
||
* transaction below and must never resurrect a compatibility mouse ghost. */
|
||
public _furnPointerMove(ev: PointerEvent, hoverAllowed: boolean): boolean {
|
||
const pending = this.host._furnTouchPending;
|
||
if (pending?.pid === ev.pointerId) {
|
||
if (Math.abs(ev.clientX - pending.sx) + Math.abs(ev.clientY - pending.sy) > 8) {
|
||
pending.cancelled = true;
|
||
}
|
||
this.host._furnPreviewInput = null;
|
||
return true;
|
||
}
|
||
const armed = this.host._decorTool === 'furniture'
|
||
? !!this.host._furnPalette
|
||
: this.host._decorTool === 'image' && !!this.host._decorImagePalette;
|
||
if (this.host._mode !== 'decor' || !armed) {
|
||
this._clearFurniturePreview();
|
||
return false;
|
||
}
|
||
if (ev.pointerType !== 'mouse' || !hoverAllowed) {
|
||
this.host._furnPreviewInput = null;
|
||
return false;
|
||
}
|
||
const raw = this._svgPoint(ev);
|
||
this.host._furnPreviewInput = { raw: [raw[0], raw[1]], free: ev.shiftKey };
|
||
return true;
|
||
}
|
||
|
||
public _furnPointerLeave(ev: PointerEvent): void {
|
||
if (this.host._furnTouchPending?.pid === ev.pointerId) {
|
||
this.host._furnTouchPending.cancelled = true;
|
||
}
|
||
this.host._furnPreviewInput = null;
|
||
}
|
||
|
||
public _furnPointerUp(ev: PointerEvent): boolean {
|
||
const pending = this.host._furnTouchPending;
|
||
if (!pending || pending.pid !== ev.pointerId) return false;
|
||
this.host._furnTouchPending = null;
|
||
if (!pending.cancelled && this.host._mode === 'decor'
|
||
&& ((this.host._decorTool === 'furniture' && this.host._furnPalette)
|
||
|| (this.host._decorTool === 'image' && this.host._decorImagePalette))) {
|
||
if (this.host._decorTool === 'furniture')
|
||
this._furnPlace(this._svgPoint(ev), ev.shiftKey, pending.pointerType);
|
||
else this._decorImagePlace(this._svgPoint(ev), pending.pointerType);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public _furnMoveUpdate(ev: PointerEvent): void {
|
||
const m = this.host._decorMove; if (!m) return;
|
||
if (m.orig.kind !== 'furniture') return;
|
||
const o: any = m.orig;
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
const W = NORM_W, H = this.host._decorH;
|
||
const p = this._svgPoint(ev);
|
||
const rawCx = (o.x + o.w / 2) * W + (p[0] - m.start[0]);
|
||
const rawCy = (o.y + o.h / 2) * H + (p[1] - m.start[1]);
|
||
let x: number, y: number;
|
||
let angle = Number(o.angle) || 0;
|
||
const angleRad = angle * Math.PI / 180, preferredNormal: [number, number] = [-Math.sin(angleRad), Math.cos(angleRad)];
|
||
const snap = ev.shiftKey ? null : snapFurnitureToWall(
|
||
rawCx, rawCy, o.h * H, this._furnWalls,
|
||
this.host._gridPitch * FURN_WALL_CELLS, this.host._gridPitch,
|
||
[rawCx, rawCy], preferredNormal);
|
||
if (snap) {
|
||
x = snap.cx / W - o.w / 2;
|
||
y = snap.cy / H - o.h / 2;
|
||
angle = snap.angle;
|
||
} else {
|
||
const a = this._decorSnap(
|
||
[rawCx - (o.w / 2) * W, rawCy - (o.h / 2) * H], ev.pointerType, m.id,
|
||
);
|
||
x = a[0] / W;
|
||
y = a[1] / H;
|
||
}
|
||
x = clampCanvasN(x); y = clampCanvasN(y);
|
||
if (Math.abs(x - o.x) > 1e-9 || Math.abs(y - o.y) > 1e-9
|
||
|| Math.abs(angle - (Number(o.angle) || 0)) > 1e-9) m.moved = true;
|
||
sp.decor = this.host._decorList.map((s) => {
|
||
if (s.id !== m.id) return s;
|
||
const out: any = { ...s, x, y };
|
||
if (angle) out.angle = Number(angle.toFixed(2));
|
||
else delete out.angle;
|
||
return out;
|
||
});
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: number }): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
const W = NORM_W, H = this.host._decorH;
|
||
sp.decor = this.host._decorList.map((s) => {
|
||
if (s.id !== id) return s;
|
||
const topLeft = resizedBoxTopLeft(
|
||
box, (s as any).angle, (point) => this._snap(point),
|
||
);
|
||
return {
|
||
...s,
|
||
x: clampCanvasN(topLeft[0] / W), y: clampCanvasN(topLeft[1] / H),
|
||
w: Math.max(this.host._gridPitch / W, Math.min(CANVAS_LIMIT * 2, box.w / W)),
|
||
h: Math.max(this.host._gridPitch / H, Math.min(CANVAS_LIMIT * 2, box.h / H)),
|
||
} as DecorShape;
|
||
});
|
||
this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorApplyFurnitureBox(id: string, box: FurnitureResizeResult): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
const W = NORM_W, H = this.host._decorH;
|
||
const minUnits = decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch);
|
||
sp.decor = this.host._decorList.map((shape) => {
|
||
if (shape.id !== id || (shape.kind !== 'furniture' && shape.kind !== 'image')) return shape;
|
||
const { flip_h: _oldFlipH, flip_v: _oldFlipV, ...rest } = shape;
|
||
return {
|
||
...rest,
|
||
x: clampCanvasN(box.x / W), y: clampCanvasN(box.y / H),
|
||
w: Math.max(minUnits / W, Math.min(CANVAS_LIMIT * 2, box.w / W)),
|
||
h: Math.max(minUnits / H, Math.min(CANVAS_LIMIT * 2, box.h / H)),
|
||
...(box.flip_h ? { flip_h: true } : {}),
|
||
...(box.flip_v ? { flip_v: true } : {}),
|
||
} as DecorShape;
|
||
});
|
||
this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _decorImagePlace(raw: number[], pointerType = 'mouse'): void {
|
||
this._decorImages.place(raw, pointerType);
|
||
}
|
||
|
||
public _renderDecorImagePlacementPreview(): TemplateResult | typeof nothing {
|
||
return this._decorImages.renderPlacementPreview();
|
||
}
|
||
|
||
public _renderMissingDecorImage(
|
||
shape: Extract<DecorShape, { kind: 'image' }>, cls: string, transform: string,
|
||
x: number, y: number, w: number, h: number,
|
||
down: (event: PointerEvent) => void, dbl: (event: MouseEvent) => void,
|
||
): TemplateResult {
|
||
return this._decorImages.renderMissing(shape, cls, transform, x, y, w, h, down, dbl);
|
||
}
|
||
|
||
public async _decorImageUpload(ev: Event, replaceSelection = false): Promise<void> {
|
||
await this._decorImages.uploadFromInput(ev, replaceSelection);
|
||
}
|
||
|
||
private async _uploadDecorImage(
|
||
file: Blob, name: string, replaceSelection: boolean,
|
||
): Promise<void> {
|
||
await this._decorImages.upload(file, name, replaceSelection);
|
||
}
|
||
|
||
public async _decorImageDelete(asset: DecorAsset): Promise<void> {
|
||
await this._decorImages.delete(asset);
|
||
}
|
||
|
||
private async _decorImageCatalogLoad(): Promise<void> {
|
||
await this._decorImages.loadCatalog();
|
||
}
|
||
|
||
public _renderDecorImagePalette(): TemplateResult {
|
||
return this._decorImages.renderImagePalette();
|
||
}
|
||
|
||
public _renderFurnPalette(): TemplateResult {
|
||
return this._decorImages.renderFurniturePalette();
|
||
}
|
||
|
||
public _openBackdropDialog(ev?: Event): void {
|
||
if (!this.host._bdMovable || !this.host._bdRect) return;
|
||
ev?.preventDefault();
|
||
ev?.stopPropagation();
|
||
this.host._bdDrag = null;
|
||
const r = this.host._bdRect;
|
||
this.host._backdropDialog = {
|
||
widthCm: decorUnitsToCm(r.w, this.host._cellCm, this.host._gridPitch),
|
||
heightCm: decorUnitsToCm(r.h, this.host._cellCm, this.host._gridPitch),
|
||
angle: this.host._angleField(r.angle),
|
||
};
|
||
}
|
||
|
||
public _saveBackdropDialog(): void {
|
||
const d = this.host._backdropDialog;
|
||
const base = this.host._bdBase, current = this.host._bdRect;
|
||
if (!d || !base || !current) return;
|
||
const angle = normalizeAngle(d.angle);
|
||
const before = this._geometrySnapshot();
|
||
const w = Math.min(base.w * PLAN_SCALE_MAX, Math.max(
|
||
base.w * PLAN_SCALE_MIN, snapToGrid(
|
||
decorCmToUnits(d.widthCm, this.host._cellCm, this.host._gridPitch), this.host._gridPitch,
|
||
),
|
||
));
|
||
const h = Math.min(base.h * PLAN_SCALE_MAX, Math.max(
|
||
base.h * PLAN_SCALE_MIN, snapToGrid(
|
||
decorCmToUnits(d.heightCm, this.host._cellCm, this.host._gridPitch), this.host._gridPitch,
|
||
),
|
||
));
|
||
const cx = current.x + current.w / 2, cy = current.y + current.h / 2;
|
||
const topLeft = resizedBoxTopLeft(
|
||
{ x: cx - w / 2, y: cy - h / 2 }, angle,
|
||
(point) => this._snap(point),
|
||
);
|
||
this._bdApply((topLeft[0] - base.x) / NORM_W, (topLeft[1] - base.y) / NORM_W,
|
||
w / base.w, h / base.h, angle);
|
||
this.host._backdropDialog = null;
|
||
this._recordGeometry(this.host._t('history.backdrop_transform'), before);
|
||
this._saveConfig();
|
||
}
|
||
|
||
public _bdApply(dx: number, dy: number, sx: number, sy: number, angle: number): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
// 6 decimals: the same precision the rest of the normalised config keeps,
|
||
// and enough for a 1000-unit canvas to be exact to a thousandth of a pixel
|
||
sp.plan_x = Number(clampCanvasN(dx).toFixed(6));
|
||
sp.plan_y = Number(clampCanvasN(dy).toFixed(6));
|
||
delete sp.plan_scale;
|
||
sp.plan_scale_x = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sx)).toFixed(6));
|
||
sp.plan_scale_y = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sy)).toFixed(6));
|
||
const a = normalizeAngle(angle);
|
||
if (a) sp.plan_angle = Number(a.toFixed(2));
|
||
else delete sp.plan_angle;
|
||
this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _bdStart(ev: PointerEvent, corner?: number[], rotate = false): boolean {
|
||
const base = this.host._bdBase, r = this.host._bdRect;
|
||
if (!base || !r) return false;
|
||
const p = this._svgPoint(ev);
|
||
const sgx = corner ? corner[0] : 0;
|
||
const sgy = corner ? corner[1] : 0;
|
||
// the corner that stays put is the OPPOSITE one
|
||
const fx = sgx > 0 ? r.x : r.x + r.w;
|
||
const fy = sgy > 0 ? r.y : r.y + r.h;
|
||
this.host._bdDrag = {
|
||
kind: rotate ? 'rotate' : corner ? 'scale' : 'move',
|
||
pid: ev.pointerId,
|
||
sx: p[0], sy: p[1],
|
||
base, p0: this.host._bdParams,
|
||
fx, fy, sgx, sgy,
|
||
rect0: { x: r.x, y: r.y, w: r.w, h: r.h, angle: r.angle },
|
||
before: this._geometrySnapshot(),
|
||
moved: false,
|
||
};
|
||
capturePointer(ev);
|
||
return true;
|
||
}
|
||
|
||
public _bdMove(ev: PointerEvent): void {
|
||
queueHouseplanPointerMove(this.host, 'backdrop', () => this._bdMoveNow(ev));
|
||
}
|
||
|
||
private _bdMoveNow(ev: PointerEvent): void {
|
||
const d = this.host._bdDrag;
|
||
if (!d) return;
|
||
const p = this._svgPoint(ev);
|
||
const b = d.base;
|
||
if (d.kind === 'move') {
|
||
const x0 = d.rect0.x, y0 = d.rect0.y;
|
||
const at = this._snap([x0 + (p[0] - d.sx), y0 + (p[1] - d.sy)]);
|
||
const changed = Math.abs(at[0] - x0) > 1e-9 || Math.abs(at[1] - y0) > 1e-9;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._bdApply((at[0] - b.x) / NORM_W, (at[1] - b.y) / NORM_W,
|
||
d.p0.sx, d.p0.sy, d.p0.angle);
|
||
return;
|
||
}
|
||
if (d.kind === 'rotate') {
|
||
const cx = d.rect0.x + d.rect0.w / 2, cy = d.rect0.y + d.rect0.h / 2;
|
||
const a0 = Math.atan2(d.sy - cy, d.sx - cx) * 180 / Math.PI;
|
||
let angle = d.p0.angle + (Math.atan2(p[1] - cy, p[0] - cx) * 180 / Math.PI - a0);
|
||
if (!ev.shiftKey) angle = Math.round(angle / DT_ANGLE_STEP) * DT_ANGLE_STEP;
|
||
angle = normalizeAngle(angle);
|
||
const changed = Math.abs(angle - d.p0.angle) > 1e-9;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
this._bdApply(d.p0.dx, d.p0.dy, d.p0.sx, d.p0.sy, angle);
|
||
return;
|
||
}
|
||
const box = resizeDecorBox(
|
||
d.rect0, d.sgx, d.sgy, p[0], p[1], !ev.shiftKey,
|
||
this.host._gridPitch, Math.min(b.w, b.h) * PLAN_SCALE_MIN,
|
||
);
|
||
const sx = box.w / Math.max(1e-9, b.w), sy = box.h / Math.max(1e-9, b.h);
|
||
const changed = Math.abs(sx - d.p0.sx) > 1e-9 || Math.abs(sy - d.p0.sy) > 1e-9
|
||
|| Math.abs(box.x - d.rect0.x) > 1e-9 || Math.abs(box.y - d.rect0.y) > 1e-9;
|
||
if (!changed && !d.moved) return;
|
||
d.moved ||= changed;
|
||
const topLeft = resizedBoxTopLeft(
|
||
box, d.p0.angle, (point) => this._snap(point),
|
||
);
|
||
this._bdApply((topLeft[0] - b.x) / NORM_W, (topLeft[1] - b.y) / NORM_W,
|
||
sx, sy, d.p0.angle);
|
||
}
|
||
|
||
public _bdReset(): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!sp) return;
|
||
const before = this._geometrySnapshot();
|
||
delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale;
|
||
delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle;
|
||
this.host._bdDrag = null;
|
||
this._recordGeometry(this.host._t('history.backdrop_transform'), before);
|
||
this._saveConfig();
|
||
this.host._showToast(this.host._t('decor.backdrop_reset_done'));
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _bdUp(): void {
|
||
flushHouseplanPointerMove(this.host, 'backdrop');
|
||
const d = this.host._bdDrag;
|
||
this.host._bdDrag = null;
|
||
if (d?.moved) {
|
||
this._recordGeometry(this.host._t('history.backdrop_transform'), d.before);
|
||
this._saveConfig();
|
||
}
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _renderBackdropFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||
const r = this.host._bdRect;
|
||
if (!this.host._bdActive || !r) return nothing;
|
||
// Two radii, one gesture — the split the text frame uses (docs/LIVE-TEXT.md
|
||
// §3) and, since 2026-08-05, every corner handle in the card. `hr` is the
|
||
// HIT radius: a fraction of the visible view, so the target stays
|
||
// finger-sized at any zoom. `kr` is what you SEE — a quarter of it, because
|
||
// a blob the size of a room is not a handle, it is an occlusion (owner:
|
||
// «уменьшить в 4 раза… они постоянно гигантские»). The clickable area is
|
||
// unchanged; only the ink shrank.
|
||
const hr = Math.max(view.w, view.h) * 0.02;
|
||
const kr = hr / 4;
|
||
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
||
const angle = normalizeAngle(r.angle);
|
||
const arm = hr * 2.2;
|
||
const corners: [number, number, string][] = [
|
||
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
|
||
];
|
||
return svg`<g class="bdframe" transform=${angle ? `rotate(${angle} ${cx} ${cy})` : nothing}>
|
||
<rect class="bdbox" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}"></rect>
|
||
<line class="dtstem" x1="${cx}" y1="${r.y}" x2="${cx}" y2="${r.y - arm}"></line>
|
||
<circle class="bdhandle dtrot" cx="${cx}" cy="${r.y - arm}" r="${hr.toFixed(1)}"
|
||
@pointerdown=${(e: PointerEvent) => {
|
||
e.stopPropagation(); e.preventDefault(); this._bdStart(e, undefined, true);
|
||
}}></circle>
|
||
<circle class="bdknob" cx="${cx}" cy="${r.y - arm}" r="${kr.toFixed(2)}"></circle>
|
||
${corners.map(([sx, sy, cur]) => {
|
||
const cx = sx < 0 ? r.x : r.x + r.w;
|
||
const cy = sy < 0 ? r.y : r.y + r.h;
|
||
return svg`<circle
|
||
class="bdhandle bd-${cur}" data-corner="${sx + ',' + sy}"
|
||
cx="${cx}" cy="${cy}" r="${hr.toFixed(1)}"
|
||
@pointerdown=${(e: PointerEvent) => {
|
||
e.stopPropagation(); e.preventDefault(); this._bdStart(e, [sx, sy]);
|
||
}}></circle><circle class="bdknob" cx="${cx}" cy="${cy}" r="${kr.toFixed(2)}"></circle>`;
|
||
})}
|
||
</g>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderEditorGroupLauncher(group: EditorToolbarGroup): TemplateResult {
|
||
return this.host._editorSecondary.renderGroupLauncher(
|
||
group,
|
||
this.host._editorToolbarGroups,
|
||
this.host._editorSecondaryCopy,
|
||
);
|
||
}
|
||
|
||
public _runEditorContext<T>(contextId: string, action: () => T): T | undefined {
|
||
return this.host._editorSecondary.runContext(
|
||
contextId, this.host._editorSecondaryContextId, action,
|
||
);
|
||
}
|
||
|
||
public _renderEditorGroupModel(group: EditorToolbarGroup): EditorSecondaryModel {
|
||
return this.host._editorSecondary.renderGroupModel(
|
||
group,
|
||
this.host._editorSecondaryContextId,
|
||
this.host._editorSecondaryCopy,
|
||
);
|
||
}
|
||
|
||
public _renderDrawWallControl(): TemplateResult {
|
||
const min = this.host._tool === 'column' ? 1 : 0;
|
||
return html`<label class="drawwall ${this.host._drawWallCm == null ? 'invalid' : ''}">${this.host._t('wallthick.field')}
|
||
<input type="number" min=${cmToField(min, this.host._imperial)}
|
||
max=${cmToField(this.host._drawWallMaxCm, this.host._imperial)} step="any"
|
||
.value=${this.host._drawWallFieldValue}
|
||
@input=${(e: Event) => {
|
||
this.host._drawWallField = (e.target as HTMLInputElement).value;
|
||
}}
|
||
title=${this.host._t(this.host._tool === 'draw' ? 'markup.draw_wall_title'
|
||
: 'physical.column_size_title')} />
|
||
<span class="opl">${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
|
||
<span class="rangehint">${this.host._t('physical.allowed_range', {
|
||
min: cmToField(min, this.host._imperial),
|
||
max: cmToField(this.host._drawWallMaxCm, this.host._imperial),
|
||
unit: this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm'),
|
||
})}</span>
|
||
</label>`;
|
||
}
|
||
|
||
public _renderPlanSecondary(): EditorSecondaryModel | null {
|
||
const contextId = this.host._editorSecondaryContextId;
|
||
const sel = this.host._physicalSel;
|
||
if (sel) {
|
||
const exists = sel.kind === 'partition'
|
||
? !!this.host._curSpaceCfg.partitions?.some((item: PartitionCfg) => item.id === sel.id)
|
||
: !!this.host._curSpaceCfg.wall_columns?.some((item: WallColumnCfg) => item.id === sel.id);
|
||
if (!exists) return null;
|
||
const label = sel.kind === 'partition' ? this.host._t('markup.partition')
|
||
: sel.kind === 'column' ? this.host._t('markup.column') : this.host._t('markup.add');
|
||
return {
|
||
contextId,
|
||
kind: 'selection',
|
||
ariaLabel: this.host._t('editor.context_actions', { object: label }),
|
||
visibleLabel: label,
|
||
content: html`
|
||
<button class="btn ghost" @click=${() => this._runEditorContext(contextId, () => {
|
||
const current = this.host._physicalSel;
|
||
if (current) this._openPhysicalDialog(current.kind, current.id);
|
||
})}><ha-icon icon="mdi:tune"></ha-icon>${this.host._t('btn.properties')}</button>
|
||
<button class="btn danger" @click=${() => this._runEditorContext(contextId, () => this._deletePhysicalSelection())}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||
</button>`,
|
||
};
|
||
}
|
||
|
||
const hasThickness = this.host._tool === 'draw' || this.host._tool === 'column';
|
||
const hintKey = this.host._tool === 'column' ? 'markup.hint_column'
|
||
: this.host._tool === 'resize' ? 'markup.hint_resize'
|
||
: this.host._tool === 'wallthick' ? 'markup.hint_wallthick'
|
||
: null;
|
||
const drawHint = this.host._tool === 'draw'
|
||
? this.host._t(this.host._path.length ? 'markup.hint_points' : 'markup.hint_start',
|
||
this.host._path.length ? { n: this.host._path.length } : undefined)
|
||
: '';
|
||
if (!hasThickness && !hintKey && !drawHint) return null;
|
||
const operation = this.host._tool === 'draw' && this.host._path.length > 0;
|
||
return {
|
||
contextId,
|
||
kind: operation ? 'operation' : 'tool',
|
||
ariaLabel: this.host._t('editor.tool_options', {
|
||
tool: this.host._t((this.host._tool === 'draw' ? 'markup.add'
|
||
: this.host._tool === 'column' ? 'markup.column'
|
||
: this.host._tool === 'resize' ? 'markup.resize'
|
||
: 'markup.wallthick') as any),
|
||
}),
|
||
content: html`
|
||
${hasThickness ? this._renderDrawWallControl() : nothing}
|
||
${drawHint ? html`<span class="hint">${drawHint}</span>` : nothing}
|
||
${hintKey ? html`<span class="hint">${this.host._t(hintKey as any)}</span>` : nothing}
|
||
${this.host._tool === 'draw' && this.host._path.length
|
||
? html`<button class="btn ghost" @click=${() => this._runEditorContext(contextId, () => {
|
||
if (this._finishWallChain()) this.host.requestUpdate();
|
||
})}>
|
||
${this.host._t('btn.reset')}
|
||
</button>` : nothing}`,
|
||
};
|
||
}
|
||
|
||
public _renderDecorSecondary(): EditorSecondaryModel | null {
|
||
const contextId = this.host._editorSecondaryContextId;
|
||
if (this.host._decorTool === 'furniture') {
|
||
return {
|
||
contextId,
|
||
kind: 'palette',
|
||
ariaLabel: this.host._t('editor.palette', { tool: this.host._t('decor.furniture') }),
|
||
launcherId: 'furniture',
|
||
dismissPolicy: this.host._furnPalette ? 'stay-open-on-canvas' : 'outside',
|
||
dismiss: () => {
|
||
if (this.host._decorTool !== 'furniture') return;
|
||
this._clearFurniturePreview();
|
||
this.host._furnPalette = null;
|
||
this._furnShiftDetach();
|
||
this.host._furnCategory = null;
|
||
this.host._decorTool = 'select';
|
||
this.host.requestUpdate();
|
||
},
|
||
content: this._renderFurnPalette(),
|
||
};
|
||
}
|
||
if (this.host._decorTool === 'image') {
|
||
return {
|
||
contextId,
|
||
kind: 'palette',
|
||
ariaLabel: this.host._t('editor.palette', { tool: this.host._t('decor.image') }),
|
||
launcherId: 'image',
|
||
dismissPolicy: this.host._decorImagePalette ? 'stay-open-on-canvas' : 'outside',
|
||
dismiss: () => {
|
||
if (this.host._decorTool !== 'image') return;
|
||
this._clearFurniturePreview();
|
||
this.host._decorImagePalette = null;
|
||
this.host._decorTool = 'select';
|
||
this.host.requestUpdate();
|
||
},
|
||
content: this._renderDecorImagePalette(),
|
||
};
|
||
}
|
||
const selected = this.host._dtSel;
|
||
if (selected) {
|
||
const label = this.host._t((`decor.${selected.kind}`) as any);
|
||
return {
|
||
contextId,
|
||
kind: 'selection',
|
||
ariaLabel: this.host._t('editor.context_actions', { object: label }),
|
||
visibleLabel: label,
|
||
content: html`
|
||
<button class="btn ghost" @click=${() => this._runEditorContext(contextId, () => {
|
||
const current = this.host._dtSel;
|
||
if (current) this._openDecorProperties(current);
|
||
})}><ha-icon icon="mdi:tune"></ha-icon>${this.host._t('btn.properties')}</button>
|
||
<button class="btn danger" @click=${() => this._runEditorContext(contextId, () => this._decorDeleteSel())}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||
</button>`,
|
||
};
|
||
}
|
||
|
||
const draws = this.host._decorTool === 'line' || this.host._decorTool === 'rect' || this.host._decorTool === 'ellipse';
|
||
const canFill = this.host._decorTool === 'rect' || this.host._decorTool === 'ellipse';
|
||
const backdrop = this.host._decorTool === 'backdrop' && !!this.host._bdRect;
|
||
if (!draws && !backdrop) return null;
|
||
return {
|
||
contextId,
|
||
kind: 'tool',
|
||
ariaLabel: this.host._t('editor.tool_options', {
|
||
tool: this.host._t((draws ? `decor.${this.host._decorTool}` : 'decor.backdrop') as any),
|
||
}),
|
||
content: html`
|
||
${draws ? html`
|
||
<hp-color-opacity .label=${this.host._t('decor.color')} .color=${this.host._decorStyle.color}
|
||
.opacity=${this.host._decorStyle.opacity} .opacityLabel=${this.host._t('space.opacity')}
|
||
.pickerLabels=${this.host._colorPickerLabels}
|
||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||
this._updateDecorStyle({ ...this.host._decorStyle, ...e.detail })}></hp-color-opacity>
|
||
<label class="drawwall">${this.host._t('decor.width')}
|
||
<input type="number" min=${this.host._decorSmallField(0.1)}
|
||
max=${this.host._decorSmallField(100)} step="0.1"
|
||
.value=${String(this.host._decorSmallField(this.host._decorStyle.widthCm))}
|
||
@input=${(e: Event) => this._updateDecorStyle({ ...this.host._decorStyle,
|
||
widthCm: this.host._decorSmallCm(Number((e.target as HTMLInputElement).value)) })} />
|
||
<span class="opl">${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
|
||
</label>
|
||
${canFill ? html`<label class="dfill"><input type="checkbox" .checked=${this.host._decorStyle.fill}
|
||
@change=${(e: Event) => this._updateDecorStyle({ ...this.host._decorStyle,
|
||
fill: (e.target as HTMLInputElement).checked })} />${this.host._t('decor.fill')}</label>
|
||
<hp-color-opacity .label=${this.host._t('decor.fill_color')} .color=${this.host._decorStyle.fillColor}
|
||
.opacity=${this.host._decorStyle.fillOpacity} .opacityLabel=${this.host._t('space.opacity')}
|
||
.pickerLabels=${this.host._colorPickerLabels}
|
||
.disabled=${!this.host._decorStyle.fill}
|
||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||
this._updateDecorStyle({ ...this.host._decorStyle,
|
||
fillColor: e.detail.color, fillOpacity: e.detail.opacity })}></hp-color-opacity>` : nothing}
|
||
` : nothing}
|
||
${backdrop ? html`<span class="bdhint">${this.host._t('decor.backdrop_hint')}</span>` : nothing}`,
|
||
};
|
||
}
|
||
|
||
public _withBackdropReset(model: EditorSecondaryModel | null): EditorSecondaryModel | null {
|
||
if (!this.host._bdMoved || this.host._bdDrag || model?.kind === 'palette') return model;
|
||
const contextId = this.host._editorSecondaryContextId;
|
||
const reset = html`<button class="btn bdreset" title=${this.host._t('decor.backdrop_reset')}
|
||
@click=${() => this._runEditorContext(contextId, () => this._bdReset())}>
|
||
<ha-icon icon="mdi:image-refresh-outline"></ha-icon>${this.host._t('decor.backdrop_reset')}
|
||
</button>`;
|
||
if (!model) {
|
||
return {
|
||
contextId,
|
||
kind: 'tool',
|
||
ariaLabel: this.host._t('editor.tool_options', { tool: this.host._t('decor.backdrop') }),
|
||
content: reset,
|
||
};
|
||
}
|
||
return {
|
||
...model,
|
||
kind: model.kind === 'selection' ? 'mixed' : model.kind,
|
||
content: html`${model.content}${reset}`,
|
||
};
|
||
}
|
||
|
||
public _renderEditorSecondary(): TemplateResult | typeof nothing {
|
||
if (!this.host._editing) return nothing;
|
||
const group = this.host._editorSecondary.activeGroup(this.host._editorToolbarGroups);
|
||
const model = group ? this._renderEditorGroupModel(group)
|
||
: this.host._mode === 'plan' ? this._renderPlanSecondary()
|
||
: this.host._mode === 'decor' ? this._withBackdropReset(this._renderDecorSecondary())
|
||
: null;
|
||
return this.host._editorSecondary.render(model, this.host._editorSecondaryDialogBlocked);
|
||
}
|
||
|
||
public _renderDecorBar(): TemplateResult {
|
||
const tools = [
|
||
['select', 'mdi:cursor-default-outline', 'decor.select'],
|
||
// moving the picture is a TOOL (docs/BACKDROP.md §2) — offered only when
|
||
// there IS a picture, so a hand-drawn space's bar is unchanged
|
||
...(this.host._bdRect ? [['backdrop', 'mdi:image-move', 'decor.backdrop'] as const] : []),
|
||
['line', 'mdi:vector-line', 'decor.line'],
|
||
['rect', 'mdi:rectangle-outline', 'decor.rect'],
|
||
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
|
||
['text', 'mdi:format-text', 'decor.text'],
|
||
// the library sits next to the shapes it belongs with (docs/FURNITURE.md)
|
||
['furniture', 'mdi:sofa-outline', 'decor.furniture'],
|
||
...(this.host._haDecorAssetsApi === DECOR_ASSETS_API_VERSION
|
||
? [['image', 'mdi:image-plus-outline', 'decor.image'] as const] : []),
|
||
['erase', 'mdi:eraser', 'decor.erase'],
|
||
] as const;
|
||
const undoName = this.host._geometryHistory.undoName;
|
||
const redoName = this.host._geometryHistory.redoName;
|
||
return html`<div class="editbar decorbar" data-hp="toolbar" data-kind="decor">
|
||
<div class="editbar-tools" tabindex="-1" ?inert=${this.host._modeTransitionBusy}>
|
||
${tools.map(
|
||
([t, ic, k]) => html`<button class="btn dtool ${this.host._decorTool === t ? 'on' : ''}" data-hp="tool" data-tool=${t}
|
||
data-editor-palette=${t === 'furniture' || t === 'image' ? t : nothing}
|
||
@click=${() => {
|
||
if ((t === 'furniture' || t === 'image') && this.host._decorTool === t) {
|
||
this._clearFurniturePreview();
|
||
this.host._furnPalette = null;
|
||
this.host._decorImagePalette = null;
|
||
this._furnShiftDetach();
|
||
this.host._furnCategory = null;
|
||
this.host._decorTool = 'select';
|
||
return;
|
||
}
|
||
if (t === 'furniture' || t === 'image') this.host._editorSecondary.openPalette();
|
||
if (t === 'image') void this._decorImageCatalogLoad();
|
||
this.host._decorTool = t as typeof this.host._decorTool;
|
||
this.host._decorDraft = null;
|
||
// the palette belongs to its tool and to nothing else: leaving the
|
||
// tool disarms whatever was chosen, so no later click can stamp it
|
||
if (t !== 'furniture') {
|
||
this._clearFurniturePreview();
|
||
this.host._furnPalette = null;
|
||
this._furnShiftDetach();
|
||
this.host._furnCategory = null;
|
||
} else {
|
||
this._clearFurniturePreview();
|
||
this.host._furnCategory = null;
|
||
}
|
||
if (t !== 'image') this.host._decorImagePalette = null;
|
||
}}
|
||
title=${this.host._t(k)}>
|
||
<ha-icon icon=${ic}></ha-icon><span class="ml">${this.host._t(k)}</span>
|
||
</button>`,
|
||
)}
|
||
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
|
||
<hp-color-opacity class="decor-default-color"
|
||
.label=${this.host._t('decor.color')}
|
||
.color=${this.host._decorStyle.color}
|
||
.opacity=${this.host._decorStyle.opacity}
|
||
.opacityLabel=${this.host._t('space.opacity')}
|
||
.pickerLabels=${this.host._colorPickerLabels}
|
||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||
this._updateDecorStyle({ ...this.host._decorStyle, ...e.detail })}>
|
||
</hp-color-opacity>
|
||
<button class="btn ghost" @click=${() => this._undoGeometry()} ?disabled=${!undoName}
|
||
title=${undoName ? this.host._t('history.undo_named', { name: undoName }) : this.host._t('history.undo_empty')}>
|
||
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('history.undo')}
|
||
</button>
|
||
<button class="btn ghost" @click=${() => this._redoGeometry()} ?disabled=${!redoName}
|
||
title=${redoName ? this.host._t('history.redo_named', { name: redoName }) : this.host._t('history.redo_empty')}>
|
||
<ha-icon icon="mdi:redo-variant"></ha-icon>${this.host._t('history.redo')}
|
||
</button>
|
||
</div>
|
||
<div class="editbar-end">
|
||
<button class="btn barclose" title=${this.host._t('title.close_editor')} data-hp="editor-close"
|
||
data-editor-navigation="view"
|
||
@click=${() => this._setMode('view')}>
|
||
<ha-icon icon="mdi:close"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
public _renderDecorEraseConfirm(): TemplateResult {
|
||
const pending = this.host._decorEraseConfirm!;
|
||
const kind = this.host._t(`decor.${pending.kind}` as any);
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="confirm" .title=${this.host._t('decor.erase_confirm_title')}
|
||
icon="mdi:eraser" dismiss-on-scrim @hp-close=${() => (this.host._decorEraseConfirm = null)}>
|
||
<div class="body"><p>${this.host._t('confirm.erase_decor', { kind })}</p></div>
|
||
<div class="row" slot="footer">
|
||
<span class="spacer"></span>
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._decorEraseConfirm = null)}>
|
||
${this.host._t('btn.cancel')}
|
||
</button>
|
||
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this._confirmDecorErase()}>
|
||
<ha-icon icon="mdi:eraser"></ha-icon>${this.host._t('decor.erase')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderDecorTextDialog(): TemplateResult {
|
||
const d = this.host._decorTextDialog!;
|
||
const ent = (d.pickerEntity || '').trim();
|
||
const st = ent ? this.host.hass?.states?.[ent] : null;
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="decor" .title=${this.host._t('decor.text_title')}
|
||
icon="mdi:format-text" dismiss-on-scrim @hp-close=${() => (this.host._decorTextDialog = null)}>
|
||
<div class="body">
|
||
<label>${this.host._t('decor.text_label')}</label>
|
||
${''/* a textarea, not an input: the user's own line breaks are kept
|
||
and rendered (centred). Enter is a NEW LINE here, so saving
|
||
moved to Ctrl/Cmd+Enter and the button. */}
|
||
<textarea class="namein dtarea" rows="3" maxlength="200" .value=${d.text} autofocus
|
||
@input=${(e: Event) => {
|
||
const el = e.target as HTMLTextAreaElement;
|
||
this._decorRememberTextSelection(el);
|
||
this.host._decorTextDialog = { ...d, text: el.value };
|
||
}}
|
||
@click=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
|
||
@keyup=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
|
||
@select=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
|
||
@blur=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
|
||
@keydown=${(e: KeyboardEvent) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
|
||
}}></textarea>
|
||
<hp-color-opacity .label=${this.host._t('decor.color')} .color=${d.color} .opacity=${d.opacity}
|
||
.opacityLabel=${this.host._t('space.opacity')} .pickerLabels=${this.host._colorPickerLabels}
|
||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||
(this.host._decorTextDialog = { ...d, ...e.detail })}></hp-color-opacity>
|
||
<label>${this.host._t('decor.text_size')}</label>
|
||
<div class="colorrow"><input class="namein" type="number" min="0.1"
|
||
max=${this.host._decorSmallField(DECOR_TEXT_CM_MAX)} step="0.1"
|
||
.value=${String(this.host._decorSmallField(d.sizeCm))}
|
||
@input=${(e: Event) => (this.host._decorTextDialog = { ...d,
|
||
sizeCm: this.host._decorTextCm(Number((e.target as HTMLInputElement).value)) })} />
|
||
<span class="opl">${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span></div>
|
||
<label>${this.host._t('decor.angle')}</label>
|
||
<input class="namein" type="number" min="-180" max="180" step="1" .value=${d.angle}
|
||
@input=${(e: Event) => (this.host._decorTextDialog = { ...d,
|
||
angle: (e.target as HTMLInputElement).value })} />
|
||
<label class="dispsection">${this.host._t('decor.live_group')}</label>
|
||
<label>${this.host._t('decor.live_entity')}</label>
|
||
<input class="namein" type="text" list="hp-dtext-ents" placeholder=${this.host._t('decor.live_entity_ph')}
|
||
.value=${d.pickerEntity || ''}
|
||
@input=${(e: Event) => (this.host._decorTextDialog = {
|
||
...d, pickerEntity: (e.target as HTMLInputElement).value,
|
||
})} />
|
||
<datalist id="hp-dtext-ents">
|
||
${Object.keys(this.host.hass?.states || {}).map((id) => html`<option value=${id}></option>`)}
|
||
</datalist>
|
||
${ent ? html`
|
||
<label>${this.host._t('decor.live_attr')}</label>
|
||
<select id="decor-live-attribute" class="namein" .value=${''}
|
||
@change=${(e: Event) => {
|
||
const value = (e.target as HTMLSelectElement).value;
|
||
if (value) this._decorInsertLiveVariable(value === '__state__' ? null : value);
|
||
}}>
|
||
<option value="">${this.host._t('decor.live_attr_ph')}</option>
|
||
<option value="__state__">${this.host._t('decor.live_state')}</option>
|
||
${Object.keys(st?.attributes || {})
|
||
.filter((a) => !!liveTextToken(ent, a))
|
||
.map((a) => html`<option value=${a}>${a}</option>`)}
|
||
</select>
|
||
` : nothing}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<span class="spacer"></span>
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._decorTextDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn primary" data-hp="dialog-confirm" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this.host._t('btn.save')}</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderDecorShapeDialog(): TemplateResult {
|
||
return this._decorImages.renderShapeDialog();
|
||
}
|
||
|
||
public _renderBackdropDialog(): TemplateResult {
|
||
const d = this.host._backdropDialog!;
|
||
const unit = this.host._t(this.host._imperial ? 'gs.unit_ft' : 'gs.unit_m');
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="decor" .title=${this.host._t('decor.backdrop_properties')}
|
||
icon="mdi:image-edit-outline" dismiss-on-scrim @hp-close=${() => (this.host._backdropDialog = null)}>
|
||
<div class="body">
|
||
<label>${this.host._t('decor.size')}</label>
|
||
<div class="colorrow"><input class="namein" type="number" min="0.01" step="0.01"
|
||
.value=${String(this.host._decorLargeField(d.widthCm))}
|
||
@input=${(e: Event) => (this.host._backdropDialog = { ...d,
|
||
widthCm: this.host._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} />
|
||
<span>×</span><input class="namein" type="number" min="0.01" step="0.01"
|
||
.value=${String(this.host._decorLargeField(d.heightCm))}
|
||
@input=${(e: Event) => (this.host._backdropDialog = { ...d,
|
||
heightCm: this.host._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} />
|
||
<span class="opl">${unit}</span></div>
|
||
<label>${this.host._t('decor.angle')}</label>
|
||
<input class="namein" type="number" min="-180" max="180" step="1" .value=${d.angle}
|
||
@input=${(e: Event) => (this.host._backdropDialog = { ...d,
|
||
angle: (e.target as HTMLInputElement).value })} />
|
||
</div>
|
||
<div class="row" slot="footer"><span class="spacer"></span>
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._backdropDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn primary" data-hp="dialog-confirm" @click=${() => this._saveBackdropDialog()}>${this.host._t('btn.save')}</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _cssPxToRender(px: number): number {
|
||
const stage = this.host._stageEl;
|
||
const view = this.host._viewOr(this.host._baseVb());
|
||
if (!stage?.clientWidth || !stage.clientHeight) return (this.host._gridPitch / 8) * px;
|
||
return Math.max(view.w / stage.clientWidth, view.h / stage.clientHeight) * px;
|
||
}
|
||
|
||
public _deleteRoomClick(raw: number[]): void {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||
if (!room) {
|
||
this.host._showToast(this.host._t('toast.delete_room_pick'));
|
||
return;
|
||
}
|
||
if (!room.id) return;
|
||
this.host._roomDeleteDialog = { roomId: room.id, name: room.name };
|
||
}
|
||
|
||
public _confirmRoomDelete = (keepWalls: boolean): void => {
|
||
const dialog = this.host._roomDeleteDialog;
|
||
const sp = this.host._curSpaceCfg;
|
||
const space = this.host._spaceModel();
|
||
if (!dialog || !sp || !space) return;
|
||
const room = space.rooms.find((candidate) => candidate.id === dialog.roomId);
|
||
if (!room) { this.host._roomDeleteDialog = null; return; }
|
||
const openCuts = this.host._openCuts();
|
||
const allIntervals = wallIntervals(
|
||
space.rooms, this.host._spaceWalls, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
const roomIntervals = allIntervals.filter((interval) => interval.roomId === room.id);
|
||
const plan = planRoomDeletion(
|
||
roomIntervals,
|
||
space.partitions,
|
||
this.host._openingsR.map((opening) => ({
|
||
...opening, x: opening.rx, y: opening.ry,
|
||
})),
|
||
this.host._gridPitch * 0.02,
|
||
);
|
||
const newCount = new Set(plan.materialize
|
||
.filter((item) => !item.reusePartitionId)
|
||
.map((item) => item.interval.key)).size;
|
||
if (keepWalls && (sp.partitions || []).length + newCount > MAX_PARTITIONS) {
|
||
this.host._showToast(this.host._t('toast.physical_limit'));
|
||
return;
|
||
}
|
||
const before = this._geometrySnapshot();
|
||
const referenceRewrite = rewriteMarkerRoomReferences(
|
||
this.host._serverCfg?.markers || [], { kind: 'delete', roomId: dialog.roomId },
|
||
);
|
||
if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before;
|
||
const materializedWalls = materializeWallIntervals(
|
||
space.rooms, this.host._spaceWalls, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
if (keepWalls) {
|
||
sp.partitions ||= [];
|
||
const targetByInterval = new Map<string, PartitionCfg>();
|
||
const createdByKey = new Map<string, PartitionCfg>();
|
||
const sourceWallId = (interval: { a: readonly number[]; b: readonly number[] }): string | null => {
|
||
const match = (sp.wall_segments || []).find((segment: any) => {
|
||
const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W];
|
||
const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W];
|
||
return (this._samePt(interval.a, a) && this._samePt(interval.b, b))
|
||
|| (this._samePt(interval.a, b) && this._samePt(interval.b, a));
|
||
});
|
||
return typeof match?.id === 'string' && match.id ? match.id : null;
|
||
};
|
||
const seed = Date.now().toString(36);
|
||
for (const item of plan.materialize) {
|
||
let target = item.reusePartitionId
|
||
? sp.partitions.find((partition: PartitionCfg) => partition.id === item.reusePartitionId)
|
||
: createdByKey.get(item.interval.key);
|
||
if (!target) {
|
||
target = {
|
||
id: sourceWallId(item.interval) || `partition-room-${seed}-${createdByKey.size}`,
|
||
a: [item.interval.a[0] / NORM_W, item.interval.a[1] / NORM_W],
|
||
b: [item.interval.b[0] / NORM_W, item.interval.b[1] / NORM_W],
|
||
cm: item.interval.cm,
|
||
};
|
||
sp.partitions.push(target);
|
||
createdByKey.set(item.interval.key, target);
|
||
}
|
||
targetByInterval.set(item.interval.key, target);
|
||
}
|
||
for (const opening of sp.openings || []) {
|
||
const intervalKey = plan.openingIntervals.get(opening.id);
|
||
const target = intervalKey ? targetByInterval.get(intervalKey) : null;
|
||
const rendered = this.host._openingsR.find((candidate) => candidate.id === opening.id);
|
||
if (!target || !rendered) continue;
|
||
const renderTarget = {
|
||
a: [target.a[0] * NORM_W, target.a[1] * NORM_W],
|
||
b: [target.b[0] * NORM_W, target.b[1] * NORM_W],
|
||
};
|
||
opening.host = {
|
||
kind: 'partition', id: target.id,
|
||
t: parameterOnPartition([rendered.rx, rendered.ry], renderTarget),
|
||
};
|
||
}
|
||
} else if (plan.removeOpeningIds.length) {
|
||
const remove = new Set(plan.removeOpeningIds);
|
||
sp.openings = (sp.openings || []).filter((opening: OpeningCfg) => !remove.has(opening.id));
|
||
if (!sp.openings.length) delete sp.openings;
|
||
}
|
||
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
|
||
if (referenceRewrite.changed && this.host._serverCfg) {
|
||
this.host._serverCfg.markers = referenceRewrite.markers;
|
||
}
|
||
this.host._cfgEpoch++;
|
||
const normalized = this.host._normalizeWalls(materializedWalls, this.host._openCuts());
|
||
if (normalized.length) sp.walls = normalized;
|
||
else delete sp.walls;
|
||
this.host._roomDeleteDialog = null;
|
||
this._commitPhysicalGeometry(this.host._t(
|
||
keepWalls ? 'history.delete_room_keep_walls' : 'history.delete_room_with_walls',
|
||
), before);
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
};
|
||
|
||
public _wallThickHit(raw: number[]): WallThickHit | null {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return null;
|
||
const pull = this.host._gridPitch * 6;
|
||
const cuts = this.host._openCuts();
|
||
type Hit = WallThickHit;
|
||
// #313: the tool serves every wall — room intervals AND independent
|
||
// masonry. On an exact overlap (the #308 duplicate) the independent wall
|
||
// wins: it owns the hit zone, exactly like the select tool, and it is
|
||
// the body the eye actually sees.
|
||
let best: { hit: Hit; d: number; independent: boolean } | null = null;
|
||
const offer = (hit: Hit, d: number, independent: boolean): void => {
|
||
if (d > pull) return;
|
||
if (!best || d < best.d - 1e-9
|
||
|| (independent && !best.independent && d <= best.d + 1e-9)) {
|
||
best = { hit, d, independent };
|
||
}
|
||
};
|
||
for (const iv of wallIntervals(
|
||
space.rooms, this.host._spaceWalls, cuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
)) {
|
||
offer({
|
||
a: iv.a, b: iv.b, roomId: iv.roomId,
|
||
segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]],
|
||
open: iv.open, cm: iv.cm, source: { kind: 'room' },
|
||
}, distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]), false);
|
||
}
|
||
for (const partition of space.partitions || []) {
|
||
const a = [partition.a[0], partition.a[1]];
|
||
const b = [partition.b[0], partition.b[1]];
|
||
offer({
|
||
a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]],
|
||
open: false, cm: Number(partition.cm) || 0,
|
||
source: { kind: 'partition', id: partition.id },
|
||
}, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true);
|
||
}
|
||
return best ? (best as { hit: Hit }).hit : null;
|
||
}
|
||
|
||
public _wallThickClick(raw: number[]): void {
|
||
const hit = this._wallThickHit(raw);
|
||
if (!hit) {
|
||
this.host._showToast(this.host._t('toast.wallthick_pick'));
|
||
return;
|
||
}
|
||
const cm = hit.cm;
|
||
const view = this.host._viewOr(this.host._baseVb());
|
||
const mx = (hit.a[0] + hit.b[0]) / 2, my = (hit.a[1] + hit.b[1]) / 2;
|
||
this.host._wallDialog = {
|
||
a: hit.a, b: hit.b,
|
||
value: cmToField(cm, this.host._imperial),
|
||
roomId: hit.roomId,
|
||
source: hit.source,
|
||
sx: ((mx - view.x) / view.w) * 100,
|
||
sy: ((my - view.y) / view.h) * 100,
|
||
};
|
||
}
|
||
|
||
public _wallThickApply(allRoom: boolean): void {
|
||
const d = this.host._wallDialog;
|
||
if (!d) return;
|
||
const sp = this.host._curSpaceCfg;
|
||
const space = this.host._spaceModel();
|
||
if (!sp || !space) return;
|
||
const text = d.value.trim();
|
||
const raw = text ? strictNumber(text) : null;
|
||
if (raw == null) { this._showPhysicalRange(100); return; }
|
||
const cmRaw = this.host._imperial ? raw * 2.54 : raw;
|
||
if (!Number.isFinite(cmRaw) || cmRaw < 0 || cmRaw > 100) {
|
||
this._showPhysicalRange(100);
|
||
return;
|
||
}
|
||
// Independent walls own their records. Zero is the same
|
||
// canonical bodyless state here as it is for a contour atom (#306).
|
||
if (d.source.kind !== 'room') {
|
||
const before = this._geometrySnapshot();
|
||
const partition = (sp.partitions || [])
|
||
.find((item: PartitionCfg) => item.id === (d.source as { id: string }).id);
|
||
if (!partition) return;
|
||
if (cmRaw === 0 && zeroWallHasOpening(sp.openings, {
|
||
kind: 'partition', id: partition.id,
|
||
})) {
|
||
this.host._showToast(this.host._t('toast.zero_wall_opening_conflict'));
|
||
return;
|
||
}
|
||
partition.cm = cmRaw;
|
||
this.host._wallDialog = null;
|
||
const committed = this._commitPhysicalGeometry(
|
||
this.host._t('history.wall_thickness'), before,
|
||
);
|
||
if (committed) this.host._showToast(this.host._t('toast.wallthick_set'));
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
const before = this._geometrySnapshot();
|
||
const cm = cmRaw > 0 ? cmRaw : null;
|
||
if (cmRaw === 0) {
|
||
const eps = this.host._gridPitch * 0.02;
|
||
const target = [d.a[0], d.a[1], d.b[0], d.b[1]];
|
||
const room = (sp.rooms || []).find((item) => item.id === d.roomId);
|
||
const ids = new Set<string>(allRoom && room && Array.isArray(room.wall_ids)
|
||
? room.wall_ids
|
||
: (sp.wall_segments || []).filter((segment: any) => {
|
||
const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W];
|
||
const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W];
|
||
return distToSegment(a, target) <= eps && distToSegment(b, target) <= eps;
|
||
}).map((segment: any) => String(segment.id)));
|
||
const blocked = (sp.openings || []).some((opening: OpeningCfg) => {
|
||
if (opening.host?.kind === 'partition') return false;
|
||
if (opening.host?.kind === 'wall' && ids.has(opening.host.id)) return true;
|
||
if (opening.host) return false;
|
||
const point = [Number(opening.x) * NORM_W, Number(opening.y) * NORM_W];
|
||
return point.every(Number.isFinite) && distToSegment(point, target) <= eps;
|
||
});
|
||
if (blocked) {
|
||
this.host._showToast(this.host._t('toast.zero_wall_opening_conflict'));
|
||
return;
|
||
}
|
||
}
|
||
let openCuts = this.host._openCuts();
|
||
if (cmRaw > 0 && openCuts.length) {
|
||
// The selected zero axis is a cut only in the PRE-edit body profile.
|
||
// Remove exactly the carriers being restored before the compatibility
|
||
// wall normalizer runs; otherwise it discards the new positive record as
|
||
// if the segment were still a zero interval (#306).
|
||
const targets: number[][] = [];
|
||
if (allRoom && d.roomId) {
|
||
const room = (sp.rooms || []).find((item) => item.id === d.roomId);
|
||
const ids = new Set<string>(Array.isArray(room?.wall_ids) ? room.wall_ids : []);
|
||
for (const segment of sp.wall_segments || []) {
|
||
if (!ids.has(segment.id)) continue;
|
||
targets.push([
|
||
Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W,
|
||
Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W,
|
||
]);
|
||
}
|
||
// A pre-v9 room has no catalogue IDs yet. Its current zero axes still
|
||
// come from the rendered room outline, so use that outline as the
|
||
// carrier set for this first explicit positive-thickness edit. The
|
||
// common v9 barrier below will materialise stable IDs atomically.
|
||
if (!targets.length) {
|
||
const renderedRoom = space.rooms.find((item) => item.id === d.roomId);
|
||
const poly = renderedRoom ? roomPoly(renderedRoom) : null;
|
||
for (let index = 0; poly && index < poly.length; index++) {
|
||
const a = poly[index], b = poly[(index + 1) % poly.length];
|
||
targets.push([a[0], a[1], b[0], b[1]]);
|
||
}
|
||
}
|
||
} else targets.push([d.a[0], d.a[1], d.b[0], d.b[1]]);
|
||
const eps = this.host._gridPitch * 0.02;
|
||
openCuts = openCuts.filter((cut) => !targets.some((target) => (
|
||
distToSegment([cut[0], cut[1]], target) <= eps
|
||
&& distToSegment([cut[2], cut[3]], target) <= eps
|
||
&& distToSegment([target[0], target[1]], cut) <= eps
|
||
&& distToSegment([target[2], target[3]], cut) <= eps
|
||
)));
|
||
}
|
||
let next: WallEntry[];
|
||
if (allRoom && d.roomId) {
|
||
next = setWallThicknessForRoom(
|
||
sp.walls, space.rooms, d.roomId, cm, this.host._wallKeyPitch, openCuts, NORM_W,
|
||
);
|
||
} else {
|
||
next = setWallThickness(sp.walls, d.a, d.b, cm, this.host._wallKeyPitch, NORM_W);
|
||
}
|
||
next = this.host._normalizeWalls(next, openCuts);
|
||
// ADR 282 Stage 1: this writer knows that absence from `next` is an
|
||
// explicit zero chosen by the user, not a missing compatibility record.
|
||
// Mirror that intent into the canonical catalogue before the common
|
||
// barrier; otherwise its lineage fallback would resurrect the old cm.
|
||
for (const segment of sp.wall_segments || []) {
|
||
segment.cm = thicknessCmAt(
|
||
next, segment.a, segment.b, GRID_STEP_N, 1,
|
||
);
|
||
}
|
||
if (next.length) sp.walls = next;
|
||
else delete sp.walls;
|
||
this.host._wallDialog = null;
|
||
if (this._commitPhysicalGeometry(this.host._t('history.wall_thickness'), before))
|
||
this.host._showToast(this.host._t(cmRaw === 0 ? 'toast.wallthick_cleared' : 'toast.wallthick_set'));
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _wallHatchDefs(color: string): TemplateResult {
|
||
if (!this.host._spaceWalls.length && !this.host._physicalBodiesR().length && !this.host._markup)
|
||
return svg`` as unknown as TemplateResult;
|
||
// The step is a physical distance now — 9.6 cm of plan, whatever the grid
|
||
// scale — so it must NOT be compensated for zoom: a wall that changes its
|
||
// hatching as you zoom is exactly what #230 is about (spec §4.2).
|
||
const step = wallHatchStepUnits(this.host._cellCm);
|
||
const stripe = 2 * (step / HATCH_BASE_STEP_UNITS);
|
||
// Bake the wall colour into the pattern — CSS vars on <pattern> content
|
||
// do not inherit from the filled path, so var(--room-stroke) fell back
|
||
// to grey while the wall outline used the real border colour.
|
||
const stroke = color || '#607d8b';
|
||
return svg`<defs>
|
||
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse"
|
||
width="${step}" height="${step}" patternTransform="rotate(45)">
|
||
<path d="M0 0 L0 ${step}" stroke="${stroke}" stroke-width="${stripe}"></path>
|
||
</pattern>
|
||
</defs>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderWallThickUi(): TemplateResult {
|
||
const hover = this.host._wallThickHover;
|
||
if (!hover || !hover.d) return svg`` as unknown as TemplateResult;
|
||
return svg`<path class="wallthick-hover ${hover.open ? 'isopen' : ''}"
|
||
d="${hover.d}"></path>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderWallThickDialog(): TemplateResult {
|
||
const d = this.host._wallDialog;
|
||
if (!d) return html``;
|
||
return html`<div class="wallthick-dlg" style="left:${d.sx.toFixed(2)}%;top:${d.sy.toFixed(2)}%"
|
||
@click=${(e: Event) => e.stopPropagation()}>
|
||
<div class="row">
|
||
<label>${this.host._t('wallthick.field')}</label>
|
||
<input type="number" min="0" max="100" step="any" .value=${d.value}
|
||
@input=${(e: Event) => {
|
||
this.host._wallDialog = { ...d, value: (e.target as HTMLInputElement).value };
|
||
}}
|
||
@keydown=${(e: KeyboardEvent) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); this._wallThickApply(false); }
|
||
}} />
|
||
<span class="opl">${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
|
||
</div>
|
||
<div class="row">
|
||
${d.source.kind === 'room' ? html`<button class="btn ghost"
|
||
@click=${() => this._wallThickApply(true)}>
|
||
${this.host._t('wallthick.apply_room')}
|
||
</button>` : nothing}
|
||
<span class="spacer"></span>
|
||
<button class="btn on" @click=${() => this._wallThickApply(false)}>
|
||
<ha-icon icon="mdi:check"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
public _openingAt(raw: readonly number[]): RenderOpening | null {
|
||
if (!this.host._openingsR.length) return null;
|
||
const roughHits = this.host._openingsR.flatMap((o) => {
|
||
const rad = o.angle * Math.PI / 180;
|
||
const dx = raw[0] - o.rx, dy = raw[1] - o.ry;
|
||
const localX = dx * Math.cos(rad) + dy * Math.sin(rad);
|
||
if (Math.abs(localX) > o.rlen / 2 + gridVisualUnits(12, this.host._cellCm)) return [];
|
||
return [{ o, localY: -dx * Math.sin(rad) + dy * Math.cos(rad) }];
|
||
});
|
||
if (!roughHits.length) return null;
|
||
const space = this.host._spaceModel();
|
||
if (!space) return null;
|
||
const openCuts = this.host._openCuts();
|
||
const wallIndex = this.host._openingWallIndexFor(space, openCuts).value;
|
||
return roughHits.find(({ o, localY }) => {
|
||
const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v;
|
||
const face = o.partitionHost || this.host._spaceWalls.length || o.type === 'gate'
|
||
? this.host._openingFace(o, wallIndex, !!faceFlipV)
|
||
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
|
||
const metrics = openingVisibleMetrics({
|
||
type: o.type,
|
||
length: o.rlen,
|
||
angle: o.angle,
|
||
amount: this.host._openingAmt(o),
|
||
flipH: !!o.flip_h,
|
||
flipV: !!o.flip_v,
|
||
base: '', tone: '',
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
face,
|
||
});
|
||
// Match the committed `.op-hit` rectangle exactly. The old radial test
|
||
// made a 3 m gate suppress placement in a huge circular area far away
|
||
// from its visible/hit geometry.
|
||
return Math.abs(localY) <= metrics.hitHalf;
|
||
})?.o || null;
|
||
}
|
||
|
||
public _resolveOpeningPlacement(raw: readonly number[]): OpeningPlacementCandidate | null {
|
||
const preset = this.host._openingPreset;
|
||
if (this.host._tool !== 'opening' || !preset) return null;
|
||
const space = this.host._spaceModel();
|
||
if (!space) return null;
|
||
const openCuts = this.host._openCuts();
|
||
const wallIndex = this.host._openingWallIndexFor(space, openCuts);
|
||
const placementKey = `${wallIndex.key}|partitions:${this.host._cfgEpoch}`;
|
||
if (!this.host._openingPlacementIntervalsCache
|
||
|| this.host._openingPlacementIntervalsCache.key !== placementKey) {
|
||
this.host._openingPlacementIntervalsCache = {
|
||
key: placementKey,
|
||
value: [
|
||
...wallIntervals(
|
||
space.rooms, this.host._spaceWalls, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
),
|
||
...partitionPlacementIntervals(space.partitions, this.host._cellCm, this.host._gridPitch),
|
||
],
|
||
};
|
||
}
|
||
if (!this.host._openingDimensionContextCache
|
||
|| this.host._openingDimensionContextCache.key !== placementKey) {
|
||
this.host._openingDimensionContextCache = {
|
||
key: placementKey,
|
||
value: buildOpeningDimensionContext({
|
||
rooms: space.rooms,
|
||
walls: this.host._spaceWalls,
|
||
openCuts,
|
||
partitions: space.partitions,
|
||
roomOpenings: this.host._roomWallOpeningInputs(),
|
||
partitionCuts: this._partitionOpeningCuts(space),
|
||
pitch: this.host._wallKeyPitch,
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
coordScale: NORM_W,
|
||
epsilon: this.host._gridPitch * 0.0002,
|
||
}),
|
||
};
|
||
}
|
||
const resolution = resolveOpeningPlacementResult({
|
||
pointer: [raw[0], raw[1]],
|
||
preset,
|
||
geometryRevision: this.host._cfgEpoch,
|
||
renderedLength: this.host._cmToUnits(preset.lengthCm),
|
||
intervals: this.host._openingPlacementIntervalsCache.value,
|
||
baseTolerance: this.host._gridPitch * 1.5,
|
||
bodyPointerPadding: this._cssPxToRender(
|
||
this.host._pointerModality.modality === 'touch' || this.host._pointerModality.modality === 'pen' ? 10 : 6,
|
||
),
|
||
gridStep: this.host._gridPitch,
|
||
});
|
||
this.host._openingJambBlockCm = resolution.jambBlockedTarget
|
||
? (resolution.jambBlockedTarget.physicalHalfWidth / this.host._gridPitch) * this.host._cellCm
|
||
: null;
|
||
const core = resolution.candidate;
|
||
if (!core) return null;
|
||
const faceFlipV = core.type === 'gate' ? !core.flipV : core.flipV;
|
||
let face: OpeningFaceOffset;
|
||
if (core.host) {
|
||
const hosted = resolvePartitionOpeningCompat({
|
||
id: 'preview', type: core.type,
|
||
x: core.x / NORM_W, y: core.y / NORM_W,
|
||
angle: core.angle, length: core.renderedLength / NORM_W,
|
||
host: core.host,
|
||
}, space.partitions, NORM_W, this.host._cellCm, this.host._gridPitch).resolved;
|
||
face = hosted
|
||
? partitionOpeningFace(hosted, faceFlipV)
|
||
: { ox: 0, oy: 0, cm: 0, side: -1 };
|
||
} else {
|
||
face = openingInnerFaceOffsetFromIndex(
|
||
wallIndex.value,
|
||
{ x: core.x, y: core.y, angle: core.angle, length: core.renderedLength, flip_v: faceFlipV },
|
||
);
|
||
}
|
||
const imperial = this.host.hass?.config?.unit_system?.length === 'mi';
|
||
// #238: placement labels are physical dimensions. They consume the ONE
|
||
// resolved candidate above and never re-run pointer snap; existing-opening
|
||
// drag deliberately keeps `_opRuler`/`openingShoulders` below.
|
||
const dimensions = resolveOpeningDimensions(
|
||
core, this.host._openingDimensionContextCache.value,
|
||
);
|
||
const labels = dimensions.map((dimension) => ({
|
||
x: dimension.label[0],
|
||
y: dimension.label[1],
|
||
text: formatLength((dimension.distance / this.host._gridPitch) * this.host._cellCm, imperial),
|
||
dimension,
|
||
}));
|
||
return { ...core, face, measure: { labels, guide: core.measure.guide } };
|
||
}
|
||
|
||
public _activateOpeningPlacement(type: OpeningPlacementType): void {
|
||
this._activateMarkupTool('opening');
|
||
if (this.host._tool !== 'opening') return;
|
||
this.host._openingPreset = openingPlacementPreset(type, ++this.host._openingPresetRevision);
|
||
this.host._openingHoverCandidate = null;
|
||
this.host._cursorPt = null;
|
||
}
|
||
|
||
public _clearOpeningPlacement(clearPreset: boolean): void {
|
||
this.host._openingHoverCandidate = null;
|
||
this.host._openingJambBlockCm = null;
|
||
if (clearPreset) {
|
||
this.host._openingPreset = null;
|
||
this.host._openingRebindId = null;
|
||
}
|
||
}
|
||
|
||
public _openingClick(raw: number[]): void {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
// Rebind is an explicit one-shot placement: the orphan's stale fallback
|
||
// position must not win hit testing when the replacement wall crosses it.
|
||
const hit = this.host._openingRebindId ? null : this._openingAt(raw);
|
||
if (hit) {
|
||
this._editOpening(hit);
|
||
return;
|
||
}
|
||
const preset = this.host._openingPreset;
|
||
if (!preset) return;
|
||
const cached = this.host._openingHoverCandidate;
|
||
const place = cached && sameOpeningPlacementInput(
|
||
cached, [raw[0], raw[1]], preset.revision, this.host._cfgEpoch,
|
||
) ? cached : this._resolveOpeningPlacement(raw);
|
||
if (!place) {
|
||
if (this.host._openingJambBlockCm != null) {
|
||
this.host._showToast(this.host._t('opening.partition_jamb_margin', {
|
||
distance: formatLength(this.host._openingJambBlockCm, this.host._imperial),
|
||
}));
|
||
return;
|
||
}
|
||
const eps = this.host._gridPitch * 1.5;
|
||
const snap = snapToWall(raw, space.rooms, eps);
|
||
if (snap && pointOnOpenCut(snap.x, snap.y, snap.angle, this.host._openCuts(), eps)) {
|
||
this.host._showToast(this.host._t('toast.opening_on_zero_wall'));
|
||
return;
|
||
}
|
||
this.host._showToast(this.host._t('toast.opening_no_wall'));
|
||
return;
|
||
}
|
||
const rebound = this.host._openingRebindId
|
||
? this.host._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === this.host._openingRebindId)
|
||
: null;
|
||
this.host._openingDialog = {
|
||
...(rebound ? {
|
||
id: rebound.id,
|
||
type: rebound.type,
|
||
lengthCm: Math.round((rebound.length * NORM_W / this.host._gridPitch) * this.host._cellCm),
|
||
lengthTouched: false,
|
||
contact: rebound.contact || '', lock: rebound.lock || '',
|
||
invert: !!rebound.invert, flipH: !!rebound.flip_h, flipV: !!rebound.flip_v,
|
||
} : {
|
||
type: place.type, lengthCm: place.lengthCm, contact: '', lock: '',
|
||
invert: false, flipH: place.flipH, flipV: place.flipV,
|
||
}),
|
||
...(place.host ? { host: place.host } : {}),
|
||
x: place.x, y: place.y, angle: place.angle,
|
||
};
|
||
this.host._openingRebindId = null;
|
||
// rulers, tick and ghost live only through the placement gesture
|
||
this.host._openingHoverCandidate = null;
|
||
this.host._cursorPt = null;
|
||
}
|
||
|
||
public _editOpening(o: RenderOpening): void {
|
||
this.host._openingDialog = {
|
||
id: o.id,
|
||
type: o.type,
|
||
lengthCm: Math.round((o.rlen / this.host._gridPitch) * this.host._cellCm),
|
||
lengthTouched: false,
|
||
contact: o.contact || '',
|
||
lock: o.lock || '',
|
||
invert: !!o.invert,
|
||
flipH: !!o.flip_h,
|
||
flipV: !!o.flip_v,
|
||
...(o.host?.kind === 'partition' ? { host: { ...o.host } } : {}),
|
||
x: o.rx, y: o.ry, angle: o.angle,
|
||
};
|
||
}
|
||
|
||
public _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
|
||
if (this.host._mode !== 'plan' || !this.host._spaceModel()) return;
|
||
// HP-1550-04: in the resize tool the wall handles own the geometry — a door
|
||
// in the middle of a wall must neither swallow the handle nor start its own
|
||
// drag (it travels with the wall through the resize pipeline instead)
|
||
if (this.host._tool === 'resize') return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
try {
|
||
capturePointer(ev);
|
||
} catch {
|
||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||
}
|
||
this.host._opDrag = {
|
||
id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false,
|
||
before: this._geometrySnapshot(),
|
||
};
|
||
}
|
||
|
||
public _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||
queueHouseplanPointerMove(this.host, 'opening', () => this._opPointerMoveNow(ev, o));
|
||
}
|
||
|
||
private _opPointerMoveNow(ev: PointerEvent, o: OpeningCfg): void {
|
||
if (!this.host._opDrag || this.host._opDrag.id !== o.id) return;
|
||
// audit L4: the other drag pipelines require 3 px before calling it a drag.
|
||
// Without it every tap counted as a drag: the properties dialog never
|
||
// opened and an unchanged config was written (which then broadcast the
|
||
// event behind the L2 data loss).
|
||
if (Math.abs(ev.clientX - this.host._opDrag.sx) + Math.abs(ev.clientY - this.host._opDrag.sy) <= 3) return;
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
const raw = this._svgPoint(ev);
|
||
const sp = this.host._curSpaceCfg;
|
||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||
if (!cfg) return;
|
||
if (cfg.host?.kind === 'partition') {
|
||
const partition = space.partitions.find((item) => item.id === cfg.host!.id);
|
||
if (!partition) return;
|
||
const dx = partition.b[0] - partition.a[0], dy = partition.b[1] - partition.a[1];
|
||
const length = Math.hypot(dx, dy);
|
||
if (!(length > 1e-9)) return;
|
||
const ux = dx / length, uy = dy / length;
|
||
const perpendicular = Math.abs(
|
||
(raw[0] - partition.a[0]) * uy - (raw[1] - partition.a[1]) * ux,
|
||
);
|
||
if (perpendicular > this.host._gridPitch * 4) return;
|
||
const half = cfg.length * NORM_W / 2;
|
||
const jamb = partitionOpeningJambMargin(partition, this.host._cellCm, this.host._gridPitch);
|
||
const shoulder = half + jamb;
|
||
let along = (raw[0] - partition.a[0]) * ux + (raw[1] - partition.a[1]) * uy;
|
||
along = Math.round(along / this.host._gridPitch) * this.host._gridPitch;
|
||
along = Math.max(shoulder, Math.min(length - shoulder, along));
|
||
if (length < shoulder * 2 - 1e-9) return;
|
||
const t = along / length;
|
||
const nx = (partition.a[0] + ux * along) / NORM_W;
|
||
const ny = (partition.a[1] + uy * along) / this.host._spaceH;
|
||
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||
if (angle >= 90) angle -= 180;
|
||
else if (angle < -90) angle += 180;
|
||
const changed = cfg.host.t !== t || cfg.x !== nx || cfg.y !== ny || cfg.angle !== angle;
|
||
this.host._opDrag.moved = true;
|
||
if (changed) this.host._opDrag.dirty = true;
|
||
cfg.host = { ...cfg.host, t };
|
||
cfg.x = nx; cfg.y = ny; cfg.angle = angle;
|
||
this.host._opMeasure = null;
|
||
if (changed) this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
const snap = snapToWall(raw, space.rooms, this.host._gridPitch * 4);
|
||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||
this.host._opDrag.moved = true;
|
||
// ruler badges on both shoulders + soft magnet to the wall's center
|
||
// (owner 2026-08-03) — the very same helper the PLACEMENT preview uses
|
||
const r = this._opRuler(snap, cfg.length * NORM_W);
|
||
this.host._opMeasure = r.measure;
|
||
const nx = r.x / NORM_W;
|
||
const ny = r.y / this.host._spaceH;
|
||
const geometryChanged = cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle;
|
||
if (geometryChanged) this.host._opDrag.dirty = true;
|
||
cfg.x = nx;
|
||
cfg.y = ny;
|
||
cfg.angle = snap.angle;
|
||
if (Number(this.host._serverCfg?.model_version || 0) >= 9) {
|
||
const host = resolveRoomOpeningHost(cfg, sp?.wall_segments || []);
|
||
if (host) cfg.host = host;
|
||
else delete cfg.host;
|
||
}
|
||
// The wall cut and room-coloured tunnel are geometry caches too. Advance
|
||
// the preview epoch so both follow the live opening instead of remaining at
|
||
// its pre-drag location until pointerup/save.
|
||
if (geometryChanged) this.host._cfgEpoch++;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _opRuler(
|
||
snap: { x: number; y: number; angle: number },
|
||
rlen: number,
|
||
): { x: number; y: number; angle: number; measure: OpMeasure | null } {
|
||
const rooms = this.host._spaceModel()?.rooms || [];
|
||
const tol = this.host._gridPitch / 2;
|
||
let cx = snap.x, cy = snap.y;
|
||
let sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
|
||
if (sh && sh.centered && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
|
||
[cx, cy] = sh.wallCenter;
|
||
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
|
||
} else if (sh) {
|
||
// Not centred: quantise the offset ALONG the wall to the grid step
|
||
// (docs/CANVAS.md §9.3). Grid-BOUND would lift the opening off a diagonal
|
||
// wall, so the wall stays the master and the grid only says WHERE on it.
|
||
// The magnet is consulted first on purpose — a wall whose middle is not a
|
||
// node must still be able to hold a centred window.
|
||
const [ax, ay] = sh.wallA, [bx, by] = sh.wallB;
|
||
const wx = bx - ax, wy = by - ay;
|
||
const len = Math.hypot(wx, wy);
|
||
if (len > 0) {
|
||
const g = this.host._gridPitch;
|
||
const half = Math.min(rlen / 2, len / 2);
|
||
let along = Math.round((((cx - ax) * wx + (cy - ay) * wy) / len) / g) * g;
|
||
along = Math.max(half, Math.min(len - half, along));
|
||
cx = ax + (along / len) * wx;
|
||
cy = ay + (along / len) * wy;
|
||
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol) || sh;
|
||
}
|
||
}
|
||
if (!sh) return { x: cx, y: cy, angle: snap.angle, measure: null };
|
||
const imperial = this.host.hass?.config?.unit_system?.length === 'mi';
|
||
const lbl = (d: number, m: number[]) =>
|
||
({ x: m[0], y: m[1], text: formatLength((d / this.host._gridPitch) * this.host._cellCm, imperial) });
|
||
return {
|
||
x: cx, y: cy, angle: snap.angle,
|
||
measure: {
|
||
labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)],
|
||
guide: sh.centered
|
||
? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle }
|
||
: null,
|
||
},
|
||
};
|
||
}
|
||
|
||
public _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||
if (!this.host._opDrag || this.host._opDrag.id !== o.id) return;
|
||
flushHouseplanPointerMove(this.host, 'opening');
|
||
const drag = this.host._opDrag;
|
||
const moved = drag.moved;
|
||
this.host._opMeasure = null; // badges and the center tick live only through the drag
|
||
// only write when the geometry actually changed (audit L4)
|
||
if (moved && drag.dirty) {
|
||
this._commitPhysicalGeometry(this.host._t('history.move_opening'), drag.before);
|
||
}
|
||
// keep the flag until the click event that follows pointerup, then let it go
|
||
const finish = () => { this.host._opDrag = null; this.host.requestUpdate(); };
|
||
if (moved) window.setTimeout(finish, 0); else finish();
|
||
}
|
||
|
||
public _opClick(ev: MouseEvent, o: RenderOpening): void {
|
||
// HP-1550-04: in the resize tool a click over an opening falls through to
|
||
// the stage (room picking) instead of opening the editor dialog
|
||
if (this.host._mode === 'plan' && this.host._tool === 'resize') return;
|
||
ev.stopPropagation();
|
||
if (this.host._opDrag?.moved) return; // that click was the tail of a drag
|
||
// openings are inert outside Plan mode (owner's decision: View must not
|
||
// interact with them at all); in Plan any click on an opening edits it
|
||
if (this.host._mode === 'plan') this._editOpening(o);
|
||
}
|
||
|
||
public _saveOpening(): void {
|
||
const d = this.host._openingDialog;
|
||
const sp = this.host._curSpaceCfg;
|
||
const model = this.host._spaceModel();
|
||
if (!d || !sp || !model) return;
|
||
const before = this._geometrySnapshot();
|
||
const H = this.host._spaceH;
|
||
const previous = (sp.openings || []).find((item: OpeningCfg) => item.id === d.id);
|
||
const o: OpeningCfg = {
|
||
...(previous || {}),
|
||
id: d.id || 'o' + Date.now().toString(36),
|
||
type: d.type,
|
||
x: d.x / NORM_W,
|
||
y: d.y / H,
|
||
angle: d.angle,
|
||
length: previous && !d.lengthTouched
|
||
? previous.length
|
||
: this.host._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
|
||
...(d.host ? { host: { ...d.host } } : {}),
|
||
};
|
||
if (o.host?.kind === 'partition') {
|
||
const strict = partitionOpeningNeedsStrictValidation(previous, o);
|
||
const resolution = strict
|
||
? resolvePartitionOpeningStrict(
|
||
o, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
)
|
||
: resolvePartitionOpeningCompat(
|
||
o, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
);
|
||
if (!resolution.resolved) {
|
||
const partition = model.partitions.find((item) => item.id === o.host!.id);
|
||
if (resolution.reason === 'does-not-fit-jamb' && partition) {
|
||
const margin = partitionOpeningJambMargin(
|
||
partition, this.host._cellCm, this.host._gridPitch,
|
||
);
|
||
this.host._showToast(this.host._t('opening.partition_jamb_margin', {
|
||
distance: formatLength(
|
||
(margin / this.host._gridPitch) * this.host._cellCm, this.host._imperial,
|
||
),
|
||
}));
|
||
} else {
|
||
this.host._showToast(this.host._t('opening.partition_orphan'));
|
||
}
|
||
return;
|
||
}
|
||
const siblings = (sp.openings || []).flatMap((item: OpeningCfg) => {
|
||
if (!item.host || item.id === o.id) return [];
|
||
const sibling = resolvePartitionOpeningCompat(
|
||
item, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
).resolved;
|
||
return sibling ? [sibling] : [];
|
||
});
|
||
if (hostedOpeningIntervalsOverlap(resolution.resolved, siblings)) {
|
||
this.host._showToast(this.host._t('toast.opening_no_wall'));
|
||
return;
|
||
}
|
||
Object.assign(o, materializePartitionOpening(o, resolution.resolved, NORM_W));
|
||
} else if (Number(this.host._serverCfg?.model_version || 0) >= 9) {
|
||
const host = resolveRoomOpeningHost(o, sp.wall_segments || []);
|
||
if (!host) {
|
||
this.host._showToast(this.host._t('toast.opening_no_wall'));
|
||
return;
|
||
}
|
||
o.host = host;
|
||
} else {
|
||
delete o.host;
|
||
}
|
||
if (d.type === 'passage') {
|
||
// The canonical passage record contains geometry only. Delete keys
|
||
// instead of writing null/false so imports and old broken records have
|
||
// one unambiguous representation after an explicit edit.
|
||
delete o.contact;
|
||
delete o.lock;
|
||
delete o.invert;
|
||
delete o.flip_h;
|
||
delete o.flip_v;
|
||
} else {
|
||
o.contact = d.contact || null;
|
||
o.lock = d.type === 'door' || d.type === 'gate' ? d.lock || null : null;
|
||
o.invert = d.invert || undefined;
|
||
o.flip_h = d.type !== 'gate' && d.flipH || undefined;
|
||
o.flip_v = d.flipV || undefined;
|
||
}
|
||
sp.openings = sp.openings || [];
|
||
const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id);
|
||
if (i >= 0) sp.openings[i] = o;
|
||
else sp.openings.push(o);
|
||
this._commitPhysicalGeometry(
|
||
this.host._t(d.id ? 'history.edit_opening' : 'history.add_opening'), before,
|
||
);
|
||
this.host._openingDialog = null;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _deleteOpening(): void {
|
||
const d = this.host._openingDialog;
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!d?.id || !sp?.openings) return;
|
||
const before = this._geometrySnapshot();
|
||
sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id);
|
||
this._commitPhysicalGeometry(this.host._t('history.delete_opening'), before);
|
||
this.host._openingDialog = null;
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _rebindPartitionOpening = (): void => {
|
||
const d = this.host._openingDialog;
|
||
if (!d?.id) return;
|
||
const openingId = d.id;
|
||
this._activateMarkupTool('opening');
|
||
if (this.host._tool !== 'opening') return;
|
||
// Tool activation clears every stale placement session. Publish the
|
||
// rebind identity only after that gate so the next click cannot degrade
|
||
// into creating a second opening.
|
||
this.host._openingRebindId = openingId;
|
||
this.host._openingPreset = {
|
||
type: d.type,
|
||
lengthCm: d.lengthCm,
|
||
flipH: d.flipH,
|
||
flipV: d.flipV,
|
||
revision: ++this.host._openingPresetRevision,
|
||
};
|
||
this.host._openingDialog = null;
|
||
this.host._openingHoverCandidate = null;
|
||
};
|
||
|
||
public _contactCandidates(): { value: string; label: string }[] {
|
||
const out: [string, string, number][] = [];
|
||
for (const eid of Object.keys(this.host.hass.states)) {
|
||
if (!this.host._openingEntityAvailable(eid)) continue;
|
||
const dom = eid.split('.')[0];
|
||
if (dom !== 'binary_sensor' && dom !== 'cover') continue;
|
||
const st = this.host.hass.states[eid];
|
||
const dc = st?.attributes?.device_class || '';
|
||
const doorish = ['door', 'window', 'opening', 'garage_door', 'garage'].includes(dc);
|
||
if (dom === 'cover' && !doorish) continue;
|
||
out.push([eid, st?.attributes?.friendly_name || eid, doorish ? 0 : 1]);
|
||
}
|
||
return out
|
||
.sort((a, b) => a[2] - b[2] || a[1].localeCompare(b[1]))
|
||
.map(([value, label]) => ({ value, label }));
|
||
}
|
||
|
||
public _lockCandidates(): { value: string; label: string }[] {
|
||
return Object.keys(this.host.hass.states)
|
||
.filter((eid) => eid.startsWith('lock.') && this.host._openingEntityAvailable(eid))
|
||
.map((eid) => ({ value: eid, label: this.host.hass.states[eid]?.attributes?.friendly_name || eid }))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
}
|
||
|
||
public _toggleOpeningEntityPicker(kind: 'contact' | 'lock'): void {
|
||
const d = this.host._openingDialog;
|
||
if (!d) return;
|
||
const nextOpen = kind === 'contact' ? !d.contactOpen : !d.lockOpen;
|
||
this.host._openingDialog = {
|
||
...d,
|
||
contactOpen: kind === 'contact' ? nextOpen : false,
|
||
lockOpen: kind === 'lock' ? nextOpen : false,
|
||
};
|
||
}
|
||
|
||
public _filterOpeningEntities(kind: 'contact' | 'lock', value: string): void {
|
||
const d = this.host._openingDialog;
|
||
if (!d) return;
|
||
this.host._openingDialog = kind === 'contact'
|
||
? { ...d, contactFilter: value }
|
||
: { ...d, lockFilter: value };
|
||
}
|
||
|
||
public _selectOpeningEntity(kind: 'contact' | 'lock', value: string): void {
|
||
const d = this.host._openingDialog;
|
||
if (!d) return;
|
||
this.host._openingDialog = kind === 'contact'
|
||
? { ...d, contact: value, contactOpen: false, contactFilter: '' }
|
||
: { ...d, lock: value, lockOpen: false, lockFilter: '' };
|
||
}
|
||
|
||
public _mergeClick(raw: number[]): void {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
const rooms = space.rooms;
|
||
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||
if (!hit?.id) return;
|
||
const hitId = hit.id;
|
||
if (!this.host._mergeSel || this.host._mergeSel === hitId) {
|
||
this.host._mergeSel = this.host._mergeSel === hitId ? null : hitId; // click again = deselect
|
||
return;
|
||
}
|
||
const a = rooms.find((r) => r.id === this.host._mergeSel);
|
||
const pa = a ? roomPoly(a) : null;
|
||
const pb = roomPoly(hit);
|
||
const merged = pa && pb ? mergeRooms(pa, pb) : null;
|
||
if (!merged) {
|
||
// only rooms sharing a wall collapse into one outline (see mergeRooms)
|
||
this.host._showToast(this.host._t('toast.merge_not_adjacent'));
|
||
this.host._mergeSel = null;
|
||
return;
|
||
}
|
||
this.host._mergeDialog = { aId: this.host._mergeSel, bId: hitId, poly: merged, pick: 'a' };
|
||
this.host._mergeSel = null;
|
||
}
|
||
|
||
public _commitMerge(): void {
|
||
const d = this.host._mergeDialog;
|
||
const sp = this.host._curSpaceCfg;
|
||
if (!d || !sp || !this.host._spaceModel()) return;
|
||
const before = this._geometrySnapshot();
|
||
const H = this.host._spaceH;
|
||
const keepId = d.pick === 'a' ? d.aId : d.bId;
|
||
const dropId = d.pick === 'a' ? d.bId : d.aId;
|
||
const referenceRewrite = rewriteMarkerRoomReferences(
|
||
this.host._serverCfg?.markers || [], { kind: 'merge', dropId, keepId },
|
||
);
|
||
if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before;
|
||
const keep = sp.rooms.find((r: any) => r.id === keepId);
|
||
if (!keep) {
|
||
this.host._mergeDialog = null;
|
||
return;
|
||
}
|
||
// the kept room keeps its id, so its label position and devices stay put
|
||
keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]);
|
||
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
|
||
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
|
||
if (referenceRewrite.changed && this.host._serverCfg) {
|
||
this.host._serverCfg.markers = referenceRewrite.markers;
|
||
}
|
||
const committed = this._commitPhysicalGeometry(this.host._t('history.merge_rooms'), before);
|
||
this.host._mergeDialog = null;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
if (committed) this.host._showToast(this.host._t('toast.rooms_merged', { name: keep.name || '' }));
|
||
}
|
||
|
||
public _splitClick(raw: number[]): void {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return;
|
||
const rooms = space.rooms;
|
||
if (!this.host._splitSel) {
|
||
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||
if (!hit?.id) return;
|
||
this.host._splitSel = { roomId: hit.id, pts: [] };
|
||
return;
|
||
}
|
||
const room = rooms.find((r) => r.id === this.host._splitSel!.roomId);
|
||
const poly = room ? roomPoly(room) : null;
|
||
if (!room || !poly) {
|
||
this.host._splitSel = null;
|
||
return;
|
||
}
|
||
// A split point lands on the room's nearest wall — the user aims at a wall,
|
||
// and rooms need not be grid-aligned (imported/legacy polygons), so snapping
|
||
// to the grid would miss the outline. The pull is capped: a click far from
|
||
// any wall (e.g. an accidental one in the middle of the room) is a miss and
|
||
// gets the toast, not a wall the user never meant. splitRoom() still rejects
|
||
// any cut that is not a clean wall-to-wall chord.
|
||
// …and it is still QUANTISED: the offset ALONG the wall moves in whole grid
|
||
// steps (docs/CANVAS.md §9), which on the axis-aligned, grid-drawn walls the
|
||
// editor itself makes IS a grid node. There is no free-position bypass.
|
||
const eps = this.host._gridPitch * 0.02;
|
||
const pull = this.host._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
|
||
const raw0 = closestPointOnBoundary(raw, poly);
|
||
const near = raw0 ? (snapPointAlongPoly(raw0, poly, this.host._gridPitch) || raw0) : raw0;
|
||
const wallPt = raw0 && near && Math.hypot(raw0[0] - raw[0], raw0[1] - raw[1]) <= pull ? near : null;
|
||
const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
|
||
const cur = this.host._splitSel.pts;
|
||
if (!cur.length) {
|
||
// the cut starts on a wall
|
||
if (!onWall) {
|
||
this.host._showToast(this.host._t('toast.split_pick_wall'));
|
||
return;
|
||
}
|
||
this.host._splitSel = { ...this.host._splitSel, pts: [wallPt!] };
|
||
return;
|
||
}
|
||
if (!onWall) {
|
||
// an interior click adds an intermediate vertex of the cut path
|
||
const mid = this._snap(raw);
|
||
if (!ptInside(mid, poly, eps)) {
|
||
this.host._showToast(this.host._t('toast.split_pick_inside'));
|
||
return;
|
||
}
|
||
this.host._splitSel = { ...this.host._splitSel, pts: [...cur, mid] };
|
||
return;
|
||
}
|
||
// a wall point finishes the cut
|
||
const parts = splitRoomPath(poly, [...cur, wallPt!], eps);
|
||
if (!parts) {
|
||
this.host._showToast(this.host._t('toast.split_bad_cut'));
|
||
return;
|
||
}
|
||
this._resetRoomDialogFields();
|
||
// the bigger part stays the room it was — name, area and devices go with it
|
||
const [p1, p2] = parts;
|
||
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
||
const fresh = main === p1 ? p2 : p1;
|
||
this.host._pendingSplit = { roomId: room.id!, mainPoly: main, newPoly: fresh };
|
||
this.host._cursorPt = null;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host._roomDialog = true;
|
||
}
|
||
|
||
public _markupMove(ev: MouseEvent): void {
|
||
if (!this.host._markup) return;
|
||
if (this.host._tool === 'column') {
|
||
this.host._cursorPt = this._snap(this._svgPoint(ev));
|
||
return;
|
||
}
|
||
if (this.host._tool === 'opening' || this.host._tool === 'wallthick') {
|
||
// hover preview: raw cursor point; snapping happens in the preview getters
|
||
this.host._cursorPt = this._svgPoint(ev);
|
||
return;
|
||
}
|
||
const architectural = this.host._tool === 'draw' && !this.host._contourClosed;
|
||
const cutting = this.host._tool === 'split' && !!this.host._splitSel?.pts?.length;
|
||
if (!architectural && !cutting) return;
|
||
const raw = this._svgPoint(ev);
|
||
if (architectural) {
|
||
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
|
||
this.host._planSnapHover = {
|
||
contextKey: resolved.contextKey,
|
||
candidate: resolved.candidate,
|
||
conflicts: resolved.conflicts,
|
||
};
|
||
this._syncPlanSnapConflictMarkers(resolved.conflicts);
|
||
this._syncPlanSnapActiveMarker(resolved.candidate);
|
||
// Before the first click there is no rubber-band to repaint. Updating the
|
||
// dedicated marker avoids walking the complete large-house Lit tree for
|
||
// every mouse move while click still resolves from the event coordinate.
|
||
if (!this.host._path.length) return;
|
||
this.host._cursorPt = resolved.point;
|
||
return;
|
||
}
|
||
this.host._cursorPt = this._snap(raw);
|
||
}
|
||
|
||
public _saveRoom(): void {
|
||
if (!this.host._areaSel && !this.host._nameSel.trim()) return;
|
||
if (!roomTempThresholdDraft(this.host._roomTempMin, this.host._roomTempMax).valid) return;
|
||
if (this.host._wallFaceBatch) {
|
||
this._decideWallFace(true);
|
||
return;
|
||
}
|
||
this._commitRoom();
|
||
}
|
||
|
||
public _decideWallFace(create: boolean): void {
|
||
const batch = this.host._wallFaceBatch;
|
||
if (!batch) return;
|
||
const candidate = batch.candidates[batch.index];
|
||
if (!candidate) return;
|
||
if (!create && (candidate.existing || candidate.repair) && batch.candidates.length === 1) {
|
||
this.host._wallFaceBatch = null;
|
||
this.host._roomDialog = false;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
const decision: WallFaceDecision = create ? {
|
||
candidate,
|
||
create: true,
|
||
name: this.host._nameSel.trim(),
|
||
area: this.host._areaSel || null,
|
||
settings: this._roomSettingsFromDialog()
|
||
? JSON.parse(JSON.stringify(this._roomSettingsFromDialog())) : null,
|
||
} : { candidate, create: false };
|
||
const decisions = [...batch.decisions, decision];
|
||
const nextIndex = batch.index + 1;
|
||
if (nextIndex < batch.candidates.length) {
|
||
this.host._wallFaceBatch = { ...batch, decisions, index: nextIndex };
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this._resetRoomDialogFields();
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
this.host._wallFaceBatch = { ...batch, decisions };
|
||
this._applyWallFaceBatch();
|
||
}
|
||
|
||
public _wallSourceCmAt(
|
||
point: number[], activePath: number[][], activeCms: number[],
|
||
): number {
|
||
const validCm = (value: unknown): number | null =>
|
||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
||
const epsilon = this.host._gridPitch * 0.02;
|
||
const model = this.host._spaceModel();
|
||
if (!model) return DRAW_WALL_DEFAULT_CM;
|
||
// Already-saved independent masonry is authoritative when the new chain
|
||
// traces it. Room-owned walls are preserved by the canonical wall helpers.
|
||
for (const segment of this._planSnapGeometrySnapshot().value.segments) {
|
||
if (segment.sourceKind === 'room'
|
||
|| distToSegment(point, [segment.a[0], segment.a[1], segment.b[0], segment.b[1]]) > epsilon) continue;
|
||
if (segment.sourceKind === 'partition') {
|
||
return validCm(model.partitions.find((item) => item.id === segment.sourceId)?.cm)
|
||
?? DRAW_WALL_DEFAULT_CM;
|
||
}
|
||
}
|
||
// Толщина отрезка активной цепочки решается тем же резолвером (#234):
|
||
// именно это значение подсвечивает инструмент «Толщина», и расхождение с
|
||
// записью здесь было тем способом, которым дефект и обнаружился.
|
||
const resolved = chainSegmentCms(
|
||
Math.max(0, activePath.length - 1), activeCms,
|
||
this.host._drawWallCm, DRAW_WALL_DEFAULT_CM,
|
||
);
|
||
for (let i = 0; i + 1 < activePath.length; i++) {
|
||
const a = activePath[i], b = activePath[i + 1];
|
||
if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) {
|
||
return resolved[i] ?? DRAW_WALL_DEFAULT_CM;
|
||
}
|
||
}
|
||
return DRAW_WALL_DEFAULT_CM;
|
||
}
|
||
|
||
public _activePathWithRepair(
|
||
path: number[][], proposal: WallFaceRepairProposal | undefined,
|
||
): number[][] {
|
||
const result = path.map((point) => [...point]);
|
||
if (!proposal) return result;
|
||
const source = this.host._activeWallChainPartitionIds.findIndex((_, index) => (
|
||
this._activeWallSourceKey(index) === proposal.sourceKey
|
||
));
|
||
const index = source >= 0 ? source + (proposal.endpoint === 'b' ? 1 : 0) : -1;
|
||
if (index >= 0 && index < result.length) result[index] = [...proposal.to];
|
||
return result;
|
||
}
|
||
|
||
public _validateWallRepair(
|
||
proposal: WallFaceRepairProposal, activePath: number[][],
|
||
): boolean {
|
||
const epsilon = this.host._gridPitch * 0.0002;
|
||
const sources = this._wallGraphSources(activePath);
|
||
const source = sources.find((item) => item.key === proposal.sourceKey);
|
||
const target = sources.find((item) => item.key === proposal.targetSourceKey);
|
||
if (!source || !target) return false;
|
||
const current = proposal.endpoint === 'a' ? source.a : source.b;
|
||
if (Math.hypot(current[0] - proposal.from[0], current[1] - proposal.from[1]) > epsilon) return false;
|
||
if (distToSegment(proposal.to, [target.a[0], target.a[1], target.b[0], target.b[1]]) > epsilon) {
|
||
return false;
|
||
}
|
||
if (repairMovesHostedPartition(
|
||
proposal, (this.host._curSpaceCfg as any)?.openings || [],
|
||
)) return false;
|
||
return true;
|
||
}
|
||
|
||
public _applyWallRepair(
|
||
proposal: WallFaceRepairProposal, batch: WallFaceBatch,
|
||
): boolean {
|
||
const sp = this.host._curSpaceCfg as any;
|
||
if (!sp || !this._validateWallRepair(proposal, batch.activePath)) return false;
|
||
const write = (point: number[]): void => {
|
||
point[0] = proposal.to[0] / NORM_W;
|
||
point[1] = proposal.to[1] / NORM_W;
|
||
};
|
||
if (!proposal.sourceKey.startsWith('static:')) return false;
|
||
const [kind, sourceId] = proposal.sourceKey.slice('static:'.length).split('|');
|
||
if (kind === 'partition') {
|
||
const partition = (sp.partitions || []).find((item: PartitionCfg) => item.id === sourceId);
|
||
if (!partition) return false;
|
||
const renderA = [partition.a[0] * NORM_W, partition.a[1] * NORM_W];
|
||
const renderB = [partition.b[0] * NORM_W, partition.b[1] * NORM_W];
|
||
const point = Math.hypot(renderA[0] - proposal.from[0], renderA[1] - proposal.from[1])
|
||
<= this.host._gridPitch * 0.0002 ? partition.a
|
||
: Math.hypot(renderB[0] - proposal.from[0], renderB[1] - proposal.from[1])
|
||
<= this.host._gridPitch * 0.0002 ? partition.b : null;
|
||
if (!point) return false;
|
||
write(point);
|
||
const activeIndex = batch.activePartitionIds.indexOf(sourceId);
|
||
if (activeIndex >= 0) {
|
||
const pathIndex = activeIndex + (proposal.endpoint === 'b' ? 1 : 0);
|
||
if (batch.activePath[pathIndex]) batch.activePath[pathIndex] = [...proposal.to];
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public _applyWallFaceBatch(): void {
|
||
const batch = this.host._wallFaceBatch;
|
||
const sp = this.host._curSpaceCfg as any;
|
||
const model = this.host._spaceModel();
|
||
if (!batch || !sp || !model) return;
|
||
const abort = (message: string, vars?: Record<string, string | number>): void => {
|
||
this.host._showToast(this.host._t(message as any, vars));
|
||
this._roomDialogCancel();
|
||
};
|
||
const accepted = batch.decisions.filter((decision) => decision.create);
|
||
const acceptedRings = accepted.map((decision) => decision.candidate.ring);
|
||
const existingRooms = model.rooms;
|
||
for (let i = 0; i < acceptedRings.length; i++) {
|
||
if (this._contourSelfIntersects(acceptedRings[i]) || polygonArea(acceptedRings[i]) <= 1e-6) {
|
||
abort('toast.contour_cannot_close');
|
||
return;
|
||
}
|
||
for (let j = i + 1; j < acceptedRings.length; j++) {
|
||
if (roomsOverlap(acceptedRings[i], acceptedRings[j])) {
|
||
abort('toast.contour_cannot_close');
|
||
return;
|
||
}
|
||
}
|
||
const ownSplit = accepted[i].candidate.split?.roomId;
|
||
const clash = existingRooms.find((room) => room.id !== ownSplit
|
||
&& !!roomPoly(room) && roomsOverlap(acceptedRings[i], roomPoly(room)!));
|
||
if (clash) {
|
||
abort('toast.room_overlap', { name: clash.name || '' });
|
||
return;
|
||
}
|
||
}
|
||
if ((sp.rooms || []).length + accepted.length > MAX_ROOMS) {
|
||
abort('toast.physical_limit');
|
||
return;
|
||
}
|
||
if (accepted.some((decision) => decision.candidate.split
|
||
&& !sp.rooms.some((room) => room.id === decision.candidate.split!.roomId))) {
|
||
abort('toast.contour_cannot_close');
|
||
return;
|
||
}
|
||
|
||
const repairs = accepted
|
||
.map((decision) => decision.candidate.repair)
|
||
.filter((repair): repair is WallFaceRepairProposal => !!repair);
|
||
if (repairs.length > 1 || (repairs[0] && !this._validateWallRepair(repairs[0], batch.activePath))) {
|
||
abort('toast.wall_repair_changed');
|
||
return;
|
||
}
|
||
if (!accepted.length) {
|
||
if (!this._finalizeWallChainPartitions(batch.activePartitionIds)) return;
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this.host._wallFaceBatch = null;
|
||
this.host._roomDialog = false;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host.requestUpdate();
|
||
this.host._showToast(this.host._t('toast.wall_chain_saved'));
|
||
return;
|
||
}
|
||
const activePartitionIds = new Set(batch.activePartitionIds);
|
||
const epsilon = this.host._gridPitch * 0.0002;
|
||
const roomLineage = accepted.map((decision) => decision.candidate.ring.map((a, index, ring) => {
|
||
const b = ring[(index + 1) % ring.length];
|
||
const carriers = model.partitions.filter((partition) => {
|
||
const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]);
|
||
if (!(length > epsilon)) return false;
|
||
const ux = (partition.b[0] - partition.a[0]) / length;
|
||
const uy = (partition.b[1] - partition.a[1]) / length;
|
||
const along = (point: number[]) => (
|
||
(point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy
|
||
);
|
||
return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||
&& distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||
&& along(a) >= -epsilon && along(a) <= length + epsilon
|
||
&& along(b) >= -epsilon && along(b) <= length + epsilon;
|
||
});
|
||
carriers.sort((left, right) => (
|
||
Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id))
|
||
|| Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1])
|
||
- Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1])
|
||
|| left.id.localeCompare(right.id)
|
||
));
|
||
return carriers[0]?.id || '';
|
||
}));
|
||
const before = this._geometrySnapshot();
|
||
if (!before) {
|
||
abort('toast.geometry_unsafe');
|
||
return;
|
||
}
|
||
const abortMutation = (): void => {
|
||
this._restoreGeometryStateLocal(before);
|
||
abort('toast.geometry_unsafe');
|
||
};
|
||
if (repairs[0] && !this._applyWallRepair(repairs[0], batch)) {
|
||
abort('toast.wall_repair_changed');
|
||
return;
|
||
}
|
||
const hasSplit = accepted.some((decision) => !!decision.candidate.split);
|
||
const splitWalls = hasSplit
|
||
? materializeWallIntervals(
|
||
model.rooms, sp.walls, this.host._openCuts(),
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
)
|
||
: null;
|
||
const wallsBeforeSplit = hasSplit && Array.isArray(sp.walls) ? sp.walls : null;
|
||
for (const decision of accepted) {
|
||
const split = decision.candidate.split;
|
||
if (!split) continue;
|
||
const main = sp.rooms.find((room) => room.id === split.roomId);
|
||
if (!main) {
|
||
abortMutation();
|
||
return;
|
||
}
|
||
main.poly = split.mainPoly.map((point) => [point[0] / NORM_W, point[1] / this.host._spaceH]);
|
||
delete main.x; delete main.y; delete main.w; delete main.h;
|
||
}
|
||
|
||
const seed = Date.now().toString(36);
|
||
const newRooms: Array<{ room: any; decision: WallFaceDecision }> = [];
|
||
accepted.forEach((decision, index) => {
|
||
const areaName = decision.area ? this.host.hass.areas[decision.area]?.name : '';
|
||
const room = {
|
||
id: `r${seed}-${index}`,
|
||
name: decision.name || areaName || this.host._t('room.default_name'),
|
||
area: decision.area || null,
|
||
poly: decision.candidate.ring.map((point) =>
|
||
[point[0] / NORM_W, point[1] / this.host._spaceH]),
|
||
...(roomLineage[index].some(Boolean) ? { wall_ids: roomLineage[index] } : {}),
|
||
...(decision.settings ? { settings: JSON.parse(JSON.stringify(decision.settings)) } : {}),
|
||
};
|
||
sp.rooms.push(room);
|
||
newRooms.push({ room, decision });
|
||
});
|
||
|
||
if (hasSplit) {
|
||
const next = this.host._normalizeWalls(splitWalls, this.host._openCuts());
|
||
if (next.length) sp.walls = next;
|
||
else if (!wallsBeforeSplit?.length) delete sp.walls;
|
||
else sp.walls = wallsBeforeSplit;
|
||
}
|
||
if (newRooms.length) {
|
||
// Make the new model visible to the canonical wall helpers before they
|
||
// key intervals. This is still inside the one history/save transaction.
|
||
this.host._cfgEpoch++;
|
||
const openCuts = this.host._openCuts();
|
||
const updatedModel = this.host._spaceModel();
|
||
if (!updatedModel) {
|
||
abortMutation();
|
||
return;
|
||
}
|
||
let walls = sp.walls;
|
||
for (const { room, decision } of newRooms) {
|
||
const ring = decision.candidate.ring;
|
||
const fallback = this._wallSourceCmAt(
|
||
ring[0], batch.activePath, batch.activeCms,
|
||
);
|
||
walls = applyWallThicknessToNewRoom(
|
||
walls, updatedModel.rooms, room.id, fallback,
|
||
this.host._wallKeyPitch, openCuts, NORM_W,
|
||
);
|
||
for (const interval of wallIntervals(
|
||
updatedModel.rooms, walls, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
)) {
|
||
if (interval.roomId !== room.id || interval.kind !== 'outer') continue;
|
||
const midpoint = [
|
||
(interval.a[0] + interval.b[0]) / 2,
|
||
(interval.a[1] + interval.b[1]) / 2,
|
||
];
|
||
walls = setWallThickness(
|
||
walls, interval.a, interval.b,
|
||
this._wallSourceCmAt(midpoint, batch.activePath, batch.activeCms),
|
||
this.host._wallKeyPitch, NORM_W,
|
||
);
|
||
}
|
||
}
|
||
const normalized = this.host._normalizeWalls(walls, openCuts);
|
||
if (normalized.length) sp.walls = normalized;
|
||
else delete sp.walls;
|
||
|
||
// Every accepted click already wrote an ordinary partition. Once the
|
||
// chain creates room faces, consume the coincident carriers into the
|
||
// canonical room-wall representation in this same transaction.
|
||
this.host._cfgEpoch++;
|
||
const reconciledModel = this.host._spaceModel();
|
||
if (!reconciledModel) {
|
||
abortMutation();
|
||
return;
|
||
}
|
||
const reconciled = reconcileCoincidentPartitions(
|
||
sp, reconciledModel, sp.walls, openCuts,
|
||
{
|
||
pitch: this.host._wallKeyPitch,
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
coordScale: NORM_W,
|
||
allowCoincidentPartitions: true, // #478 room acceptance consumes its wall carriers.
|
||
},
|
||
);
|
||
if (reconciled.walls.length) sp.walls = reconciled.walls;
|
||
else delete sp.walls;
|
||
if (reconciled.partitions.length) sp.partitions = reconciled.partitions;
|
||
else delete sp.partitions;
|
||
if (reconciled.openings.length) sp.openings = reconciled.openings;
|
||
else delete sp.openings;
|
||
}
|
||
|
||
if (!this._commitPhysicalGeometry(this.host._t('history.wall_face_batch'), before)) return;
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this.host._wallFaceBatch = null;
|
||
this.host._roomDialog = false;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
// Match the ordinary room-save contract: newly area-bound devices are
|
||
// auto-added and their initial positions are pinned immediately, while a
|
||
// clean split leaves the surviving parent's existing layout untouched.
|
||
const boundAreas = new Set(accepted
|
||
.map((decision) => decision.area)
|
||
.filter((area): area is string => !!area));
|
||
if (boundAreas.size) {
|
||
const next = { ...this.host._layout };
|
||
for (const device of this.host._devices) {
|
||
if (!boundAreas.has(device.area || '') || device.space !== this.host._space) continue;
|
||
if (this.host._layout[device.id]) continue;
|
||
const position = this.host._defPos[device.id];
|
||
if (!position) continue;
|
||
next[device.id] = {
|
||
s: this.host._space, x: position.x / NORM_W, y: position.y / NORM_W,
|
||
};
|
||
this.host._dirtyPos.add(device.id);
|
||
}
|
||
this.host._layout = next;
|
||
this.host._persistLayout();
|
||
}
|
||
this.host._showToast(this.host._t(accepted.length ? 'toast.wall_rooms_saved' : 'toast.wall_chain_saved', {
|
||
n: accepted.length,
|
||
}));
|
||
}
|
||
|
||
public _commitRoom(): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
const space = this.host._spaceModel();
|
||
if (!sp || !space) return;
|
||
const before = this._geometrySnapshot();
|
||
if (!before) return;
|
||
const abortMutation = (): void => {
|
||
this._restoreGeometryStateLocal(before);
|
||
this.host._showToast(this.host._t('toast.geometry_unsafe'));
|
||
};
|
||
const H = this.host._spaceH;
|
||
const wasSplit = !!this.host._pendingSplit;
|
||
// Split changes the two source-wall edges into shorter children. Preserve
|
||
// the effective old profile while the original outline still proves the
|
||
// full extent of legacy midpoint-only wall keys; after the mutation that
|
||
// information is irrecoverable and one child would normalise to 0 cm.
|
||
const splitWalls = wasSplit
|
||
? materializeWallIntervals(
|
||
space.rooms, sp.walls, this.host._openCuts(),
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
)
|
||
: null;
|
||
const wallsBeforeSplit = wasSplit && Array.isArray(sp.walls) ? sp.walls : null;
|
||
let verts: number[][];
|
||
if (this.host._pendingSplit) {
|
||
// apply the cut now: the bigger part keeps the original room, this dialog names the rest
|
||
const main = sp.rooms.find((r: any) => r.id === this.host._pendingSplit!.roomId);
|
||
if (!main) {
|
||
this.host._pendingSplit = null;
|
||
this.host._splitSel = null;
|
||
this.host._roomDialog = false;
|
||
return;
|
||
}
|
||
main.poly = this.host._pendingSplit.mainPoly.map((p) => [p[0] / NORM_W, p[1] / H]);
|
||
delete main.x; delete main.y; delete main.w; delete main.h;
|
||
verts = this.host._pendingSplit.newPoly;
|
||
} else {
|
||
if (!this.host._contourClosed) return;
|
||
verts = this.host._path.slice(0, -1); // without the duplicated closing vertex
|
||
}
|
||
const areaName = this.host._areaSel ? this.host.hass.areas[this.host._areaSel]?.name : '';
|
||
const newRoom: any = {
|
||
id: 'r' + Date.now().toString(36),
|
||
name: this.host._nameSel || areaName || this.host._t('room.default_name'),
|
||
area: this.host._areaSel || null,
|
||
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
|
||
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
|
||
};
|
||
sp.rooms.push(newRoom);
|
||
// A Split rewrites the parent outline and adds a child, so a span that used
|
||
// to sit between the parent and a neighbour may now belong to the child.
|
||
// Geometry first, then spans and the derived open_to — otherwise border
|
||
// trimming reads the new geometry while glow reads the old connectivity
|
||
// (AUD-159B6-02).
|
||
if (wasSplit) {
|
||
const next = this.host._normalizeWalls(splitWalls, this.host._openCuts());
|
||
if (next.length) sp.walls = next;
|
||
else if (!wallsBeforeSplit?.length) delete sp.walls;
|
||
else sp.walls = wallsBeforeSplit;
|
||
}
|
||
// Draw-session wall thickness: apply to new edges only; keep neighbour cm
|
||
// on shared stretches. Split naming does not use the Draw field.
|
||
if (!wasSplit) {
|
||
const edgeCms = chainSegmentCms(
|
||
verts.length,
|
||
[...this.host._wallChainSegmentCms, this.host._closingWallCm ?? undefined],
|
||
this.host._drawWallCm, DRAW_WALL_DEFAULT_CM,
|
||
);
|
||
const cm = edgeCms[0];
|
||
if (cm != null) {
|
||
this.host._cfgEpoch++; // the new room must be in the model before keying
|
||
const openCuts = this.host._openCuts();
|
||
const updatedSpace = this.host._spaceModel();
|
||
if (!updatedSpace) {
|
||
abortMutation();
|
||
return;
|
||
}
|
||
let next = applyWallThicknessToNewRoom(
|
||
sp.walls, updatedSpace.rooms, newRoom.id, cm,
|
||
this.host._wallKeyPitch, openCuts, NORM_W,
|
||
);
|
||
// The room may have been drawn while the toolbar thickness changed.
|
||
// Shared stretches keep the already-existing physical wall; every
|
||
// outer atomic stretch receives the value of its source draft edge.
|
||
for (const iv of wallIntervals(
|
||
updatedSpace.rooms, next, openCuts,
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
)) {
|
||
if (iv.roomId !== newRoom.id || iv.kind !== 'outer') continue;
|
||
const mid = [(iv.a[0] + iv.b[0]) / 2, (iv.a[1] + iv.b[1]) / 2];
|
||
const source = verts.findIndex((a, i) => {
|
||
const b = verts[(i + 1) % verts.length];
|
||
return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this.host._gridPitch * 0.02;
|
||
});
|
||
if (source >= 0) next = setWallThickness(
|
||
next, iv.a, iv.b, edgeCms[source],
|
||
this.host._wallKeyPitch, NORM_W,
|
||
);
|
||
}
|
||
next = this.host._normalizeWalls(next, openCuts);
|
||
if (next.length) sp.walls = next;
|
||
else delete sp.walls;
|
||
|
||
const reconciledModel = this.host._spaceModel();
|
||
if (!reconciledModel) {
|
||
abortMutation();
|
||
return;
|
||
}
|
||
const reconciled = reconcileCoincidentPartitions(
|
||
sp, reconciledModel, sp.walls, openCuts,
|
||
{
|
||
pitch: this.host._wallKeyPitch,
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
coordScale: NORM_W,
|
||
allowCoincidentPartitions: true,
|
||
},
|
||
);
|
||
if (reconciled.walls.length) sp.walls = reconciled.walls;
|
||
else delete sp.walls;
|
||
if (reconciled.partitions.length) sp.partitions = reconciled.partitions;
|
||
else delete sp.partitions;
|
||
if (reconciled.openings.length) sp.openings = reconciled.openings;
|
||
else delete sp.openings;
|
||
}
|
||
}
|
||
const committed = this._commitPhysicalGeometry(
|
||
this.host._t(wasSplit ? 'history.split_room' : 'history.add_room'), before,
|
||
);
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this.host._pendingSplit = null;
|
||
this.host._splitSel = null;
|
||
const boundArea = this.host._areaSel;
|
||
this.host._areaSel = '';
|
||
this.host._nameSel = '';
|
||
this.host._roomDialog = false;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
if (!committed) return;
|
||
// auto-add the area's device icons + PIN their positions in the layout,
|
||
// so that icons do not get reshuffled when the order in the HA registry changes.
|
||
let added = 0;
|
||
if (boundArea) {
|
||
const H2 = NORM_W;
|
||
const next = { ...this.host._layout };
|
||
for (const d of this.host._devices) {
|
||
if (d.area !== boundArea || d.space !== this.host._space) continue;
|
||
added++;
|
||
if (this.host._layout[d.id]) continue; // placed manually — leave it alone
|
||
const dp = this.host._defPos[d.id];
|
||
if (!dp) continue;
|
||
next[d.id] = { s: this.host._space, x: dp.x / NORM_W, y: dp.y / H2 };
|
||
this.host._dirtyPos.add(d.id);
|
||
}
|
||
this.host._layout = next;
|
||
this.host._persistLayout();
|
||
}
|
||
const roomsN = this.host._model.find((s) => s.id === this.host._space)?.rooms.length || 0;
|
||
this.host._showToast(
|
||
boundArea
|
||
? this.host._t('toast.room_saved', { n: roomsN, added })
|
||
: this.host._t('toast.room_saved_no_area', { n: roomsN }),
|
||
);
|
||
}
|
||
|
||
public _cancelPath(): void {
|
||
this.host._wallRepairDiagnostic = null;
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this._clearPlanSnapHover();
|
||
this.host._roomDialog = false;
|
||
this.host._pendingSplit = null;
|
||
this.host._wallFaceBatch = null;
|
||
this.host._splitSel = null;
|
||
this.host._mergeSel = null;
|
||
this.host._mergeDialog = null;
|
||
this.host._physicalSel = null;
|
||
this.host._physicalDrag = null;
|
||
this.host._physicalRotate = null;
|
||
this._clearOpeningPlacement(true);
|
||
}
|
||
|
||
public _roomDialogCancel(): void {
|
||
this.host._roomDialog = false;
|
||
if (this.host._roomEditId) {
|
||
this.host._roomEditId = null;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
return;
|
||
}
|
||
if (this.host._wallFaceBatch) {
|
||
const batch = this.host._wallFaceBatch;
|
||
this.host._wallFaceBatch = null;
|
||
this.host._path = batch.activePath.map((point) => [...point]);
|
||
this.host._wallChainSegmentCms = [...batch.activeCms];
|
||
this.host._activeWallChainPartitionIds = [...batch.activePartitionIds];
|
||
this.host._activeWallChainId ||= `chain-${Date.now().toString(36)}`;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host.requestUpdate();
|
||
return;
|
||
}
|
||
if (this.host._pendingSplit) {
|
||
// nothing was applied yet — drop the cut entirely, the room stays whole
|
||
this.host._pendingSplit = null;
|
||
this.host._splitSel = null;
|
||
return;
|
||
}
|
||
this.host._undoPoint();
|
||
}
|
||
|
||
public _openDeviceInbox(): void {
|
||
this.host._deviceInboxReturn = null;
|
||
this.host._deviceInbox = this.host._deviceInbox || {
|
||
tab: 'on_plan', search: '', showEntities: false, onlyNew: false, limit: 100,
|
||
};
|
||
}
|
||
|
||
public _closeMarkerDialog(): void {
|
||
if (this._radarSetup.isDirty()) { void this._closeMarkerDialogGuarded(); return; }
|
||
this._radarSetup.reset();
|
||
finishMarkerDialogClose(this.host);
|
||
}
|
||
private async _closeMarkerDialogGuarded(): Promise<void> {
|
||
if (!await this._radarSetup.discardIfAllowed()) {
|
||
await this.host.updateComplete; this.host.renderRoot
|
||
.querySelector<HpDialog>('#marker-dialog')?.rejectClose();
|
||
return;
|
||
}
|
||
this._closeMarkerDialog();
|
||
}
|
||
|
||
public _deviceInboxCandidates(showEntities: boolean) {
|
||
return bindingCandidates({
|
||
hass: this.host._planHass,
|
||
devices: this.host._devices,
|
||
markers: this.host._markers,
|
||
showEntities,
|
||
labels: {
|
||
device: this.host._t('marker.sub_device'),
|
||
z2mGroup: this.host._t('marker.sub_z2m_group'),
|
||
group: this.host._t('marker.sub_group'),
|
||
helper: this.host._t('marker.sub_helper'),
|
||
entity: this.host._t('marker.sub_entity'),
|
||
},
|
||
});
|
||
}
|
||
|
||
public _deviceInboxRows(): DeviceInboxRow[] {
|
||
const dialog = this.host._deviceInbox || this.host._deviceInboxReturn;
|
||
const showEntities = !!dialog?.showEntities;
|
||
const key = [
|
||
this.host._haRegistry.revision, this.host._cfgRev, this.host._cfgEpoch, this.host._regSignature,
|
||
this.host._newSyncKey, showEntities ? 1 : 0, this.host._showAll ? 1 : 0,
|
||
langOf(this.host.hass, this.host._config?.language),
|
||
].join('|');
|
||
if (this.host._deviceInboxMemo?.key === key) return this.host._deviceInboxMemo.rows;
|
||
const candidates = this._deviceInboxCandidates(showEntities);
|
||
const bindings = new Set<string>(candidates.map((item) => item.value));
|
||
for (const marker of this.host._markers) if (marker.binding && marker.binding !== 'virtual') bindings.add(marker.binding);
|
||
for (const device of this.host._devices) {
|
||
if (device.bindingKind && device.bindingKind !== 'virtual' && device.bindingRef) {
|
||
bindings.add(`${device.bindingKind}:${device.bindingRef}`);
|
||
}
|
||
}
|
||
const statuses = new Map<string, HaBindingStatus>();
|
||
for (const binding of bindings) statuses.set(binding, this.host._bindingStatus(binding));
|
||
|
||
const areaMap = this.host._areaToSpace;
|
||
const areaNames: Record<string, string> = {};
|
||
for (const [id, area] of Object.entries<any>(this.host.hass?.areas || {})) {
|
||
areaNames[id] = areaMap[id]?.room?.name || area?.name || id;
|
||
}
|
||
// A room may keep a valid HA area reference even when the current user has
|
||
// only a limited area-registry snapshot. Its plan-visible name is still
|
||
// authoritative for the catalog and search.
|
||
for (const [id, target] of Object.entries(areaMap)) {
|
||
areaNames[id] = target.room.name || areaNames[id] || id;
|
||
}
|
||
const spaceNames = Object.fromEntries(this.host._model.map((space) => [space.id, space.title]));
|
||
const spaceByArea = Object.fromEntries(
|
||
Object.entries(areaMap).map(([area, value]) => [area, value.space]),
|
||
);
|
||
const integrationByBinding: Record<string, string> = {};
|
||
const devicePlatforms = new Map<string, Set<string>>();
|
||
for (const [entityId, entity] of Object.entries<any>(this.host._fullRegistryHass.entities || {})) {
|
||
const platform = String(entity?.platform || '').trim();
|
||
if (platform) integrationByBinding[`entity:${entityId}`] = platform;
|
||
if (platform && entity?.device_id) {
|
||
const set = devicePlatforms.get(entity.device_id) || new Set<string>();
|
||
set.add(platform);
|
||
devicePlatforms.set(entity.device_id, set);
|
||
}
|
||
}
|
||
for (const [deviceId, platforms] of devicePlatforms) {
|
||
integrationByBinding[`device:${deviceId}`] = [...platforms].sort().join(', ');
|
||
}
|
||
const reasonByBinding: Record<string, DeviceInboxReason> = {};
|
||
for (const [deviceId, device] of Object.entries<any>(this.host._fullRegistryHass.devices || {})) {
|
||
const binding = `device:${deviceId}`;
|
||
const platforms = devicePlatforms.get(deviceId) || new Set<string>();
|
||
const identifierDomain = Array.isArray(device?.identifiers?.[0])
|
||
? String(device.identifiers[0][0] || '') : '';
|
||
// #44 r1-M2: a device without a single platform-bearing entity still has
|
||
// an integration name — its identifier domain. Without this fallback the
|
||
// reason text would render the raw {integration} placeholder.
|
||
if (identifierDomain && !integrationByBinding[binding]) {
|
||
integrationByBinding[binding] = identifierDomain;
|
||
}
|
||
const excluded = [identifierDomain, ...platforms].some((domain) => this.host._excluded.has(domain));
|
||
if (device?.entry_type === 'service') reasonByBinding[binding] = 'service_entry';
|
||
else if (excluded) reasonByBinding[binding] = 'excluded_integration';
|
||
else if (device?.model === 'Group') reasonByBinding[binding] = 'grouped_light';
|
||
else if (/scene/i.test(device?.model || '')) reasonByBinding[binding] = 'excluded_domain';
|
||
else if (/bridge/i.test(`${device?.model || ''}${device?.name || ''}`)
|
||
|| (identifierDomain === 'myheat' && device?.via_device_id)) {
|
||
reasonByBinding[binding] = 'represented_by_parent';
|
||
}
|
||
}
|
||
const rows = buildDeviceInbox({
|
||
devices: this.host._devices,
|
||
markers: this.host._markers,
|
||
candidates,
|
||
statuses,
|
||
newDeviceIds: this.host._newIds,
|
||
showHiddenOnPlan: this.host._showAll,
|
||
areaNames, spaceNames, spaceByArea, integrationByBinding, reasonByBinding,
|
||
});
|
||
this.host._deviceInboxMemo = { key, rows };
|
||
return rows;
|
||
}
|
||
|
||
public _deviceForInboxRow(row: DeviceInboxRow): DevItem | null {
|
||
const runtime = row.deviceId ? this.host._devices.find((item) => item.id === row.deviceId) : null;
|
||
if (runtime) return runtime;
|
||
const marker = row.markerId ? this.host._markers.find((item) => item.id === row.markerId) : null;
|
||
if (!marker || marker.removed) return null;
|
||
return {
|
||
id: marker.id,
|
||
name: row.name,
|
||
model: row.model,
|
||
area: row.areaId,
|
||
space: row.spaceId || this.host._space,
|
||
hidden: marker.hidden === true || row.status.kind === 'ha_disabled',
|
||
userHidden: marker.hidden === true,
|
||
bindingStatus: row.status,
|
||
icon: row.icon,
|
||
entities: row.status.kind === 'active' ? row.status.enabledEntityIds : [],
|
||
allEntities: row.status.allEntityIds,
|
||
primary: row.status.kind === 'active' ? row.status.enabledEntityIds[0] : undefined,
|
||
marker,
|
||
bindingKind: row.kind,
|
||
bindingRef: row.binding.slice(row.binding.indexOf(':') + 1),
|
||
pdfs: marker.pdfs || [],
|
||
};
|
||
}
|
||
|
||
public _openInboxMarker(row: DeviceInboxRow, add = false): void {
|
||
const snapshot = this.host._deviceInbox;
|
||
if (!snapshot) return;
|
||
this.host._deviceInboxReturn = { ...snapshot, anchor: row.key };
|
||
this.host._deviceInbox = null;
|
||
if (!add) {
|
||
const device = this._deviceForInboxRow(row);
|
||
if (device) this._openMarkerDialog(device);
|
||
else this._closeMarkerDialog();
|
||
return;
|
||
}
|
||
this._openMarkerDialog();
|
||
if (!this.host._markerDialog) {
|
||
this._closeMarkerDialog();
|
||
return;
|
||
}
|
||
this.host._markerDialog = {
|
||
...this.host._markerDialog,
|
||
bindingMode: 'ha', binding: row.binding, bindingOpen: false,
|
||
showEntities: row.kind === 'entity', name: '',
|
||
};
|
||
}
|
||
|
||
public async _setInboxHidden(row: DeviceInboxRow, hidden: boolean): Promise<void> {
|
||
const dialog = this.host._deviceInbox;
|
||
const cfg = this.host._serverCfg;
|
||
if (!dialog || !cfg || dialog.busy || row.status.kind !== 'active') return;
|
||
const previous = cfg.markers || [];
|
||
const live = previous.find((marker) => !marker.removed && marker.binding === row.binding);
|
||
if (!hidden && !live) return;
|
||
const id = live?.id || markerIdForBinding(
|
||
row.binding, row.markerId, () => `m_${Date.now().toString(36)}`,
|
||
);
|
||
const next: Marker = live
|
||
? { ...live, hidden }
|
||
: { id, binding: row.binding, hidden: true };
|
||
cfg.markers = [
|
||
...previous.filter((marker) => marker.id !== id
|
||
&& (marker.binding !== row.binding || marker.removed === true)),
|
||
next,
|
||
];
|
||
this.host._deviceInbox = { ...dialog, busy: row.key, anchor: row.key };
|
||
try {
|
||
await this._saveConfigNow();
|
||
this.host._regSignature = '';
|
||
this.host._deviceInboxMemo = null;
|
||
this.host._maybeRebuildDevices();
|
||
if (this.host._deviceInbox) this.host._deviceInbox = { ...this.host._deviceInbox, busy: undefined };
|
||
this.host._showToast(this.host._t('device_inbox.saved'));
|
||
} catch (error: any) {
|
||
if (this.host._serverCfg === cfg) cfg.markers = previous;
|
||
if (this.host._deviceInbox) this.host._deviceInbox = { ...this.host._deviceInbox, busy: undefined };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(error) }));
|
||
}
|
||
}
|
||
|
||
public _findInboxDevice(row: DeviceInboxRow): void {
|
||
if (!row.canFind) return;
|
||
const device = this._deviceForInboxRow(row);
|
||
if (!device) return;
|
||
this.host._deviceInbox = null;
|
||
if (device.space && device.space !== this.host._space) {
|
||
if (!this.host._commitSpace(device.space)) return;
|
||
this.host._restoreZoom();
|
||
}
|
||
const focus = () => {
|
||
const current = this.host._devices.find((item) => item.id === device.id) || device;
|
||
const point = this.host._pos(current);
|
||
this.host._applyView(this.host._zoom, point.x, point.y);
|
||
this.host._selId = current.id;
|
||
this.host.requestUpdate();
|
||
window.setTimeout(() => {
|
||
if (this.host._selId === current.id) {
|
||
this.host._selId = null;
|
||
this.host.requestUpdate();
|
||
}
|
||
}, 1500);
|
||
};
|
||
requestAnimationFrame(() => requestAnimationFrame(focus));
|
||
}
|
||
|
||
public _deviceInboxTabKey = (event: KeyboardEvent): void => {
|
||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||
const dialog = this.host._deviceInbox;
|
||
if (!dialog) return;
|
||
const tabs: DeviceInboxCategory[] = ['on_plan', 'available', 'hidden', 'readd'];
|
||
const offset = event.key === 'ArrowRight' ? 1 : -1;
|
||
const index = (tabs.indexOf(dialog.tab) + offset + tabs.length) % tabs.length;
|
||
this.host._deviceInbox = { ...dialog, tab: tabs[index], limit: 100, onlyNew: false };
|
||
event.preventDefault();
|
||
};
|
||
|
||
public _openMarkerDialog(d?: DevItem): void {
|
||
// A global-camera snapshot belongs to one explicit expansion only; never
|
||
// carry it across device-dialog sessions.
|
||
this.host._vacAllCamerasFor = null;
|
||
this.host._vacAllCameraCache = null;
|
||
if (d) this.host._ackNewDevice(d.id);
|
||
if (!this.host._norm) {
|
||
this.host._showToast(this.host._t('toast.marker_needs_server'));
|
||
return;
|
||
}
|
||
if (d) {
|
||
const marker = d.marker;
|
||
const hasIsLight = Object.prototype.hasOwnProperty.call(marker || {}, 'is_light');
|
||
const hasLightEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'light_entity');
|
||
const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color');
|
||
const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge');
|
||
const hasValueSource = Object.prototype.hasOwnProperty.call(marker || {}, 'value_source');
|
||
const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action');
|
||
const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity');
|
||
const glowOverride = normalizeGlowColorOverride(marker?.glow_color);
|
||
const currentBadge = this.host._devicePresentation(d, true).valueBadge;
|
||
const badgeCandidates = valueBadgeCandidates(this.host._planHass, d, this.host._devices);
|
||
const recommendedBadge = recommendedValueBadgeSource(this.host._planHass, d, badgeCandidates);
|
||
const radarRecognition = recognizeRadar(d, this.host._fullRegistryHass);
|
||
const radarSpace = this.host._spaceModelById(d.space) || this.host._spaceModel();
|
||
const markerPoint = this.host._pos(d);
|
||
const radar = radarSpace
|
||
? radarDraft(d, radarSpace, [markerPoint.x, markerPoint.y], this.host._fullRegistryHass)
|
||
: null;
|
||
this.host._markerDialog = {
|
||
devId: d.id,
|
||
name: d.name,
|
||
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
|
||
bindingMode: d.bindingKind === 'virtual' ? 'virtual' : 'ha',
|
||
bindingOpen: false,
|
||
// a marker bound to an ENTITY of a device only shows up with the box on
|
||
showEntities: d.bindingKind === 'entity' && !!this.host._fullRegistryHass.entities[d.bindingRef || '']?.device_id,
|
||
bindingFilter: '',
|
||
icon: d.marker?.icon || '',
|
||
autoIcon: d.icon || '',
|
||
display: normalizeDeviceDisplay(d.marker?.display),
|
||
rippleColor: safeStoredColor(d.marker?.ripple_color, ''),
|
||
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 1.5,
|
||
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
|
||
angle: Number(d.marker?.angle) || 0,
|
||
tapAction: projectedTapAction(d.marker?.tap_action, d.primary?.split('.')[0]),
|
||
tapActionTouched: false,
|
||
originalHasTapAction: hasTapAction,
|
||
originalTapAction: d.marker?.tap_action,
|
||
tapHintAnnouncement: '',
|
||
toggleEntity: marker?.toggle_entity || '',
|
||
toggleEntityTouched: false,
|
||
originalHasToggleEntity: hasToggleEntity,
|
||
originalToggleEntity: marker?.toggle_entity,
|
||
tapTarget: d.marker?.tap_target || '',
|
||
tapConfirm: d.marker?.tap_confirm === true,
|
||
runFilter: '',
|
||
// Keep unknown, temporarily inactive and duplicate external targets
|
||
// byte-for-byte across Open → Save. Runtime uses the filtered
|
||
// effective projection; a legacy self-reference is not a light source.
|
||
controls: persistedExternalControls(d.marker?.binding, d.marker?.controls, d.entities),
|
||
controlsFilter: '',
|
||
lightRole: marker?.is_light === true ? 'always' : marker?.is_light === false ? 'never' : 'auto',
|
||
lightRoleTouched: false,
|
||
originalHasIsLight: hasIsLight,
|
||
originalIsLight: marker?.is_light,
|
||
lightEntity: marker?.light_entity || '',
|
||
lightEntityTouched: false,
|
||
originalHasLightEntity: hasLightEntity,
|
||
originalLightEntity: marker?.light_entity,
|
||
glowMode: glowOverride?.bri != null ? 'fixed' : glowOverride ? 'color' : 'auto',
|
||
glowColor: glowOverride?.c || this.host._fillColors.glow_light.c,
|
||
glowBrightness: Math.max(1, Math.round((glowOverride?.bri ?? 1) * 100)),
|
||
glowColorDrafted: !!glowOverride,
|
||
glowBrightnessDrafted: glowOverride?.bri != null,
|
||
glowTouched: false,
|
||
originalHasGlowColor: hasGlowColor && (!!glowOverride || marker?.glow_color === null),
|
||
originalGlowColor: glowOverride || (marker?.glow_color === null ? null : undefined),
|
||
valueBadgeEnabled: hasValueBadge ? marker?.value_badge?.enabled === true : !!currentBadge,
|
||
valueBadgeSource: marker?.value_badge?.source || currentBadge?.source || recommendedBadge,
|
||
valueBadgePosition: marker?.value_badge?.position || currentBadge?.position || 'right',
|
||
valueBadgeTouched: false,
|
||
originalHasValueBadge: hasValueBadge,
|
||
originalValueBadge: marker?.value_badge,
|
||
valueSource: marker?.value_source || null,
|
||
valueSourceTouched: false,
|
||
originalHasValueSource: hasValueSource,
|
||
originalValueSource: marker?.value_source,
|
||
useClimateTemp: d.marker?.use_climate_temp === true,
|
||
glowRadius: Number(d.marker?.glow_radius_cm) > 0
|
||
? String(this.host._imperial
|
||
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
|
||
: Math.round(Number(d.marker!.glow_radius_cm)) / 100)
|
||
: '',
|
||
model: d.model || '',
|
||
link: d.link || '',
|
||
description: d.description || '',
|
||
pdfs: [...(d.pdfs || [])],
|
||
room: d.marker?.room_id
|
||
? d.space + '#@' + d.marker.room_id
|
||
: d.space && d.area ? d.space + '#' + d.area : '',
|
||
roomTouched: false,
|
||
radar,
|
||
radarEligible: radarRecognition.eligible,
|
||
radarTouched: false,
|
||
radarRemove: false,
|
||
hideFromPlan: d.marker?.hidden === true,
|
||
busy: false,
|
||
};
|
||
} else {
|
||
this.host._markerDialog = {
|
||
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
|
||
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
|
||
display: 'badge', rippleColor: '', rippleSize: 1.5, size: 1, angle: 0,
|
||
tapAction: 'info', tapActionTouched: false,
|
||
originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '',
|
||
toggleEntity: '', toggleEntityTouched: false,
|
||
originalHasToggleEntity: false, originalToggleEntity: undefined,
|
||
tapTarget: '', tapConfirm: false, runFilter: '',
|
||
controls: [], controlsFilter: '',
|
||
lightRole: 'auto', lightRoleTouched: false,
|
||
originalHasIsLight: false, originalIsLight: undefined,
|
||
lightEntity: '', lightEntityTouched: false,
|
||
originalHasLightEntity: false, originalLightEntity: undefined,
|
||
glowMode: 'auto', glowColor: this.host._fillColors.glow_light.c, glowBrightness: 100,
|
||
glowColorDrafted: false, glowBrightnessDrafted: false, glowTouched: false,
|
||
originalHasGlowColor: false, originalGlowColor: undefined,
|
||
valueBadgeEnabled: false, valueBadgeSource: null, valueBadgePosition: 'right',
|
||
valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined,
|
||
valueSource: null, valueSourceTouched: false,
|
||
originalHasValueSource: false, originalValueSource: undefined,
|
||
useClimateTemp: false, glowRadius: '', model: '',
|
||
link: '', description: '', pdfs: [], room: '', roomTouched: false,
|
||
radar: null, radarEligible: false, radarTouched: false, radarRemove: false,
|
||
hideFromPlan: false, busy: false,
|
||
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||
};
|
||
}
|
||
// #600 К10: снимок черновика — Save только при изменениях в edit
|
||
if (this.host._markerDialog) rememberMarkerBaseline(this.host, this.host._markerDialog);
|
||
}
|
||
|
||
public _runCandidates(): { value: string; label: string; sub: string }[] {
|
||
const out: { value: string; label: string; sub: string }[] = [];
|
||
for (const dom of RUN_TARGET_DOMAINS) {
|
||
for (const [eid, st] of Object.entries<any>(this.host.hass.states)) {
|
||
if (!eid.startsWith(dom + '.')) continue;
|
||
if (!this.host._planEntityAvailable(eid)) continue;
|
||
out.push({
|
||
value: eid,
|
||
label: st?.attributes?.friendly_name || eid,
|
||
sub: this.host._t(('run.' + dom) as any),
|
||
});
|
||
}
|
||
}
|
||
return out.sort((a, b) => a.sub.localeCompare(b.sub) || a.label.localeCompare(b.label));
|
||
}
|
||
|
||
public _bindingCandidates(): { value: string; label: string; sub: string }[] {
|
||
const list = bindingCandidates({
|
||
hass: this.host._planHass,
|
||
devices: this.host._devices,
|
||
markers: this.host._markers,
|
||
showEntities: !!this.host._markerDialog?.showEntities,
|
||
currentBinding: this.host._markerDialog?.binding,
|
||
currentDeviceId: this.host._markerDialog?.devId,
|
||
labels: {
|
||
device: this.host._t('marker.sub_device'),
|
||
z2mGroup: this.host._t('marker.sub_z2m_group'),
|
||
group: this.host._t('marker.sub_group'),
|
||
helper: this.host._t('marker.sub_helper'),
|
||
entity: this.host._t('marker.sub_entity'),
|
||
},
|
||
});
|
||
const f = (this.host._markerDialog?.bindingFilter || '').toLowerCase().trim();
|
||
const filtered = f
|
||
? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f))
|
||
: list;
|
||
filtered.sort((a, b) => a.label.localeCompare(b.label));
|
||
return filtered.slice(0, 200);
|
||
}
|
||
|
||
public _keepClosedAsPartitions = (): void => {
|
||
if (this.host._wallFaceBatch) {
|
||
this._decideWallFace(false);
|
||
return;
|
||
}
|
||
if (!this.host._contourClosed || this.host._pendingSplit || !this.host._curSpaceCfg) return;
|
||
// Every accepted edge is already an ordinary partition. Rejecting all
|
||
// detected faces only ends the session; it must not duplicate the chain.
|
||
if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return;
|
||
this.host._roomDialog = false;
|
||
this.host._path = [];
|
||
this.host._activeWallChainId = null;
|
||
this.host._activeWallChainPartitionIds = [];
|
||
this.host._wallChainSegmentCms = [];
|
||
this.host._wallChainRedo = [];
|
||
this.host._closingWallCm = null;
|
||
this.host.requestUpdate();
|
||
};
|
||
|
||
public _backupErrorText(e: any): string {
|
||
const code = e?.code ?? e?.error;
|
||
if (typeof code === 'string') {
|
||
const key = `backup.error.${code}`;
|
||
const translated = this.host._t(key as I18nKey);
|
||
if (translated !== key) return translated;
|
||
}
|
||
return this.host._errText(e);
|
||
}
|
||
|
||
public async _pickMarkerFiles(ev: Event): Promise<void> {
|
||
const input = ev.target as HTMLInputElement;
|
||
const files = input.files ? [...input.files] : [];
|
||
input.value = '';
|
||
if (!files.length || !this.host._markerDialog) return;
|
||
const mid = this.host._markerDialog.uploadId || this.host._markerDialog.devId || 'new';
|
||
const uploaded: PdfRef[] = [];
|
||
for (const file of files) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('marker_id', mid);
|
||
fd.append('file', file, file.name);
|
||
// fetchWithAuth refreshes a stale access_token itself; the fallback is the raw token
|
||
const resp: Response = this.host.hass?.fetchWithAuth
|
||
? await this.host.hass.fetchWithAuth('/api/houseplan/upload', { method: 'POST', body: fd })
|
||
: await fetch('/api/houseplan/upload', {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: this.host.hass?.auth?.data?.access_token
|
||
? { authorization: `Bearer ${this.host.hass.auth.data.access_token}` }
|
||
: {},
|
||
});
|
||
const json = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || json.error) {
|
||
const map: Record<string, string> = {
|
||
too_large: this.host._t('err.too_large', { mb: json.max_mb || 50 }),
|
||
bad_ext: this.host._t('err.bad_ext'),
|
||
unauthorized: this.host._t('err.unauthorized'),
|
||
};
|
||
throw new Error(map[json.error] || json.error || 'HTTP ' + resp.status);
|
||
}
|
||
uploaded.push({ name: json.name || file.name, url: json.url });
|
||
} catch (e: any) {
|
||
this.host._showToast(this.host._t('toast.file_failed', { name: file.name, err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
// the dialog might have closed during the upload — add only if it is still open
|
||
if (uploaded.length && this.host._markerDialog) {
|
||
this.host._markerDialog = { ...this.host._markerDialog, pdfs: [...this.host._markerDialog.pdfs, ...uploaded] };
|
||
this.host._showToast(this.host._t('toast.files_attached', { n: uploaded.length }));
|
||
}
|
||
}
|
||
|
||
public _removeMarkerPdf(url: string): void {
|
||
if (!this.host._markerDialog) return;
|
||
this.host._markerDialog = {
|
||
...this.host._markerDialog,
|
||
pdfs: this.host._markerDialog.pdfs.filter((p) => p.url !== url),
|
||
};
|
||
}
|
||
|
||
public _markerLightFields(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): Partial<Marker> {
|
||
const fields: Partial<Marker> = {};
|
||
if (!d.lightRoleTouched) {
|
||
if (d.originalHasIsLight) fields.is_light = d.originalIsLight ?? null;
|
||
} else if (d.lightRole === 'always') fields.is_light = true;
|
||
else if (d.lightRole === 'never') fields.is_light = false;
|
||
|
||
if (!d.lightEntityTouched) {
|
||
if (d.originalHasLightEntity) fields.light_entity = d.originalLightEntity ?? null;
|
||
} else if (d.lightEntity) {
|
||
fields.light_entity = d.lightEntity;
|
||
}
|
||
|
||
if (!d.glowTouched) {
|
||
if (d.originalHasGlowColor) fields.glow_color = d.originalGlowColor ?? null;
|
||
} else if (d.glowMode !== 'auto') {
|
||
const glow: NonNullable<Marker['glow_color']> = {
|
||
c: safeStoredColor(d.glowColor, this.host._fillColors.glow_light.c),
|
||
};
|
||
if (d.glowMode === 'fixed') {
|
||
glow.bri = Math.max(0.01, Math.min(1, Math.round(d.glowBrightness) / 100));
|
||
}
|
||
fields.glow_color = glow;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
public _markerTapActionFields(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): Pick<Marker, 'tap_action'> | Record<string, never> {
|
||
if (!d.tapActionTouched) {
|
||
return d.originalHasTapAction ? { tap_action: d.originalTapAction ?? null } : {};
|
||
}
|
||
return { tap_action: d.tapAction || null };
|
||
}
|
||
|
||
public _markerToggleEntityFields(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): Pick<Marker, 'toggle_entity'> | Record<string, never> {
|
||
return toggleEntityWriteFields({
|
||
touched: d.toggleEntityTouched,
|
||
originalHas: d.originalHasToggleEntity,
|
||
original: d.originalToggleEntity,
|
||
value: d.toggleEntity,
|
||
});
|
||
}
|
||
|
||
public async _saveMarker(): Promise<void> {
|
||
const dlg = this.host._markerDialog;
|
||
if (!dlg || dlg.busy) return;
|
||
const effectiveTapAction = this._effectiveMarkerTapAction(dlg);
|
||
if (dlg.bindingMode === 'ha' && (!dlg.binding || dlg.binding === 'virtual')) return;
|
||
if (dlg.binding === 'virtual' && !dlg.name.trim()) return;
|
||
if (effectiveTapAction === 'run' && !dlg.tapTarget) return;
|
||
if (dlg.valueBadgeTouched && dlg.valueBadgeEnabled && !dlg.valueBadgeSource) return;
|
||
if (dlg.bindingMode === 'ha') {
|
||
const status = this.host._bindingStatus(dlg.binding);
|
||
const previous = dlg.devId
|
||
? this.host._markers.find((marker) => marker.id === dlg.devId) : null;
|
||
const replacingWithInactive = !previous || previous.binding !== dlg.binding;
|
||
if (status.kind !== 'active' && replacingWithInactive) {
|
||
this.host._showToast(this.host._t(status.kind === 'ha_disabled'
|
||
? 'toast.ha_disabled_add' : 'toast.ha_binding_unverified'));
|
||
return;
|
||
}
|
||
// Saving metadata for an already-disabled binding is allowed, but a
|
||
// blocked Show attempt may never erase an older explicit user hidden.
|
||
if (status.kind === 'ha_disabled' && previous?.hidden === true && !dlg.hideFromPlan) {
|
||
this.host._markerDialog = { ...dlg, hideFromPlan: true };
|
||
this.host._showToast(this.host._t(status.reason === 'entity'
|
||
? 'toast.ha_disabled_show_entity' : 'toast.ha_disabled_show_device'));
|
||
return;
|
||
}
|
||
}
|
||
const cfg = this.host._serverCfg;
|
||
if (!cfg) return;
|
||
const baseRevision = this.host._cfgRev;
|
||
const baseContent = contentFingerprint(cfg);
|
||
let candidate = JSON.parse(JSON.stringify(cfg)) as ServerConfig;
|
||
const markers = candidate.markers || [];
|
||
const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
|
||
const oldId = dlg.devId;
|
||
const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null;
|
||
const previousMarker = oldId ? markers.find((candidate) => candidate.id === oldId) : null;
|
||
const previousExplicit = !!previousMarker && (
|
||
(typeof previousMarker.area === 'string' && previousMarker.area.length > 0)
|
||
|| (previousMarker.area === null && !!previousMarker.space && !!previousMarker.room_id)
|
||
);
|
||
// Opening a registry-following marker shows its effective HA room in the
|
||
// select, but that display value is not an explicit override. Preserve
|
||
// absence until the user actually changes the selector; this also prevents
|
||
// a dialog opened during an HA Area transition from saving its stale draft.
|
||
const writePlacement = dlg.binding === 'virtual' || dlg.roomTouched || previousExplicit;
|
||
const roomRef = dlg.binding === 'virtual' || dlg.roomTouched ? parseRoomRef(dlg.room) : null;
|
||
let space: string | null = previousExplicit && !dlg.roomTouched
|
||
? previousMarker?.space || null : roomRef?.space || null;
|
||
const area: string | null = previousExplicit && !dlg.roomTouched
|
||
? previousMarker?.area || null : roomRef?.area || null;
|
||
const roomId: string | null = previousExplicit && !dlg.roomTouched
|
||
? previousMarker?.room_id || null : roomRef?.roomId || null;
|
||
const explicitSpaceId = space || prevDev?.space || null;
|
||
const targetSpaceModel = explicitSpaceId
|
||
? this.host._spaceModelById(explicitSpaceId)
|
||
: this.host._spaceModel();
|
||
if (!targetSpaceModel) return;
|
||
const targetSpaceId = targetSpaceModel.id;
|
||
if (dlg.binding === 'virtual' && !space) space = targetSpaceId;
|
||
let radarField: Pick<Marker, 'radar'> | Record<string, never> = {};
|
||
if (dlg.radarTouched) {
|
||
if (dlg.radarRemove) {
|
||
radarField = { radar: null };
|
||
} else if (dlg.radar) {
|
||
const radar = radarConfigFromDraft(dlg.radar, targetSpaceModel.cellCm || 5);
|
||
if (!radar) {
|
||
this.host._showToast(this.host._t('radar.invalid'));
|
||
return;
|
||
}
|
||
radarField = { radar };
|
||
}
|
||
} else if (previousMarker && Object.prototype.hasOwnProperty.call(previousMarker, 'radar')) {
|
||
radarField = { radar: previousMarker.radar };
|
||
}
|
||
this.host._markerDialog = { ...dlg, busy: true };
|
||
let attempt: OptimisticAttempt<ServerConfig> | null = null;
|
||
let configAccepted = false;
|
||
try {
|
||
const replacedRemovedIds = dlg.binding === 'virtual'
|
||
? []
|
||
: markers
|
||
.filter((m) => m.removed && m.binding === dlg.binding)
|
||
.map((m) => m.id);
|
||
const replacingRemoved = replacedRemovedIds.length > 0;
|
||
const controls = persistedExternalControls(
|
||
dlg.binding, dlg.controls, this.host._bindingEntities(dlg.binding),
|
||
);
|
||
// the vacuum block is edited live outside the dialog transaction —
|
||
// the rebuild below must carry it over, not erase it
|
||
const prevVac = markers.find((m0: Marker) => m0.id === id || m0.id === oldId)?.vacuum || null;
|
||
const marker: Marker = {
|
||
id,
|
||
vacuum: prevVac,
|
||
...radarField,
|
||
binding: dlg.binding,
|
||
name: dlg.name.trim() || null,
|
||
icon: dlg.icon || null,
|
||
display: dlg.display !== 'badge' ? dlg.display : null,
|
||
ripple_color: dlg.display === 'icon_ripple' && dlg.rippleColor ? dlg.rippleColor : null,
|
||
ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 1.5 ? dlg.rippleSize : null,
|
||
size: dlg.size !== 1 ? dlg.size : null,
|
||
angle: dlg.angle ? dlg.angle : null,
|
||
...this._markerTapActionFields(dlg),
|
||
...this._markerToggleEntityFields(dlg),
|
||
tap_target: effectiveTapAction === 'run' ? dlg.tapTarget || null : null,
|
||
tap_confirm: dlg.tapConfirm ? true : null,
|
||
controls: controls.length ? controls : null,
|
||
// pdfs may be rewritten below when rebinding changes the marker id
|
||
...this._markerLightFields(dlg),
|
||
...this._markerValueBadgeFields(dlg),
|
||
...this._markerValueSourceFields(dlg),
|
||
use_climate_temp: dlg.useClimateTemp ? true : null,
|
||
glow_radius_cm: (() => {
|
||
const v = strictNumber(dlg.glowRadius);
|
||
if (v == null || v <= 0) return null;
|
||
return Math.round(this.host._imperial ? v * 30.48 : v * 100);
|
||
})(),
|
||
model: dlg.model.trim() || null,
|
||
link: dlg.link.trim() || null,
|
||
description: dlg.description.trim() || null,
|
||
pdfs: dlg.pdfs,
|
||
// Explicit false, not absence: a marker of any kind is what tells the
|
||
// seeder "the user decided" — unticking must not invite a re-hide
|
||
// (docs/FILTERING.md).
|
||
hidden: dlg.hideFromPlan ? true : false,
|
||
};
|
||
// save the room choice (always for virtual ones; for bound ones — if chosen)
|
||
if (writePlacement) {
|
||
marker.space = space;
|
||
marker.area = area;
|
||
marker.room_id = roomId;
|
||
}
|
||
// the room changed → move the icon to its center
|
||
const prevRoomId = prevDev?.marker?.room_id ?? null;
|
||
const roomChanged = dlg.roomTouched && prevDev != null
|
||
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
|
||
// Rebinding changes the marker id, so the uploaded files must follow.
|
||
// Order matters (review CR-2): COPY first, save the config, and only then
|
||
// delete the old folder. If the save is rejected, the old urls in the
|
||
// stored config still resolve — the files never left. A failed copy
|
||
// leaves the urls untouched and tells the user (review CR-3).
|
||
let cleanupOldFiles = false;
|
||
// a new icon uploaded into its own staging folder; an edited one into its
|
||
// own id. Either way the files move to the final id here.
|
||
const fileSrc = dlg.uploadId || oldId;
|
||
if (fileSrc && fileSrc !== id && marker.pdfs?.length) {
|
||
try {
|
||
const res: any = await this.host.hass.callWS({
|
||
type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id,
|
||
});
|
||
const mapping = res?.mapping || {};
|
||
marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping);
|
||
cleanupOldFiles = Object.keys(mapping).length > 0;
|
||
} catch (e: any) {
|
||
this.host._showToast(this.host._t('toast.files_migrate_failed', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
// Rebinding changes source identity. Rewrite every marker:* edge in the
|
||
// same config transaction before replacing the marker itself.
|
||
candidate.markers = markers;
|
||
if (oldId && oldId !== id)
|
||
candidate.markers = rewriteMarkerControlReferences(candidate.markers, oldId, id);
|
||
if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state'
|
||
&& marker.value_badge.source.ref === `marker:${oldId}`) {
|
||
marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` };
|
||
}
|
||
if (oldId && oldId !== id && marker.value_source?.kind === 'derived_marker_state'
|
||
&& marker.value_source.ref === `marker:${oldId}`) {
|
||
marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` };
|
||
}
|
||
// remove the previous marker (by the old id and by the new id)
|
||
candidate.markers = candidate.markers.filter(
|
||
(m) => m.id !== id && m.id !== oldId
|
||
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
|
||
);
|
||
candidate.markers.push(marker);
|
||
const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds);
|
||
if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId);
|
||
obsoleteAreaSnapshotIds.delete(id);
|
||
if (obsoleteAreaSnapshotIds.size && candidate.settings?.marker_area_snapshot) {
|
||
candidate.settings = {
|
||
...candidate.settings,
|
||
marker_area_snapshot: removeMarkerAreaSnapshots(
|
||
candidate.settings.marker_area_snapshot, obsoleteAreaSnapshotIds,
|
||
),
|
||
};
|
||
}
|
||
// Position rule (owner's decision, v1.33.4): editing an existing icon —
|
||
// rebinding it to another HA device/entity or to another room — must NOT
|
||
// move it. Its current position (saved or the ephemeral auto one) is
|
||
// migrated to the new marker id. Only two cases still center the icon:
|
||
// a truly NEW icon, and a move to a room in a DIFFERENT space (keeping
|
||
// the old coordinates there would be meaningless).
|
||
// Write POINT-WISE (layout/update), not the whole layout — a full layout/set
|
||
// overwrites positions changed in other windows (the v1.4.4 incident).
|
||
let newPos: { s: string; x: number; y: number } | null = null;
|
||
const prevRec = oldId ? this.host._layout[oldId] : null;
|
||
const prevPos = prevRec
|
||
? { s: prevRec.s || prevDev?.space || this.host._space, x: prevRec.x, y: prevRec.y }
|
||
: oldId && prevDev && this.host._defPos[oldId]
|
||
? this.host._normPos(prevDev.space, this.host._defPos[oldId].x, this.host._defPos[oldId].y)
|
||
: null;
|
||
if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId) {
|
||
// stays in place; pin it under the (possibly new) id
|
||
if (id !== oldId || !this.host._layout[id] || roomChanged) {
|
||
newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y };
|
||
}
|
||
} else if (replacingRemoved || !this.host._layout[id] || roomChanged) {
|
||
let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2;
|
||
let cy = targetSpaceModel.vb[1] + targetSpaceModel.vb[3] / 2;
|
||
const room = roomId
|
||
? targetSpaceModel.rooms.find((r) => r.id === roomId)
|
||
: area
|
||
? targetSpaceModel.rooms.find((r) => r.area === area)
|
||
: undefined;
|
||
if (room) [cx, cy] = this.host._roomCenter(room);
|
||
newPos = this.host._normPos(targetSpaceId, cx, cy);
|
||
}
|
||
// File copy is async: never install a candidate built from a replaced root.
|
||
if (this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision || contentFingerprint(cfg) !== baseContent) {
|
||
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.conflict'));
|
||
return;
|
||
}
|
||
candidate = this._prepareConfigCandidate(candidate);
|
||
attempt = this.host._adoption.beginOptimistic(cfg, candidate);
|
||
this.host._serverCfg = candidate;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.cancel();
|
||
await this._saveConfigNow(attempt);
|
||
configAccepted = true;
|
||
if (newPos) {
|
||
const pos = canonicalizePosition(newPos);
|
||
this.host._layout = { ...this.host._layout, [id]: pos };
|
||
this.host._noteLayoutRev(await this.host.hass.callWS({
|
||
type: 'houseplan/layout/update', device_id: id, pos,
|
||
}));
|
||
}
|
||
const obsoleteIds = new Set(replacedRemovedIds);
|
||
if (oldId && oldId !== id) obsoleteIds.add(oldId);
|
||
obsoleteIds.delete(id);
|
||
for (const obsoleteId of obsoleteIds) {
|
||
// Rebinding or replacing a legacy tombstone changed the icon id.
|
||
delete this.host._layout[obsoleteId];
|
||
await this.host.hass.callWS({ type: 'houseplan/layout/delete', device_id: obsoleteId })
|
||
.then((r: any) => this.host._noteLayoutRev(r)).catch(() => undefined);
|
||
}
|
||
// the config is committed — now it is safe to drop the old folder
|
||
if (cleanupOldFiles && fileSrc) {
|
||
await this.host.hass
|
||
.callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc })
|
||
.catch(() => undefined); // leftovers are harmless; broken links are not
|
||
}
|
||
forgetMarkerBaseline(this.host);
|
||
this._closeMarkerDialog();
|
||
this.host._cancelDeviceDrag();
|
||
this.host._devicePositionHistory.clear();
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host._showToast(this.host._t('toast.marker_saved'));
|
||
} catch (e: any) {
|
||
// audit L3: the dialog may have been closed (Esc) while the save was
|
||
// in flight — spreading null yields a truthy husk and the renderer
|
||
// then crashes, blanking the whole card. The toast below is the
|
||
// only remaining signal, so it must still fire.
|
||
if (!configAccepted && attempt) {
|
||
this.host._rollbackOptimistic(attempt);
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
}
|
||
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public async _deleteMarker(): Promise<void> {
|
||
const dlg = this.host._markerDialog;
|
||
if (!dlg || dlg.busy || !dlg.devId) return;
|
||
const d = dlg.devId ? this.host._devices.find((x) => x.id === dlg.devId) : null;
|
||
const persisted = this.host._markers.find((marker) => marker.id === dlg.devId);
|
||
if (!d && !persisted) return;
|
||
const expectedBinding = d
|
||
? d.bindingKind === 'virtual' ? 'virtual'
|
||
: d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : ''
|
||
: persisted!.binding;
|
||
if (!expectedBinding) return;
|
||
const label = dlg.name || this.host._t('device.fallback');
|
||
const targetId = dlg.devId;
|
||
const accepted = await this.host._confirmDanger({
|
||
key: 'remove-marker',
|
||
kind: 'destructive',
|
||
title: this.host._t('confirm.remove_marker_title'),
|
||
message: this.host._t('confirm.remove_marker_body'),
|
||
objectName: label,
|
||
confirmLabel: this.host._t('btn.delete'),
|
||
cancelLabel: this.host._t('btn.cancel'),
|
||
});
|
||
const currentDialog = this.host._markerDialog;
|
||
if (!accepted || !currentDialog || currentDialog.busy || currentDialog.devId !== targetId) return;
|
||
const currentDevice = this.host._devices.find((item) => item.id === targetId);
|
||
const currentPersisted = this.host._markers.find((marker) => marker.id === targetId);
|
||
if (!currentDevice && !currentPersisted) return;
|
||
const cfg = this.host._serverCfg;
|
||
if (!cfg) return;
|
||
cfg.markers = cfg.markers || [];
|
||
const previousMarkers = cfg.markers;
|
||
const binding = currentDevice
|
||
? currentDevice.bindingKind === 'virtual' ? 'virtual'
|
||
: currentDevice.bindingKind && currentDevice.bindingRef
|
||
? `${currentDevice.bindingKind}:${currentDevice.bindingRef}` : ''
|
||
: currentPersisted!.binding;
|
||
if (!binding || binding !== expectedBinding) return;
|
||
const deletion = deletePlanMarkerRecords(
|
||
cfg.markers, targetId, binding, binding === 'virtual',
|
||
);
|
||
cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds);
|
||
const cleanupIds = deletion.cleanupIds;
|
||
if (cleanupIds.size && cfg.settings?.marker_area_snapshot) {
|
||
cfg.settings = {
|
||
...cfg.settings,
|
||
marker_area_snapshot: removeMarkerAreaSnapshots(
|
||
cfg.settings.marker_area_snapshot, cleanupIds,
|
||
),
|
||
};
|
||
}
|
||
this.host._markerDialog = { ...currentDialog, busy: true };
|
||
try {
|
||
await this._saveConfigNow();
|
||
// Housekeeping follows the durable config write. Every call is
|
||
// idempotent and best-effort: the device is already deleted even if an
|
||
// old integration cannot yet serve one of the cleanup commands.
|
||
for (const id of cleanupIds) {
|
||
delete this.host._layout[id];
|
||
delete this.host._defPos[id];
|
||
this.host._dirtyPos.delete(id);
|
||
this.host._sentPos.delete(id);
|
||
const activity = this.host._activityRt.get(id);
|
||
if (activity) clearTimeout(activity.timer);
|
||
this.host._activityRt.delete(id);
|
||
this.host._vacRt.delete(id);
|
||
delete this.host._vacSrvTrails[id];
|
||
await this.host.hass.callWS({ type: 'houseplan/layout/delete', device_id: id })
|
||
.then((r: any) => this.host._noteLayoutRev(r)).catch(() => undefined);
|
||
await this.host.hass.callWS({ type: 'houseplan/files/cleanup', marker_id: id })
|
||
.catch(() => undefined);
|
||
await this.host.hass.callWS({ type: 'houseplan/trail/delete', marker_id: id })
|
||
.catch(() => undefined);
|
||
}
|
||
if (this.host._deviceInboxReturn) {
|
||
this.host._deviceInboxReturn = { ...this.host._deviceInboxReturn, tab: 'readd', anchor: binding };
|
||
}
|
||
this._closeMarkerDialog();
|
||
this.host._cancelDeviceDrag();
|
||
this.host._devicePositionHistory.clear();
|
||
if (this.host._infoCard?.id === targetId) this.host._closeInfoCard();
|
||
if (this.host._selId === targetId) this.host._selId = null;
|
||
if (this.host._drag?.id === targetId) this.host._drag = null;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host._showToast(this.host._t('toast.marker_removed'));
|
||
} catch (e: any) {
|
||
if (this.host._serverCfg === cfg) cfg.markers = previousMarkers;
|
||
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
|
||
if (!this.host._serverStorage || !this.host._serverCfg) {
|
||
this.host._showToast(this.host._t('toast.integration_missing'));
|
||
return;
|
||
}
|
||
if (mode === 'edit') {
|
||
const sp = this.host._serverCfg!.spaces.find((x: any) => x.id === spaceId);
|
||
if (!sp) return;
|
||
const disp = spaceDisplayOf(sp);
|
||
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
|
||
? customFillOf(sp.settings.custom_fill) : null;
|
||
// Space-level None is no longer a separate UI choice. Project it to the
|
||
// same custom colour at zero opacity, so merely opening/saving settings
|
||
// cannot change either the visible floor or the Glow darkness.
|
||
const dialogCustom = disp.fill === 'none'
|
||
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
|
||
: storedCustom;
|
||
this.host._spaceDialog = {
|
||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||
source: sp.plan_url ? 'file' : 'draw',
|
||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||
zeroWallStyle: zeroWallStyleOf(sp),
|
||
displayTouched: true,
|
||
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
|
||
roomColor: disp.color, roomOpacity: disp.opacity,
|
||
// `none` is a legacy space token. The current editor represents its
|
||
// exact appearance as a transparent user colour.
|
||
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
|
||
customFill: dialogCustom,
|
||
glowEnabled: disp.glow,
|
||
bgColor: disp.bgColor,
|
||
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
|
||
northDeg: northDegOf({}, sp.settings),
|
||
northDegInput: northDegOf({}, sp.settings) === null ? '' : String(northDegOf({}, sp.settings)),
|
||
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
|
||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||
tempMinInput: String(disp.tempMin), tempMaxInput: String(disp.tempMax),
|
||
showLqi: disp.showLqi ?? this.host._config?.show_signal ?? true,
|
||
cardFontScale: disp.cardFontScale,
|
||
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
|
||
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
|
||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||
cellCmInput: gridCellFieldValue(
|
||
Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this.host._imperial,
|
||
),
|
||
cellCmTouched: false,
|
||
busy: false,
|
||
};
|
||
} else {
|
||
this.host._spaceDialog = {
|
||
mode, title: '', planUrl: null, planFile: null,
|
||
...initialSpaceDisplayDraft(),
|
||
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
|
||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||
// `custom` replaces the old `none` choice in the editor, but creating
|
||
// a space must preserve the old no-floor visual by default. The user
|
||
// can make this colour visible explicitly with the opacity control.
|
||
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
|
||
glowEnabled: true,
|
||
bgColor: null,
|
||
bgMode: 'daynight', northDeg: null, northDegInput: '', sunRays: null,
|
||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||
tempMinInput: String(DEFAULT_TEMP_MIN), tempMaxInput: String(DEFAULT_TEMP_MAX),
|
||
showLqi: this.host._config?.show_signal ?? true,
|
||
cardFontScale: 1,
|
||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||
cellCm: newSpaceCellCm(this.host._imperial),
|
||
cellCmInput: gridCellFieldValue(newSpaceCellCm(this.host._imperial), this.host._imperial),
|
||
cellCmTouched: false,
|
||
busy: false,
|
||
};
|
||
}
|
||
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
|
||
if (this.host._spaceDialog) rememberSpaceDialogBaseline(this.host, this.host._spaceDialog);
|
||
}
|
||
|
||
public async _pickPlanFile(ev: Event): Promise<void> {
|
||
const input = ev.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file || !this.host._spaceDialog) return;
|
||
// #39: re-selecting the same file after a guard decision must fire again.
|
||
input.value = '';
|
||
this._decorAssetGuardReplace = null;
|
||
const classified = await classifyPlanFile(file);
|
||
if (classified.kind === 'reject') {
|
||
this.host._showToast(this.host._t('toast.plan_formats'));
|
||
return;
|
||
}
|
||
if (classified.kind === 'guard') {
|
||
this.host._backdropGuard = classified.state;
|
||
return;
|
||
}
|
||
const payload = await encodePlanFile(file, classified.ext, file.name);
|
||
if (!this.host._spaceDialog) return;
|
||
this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload };
|
||
}
|
||
|
||
public _renderBackdropGuard(): TemplateResult | typeof nothing {
|
||
const decorReplace = this._decorAssetGuardReplace;
|
||
if (decorReplace !== null) {
|
||
return renderBackdropGuard(
|
||
this.host,
|
||
() => undefined,
|
||
() => {
|
||
this.host._backdropGuard = null;
|
||
this._decorAssetGuardReplace = null;
|
||
},
|
||
this.host.hass,
|
||
(blob, name) => this._uploadDecorImage(blob, name, decorReplace),
|
||
(this.host._backdropGuard?.file.size || 0) <= 2 * 1024 * 1024,
|
||
) ?? nothing;
|
||
}
|
||
return renderBackdropGuard(
|
||
this.host,
|
||
(payload) => {
|
||
if (this.host._spaceDialog) {
|
||
this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload };
|
||
}
|
||
},
|
||
() => { this.host._backdropGuard = null; },
|
||
this.host.hass,
|
||
) ?? nothing;
|
||
}
|
||
|
||
public _toggleServerPlans = async (): Promise<void> => {
|
||
const d = this.host._spaceDialog;
|
||
if (!d) return;
|
||
if (d.pickSaved) {
|
||
this.host._spaceDialog = { ...d, pickSaved: false };
|
||
return;
|
||
}
|
||
this.host._spaceDialog = { ...d, pickSaved: true, savedBusy: true };
|
||
try {
|
||
const r: any = await this.host.hass.callWS({ type: 'houseplan/plans/list' });
|
||
const cur = this.host._spaceDialog;
|
||
if (cur) this.host._spaceDialog = { ...cur, saved: r?.plans || [], savedBusy: false };
|
||
} catch (e: any) {
|
||
const cur = this.host._spaceDialog;
|
||
if (cur) this.host._spaceDialog = { ...cur, saved: [], savedBusy: false };
|
||
this.host._showToast(this.host._t('toast.plans_list_failed', { err: this.host._errText(e) }));
|
||
}
|
||
};
|
||
|
||
public _useServerPlan(url: string): void {
|
||
const d = this.host._spaceDialog;
|
||
if (!d) return;
|
||
// Attach immediately — the click should not wait for anything. The OLD
|
||
// file's proportions go right away: they describe the previous image, and
|
||
// a Save racing the read must get "unknown", never "the wrong shape".
|
||
this.host._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined };
|
||
this.host._aspectJob = this._readPlanAspect(url);
|
||
}
|
||
|
||
public async _readPlanAspect(url: string): Promise<number> {
|
||
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
|
||
const src = this.host._display(url);
|
||
if (src) {
|
||
const ratio = await new Promise<number>((res) => {
|
||
const img = new Image();
|
||
img.onload = () => res(img.naturalWidth && img.naturalHeight
|
||
? img.naturalWidth / img.naturalHeight : 0);
|
||
img.onerror = () => res(0);
|
||
img.src = src;
|
||
});
|
||
const cur = this.host._spaceDialog;
|
||
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
|
||
this.host._spaceDialog = { ...cur, savedAspect: ratio };
|
||
return ratio;
|
||
}
|
||
return 0;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
if (this.host._spaceDialog?.planUrl !== url) return 0; // the user moved on
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
public async _deleteServerPlan(name: string): Promise<void> {
|
||
const dialog = this.host._spaceDialog;
|
||
const plan = dialog?.saved?.find((candidate) => candidate.name === name);
|
||
if (!dialog || !plan || plan.used_by.length || plan.url === dialog.planUrl) return;
|
||
const accepted = await this.host._confirmDanger({
|
||
key: 'delete-plan',
|
||
kind: 'destructive',
|
||
title: this.host._t('confirm.delete_plan_title'),
|
||
message: this.host._t('confirm.delete_plan_body'),
|
||
objectName: name,
|
||
confirmLabel: this.host._t('btn.delete'),
|
||
cancelLabel: this.host._t('btn.cancel'),
|
||
});
|
||
const currentDialog = this.host._spaceDialog;
|
||
const currentPlan = currentDialog?.saved?.find((candidate) => candidate.name === name);
|
||
if (!accepted || !currentDialog || !currentPlan
|
||
|| currentPlan.url !== plan.url || currentPlan.modified !== plan.modified
|
||
|| currentPlan.used_by.length || currentPlan.url === currentDialog.planUrl) return;
|
||
try {
|
||
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
|
||
const d = this.host._spaceDialog;
|
||
if (d?.saved) this.host._spaceDialog = { ...d, saved: d.saved.filter((p) => p.name !== name) };
|
||
} catch (e: any) {
|
||
this.host._showToast(this.host._t('toast.plan_delete_failed', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public _renderServerPlans(d: NonNullable<typeof this.host._spaceDialog>): TemplateResult {
|
||
if (d.savedBusy) return html`<div class="savedplans muted">${this.host._t('space.loading')}</div>`;
|
||
const list = d.saved || [];
|
||
if (!list.length) return html`<div class="savedplans muted">${this.host._t('space.no_saved')}</div>`;
|
||
const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB');
|
||
return html`<div class="savedplans">
|
||
${list.map((p) => html`
|
||
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
|
||
<img src=${this.host._display(p.url)} alt="" loading="lazy" decoding="async" />
|
||
<div class="savedmeta">
|
||
<b>${p.name}</b>
|
||
<span class="muted">${kb(p.size)}${p.used_by.length
|
||
? ' · ' + this.host._t('space.used_by', { list: p.used_by.join(', ') })
|
||
: ''}</span>
|
||
</div>
|
||
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
||
?disabled=${p.url === d.planUrl}>${this.host._t('btn.use')}</button>
|
||
<button class="btn ghost danger"
|
||
title=${p.used_by.length || p.url === d.planUrl ? this.host._t('space.in_use') : this.host._t('btn.delete')}
|
||
?disabled=${p.used_by.length > 0 || p.url === d.planUrl}
|
||
@click=${() => this._deleteServerPlan(p.name)}>
|
||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||
</button>
|
||
</div>`)}
|
||
</div>`;
|
||
}
|
||
|
||
public async _saveSpaceDialog(): Promise<void> {
|
||
const d = this.host._spaceDialog;
|
||
if (!d || d.busy || !d.title.trim()) return;
|
||
if (spaceDialogProblems(d, 'space-dialog', this.host._imperial).length) return;
|
||
if (d.source === 'file' && !d.planFile && !d.planUrl) {
|
||
this.host._showToast(this.host._t('toast.plan_required'));
|
||
return;
|
||
}
|
||
const wasFirst = d.mode === 'create' && (this.host._serverCfg?.spaces.length || 0) === 0;
|
||
this.host._spaceDialog = { ...d, busy: true };
|
||
try {
|
||
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
|
||
|
||
/* Upload BEFORE touching the config, and never hold a reference to a
|
||
config object across an await. `houseplan/config/get` runs on every
|
||
`houseplan_config_updated` event and REPLACES `_serverCfg`; a space
|
||
object captured before the upload is then detached, so plan_url,
|
||
aspect and settings were written into an orphan and the save shipped
|
||
the untouched config. Symptom: the file lands on disk, the plan never
|
||
appears, and re-saving does not help (owner's install, 2026-07-27). */
|
||
let uploaded: { url: string; aspect: number } | null = null;
|
||
if (d.source === 'file' && d.planFile) {
|
||
const resp = await this.host.hass.callWS({
|
||
type: 'houseplan/plan/set', space_id: spaceId, ext: d.planFile.ext, data: d.planFile.b64,
|
||
});
|
||
uploaded = { url: resp.url, aspect: d.planFile.aspect };
|
||
}
|
||
|
||
// A plan picked from the server list reads its proportions from the
|
||
// image, and Save used to outrun that read: the snapshot still carried
|
||
// the PREVIOUS file's ratio and a wide plan came out at the old shape
|
||
// for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and
|
||
// the dialog is already busy. Unknown stays unknown, never the old
|
||
// value: a square fallback is honest, an inherited ratio is not.
|
||
let pickedAspect: number | null = d.savedAspect || null;
|
||
if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this.host._aspectJob) {
|
||
pickedAspect = (await this.host._aspectJob) || null;
|
||
}
|
||
|
||
// from here on: no awaits until the save, so `sp` cannot be orphaned
|
||
const cfg = this.host._serverCfg!;
|
||
let sp: any;
|
||
if (d.mode === 'create') {
|
||
sp = createEmptySpaceConfig(spaceId, d.title.trim());
|
||
cfg.spaces.push(sp);
|
||
} else {
|
||
sp = cfg.spaces.find((x: any) => x.id === spaceId);
|
||
if (!sp) throw new Error('space ' + spaceId + ' is gone from the config');
|
||
sp.title = d.title.trim();
|
||
}
|
||
if (uploaded) {
|
||
sp.plan_url = uploaded.url;
|
||
// the image's own proportions, so it can be centred before it loads
|
||
sp.plan_aspect = uploaded.aspect;
|
||
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
|
||
// picked from the server list: no upload, just a reference — and the
|
||
// previous image's proportions never survive the switch
|
||
sp.plan_url = d.planUrl;
|
||
sp.plan_aspect = pickedAspect;
|
||
}
|
||
// switching an existing space to "draw" detaches its background image
|
||
// (the uploaded file stays on disk; only the reference is cleared).
|
||
// Its transform goes with it — there is nothing left for plan_x/plan_y/
|
||
// plan_scale to describe, and a stale one would silently apply to the
|
||
// NEXT picture uploaded here (docs/BACKDROP.md §1).
|
||
if (d.source === 'draw') {
|
||
sp.plan_url = null; sp.plan_aspect = null;
|
||
delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale;
|
||
delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle;
|
||
}
|
||
// Persist exactly what the dialog showed. Source defaults are projected
|
||
// visibly while editing the create state, never hidden here at Save.
|
||
sp.settings = {
|
||
...(sp.settings || {}),
|
||
show_borders: d.showBorders,
|
||
show_names: d.showNames,
|
||
// written only when ON: a plan that never hid anything stores nothing
|
||
hide_decor: d.hideDecor || undefined,
|
||
hide_openings: d.hideOpenings || undefined,
|
||
room_color: d.roomColor,
|
||
room_opacity: d.roomOpacity,
|
||
bg_color: d.bgColor || undefined, // empty = inherit the general setting
|
||
bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit
|
||
north_deg: d.northDeg ?? undefined,
|
||
sun_rays: d.sunRays ?? undefined,
|
||
fill_mode: d.fillMode,
|
||
custom_fill: d.customFill || undefined,
|
||
// Always materialize the independent value. In particular, editing a
|
||
// legacy fill_mode:'glow' space preserves its appearance atomically
|
||
// while replacing the old token with the selected data fill.
|
||
glow_enabled: d.glowEnabled,
|
||
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
||
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
||
show_lqi: d.showLqi,
|
||
card_font_scale: d.cardFontScale !== 1 ? d.cardFontScale : undefined,
|
||
label_temp: d.labelTemp,
|
||
label_hum: d.labelHum,
|
||
label_lqi: d.labelLqi,
|
||
label_light: d.labelLight,
|
||
};
|
||
// This is a geometry policy of the space, not a visual room setting.
|
||
sp.zero_wall_style = d.zeroWallStyle;
|
||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0
|
||
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5;
|
||
// Nothing to clean up from here: the backend collects the superseded
|
||
// file inside the same locked transaction that accepted this config
|
||
// (review R3-1). A cleanup driven from the client could not be ordered
|
||
// against another client's commit and deleted its freshly saved plan.
|
||
await this._saveConfigNow();
|
||
this.host._spaceDialog = null;
|
||
if (d.mode === 'create') this.host._commitSpace(sp.id);
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
if (this.host._importQueue.length) {
|
||
// floors-import wizard: proceed to the next floor
|
||
this._openNextImport();
|
||
} else if (wasFirst || this.host._importTotal > 0) {
|
||
// guide the user onward: straight into room markup mode
|
||
this.host._importTotal = 0;
|
||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
|
||
this._setMode('plan');
|
||
this.host._tool = 'draw';
|
||
this.host._path = [];
|
||
this.host._cursorPt = null;
|
||
this._primeDrawWallField();
|
||
this.host._showToast(this.host._t(wasFirst && !this.host._importTotal ? 'toast.space_added_onboard' : 'import.done'));
|
||
} else {
|
||
this.host._showToast(d.mode === 'create' ? this.host._t('toast.space_added') : this.host._t('toast.space_saved'));
|
||
// A freshly created space is empty — View has nothing to show. Open
|
||
// the plan editor (draw tool) so the user can mark rooms immediately,
|
||
// same as the first-space onboard path above.
|
||
if (d.mode === 'create') {
|
||
if (this.host._mode !== 'plan') this._setMode('plan');
|
||
else {
|
||
this.host._tool = 'draw';
|
||
this.host._path = [];
|
||
this.host._cursorPt = null;
|
||
this._primeDrawWallField();
|
||
this.host._saveNav();
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
// The dialog edits the reactive config optimistically before config/set.
|
||
// A rejected create used to leave that unsaved space on screen; the next
|
||
// delete then flushed the same malformed write and repeated its error.
|
||
// Re-adopt server truth for every failed space transaction. Conflict is
|
||
// already reloaded by _saveConfigNow; do not issue the same read twice.
|
||
// A lost response is safe too: if the server did commit, config/get
|
||
// returns it.
|
||
if (e?.code !== 'conflict') await this.host._reloadConfigOnly(true);
|
||
// audit L3: the dialog may have been closed (Esc) while the save was
|
||
// in flight — spreading null yields a truthy husk and the renderer
|
||
// then crashes, blanking the whole card. The toast below is the
|
||
// only remaining signal, so it must still fire.
|
||
if (this.host._spaceDialog) this.host._spaceDialog = { ...this.host._spaceDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public async _deleteSpace(): Promise<void> {
|
||
const d = this.host._spaceDialog;
|
||
if (!d || d.mode !== 'edit') return;
|
||
const serverCfg = this.host._serverCfg;
|
||
if (!serverCfg) return;
|
||
const sp = serverCfg.spaces.find((x: any) => x.id === d.spaceId);
|
||
const dependencies = collectSpaceMarkerDependencies(
|
||
serverCfg, this.host._layout || {}, d.spaceId || '',
|
||
);
|
||
const deletingLastSpace = serverCfg.spaces.length === 1
|
||
&& serverCfg.spaces[0]?.id === d.spaceId;
|
||
if (dependencies.count && !deletingLastSpace) {
|
||
this.host._spaceDialog = { ...d, deleteBlockers: dependencies.count };
|
||
return;
|
||
}
|
||
if (!sp) return;
|
||
const spaceId = d.spaceId!;
|
||
const accepted = await this.host._confirmDanger({
|
||
key: 'delete-space',
|
||
kind: 'destructive',
|
||
title: this.host._t('confirm.delete_space_title'),
|
||
message: spaceDeletionMessage(this.host._t('confirm.delete_space_body'),
|
||
this.host._t('confirm.delete_space_vac_routes'), dependencies.routeCount),
|
||
objectName: sp.title,
|
||
confirmLabel: this.host._t('btn.delete'),
|
||
cancelLabel: this.host._t('btn.cancel'),
|
||
});
|
||
const currentDialog = this.host._spaceDialog;
|
||
const currentConfig = this.host._serverCfg;
|
||
if (!accepted || !currentDialog || currentDialog.mode !== 'edit'
|
||
|| currentDialog.busy || currentDialog.spaceId !== spaceId || !currentConfig) return;
|
||
const currentSpace = currentConfig.spaces.find((candidate) => candidate.id === spaceId);
|
||
if (!currentSpace) return;
|
||
const currentDependencies = collectSpaceMarkerDependencies(
|
||
currentConfig, this.host._layout || {}, spaceId,
|
||
);
|
||
const currentlyDeletingLastSpace = currentConfig.spaces.length === 1
|
||
&& currentConfig.spaces[0]?.id === spaceId;
|
||
if (currentDependencies.count && !currentlyDeletingLastSpace) {
|
||
this.host._spaceDialog = {
|
||
...currentDialog, deleteBlockers: currentDependencies.count,
|
||
};
|
||
return;
|
||
}
|
||
this.host._spaceDialog = { ...currentDialog, deleteBlockers: 0, busy: true };
|
||
try {
|
||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
|
||
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
|
||
await this.host._writeChain;
|
||
await this.host.hass.callWS({
|
||
type: 'houseplan/space/delete',
|
||
space_id: spaceId,
|
||
expected_config_rev: this.host._cfgRev,
|
||
expected_layout_rev: this.host._layoutRev,
|
||
});
|
||
const [configResponse, layoutResponse] = await Promise.all([
|
||
this.host._getAuthoritativeConfig(),
|
||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||
]);
|
||
// #500: revisions come with the re-read bodies, never from the delete reply.
|
||
const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'space-delete', profile: 'post-write' });
|
||
// Asset wait: nothing adopted, the scheduled reload owns the tail (same as every reload path).
|
||
if (adopted.status !== 'adopted') { this.host._spaceDialog = { ...currentDialog, busy: false }; this.host.requestUpdate(); return; }
|
||
this.host._spaceDialog = null;
|
||
if (this.host._space === spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host._showToast(this.host._t('toast.space_deleted'));
|
||
} catch (e: any) {
|
||
if (e?.code === 'conflict' || e?.code === 'space_in_use') {
|
||
await Promise.all([this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly()]);
|
||
}
|
||
const refreshedConfig = this.host._serverCfg;
|
||
if (this.host._spaceDialog && refreshedConfig) {
|
||
const refreshed = collectSpaceMarkerDependencies(
|
||
refreshedConfig, this.host._layout || {}, spaceId,
|
||
);
|
||
const stillLastSpace = refreshedConfig.spaces.length === 1
|
||
&& refreshedConfig.spaces[0]?.id === spaceId;
|
||
this.host._spaceDialog = {
|
||
...this.host._spaceDialog,
|
||
busy: false,
|
||
deleteBlockers: stillLastSpace ? 0 : refreshed.count,
|
||
};
|
||
}
|
||
this.host._showToast(this.host._t('toast.delete_failed', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public async _saveConfigNow(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
|
||
this.host._cfgEpoch++;
|
||
try {
|
||
// same queue as the debounced writer: a dialog saving while a background
|
||
// write is still out must not race it into a self-inflicted conflict
|
||
await this._writeConfig(attempt);
|
||
} catch (e: any) {
|
||
if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite();
|
||
else if (e?.code === 'conflict') await this.host._reloadConfigOnly();
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
public _saveSpaceCopy(): Promise<void> {
|
||
return saveSpaceCopy(this.host, {
|
||
clearGeometryGesture: () => this._clearGeometryGesture(),
|
||
optimizeReferenceContext: () => this._optimizeReferenceContext(false),
|
||
reportPreflightFailure: (result, config) => this.optimizePlans.reportPreflightFailure(result, config),
|
||
saveConfigNow: (attempt) => this._saveConfigNow(attempt),
|
||
setMode: () => this._setMode('plan'),
|
||
showWallModelMigrationBlocked: (error) => this._showWallModelMigrationBlocked(error),
|
||
});
|
||
}
|
||
|
||
public _startImport(): void {
|
||
const dlg = this.host._importDialog;
|
||
if (!dlg) return;
|
||
const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name);
|
||
this.host._importDialog = null;
|
||
if (!titles.length) {
|
||
this._openSpaceDialog('create');
|
||
return;
|
||
}
|
||
this.host._importQueue = titles;
|
||
this.host._importTotal = titles.length;
|
||
this._openNextImport();
|
||
}
|
||
|
||
public _openNextImport(): void {
|
||
const title = this.host._importQueue.shift();
|
||
if (title === undefined) return;
|
||
this.host._spaceDialog = {
|
||
mode: 'create', title, planUrl: null, planFile: null,
|
||
...initialSpaceDisplayDraft(),
|
||
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
|
||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||
customFill: null,
|
||
glowEnabled: true,
|
||
bgColor: null,
|
||
bgMode: 'daynight', northDeg: null, northDegInput: '', sunRays: null,
|
||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||
tempMinInput: String(DEFAULT_TEMP_MIN), tempMaxInput: String(DEFAULT_TEMP_MAX),
|
||
showLqi: this.host._config?.show_signal ?? true,
|
||
cardFontScale: 1,
|
||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||
cellCm: newSpaceCellCm(this.host._imperial),
|
||
cellCmInput: gridCellFieldValue(newSpaceCellCm(this.host._imperial), this.host._imperial),
|
||
cellCmTouched: false,
|
||
busy: false,
|
||
};
|
||
}
|
||
|
||
public _skipImport(): void {
|
||
this.host._spaceDialog = null;
|
||
if (this.host._importQueue.length) this._openNextImport();
|
||
else if (this.host._importTotal > 0 && this.host._model.length) {
|
||
this.host._importTotal = 0;
|
||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
|
||
this._setMode('plan');
|
||
this.host._showToast(this.host._t('import.done'));
|
||
}
|
||
}
|
||
|
||
public _renderImportDialog(): TemplateResult {
|
||
const d = this.host._importDialog!;
|
||
const n = d.floors.filter((f) => f.checked).length;
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="import" .title=${this.host._t('import.title')} icon="mdi:home-floor-1"
|
||
@hp-close=${() => (this.host._importDialog = null)}>
|
||
<div class="body">
|
||
<div class="rhint">${this.host._t('import.hint')}</div>
|
||
${d.floors.map(
|
||
(f, i) => html`<label class="floorrow">
|
||
<input type="checkbox" .checked=${f.checked}
|
||
@change=${(e: Event) => {
|
||
const floors = [...d.floors];
|
||
floors[i] = { ...f, checked: (e.target as HTMLInputElement).checked };
|
||
this.host._importDialog = { floors };
|
||
}} />
|
||
<span>${f.name}</span>
|
||
${f.level != null ? html`<span class="floorlvl">L${f.level}</span>` : nothing}
|
||
</label>`,
|
||
)}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" @click=${() => { this.host._importDialog = null; this._openSpaceDialog('create'); }}>
|
||
${this.host._t('import.manual')}
|
||
</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._startImport()} ?disabled=${!n}>
|
||
<ha-icon icon="mdi:import"></ha-icon>${this.host._t('import.start', { n })}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _openSettingsDialog = (): void => {
|
||
if (!this.host._norm) return;
|
||
// deep copy so the dialog edits do not leak into the live palette
|
||
const cm = this.host._glowRadiusCm;
|
||
const glowRadius = this.host._imperial
|
||
? Math.round((cm / 30.48) * 10) / 10
|
||
: Math.round(cm) / 100;
|
||
this.host._settingsDialog = {
|
||
colors: JSON.parse(JSON.stringify(this.host._fillColors)),
|
||
glowRadius,
|
||
glowRadiusInput: String(glowRadius),
|
||
bgColor: stageBgOf(this.host._settings, { bgColor: null }) || null,
|
||
northDeg: northDegOf(this.host._settings, {}),
|
||
northDegInput: northDegOf(this.host._settings, {}) === null ? '' : String(northDegOf(this.host._settings, {})),
|
||
bgMode: bgModeOf(this.host._settings, {}),
|
||
sunRays: sunRaysOn(this.host._settings, {}),
|
||
sunRayOrigin: sunRayOriginOf(this.host._settings),
|
||
radarShowLive: this.host._settings.radar?.show_live !== false,
|
||
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
|
||
};
|
||
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
|
||
rememberGeneralBaseline(this.host, this.host._settingsDialog);
|
||
};
|
||
|
||
public _openSupportDialog = (): void => {
|
||
if (!this.host._norm || !this.host._canEdit) return;
|
||
clearTimeout(this._supportExpiryTimer);
|
||
this._supportExpiryTimer = undefined;
|
||
this._supportPreviewGeneration += 1;
|
||
this.host._supportDialog = newSupportDialogState();
|
||
};
|
||
|
||
private _supportPatch(
|
||
draftId: string,
|
||
patch: Partial<SupportDialogState>,
|
||
): SupportDialogState | null {
|
||
const current = this.host._supportDialog;
|
||
if (!current || current.draftId !== draftId) return null;
|
||
const next = { ...current, ...patch };
|
||
this.host._supportDialog = next;
|
||
return next;
|
||
}
|
||
|
||
private async _focusSupport(selector: string): Promise<void> {
|
||
await this.host.updateComplete;
|
||
this.host.renderRoot.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
|
||
}
|
||
|
||
private _updateSupportDraft(field: 'contact' | 'message', value: string): void {
|
||
const current = this.host._supportDialog;
|
||
if (!current || current.status === 'building' || current.status === 'sending'
|
||
|| current.status === 'success') return;
|
||
const candidate: SupportDialogState = { ...current, [field]: value };
|
||
// Validate text independently from the optional preview. Editing must clear
|
||
// stale transport errors, while an expired prepared package remains explicit.
|
||
const textError = supportDraftError({ ...candidate, attach: false, preview: null });
|
||
const showRequired = textError === 'message_required'
|
||
&& current.errorCode === 'validation.message_required';
|
||
const immediateError = textError === 'message_too_long' || textError === 'contact_too_long';
|
||
if (showRequired || immediateError) {
|
||
this._supportPatch(current.draftId, {
|
||
[field]: value,
|
||
status: 'error',
|
||
errorCode: `validation.${textError}`,
|
||
});
|
||
return;
|
||
}
|
||
if (candidate.attach && candidate.preview && candidate.preview.expiresAt <= Date.now()) {
|
||
this._supportPatch(current.draftId, {
|
||
[field]: value,
|
||
status: 'error',
|
||
errorCode: 'support_preview_expired',
|
||
});
|
||
return;
|
||
}
|
||
this._supportPatch(current.draftId, {
|
||
[field]: value,
|
||
status: candidate.attach && candidate.preview ? 'ready' : 'idle',
|
||
errorCode: '',
|
||
});
|
||
}
|
||
|
||
public async _discardSupportPreview(token: string): Promise<void> {
|
||
if (!token) return;
|
||
try {
|
||
await this.host.hass.callWS({ type: 'houseplan/support/preview/discard', token });
|
||
} catch {
|
||
// Cleanup is best-effort; backend TTL is the final privacy guard.
|
||
}
|
||
}
|
||
|
||
public async _closeSupportDialog(): Promise<void> {
|
||
const dialog = this.host._supportDialog;
|
||
if (!dialog) return;
|
||
if (dialog.status === 'building' || dialog.status === 'sending') {
|
||
const lang = langOf(this.host.hass, this.host._config?.language);
|
||
const accepted = await this.host._confirmDanger({
|
||
key: 'close-support-busy',
|
||
kind: 'warning',
|
||
title: supportT(lang, 'support.close_busy_title'),
|
||
message: supportT(lang, 'support.close_busy_body'),
|
||
confirmLabel: this.host._t('btn.close'),
|
||
cancelLabel: this.host._t('btn.cancel'),
|
||
});
|
||
if (!accepted) {
|
||
await this.host.updateComplete;
|
||
this.host.renderRoot.querySelector<HpDialog>('#support-dialog')?.rejectClose();
|
||
return;
|
||
}
|
||
}
|
||
clearTimeout(this._supportExpiryTimer);
|
||
this._supportExpiryTimer = undefined;
|
||
this.host._supportDialog = null;
|
||
if (dialog.preview?.token && dialog.status !== 'success') {
|
||
void this._discardSupportPreview(dialog.preview.token);
|
||
}
|
||
}
|
||
|
||
private _scheduleSupportExpiry(draftId: string, preview: SupportPreview): void {
|
||
clearTimeout(this._supportExpiryTimer);
|
||
const delay = Math.max(0, preview.expiresAt - Date.now());
|
||
this._supportExpiryTimer = window.setTimeout(() => {
|
||
const current = this.host._supportDialog;
|
||
if (!current || current.draftId !== draftId || current.preview?.token !== preview.token
|
||
|| !current.attach || current.status === 'success' || current.status === 'sending') return;
|
||
this._supportPatch(draftId, {
|
||
status: 'error',
|
||
errorCode: 'support_preview_expired',
|
||
});
|
||
}, Math.min(delay + 20, 2_147_483_647));
|
||
}
|
||
|
||
private _supportFacts(): ReturnType<typeof supportRuntimeFacts> {
|
||
const registry = this.host._haRegistry;
|
||
return supportRuntimeFacts({
|
||
userAgent: globalThis.navigator?.userAgent || '',
|
||
language: langOf(this.host.hass, this.host._config?.language),
|
||
registryAccess: registry.access,
|
||
registryLastSuccess: registry.lastSuccess,
|
||
});
|
||
}
|
||
|
||
private _supportPreviewRequestIsCurrent(draftId: string, generation: number): boolean {
|
||
const current = this.host._supportDialog;
|
||
return generation === this._supportPreviewGeneration
|
||
&& current?.draftId === draftId
|
||
&& current.attach;
|
||
}
|
||
|
||
private async _buildSupportPreview(draftId: string): Promise<void> {
|
||
const current = this.host._supportDialog;
|
||
if (!current || current.draftId !== draftId || !current.attach
|
||
|| !supportApiCompatible(this.host._haSupportApi)) return;
|
||
const generation = ++this._supportPreviewGeneration;
|
||
this._supportPatch(draftId, { status: 'building', errorCode: '' });
|
||
let issuedToken = '';
|
||
try {
|
||
const response: unknown = await this.host.hass.callWS({
|
||
type: 'houseplan/support/preview',
|
||
card_version: displayVersion(CARD_VERSION),
|
||
...this._supportFacts(),
|
||
draft_id: draftId,
|
||
});
|
||
const payload = response && typeof response === 'object'
|
||
? response as Partial<Record<
|
||
'text' | 'size' | 'expires_in' | 'spaces' | 'token' | 'sha256' | 'version' | 'format',
|
||
unknown
|
||
>> : {};
|
||
const now = Date.now();
|
||
const text = typeof payload.text === 'string' ? payload.text : '';
|
||
const size = Number(payload.size);
|
||
const expiresIn = Number(payload.expires_in);
|
||
const spaces = Number(payload.spaces);
|
||
const token = String(payload.token || '');
|
||
if (/^[0-9a-f]{48}$/.test(token)) issuedToken = token;
|
||
const sha256 = String(payload.sha256 || '');
|
||
const version = Number(payload.version);
|
||
const format = String(payload.format || '');
|
||
const byteSize = new TextEncoder().encode(text).byteLength;
|
||
if (!/^[0-9a-f]{48}$/.test(token) || !/^[0-9a-f]{64}$/.test(sha256)
|
||
|| format !== 'houseplan-support-package' || version !== 1
|
||
|| !Number.isInteger(size) || size !== byteSize || !Number.isInteger(spaces) || spaces < 0
|
||
|| !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 600 || !text.endsWith('\n')) {
|
||
throw { code: 'support_rejected' };
|
||
}
|
||
const preview: SupportPreview = {
|
||
token,
|
||
expiresAt: now + expiresIn * 1000,
|
||
size,
|
||
sha256,
|
||
spaces,
|
||
format,
|
||
version,
|
||
text,
|
||
preparedAt: now,
|
||
};
|
||
if (!this._supportPreviewRequestIsCurrent(draftId, generation)) {
|
||
issuedToken = '';
|
||
void this._discardSupportPreview(token);
|
||
return;
|
||
}
|
||
if (!this._supportPatch(draftId, {
|
||
status: 'ready',
|
||
preview,
|
||
errorCode: '',
|
||
rawOpen: false,
|
||
})) {
|
||
issuedToken = '';
|
||
void this._discardSupportPreview(token);
|
||
return;
|
||
}
|
||
issuedToken = '';
|
||
this._scheduleSupportExpiry(draftId, preview);
|
||
} catch (error: unknown) {
|
||
// The backend has allocated every syntactically valid token it returns.
|
||
// Cleanup is independent from UI currentness: invalid, stale or locally
|
||
// unadoptable responses must not occupy a slot until TTL.
|
||
if (issuedToken) {
|
||
const token = issuedToken;
|
||
issuedToken = '';
|
||
void this._discardSupportPreview(token);
|
||
}
|
||
if (!this._supportPreviewRequestIsCurrent(draftId, generation)) return;
|
||
if (!this._supportPatch(draftId, {
|
||
status: 'error',
|
||
errorCode: supportErrorCode(error),
|
||
})) return;
|
||
void this._focusSupport('#support-error');
|
||
}
|
||
}
|
||
|
||
public async _setSupportAttachment(attach: boolean): Promise<void> {
|
||
const current = this.host._supportDialog;
|
||
if (!current || current.status === 'sending' || current.status === 'success') return;
|
||
const token = current.preview?.token || '';
|
||
if (!attach) {
|
||
this._supportPreviewGeneration += 1;
|
||
clearTimeout(this._supportExpiryTimer);
|
||
this._supportExpiryTimer = undefined;
|
||
this._supportPatch(current.draftId, {
|
||
attach: false,
|
||
status: 'idle',
|
||
preview: null,
|
||
rawOpen: false,
|
||
errorCode: '',
|
||
});
|
||
if (token) void this._discardSupportPreview(token);
|
||
return;
|
||
}
|
||
this._supportPatch(current.draftId, { attach: true, errorCode: '' });
|
||
await this._buildSupportPreview(current.draftId);
|
||
}
|
||
|
||
public async _refreshSupportPreview(): Promise<void> {
|
||
const current = this.host._supportDialog;
|
||
if (!current || !current.attach || current.status === 'building' || current.status === 'sending') return;
|
||
await this._buildSupportPreview(current.draftId);
|
||
}
|
||
|
||
public _downloadSupportPreview(): void {
|
||
const preview = this.host._supportDialog?.preview;
|
||
if (!preview) return;
|
||
const blob = new Blob([preview.text], { type: 'application/json;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = `houseplan-support-${preview.sha256.slice(0, 12)}.json`;
|
||
anchor.style.display = 'none';
|
||
document.body.append(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
}
|
||
|
||
private async _copySupportText(text: string, successKey: SupportI18nKey): Promise<void> {
|
||
const lang = langOf(this.host.hass, this.host._config?.language);
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
this.host._showToast(supportT(lang, successKey));
|
||
} catch {
|
||
this.host._showToast(supportT(lang, 'support.copy_failed'));
|
||
}
|
||
}
|
||
|
||
public async _submitSupport(): Promise<void> {
|
||
const current = this.host._supportDialog;
|
||
if (!current || !supportApiCompatible(this.host._haSupportApi)) return;
|
||
const validation = supportDraftError(current);
|
||
if (validation) {
|
||
this._supportPatch(current.draftId, {
|
||
status: 'error',
|
||
errorCode: `validation.${validation}`,
|
||
});
|
||
void this._focusSupport('#support-message');
|
||
return;
|
||
}
|
||
if (!supportCanSubmit(current)) return;
|
||
const submission = supportSubmissionIdentity(current);
|
||
const sending = this._supportPatch(current.draftId, {
|
||
status: 'sending',
|
||
errorCode: '',
|
||
idempotencyKey: submission.idempotencyKey,
|
||
submissionFingerprint: submission.fingerprint,
|
||
});
|
||
if (!sending) return;
|
||
try {
|
||
const response: unknown = await this.host.hass.callWS({
|
||
type: 'houseplan/support/submit',
|
||
message: sending.message.trim(),
|
||
contact: sending.contact.trim(),
|
||
...(sending.attach && sending.preview ? { preview_token: sending.preview.token } : {}),
|
||
idempotency_key: sending.idempotencyKey,
|
||
});
|
||
const reportId = response && typeof response === 'object' && 'report_id' in response
|
||
? String((response as { report_id?: unknown }).report_id || '') : '';
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(reportId)) {
|
||
throw { code: 'support_unavailable' };
|
||
}
|
||
clearTimeout(this._supportExpiryTimer);
|
||
this._supportExpiryTimer = undefined;
|
||
if (!this._supportPatch(current.draftId, {
|
||
status: 'success',
|
||
reportId,
|
||
errorCode: '',
|
||
})) return;
|
||
void this._focusSupport('#support-receipt');
|
||
} catch (error: unknown) {
|
||
if (!this._supportPatch(current.draftId, {
|
||
status: 'error',
|
||
errorCode: supportErrorCode(error),
|
||
})) return;
|
||
void this._focusSupport('#support-error');
|
||
}
|
||
}
|
||
|
||
private _supportErrorText(state: SupportDialogState): string {
|
||
const lang = langOf(this.host.hass, this.host._config?.language);
|
||
if (state.errorCode.startsWith('validation.')) {
|
||
const suffix = state.errorCode.slice('validation.'.length);
|
||
return supportT(lang, `support.validation.${suffix}` as SupportI18nKey);
|
||
}
|
||
return supportT(
|
||
lang,
|
||
`support.error.${state.errorCode || 'support_unavailable'}` as SupportI18nKey,
|
||
);
|
||
}
|
||
|
||
private _supportMessageKeydown(event: KeyboardEvent): void {
|
||
if (event.key !== 'Enter' || (!event.ctrlKey && !event.metaKey)) return;
|
||
event.preventDefault();
|
||
void this._submitSupport();
|
||
}
|
||
|
||
public _renderSupportDialog(): TemplateResult {
|
||
const state = this.host._supportDialog!;
|
||
const lang = langOf(this.host.hass, this.host._config?.language);
|
||
const guideUrl = lang === 'ru'
|
||
? 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.ru.md'
|
||
: 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.md';
|
||
const compatible = supportApiCompatible(this.host._haSupportApi);
|
||
const st = (key: SupportI18nKey, vars?: Record<string, string | number>): string =>
|
||
supportT(lang, key, vars);
|
||
const busy = state.status === 'building' || state.status === 'sending';
|
||
const validation = supportDraftError(state);
|
||
const preparedMinutes = state.preview
|
||
? Math.max(0, Math.floor((Date.now() - state.preview.preparedAt) / 60_000)) : 0;
|
||
const manualRecovery = state.status === 'error'
|
||
&& !state.errorCode.startsWith('validation.');
|
||
const contactInvalid = state.errorCode === 'validation.contact_too_long';
|
||
const messageInvalid = state.errorCode === 'validation.message_required'
|
||
|| state.errorCode === 'validation.message_too_long';
|
||
return html`<hp-dialog id="support-dialog" .hass=${this.host.hass} data-kind="support"
|
||
.title=${this.host._t('support.title')} icon="mdi:help-circle-outline" wide dismiss-on-scrim
|
||
@hp-close=${() => void this._closeSupportDialog()}>
|
||
<div class="body supportbody">
|
||
<section class="supportsection" aria-labelledby="support-about-heading">
|
||
<h3 id="support-about-heading">${st('support.about_group')}</h3>
|
||
<div class="aboutver">${this.host._t('gs.about_version', { v: displayVersion(CARD_VERSION) })}</div>
|
||
<div class="supportlinks">
|
||
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener noreferrer">
|
||
<ha-icon icon="mdi:github"></ha-icon>${this.host._t('gs.about_github')}</a>
|
||
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener noreferrer">
|
||
<ha-icon icon="mdi:send"></ha-icon>${this.host._t('gs.about_telegram')}</a>
|
||
</div>
|
||
</section>
|
||
<section class="supportsection" aria-labelledby="support-docs-heading">
|
||
<h3 id="support-docs-heading">${st('support.guide_group')}</h3>
|
||
<a class="aboutlink" href=${guideUrl} target="_blank" rel="noopener noreferrer">
|
||
<ha-icon icon="mdi:book-open-page-variant-outline"></ha-icon>${st('support.guide')}</a>
|
||
</section>
|
||
${compatible ? html`
|
||
<section class="supportsection supportform" aria-labelledby="support-form-heading">
|
||
<h3 id="support-form-heading">${st('support.form_group')}</h3>
|
||
<label for="support-contact">${st('support.contact')}</label>
|
||
<input id="support-contact" class="namein" type="text" autocomplete="off"
|
||
aria-invalid=${contactInvalid ? 'true' : 'false'}
|
||
aria-describedby=${contactInvalid ? 'support-error' : nothing}
|
||
.value=${state.contact} ?disabled=${busy || state.status === 'success'}
|
||
@input=${(event: Event) => this._updateSupportDraft(
|
||
'contact', (event.target as HTMLInputElement).value,
|
||
)} />
|
||
<label for="support-message">${st('support.message')}</label>
|
||
<textarea id="support-message" class="supportmessage" required
|
||
aria-invalid=${messageInvalid ? 'true' : 'false'}
|
||
aria-describedby=${messageInvalid ? 'support-error' : nothing}
|
||
.value=${state.message} ?disabled=${busy || state.status === 'success'}
|
||
@keydown=${(event: KeyboardEvent) => this._supportMessageKeydown(event)}
|
||
@input=${(event: Event) => this._updateSupportDraft(
|
||
'message', (event.target as HTMLTextAreaElement).value,
|
||
)}></textarea>
|
||
<label class="srcrow supportattach">
|
||
<input type="checkbox" .checked=${state.attach} aria-describedby="support-attach-hint"
|
||
?disabled=${busy || state.status === 'success'}
|
||
@change=${(event: Event) => void this._setSupportAttachment(
|
||
(event.target as HTMLInputElement).checked,
|
||
)} />
|
||
<span>${st('support.attach')}</span>
|
||
</label>
|
||
<p id="support-attach-hint" class="rhint">${st('support.attach_hint')}</p>
|
||
${state.attach ? html`<p class="supportwarning" role="note">
|
||
<ha-icon icon="mdi:shield-alert-outline"></ha-icon>
|
||
<span>${st('support.geometry_warning')}</span>
|
||
</p>` : nothing}
|
||
${state.status === 'building' ? html`<div class="supportstatus" role="status" aria-live="polite">
|
||
<ha-icon icon="mdi:progress-clock"></ha-icon>${st('support.building')}
|
||
</div>` : nothing}
|
||
${state.attach && state.preview ? html`
|
||
<div class="supportpreview">
|
||
<div class="supportsummary">${st('support.preview_summary', {
|
||
version: state.preview.version,
|
||
spaces: state.preview.spaces,
|
||
size: supportSizeKiB(state.preview.size),
|
||
})}</div>
|
||
<div class="supporthash"><span>SHA-256</span><code>${state.preview.sha256}</code></div>
|
||
<div class="rhint">${st('support.prepared', { n: preparedMinutes })}</div>
|
||
<details ?open=${state.rawOpen}
|
||
@toggle=${(event: Event) => this._supportPatch(state.draftId, {
|
||
rawOpen: (event.currentTarget as HTMLDetailsElement).open,
|
||
})}>
|
||
<summary>${st('support.show_data')}</summary>
|
||
${state.rawOpen ? html`<textarea class="supportraw" readonly
|
||
.value=${state.preview.text}></textarea>` : nothing}
|
||
</details>
|
||
<div class="supportactions">
|
||
<button type="button" class="btn ghost" @click=${() => this._downloadSupportPreview()}>
|
||
<ha-icon icon="mdi:download"></ha-icon>${st('support.download')}
|
||
</button>
|
||
<button type="button" class="btn ghost" @click=${() => void this._refreshSupportPreview()}
|
||
?disabled=${busy || state.status === 'success'}>
|
||
<ha-icon icon="mdi:refresh"></ha-icon>${st('support.refresh')}
|
||
</button>
|
||
</div>
|
||
</div>` : nothing}
|
||
<p class="rhint supportprivacy">${st('support.privacy')}</p>
|
||
${state.status === 'error' ? html`
|
||
<div id="support-error" class="supporterror" role="alert" tabindex="-1">
|
||
<strong>${st('support.error_title')}</strong>
|
||
<span>${this._supportErrorText(state)}</span>
|
||
</div>` : nothing}
|
||
${state.status === 'error' && state.attach && !state.preview ? html`
|
||
<div class="supportactions">
|
||
<button type="button" class="btn ghost"
|
||
@click=${() => void this._refreshSupportPreview()}>
|
||
<ha-icon icon="mdi:refresh"></ha-icon>${st('support.refresh')}
|
||
</button>
|
||
</div>` : nothing}
|
||
${manualRecovery ? html`
|
||
<div class="supportmanual">
|
||
<strong>${st('support.manual_recovery')}</strong>
|
||
<div class="supportactions">
|
||
<button type="button" class="btn ghost" @click=${() => void this._copySupportText(
|
||
state.message, 'support.message_copied',
|
||
)}>${st('support.copy_message')}</button>
|
||
${state.preview ? html`<button type="button" class="btn ghost"
|
||
@click=${() => this._downloadSupportPreview()}>${st('support.download')}</button>` : nothing}
|
||
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener noreferrer">Telegram</a>
|
||
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card/issues" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||
</div>
|
||
</div>` : nothing}
|
||
${state.status === 'success' ? html`
|
||
<div id="support-receipt" class="supportsuccess" role="status" aria-live="polite" tabindex="-1">
|
||
<strong>${st('support.success', { id: state.reportId })}</strong>
|
||
<button type="button" class="btn ghost" @click=${() => void this._copySupportText(
|
||
state.reportId, 'support.id_copied',
|
||
)}>${st('support.copy_id')}</button>
|
||
</div>` : nothing}
|
||
</section>` : html`
|
||
<div class="supportupdate" role="status">
|
||
<ha-icon icon="mdi:update"></ha-icon>
|
||
<span>${st('support.update_required')}</span>
|
||
</div>`}
|
||
</div>
|
||
<div class="row supportfooter" slot="footer">
|
||
<button type="button" class="btn ghost" data-hp="dialog-cancel" @click=${() => void this._closeSupportDialog()}>
|
||
${this.host._t('btn.close')}
|
||
</button>
|
||
<span class="spacer"></span>
|
||
${compatible && state.status !== 'success' ? html`
|
||
<button type="button" class="btn on" data-hp="dialog-confirm" @click=${() => void this._submitSupport()}
|
||
?disabled=${!supportCanSubmit(state)}>
|
||
<ha-icon icon="mdi:send"></ha-icon>${state.status === 'sending'
|
||
? st('support.sending')
|
||
: state.status === 'error' && !validation
|
||
? st('support.retry') : st('support.send')}
|
||
</button>` : nothing}
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _checkOptimizeGeometryImpl(config: ServerConfig): OptimizeGeometryPreflightResult {
|
||
return checkOptimizeGeometry(config, {
|
||
fallbackSpaceName: (index) => this.host._t('gs.align_preflight_space', {
|
||
n: String(index),
|
||
}),
|
||
});
|
||
}
|
||
|
||
public _checkSpacePhysicalGeometryImpl(
|
||
config: ServerConfig,
|
||
spaceId: string,
|
||
captureWallGeometry?: (
|
||
geometry: ReturnType<typeof wallBodiesGeometry>,
|
||
) => void,
|
||
) {
|
||
return checkSpacePhysicalGeometry(config, spaceId, {
|
||
fallbackSpaceName: (index) => this.host._t('gs.align_preflight_space', { n: String(index) }),
|
||
captureWallGeometry: captureWallGeometry
|
||
? (_input, geometry) => captureWallGeometry(geometry)
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
public _optimizeReferenceContext(
|
||
removeLiveMissingPositions: boolean,
|
||
): SpaceReferenceRepairContext {
|
||
const registry = this.host._haRegistry;
|
||
const full = this.host._fullRegistryHass;
|
||
const names: Record<string, string> = {};
|
||
const humanName = (...values: unknown[]): string => {
|
||
for (const value of values) {
|
||
const name = String(value || '').trim();
|
||
if (name) return name;
|
||
}
|
||
return '';
|
||
};
|
||
for (const [deviceId, device] of Object.entries<any>(registry.devices || {})) {
|
||
names[deviceId] = humanName(device?.name_by_user, device?.name, device?.model);
|
||
}
|
||
for (const [entityId, entity] of Object.entries<any>(registry.entities || {})) {
|
||
names[`lg_${entityId}`] = humanName(
|
||
this.host.hass?.states?.[entityId]?.attributes?.friendly_name,
|
||
entity?.name,
|
||
entity?.original_name,
|
||
);
|
||
}
|
||
for (const device of this.host._devices) names[device.id] = humanName(device.name, names[device.id]);
|
||
for (const marker of this.host._serverCfg?.markers || []) {
|
||
const separator = String(marker.binding || '').indexOf(':');
|
||
const kind = separator > 0 ? marker.binding.slice(0, separator) : '';
|
||
const ref = separator > 0 ? marker.binding.slice(separator + 1) : '';
|
||
const bound = kind === 'device'
|
||
? full?.devices?.[ref]
|
||
: kind === 'entity' ? full?.entities?.[ref] : null;
|
||
names[marker.id] = humanName(
|
||
marker.name,
|
||
kind === 'device' ? bound?.name_by_user : null,
|
||
bound?.name,
|
||
kind === 'entity' ? this.host.hass?.states?.[ref]?.attributes?.friendly_name : null,
|
||
names[marker.id],
|
||
);
|
||
}
|
||
return {
|
||
effectiveAreaByMarker: Object.fromEntries(
|
||
this.host._devices
|
||
.filter((device) => !device.virtual && !!device.area)
|
||
.map((device) => [device.id, device.area]),
|
||
),
|
||
ownerRoster: {
|
||
authoritative: registry.authoritative,
|
||
deviceIds: Object.keys(registry.devices || {}),
|
||
// State-only YAML entities are positive existence evidence even though
|
||
// an authoritative Entity Registry cannot list them.
|
||
entityIds: [...new Set([
|
||
...Object.keys(registry.entities || {}),
|
||
...Object.keys(this.host.hass?.states || {}),
|
||
])],
|
||
names,
|
||
},
|
||
removeLiveMissingPositions,
|
||
};
|
||
}
|
||
|
||
public async _undoPlanOptimization(): Promise<void> {
|
||
if (!this.host._canOptimizeUndo || this.host._optimizeUndoBusy) return;
|
||
const undoKind = this.host._undoKind;
|
||
this._clearGeometryGesture();
|
||
this.host._optimizeUndoBusy = true;
|
||
this.host.requestUpdate();
|
||
try {
|
||
await this.host.hass.callWS({
|
||
type: 'houseplan/plan/optimize_undo',
|
||
expected_config_rev: this.host._cfgRev,
|
||
expected_layout_rev: this.host._layoutRev,
|
||
});
|
||
const [cfgResp, layResp] = await Promise.all([
|
||
this.host._getAuthoritativeConfig(),
|
||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||
]);
|
||
// config/get is the sole authority for runtime capabilities, including
|
||
// integration_version. Reuse the full-card adopter instead of leaving
|
||
// this direct optimization-undo path with stale version state (#462).
|
||
const adopted = await this.host._adoptAuthoritative({ cfgResp, layResp, reason: 'optimize-undo', profile: 'post-write' });
|
||
if (adopted.status !== 'adopted') return; // asset wait: the scheduled reload owns the tail
|
||
this.host._geometryHistory.clear();
|
||
this.host._cancelDeviceDrag();
|
||
this.host._devicePositionHistory.clear();
|
||
this.host._canOptimizeUndo = false;
|
||
this.host._undoKind = null;
|
||
this.host._cfgEpoch++;
|
||
this.host._modelCache = null;
|
||
this.host._frame = null;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host._cacheSnapshot();
|
||
this.host.requestUpdate();
|
||
this.host._showToast(this.host._t(undoKind === 'import' ? 'backup.import_undone' : 'gs.optimize_undone'));
|
||
} catch (e: any) {
|
||
this.host._canOptimizeUndo = false;
|
||
this.host._undoKind = null;
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
} finally {
|
||
this.host._optimizeUndoBusy = false;
|
||
this.host.requestUpdate();
|
||
}
|
||
}
|
||
|
||
public _openBackupExport = (): void => {
|
||
this.host._settingsDialog = null;
|
||
this.host._backupExportDialog = { kind: 'full', planOnly: false, busy: false, error: '' };
|
||
};
|
||
|
||
public async _runBackupExport(): Promise<void> {
|
||
const d = this.host._backupExportDialog;
|
||
if (!d || d.busy) return;
|
||
this.host._backupExportDialog = { ...d, busy: true, error: '' };
|
||
try {
|
||
const response: any = await this.host.hass.callWS({
|
||
type: 'houseplan/export/create',
|
||
kind: d.kind,
|
||
space_id: d.kind === 'space' ? this.host._space : undefined,
|
||
...(d.kind === 'space' && d.planOnly ? { plan_only: true } : {}),
|
||
card_version: displayVersion(CARD_VERSION),
|
||
});
|
||
const blob = new Blob([JSON.stringify(response.document, null, 2) + '\n'], {
|
||
type: 'application/json;charset=utf-8',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = response.filename || `houseplan-${d.kind}.json`;
|
||
anchor.style.display = 'none';
|
||
document.body.append(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
this.host._backupExportDialog = null;
|
||
this.host._showToast(this.host._t('backup.export_done'));
|
||
} catch (error: any) {
|
||
if (this.host._backupExportDialog) {
|
||
this.host._backupExportDialog = { ...this.host._backupExportDialog, busy: false, error: this._backupErrorText(error) };
|
||
}
|
||
}
|
||
}
|
||
|
||
public async _pickBackupImport(ev: Event): Promise<void> {
|
||
const input = ev.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
this.host._settingsDialog = null;
|
||
this.host._backupImportDialog = {
|
||
filename: file.name, size: file.size, token: '', preview: null,
|
||
expectedConfigRev: this.host._cfgRev, expectedLayoutRev: this.host._layoutRev,
|
||
duplicatePolicy: 'skip', confirmMissing: false, busy: true, error: '',
|
||
};
|
||
try {
|
||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
|
||
await this.host._writeChain;
|
||
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
|
||
const path = '/api/houseplan/import/preview?duplicate_policy=skip';
|
||
const init = { method: 'POST', body: file, headers: { 'content-type': 'application/json' } };
|
||
const response: Response = this.host.hass?.fetchWithAuth
|
||
? await this.host.hass.fetchWithAuth(path, init)
|
||
: await fetch(path, {
|
||
...init,
|
||
headers: {
|
||
...init.headers,
|
||
...(this.host.hass?.auth?.data?.access_token
|
||
? { authorization: `Bearer ${this.host.hass.auth.data.access_token}` } : {}),
|
||
},
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok || data.error) throw Object.assign(new Error(data.message || data.error || `HTTP ${response.status}`), { code: data.error });
|
||
if (this.host._backupImportDialog) {
|
||
this.host._backupImportDialog = {
|
||
...this.host._backupImportDialog,
|
||
token: data.token,
|
||
preview: data.preview,
|
||
expectedConfigRev: data.expected_config_rev,
|
||
expectedLayoutRev: data.expected_layout_rev,
|
||
busy: false,
|
||
};
|
||
}
|
||
} catch (error: any) {
|
||
if (this.host._backupImportDialog) {
|
||
this.host._backupImportDialog = { ...this.host._backupImportDialog, busy: false, error: this._backupErrorText(error) };
|
||
}
|
||
}
|
||
}
|
||
|
||
public async _setBackupDuplicatePolicy(policy: 'skip' | 'virtual'): Promise<void> {
|
||
const d = this.host._backupImportDialog;
|
||
if (!d || d.busy || !d.token || policy === d.duplicatePolicy) return;
|
||
this.host._backupImportDialog = { ...d, duplicatePolicy: policy, busy: true, error: '' };
|
||
try {
|
||
const response: any = await this.host.hass.callWS({
|
||
type: 'houseplan/import/revalidate', token: d.token, duplicate_policy: policy,
|
||
});
|
||
if (this.host._backupImportDialog) {
|
||
this.host._backupImportDialog = {
|
||
...this.host._backupImportDialog,
|
||
preview: { ...d.preview, ...response.preview },
|
||
expectedConfigRev: response.expected_config_rev,
|
||
expectedLayoutRev: response.expected_layout_rev,
|
||
confirmMissing: false,
|
||
busy: false,
|
||
};
|
||
}
|
||
} catch (error: any) {
|
||
if (this.host._backupImportDialog) {
|
||
this.host._backupImportDialog = {
|
||
...this.host._backupImportDialog,
|
||
duplicatePolicy: d.duplicatePolicy,
|
||
preview: d.preview,
|
||
confirmMissing: d.confirmMissing,
|
||
busy: false,
|
||
error: this._backupErrorText(error),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
public async _applyBackupImport(): Promise<void> {
|
||
const d = this.host._backupImportDialog;
|
||
if (!d || d.busy || !d.token || !d.preview) return;
|
||
if (d.preview.confirmation_required && !d.confirmMissing) return;
|
||
this.host._backupImportDialog = { ...d, busy: true, error: '' };
|
||
const previousSpace = this.host._space;
|
||
try {
|
||
const result: any = await this.host.hass.callWS({
|
||
type: 'houseplan/import/apply',
|
||
token: d.token,
|
||
expected_config_rev: d.expectedConfigRev,
|
||
expected_layout_rev: d.expectedLayoutRev,
|
||
duplicate_policy: d.duplicatePolicy,
|
||
confirm_missing_content: d.confirmMissing,
|
||
});
|
||
const [configResponse, layoutResponse] = await Promise.all([
|
||
this.host._getAuthoritativeConfig(),
|
||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||
]);
|
||
const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'import-apply', profile: 'post-write' });
|
||
if (adopted.status !== 'adopted') { this.host._backupImportDialog = { ...d, busy: false, error: '' }; this.host.requestUpdate(); return; }
|
||
this.host._geometryHistory.clear();
|
||
this.host._dirtyPos.clear();
|
||
this.host._sentPos.clear();
|
||
this.host._defPos = {};
|
||
this.host._cfgEpoch++;
|
||
this.host._modelCache = null;
|
||
this.host._frame = null;
|
||
this.host._visibleDeviceSnapshot = null;
|
||
this.host._candidateDeviceSnapshot = null;
|
||
this.host._stagedDeviceSnapshotToken = -1;
|
||
this.host._capturedSnapshotConfigEpoch = -1;
|
||
this.host._regSignature = '';
|
||
this.host._signer.invalidate(this.host.hass);
|
||
this.host._resign();
|
||
this.host._maybeRebuildDevices();
|
||
const spaces = this.host._serverCfg?.spaces || [];
|
||
const nextSpace = result.kind === 'space' && result.space_id
|
||
? result.space_id
|
||
: spaces.some((space) => space.id === previousSpace)
|
||
? previousSpace : spaces[0]?.id || this.host._space;
|
||
if (this.host._hasFixedFloor) this.host._adoptInitialSpace(this.host._model, true);
|
||
else this.host._commitSpace(nextSpace);
|
||
this.host._backupImportDialog = null;
|
||
this.host._cacheSnapshot();
|
||
this.host.requestUpdate();
|
||
const outcomeCounts = result.kind === 'space' ? d.preview.counts : result.counts;
|
||
this.host._showToast(this.host._t(result.kind === 'space' ? 'backup.space_done' : 'backup.full_done', {
|
||
spaces: String(outcomeCounts?.spaces || 0), rooms: String(outcomeCounts?.rooms || 0),
|
||
markers: String(outcomeCounts?.markers || 0),
|
||
refs: String(result.repaired_target_refs || 0),
|
||
}));
|
||
} catch (error: any) {
|
||
if (error?.code === 'conflict' && this.host._backupImportDialog?.token) {
|
||
try {
|
||
const current = this.host._backupImportDialog;
|
||
const refreshed: any = await this.host.hass.callWS({
|
||
type: 'houseplan/import/revalidate', token: current.token,
|
||
duplicate_policy: current.duplicatePolicy,
|
||
});
|
||
this.host._backupImportDialog = {
|
||
...current,
|
||
preview: { ...current.preview, ...refreshed.preview },
|
||
expectedConfigRev: refreshed.expected_config_rev,
|
||
expectedLayoutRev: refreshed.expected_layout_rev,
|
||
confirmMissing: false,
|
||
busy: false,
|
||
error: this.host._t('backup.revalidated'),
|
||
};
|
||
return;
|
||
} catch (refreshError: any) {
|
||
error = refreshError;
|
||
}
|
||
}
|
||
if (this.host._backupImportDialog) {
|
||
this.host._backupImportDialog = { ...this.host._backupImportDialog, busy: false, error: this._backupErrorText(error) };
|
||
}
|
||
}
|
||
}
|
||
|
||
public _renderBackupExportDialog(): TemplateResult {
|
||
const d = this.host._backupExportDialog!;
|
||
const currentSpace = (this.host._serverCfg?.spaces || []).find((space) => space.id === this.host._space);
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="backup" .title=${this.host._t('backup.export_title')}
|
||
icon="mdi:download" dismiss-on-scrim @hp-close=${() => (this.host._backupExportDialog = null)}>
|
||
<div class="body backupbody">
|
||
<div class="rhint">${this.host._t('backup.export_hint')}</div>
|
||
<label class="srcrow"><input type="radio" name="backup-kind" value="full"
|
||
.checked=${d.kind === 'full'}
|
||
@change=${() => (this.host._backupExportDialog = { ...d, kind: 'full', planOnly: false })} />
|
||
<span>${this.host._t('backup.full')}</span></label>
|
||
<label class="srcrow"><input type="radio" name="backup-kind" value="space"
|
||
.checked=${d.kind === 'space'} ?disabled=${!currentSpace}
|
||
@change=${() => (this.host._backupExportDialog = { ...d, kind: 'space' })} />
|
||
<span>${currentSpace
|
||
? this.host._t('backup.current_space_title', { title: currentSpace.title || currentSpace.id })
|
||
: this.host._t('backup.no_current_space')}</span></label>
|
||
${d.kind === 'space' && currentSpace ? html`<label class="srcrow backupplanonly">
|
||
<input type="checkbox" .checked=${d.planOnly}
|
||
@change=${(event: Event) => (this.host._backupExportDialog = {
|
||
...d, planOnly: (event.target as HTMLInputElement).checked,
|
||
})} />
|
||
<span><b>${this.host._t('backup.plan_only')}</b><small>${this.host._t('backup.plan_only_hint')}</small></span>
|
||
</label>` : nothing}
|
||
<div class="backupwarn">${this.host._t('backup.privacy_warning')}</div>
|
||
${d.error ? html`<div class="backuperror" role="alert">${d.error}</div>` : nothing}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" autofocus data-hp="dialog-cancel" @click=${() => (this.host._backupExportDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn on" data-hp="dialog-confirm" ?disabled=${d.busy || (d.kind === 'space' && !currentSpace)}
|
||
@click=${() => this._runBackupExport()}>
|
||
<ha-icon icon="mdi:download"></ha-icon>${d.busy ? '…' : this.host._t('backup.download')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderBackupImportDialog(): TemplateResult {
|
||
type ContentItem = { kind?: string; url: string; state?: string };
|
||
const d = this.host._backupImportDialog!;
|
||
const p = d.preview;
|
||
const decorContent = ((p?.content || []) as ContentItem[])
|
||
.filter((item) => item.kind === 'decor_asset');
|
||
const decorAssetCount = new Set(decorContent.map((item) => item.url)).size;
|
||
const missingDecor = decorContent.filter((item) => item.state === 'missing_preserved');
|
||
const missingDecorAssetCount = new Set(missingDecor.map((item) => item.url)).size;
|
||
const counts = p?.counts || {};
|
||
const draftMigration = p?.migration || {};
|
||
const report = p?.reference_report || {};
|
||
const sum = (values: any): number => Object.values(values || {}).reduce(
|
||
(total: number, value: any) => total + (Number(value) || 0), 0,
|
||
);
|
||
const allReportRows: Array<[
|
||
'incoming_remapped' | 'target_repaired' | 'preserved_unresolved'
|
||
| 'collisions' | 'dropped_links' | 'bounded_lineages',
|
||
number,
|
||
]> = [
|
||
['incoming_remapped', sum(report.remapped?.incoming)],
|
||
['target_repaired', sum(report.remapped?.target)],
|
||
['preserved_unresolved', sum(report.preservedUnresolved)],
|
||
['collisions', sum(report.collisions)],
|
||
['dropped_links', sum(report.droppedIncomingLinks)],
|
||
['bounded_lineages', Number(report.boundedLineages) || 0],
|
||
];
|
||
const reportRows = allReportRows.filter(([, value]) => value > 0);
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="backup" .title=${this.host._t('backup.import_title')}
|
||
icon="mdi:upload" wide dismiss-on-scrim @hp-close=${() => (this.host._backupImportDialog = null)}>
|
||
<div class="body backupbody" aria-busy=${d.busy ? 'true' : 'false'}>
|
||
<div class="backupfile"><b>${d.filename}</b><span>${(d.size / 1024).toFixed(1)} KB</span></div>
|
||
${d.busy && !p ? html`<div class="rhint" role="status" aria-live="polite">${this.host._t('backup.reading')}</div>` : nothing}
|
||
${d.error ? html`<div class="backuperror" role="alert">${d.error}</div>` : nothing}
|
||
${p ? html`
|
||
<div class="backupsummary">
|
||
<b>${this.host._t(p.kind === 'full' ? 'backup.full' : 'backup.current_space')}</b>
|
||
${p.plan_only ? html`<span class="backupplanonlystatus">${this.host._t('backup.plan_only_preview')}</span>` : nothing}
|
||
<span>${this.host._t(p.source === 'same' ? 'backup.same_source' : 'backup.foreign_source')}</span>
|
||
<span>${this.host._t('backup.created', { value: p.created_at || '—' })}</span>
|
||
<span>${this.host._t('backup.versions', {
|
||
card: p.card_version || '—', integration: p.integration_version || '—',
|
||
model: String(p.model_version ?? '—'),
|
||
})}</span>
|
||
</div>
|
||
<div class="backupcounts">
|
||
<span>${this.host._t('backup.count_spaces', { n: p.kind === 'full'
|
||
? `${p.current_counts?.spaces || 0} → ${counts.spaces || 0}` : String(counts.spaces || 0) })}</span>
|
||
<span>${this.host._t('backup.count_rooms', { n: p.kind === 'full'
|
||
? `${p.current_counts?.rooms || 0} → ${counts.rooms || 0}` : String(counts.rooms || 0) })}</span>
|
||
<span>${this.host._t('backup.count_walls', { n: p.kind === 'full'
|
||
? `${p.current_counts?.walls || 0} → ${counts.walls || 0}` : String(counts.walls || 0) })}</span>
|
||
<span>${this.host._t('backup.count_openings', { n: p.kind === 'full'
|
||
? `${p.current_counts?.openings || 0} → ${counts.openings || 0}` : String(counts.openings || 0) })}</span>
|
||
<span>${this.host._t('backup.count_decor', { n: p.kind === 'full'
|
||
? `${p.current_counts?.decor || 0} → ${counts.decor || 0}` : String(counts.decor || 0) })}</span>
|
||
<span>${this.host._t('backup.count_markers', { n: p.kind === 'full'
|
||
? `${p.current_counts?.markers || 0} → ${counts.markers || 0}` : String(counts.markers || 0) })}</span>
|
||
<span>${this.host._t('backup.count_layout', { n: p.kind === 'full'
|
||
? `${p.current_counts?.layout || 0} → ${counts.layout || 0}` : String(counts.layout || 0) })}</span>
|
||
</div>
|
||
${p.bindings ? html`<div class="rhint">${this.host._t('backup.bindings', {
|
||
device: String(p.bindings.device || 0), entity: String(p.bindings.entity || 0),
|
||
virtual: String(p.bindings.virtual || 0), legacy: String(p.legacy_positions || 0),
|
||
})}</div><div class="rhint">${this.host._t('backup.binding_status', {
|
||
active: String(p.bindings.active || 0), disabled: String(p.bindings.disabled || 0),
|
||
missing: String(p.bindings.missing || 0),
|
||
})}</div>` : nothing}
|
||
${p.missing_areas?.length ? html`<div class="backupwarn">${this.host._t('backup.missing_areas', {
|
||
areas: p.missing_areas.join(', '),
|
||
})}</div>` : nothing}
|
||
${p.dropped_marker_links ? html`<div class="backupwarn">${this.host._t('backup.dropped_marker_links', {
|
||
n: String(p.dropped_marker_links),
|
||
})}</div>` : nothing}
|
||
${draftMigration.room_drafts || draftMigration.room_draft_segments ? html`
|
||
<div class="rhint">${this.host._t('backup.room_drafts_migrated', {
|
||
drafts: String(draftMigration.room_drafts || 0),
|
||
segments: String(draftMigration.room_draft_segments || 0),
|
||
})}</div>` : nothing}
|
||
${p.repaired_target_refs ? html`<div class="rhint">${this.host._t('backup.repaired_target_refs', {
|
||
n: String(p.repaired_target_refs),
|
||
})}</div>` : nothing}
|
||
${p.preserved_unresolved_refs ? html`
|
||
<div class="backupwarn">${this.host._t('backup.preserved_unresolved_refs', {
|
||
n: String(p.preserved_unresolved_refs),
|
||
})}<br />${this.host._t('backup.preserved_unresolved_hint')}</div>
|
||
` : nothing}
|
||
${reportRows.length ? html`<details class="backupdetails">
|
||
<summary>${this.host._t('backup.import_details')}</summary>
|
||
<div>
|
||
${reportRows.map(([key, value]) => html`<span>${this.host._t(
|
||
`backup.import_detail.${key}`, { n: String(value) },
|
||
)}</span>`)}
|
||
${(report.examples || []).slice(0, 8).map((item) => html`
|
||
<code>${item.owner} → ${item.reference}</code>
|
||
`)}
|
||
</div>
|
||
</details>` : nothing}
|
||
${p.kind === 'full' ? html`
|
||
<div class="backupwarn">${this.host._t('backup.replace_warning')}</div>
|
||
${p.source === 'foreign' ? html`<div class="rhint">${this.host._t('backup.foreign_bookkeeping')}</div>` : nothing}` : html`
|
||
<div class="backupsummary"><b>${this.host._t('backup.final_name')}</b><span>${p.space_title}</span></div>
|
||
<div class="rhint">${this.host._t('backup.target_settings')}</div>
|
||
${p.duplicates ? html`<fieldset class="backupchoices"><legend>${this.host._t('backup.duplicates', { n: String(p.duplicates) })}</legend>
|
||
<label><input type="radio" name="duplicate-policy" .checked=${d.duplicatePolicy === 'skip'}
|
||
@change=${() => this._setBackupDuplicatePolicy('skip')} />${this.host._t('backup.skip')}</label>
|
||
<label><input type="radio" name="duplicate-policy" .checked=${d.duplicatePolicy === 'virtual'}
|
||
@change=${() => this._setBackupDuplicatePolicy('virtual')} />${this.host._t('backup.virtual_copy')}</label>
|
||
</fieldset>` : nothing}`}
|
||
${p.content?.length ? html`<div class="backupcontent">
|
||
<b>${this.host._t('backup.content')}</b>
|
||
${decorContent.length ? html`<span>${this.host._t('backup.decor_images_summary', {
|
||
assets: decorAssetCount, objects: decorContent.length,
|
||
missing: missingDecorAssetCount,
|
||
})}</span>` : nothing}
|
||
${(p.content as ContentItem[]).filter((item) => item.kind !== 'decor_asset').map((item) => html`<span>${item.url} · ${this.host._t(
|
||
item.state === 'available' ? 'backup.content_available'
|
||
: item.state === 'external' ? 'backup.content_external'
|
||
: 'backup.content_detach_required',
|
||
)}</span>`)}
|
||
</div>` : nothing}
|
||
${p.confirmation_required ? html`<label class="srcrow backupconfirm">
|
||
<input type="checkbox" .checked=${d.confirmMissing}
|
||
@change=${(e: Event) => (this.host._backupImportDialog = { ...d, confirmMissing: (e.target as HTMLInputElement).checked })} />
|
||
<span>${this.host._t(missingDecor.length
|
||
? 'backup.confirm_missing_images' : 'backup.confirm_detach')}</span>
|
||
</label>` : nothing}
|
||
` : nothing}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" autofocus data-hp="dialog-cancel" @click=${() => (this.host._backupImportDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<span class="spacer"></span>
|
||
${p ? html`<button class="btn ${p.kind === 'full' ? 'danger' : 'on'}" data-hp="dialog-confirm"
|
||
?disabled=${d.busy || (p.confirmation_required && !d.confirmMissing)} @click=${() => this._applyBackupImport()}>
|
||
<ha-icon icon=${p.kind === 'full' ? 'mdi:database-import' : 'mdi:plus'}></ha-icon>
|
||
${d.busy ? '…' : this.host._t(p.kind === 'full' ? 'backup.replace' : 'backup.add')}
|
||
</button>` : nothing}
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||
const d = this.host._settingsDialog!;
|
||
this.host._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } };
|
||
}
|
||
|
||
/** #377: every UI change of the session default style flows through here —
|
||
* the style updates instantly, the persist is debounced so a palette drag
|
||
* produces one config write. */
|
||
public _updateDecorStyle(next: DecorStyle): void {
|
||
this.host._decorStyle = next;
|
||
if (this._decorStylePersistTimer !== null) window.clearTimeout(this._decorStylePersistTimer);
|
||
this._decorStylePersistTimer = window.setTimeout(() => {
|
||
this._decorStylePersistTimer = null;
|
||
this._persistDecorStyle();
|
||
}, 1000);
|
||
}
|
||
|
||
private _decorStylePersistTimer: number | null = null;
|
||
|
||
/** #377: mirror the current default style into settings.decor_default_style.
|
||
* The default itself is stored as the ABSENCE of the key; a no-op diff does
|
||
* not touch the store. Uses the ordinary serialized write path (expected_rev). */
|
||
private _persistDecorStyle(): void {
|
||
const cfg = this.host._serverCfg;
|
||
if (!cfg) return;
|
||
const patch = decorStyleToSettings(this.host._decorStyle);
|
||
const settings: Record<string, unknown> = { ...(cfg.settings as object) };
|
||
const before = JSON.stringify(settings.decor_default_style ?? null);
|
||
if (patch) settings.decor_default_style = patch;
|
||
else delete settings.decor_default_style;
|
||
if (JSON.stringify(settings.decor_default_style ?? null) === before) return;
|
||
this.host._serverCfg = { ...cfg, settings } as typeof cfg;
|
||
this._saveConfig();
|
||
}
|
||
|
||
public async _saveSettingsDialog(): Promise<void> {
|
||
const d = this.host._settingsDialog;
|
||
if (!d || d.busy) return;
|
||
if (generalProblems(d).length) return;
|
||
this.host._settingsDialog = { ...d, busy: true };
|
||
let attempt: OptimisticAttempt<ServerConfig> | null = null;
|
||
try {
|
||
const cfg = this.host._serverCfg!;
|
||
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
|
||
let settings: ServerConfig['settings'] & Record<string, unknown> = { ...cfg.settings };
|
||
if (isDefault) delete settings.fill_colors;
|
||
else settings.fill_colors = d.colors;
|
||
const cm = this.host._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
|
||
if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm);
|
||
else delete settings.glow_radius_cm;
|
||
if (d.bgColor) settings.bg_color = d.bgColor;
|
||
else delete settings.bg_color;
|
||
// Legacy absence means static; new installations/spaces materialise daynight.
|
||
if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)
|
||
settings.north_deg = d.northDeg;
|
||
else delete settings.north_deg;
|
||
settings.bg_mode = d.bgMode;
|
||
if (d.sunRays) settings.sun_rays = true;
|
||
else delete settings.sun_rays;
|
||
settings.sun_ray_origin = d.sunRayOrigin;
|
||
if (d.showRoomTooltip) delete settings.show_room_tooltip;
|
||
else settings.show_room_tooltip = false;
|
||
const radarSettings = settings.radar;
|
||
if (!radarSettings || radarSettings.version == null || radarSettings.version === 1) {
|
||
settings.radar = { ...(radarSettings || {}), version: 1 };
|
||
if (d.radarShowLive) delete settings.radar.show_live;
|
||
else settings.radar.show_live = false;
|
||
}
|
||
settings = writeZigbeeTopologySettings(settings, d.zigbeeTopology);
|
||
// Old configs may still contain this accepted field, but weather no
|
||
// longer affects sunlight; saving general settings cleans it up.
|
||
delete settings.weather_entity;
|
||
const nextConfig = { ...cfg, settings };
|
||
attempt = this.host._adoption.beginOptimistic(cfg, nextConfig);
|
||
this.host._serverCfg = nextConfig;
|
||
await this._saveConfigNow(attempt);
|
||
if (!d.showRoomTooltip && this.host._tip?.room) this.host._tip = null;
|
||
this.host._settingsDialog = null;
|
||
this.host.requestUpdate();
|
||
this.host._showToast(this.host._t('gs.saved'));
|
||
} catch (e: any) {
|
||
// Esc may close the dialog in flight; the toast must still fire (audit L3).
|
||
// Never overwrite an authoritative conflict reload or newer edit (#439).
|
||
if (attempt) this.host._rollbackOptimistic(attempt);
|
||
if (this.host._settingsDialog) this.host._settingsDialog = { ...this.host._settingsDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public _boolInput(checked: boolean, onChange: (v: boolean) => void, disabled = false): TemplateResult {
|
||
const h = (e: Event) => onChange(!!(e.target as HTMLInputElement).checked);
|
||
return customElements.get('ha-switch')
|
||
? html`<ha-switch .checked=${checked} .disabled=${disabled} @change=${h}></ha-switch>`
|
||
: html`<input type="checkbox" .checked=${checked} ?disabled=${disabled} @change=${h} />`;
|
||
}
|
||
|
||
public _rangeInput(
|
||
min: number, max: number, step: number, value: number,
|
||
onInput: (v: number) => void, disabled = false, ariaLabel?: string,
|
||
): TemplateResult {
|
||
const h = (e: Event) => {
|
||
const n = Number((e.target as HTMLInputElement).value);
|
||
if (Number.isFinite(n)) onInput(n);
|
||
};
|
||
return customElements.get('ha-slider')
|
||
? html`<ha-slider .min=${min} .max=${max} .step=${step} .value=${value}
|
||
.disabled=${disabled} aria-label=${ariaLabel || nothing} @input=${h} @change=${h}></ha-slider>`
|
||
: html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)}
|
||
?disabled=${disabled} aria-label=${ariaLabel || nothing} @input=${h} />`;
|
||
}
|
||
|
||
public _renderSettingsDialog(): TemplateResult { return renderGeneralSettingsDialog.call(this); }
|
||
|
||
public _openRulesDialog = (): void => {
|
||
if (!this.host._norm) return;
|
||
const custom = this.host._settings.icon_rules;
|
||
const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r }));
|
||
this.host._rulesDialog = { rules, test: '', busy: false };
|
||
};
|
||
|
||
public _rulesSet(rules: IconRule[]): void {
|
||
this.host._rulesDialog = { ...this.host._rulesDialog!, rules };
|
||
}
|
||
|
||
public async _saveRules(): Promise<void> {
|
||
const dlg = this.host._rulesDialog;
|
||
if (!dlg || dlg.busy) return;
|
||
const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim());
|
||
this.host._rulesDialog = { ...dlg, busy: true };
|
||
try {
|
||
const cfg = this.host._serverCfg!;
|
||
const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES);
|
||
const settings: any = { ...cfg.settings };
|
||
if (isDefault) delete settings.icon_rules;
|
||
else settings.icon_rules = cleaned;
|
||
this.host._serverCfg = { ...cfg, settings };
|
||
await this._saveConfigNow();
|
||
this.host._rulesDialog = null;
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host._showToast(this.host._t('rules.saved'));
|
||
} catch (e: any) {
|
||
// audit L3: the dialog may have been closed (Esc) while the save was
|
||
// in flight — spreading null yields a truthy husk and the renderer
|
||
// then crashes, blanking the whole card. The toast below is the
|
||
// only remaining signal, so it must still fire.
|
||
if (this.host._rulesDialog) this.host._rulesDialog = { ...this.host._rulesDialog, busy: false };
|
||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||
}
|
||
}
|
||
|
||
public _renderRulesDialog(): TemplateResult {
|
||
const d = this.host._rulesDialog!;
|
||
const compiled = compileIconRules(d.rules);
|
||
const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null;
|
||
const move = (i: number, delta: number) => {
|
||
const r = [...d.rules];
|
||
const j = i + delta;
|
||
if (j < 0 || j >= r.length) return;
|
||
[r[i], r[j]] = [r[j], r[i]];
|
||
this._rulesSet(r);
|
||
};
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="rules" .title=${this.host._t('rules.title')}
|
||
icon="mdi:shape-plus-outline" wide @hp-close=${() => (this.host._rulesDialog = null)}>
|
||
<div class="body">
|
||
<div class="rhint">${this.host._t('rules.hint')}</div>
|
||
<div class="rtest">
|
||
<input class="namein" type="text" placeholder=${this.host._t('rules.test_ph')}
|
||
.value=${d.test}
|
||
@input=${(e: Event) => (this.host._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} />
|
||
${testIcon ? html`<ha-icon icon=${testIcon}></ha-icon><span class="rtesticon">${testIcon}</span>` : nothing}
|
||
</div>
|
||
${d.rules.map((r, i) => {
|
||
const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern);
|
||
return html`<div class="rrow">
|
||
<input class="namein rpat ${bad ? 'bad' : ''}" type="text"
|
||
placeholder=${this.host._t('rules.pattern_ph')}
|
||
title=${bad ? this.host._t('rules.invalid') : ''}
|
||
.value=${r.pattern}
|
||
@input=${(e: Event) => {
|
||
const rules = [...d.rules];
|
||
rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value };
|
||
this._rulesSet(rules);
|
||
}} />
|
||
<input class="namein ricon" type="text" placeholder=${this.host._t('rules.icon_ph')}
|
||
.value=${r.icon}
|
||
@input=${(e: Event) => {
|
||
const rules = [...d.rules];
|
||
rules[i] = { ...r, icon: (e.target as HTMLInputElement).value };
|
||
this._rulesSet(rules);
|
||
}} />
|
||
<ha-icon class="rprev" icon=${r.icon || 'mdi:chip'}></ha-icon>
|
||
<ha-icon class="ract" icon="mdi:arrow-up" title=${this.host._t('btn.up')}
|
||
@click=${() => move(i, -1)}></ha-icon>
|
||
<ha-icon class="ract" icon="mdi:arrow-down" title=${this.host._t('btn.down')}
|
||
@click=${() => move(i, 1)}></ha-icon>
|
||
<ha-icon class="ract del" icon="mdi:close" title=${this.host._t('btn.delete')}
|
||
@click=${() => this._rulesSet(d.rules.filter((_, j) => j !== i))}></ha-icon>
|
||
</div>`;
|
||
})}
|
||
<button class="btn ghost" @click=${() => this._rulesSet([...d.rules, { pattern: '', icon: '' }])}>
|
||
<ha-icon icon="mdi:plus"></ha-icon>${this.host._t('rules.add')}
|
||
</button>
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
|
||
${this.host._t('rules.reset')}
|
||
</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._rulesDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveRules()} ?disabled=${d.busy}>
|
||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this.host._t('btn.save')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
|
||
this.host._kioskScale = { ...this.host._kioskScale, ...patch };
|
||
try {
|
||
localStorage.setItem(LS_KIOSK, JSON.stringify(this.host._kioskScale));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
public _renderKioskDialog(): TemplateResult {
|
||
const k = this.host._kioskScale;
|
||
const row = (key: 'icon' | 'font', label: string) => html`<label>${label}</label>
|
||
<div class="colorrow">
|
||
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))}
|
||
<span class="opv">${Math.round(k[key] * 100)}%</span>
|
||
</div>`;
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="kiosk" .title=${this.host._t('kiosk.title')} icon="mdi:tablet"
|
||
dismiss-on-scrim @hp-close=${() => (this.host._kioskDialog = false)}>
|
||
<div class="body">
|
||
<div class="rhint">${this.host._t('kiosk.hint')}</div>
|
||
${row('icon', this.host._t('kiosk.icon_scale'))}
|
||
${row('font', this.host._t('kiosk.font_scale'))}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" @click=${() => this._saveKioskScale({ icon: 1, font: 1 })}>${this.host._t('gs.reset')}</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn on" data-hp="dialog-cancel" @click=${() => (this.host._kioskDialog = false)}>${this.host._t('btn.close')}</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
|
||
const dev = this.host._devices.find((x) => x.id === dlg.devId);
|
||
if (!dev || !this.host._isVacDev(dev)) return nothing;
|
||
const v = dev.marker?.vacuum || {};
|
||
const includeAllCameras = this.host._vacAllCamerasFor === dev.id;
|
||
const resolution = this.host._vacSourceResolution(dev, includeAllCameras);
|
||
const src = resolution.entityId;
|
||
const selected = resolution.candidates.find((candidate) => candidate.entityId === src) || null;
|
||
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
|
||
const canUseSource = v.live !== false
|
||
&& (resolution.status === 'ok' || resolution.status === 'unsupported');
|
||
const planRooms = this._vacPlanRoomAnchors(dev.space);
|
||
const roomMatches = tele
|
||
? vacRoomNameMatchCount(tele.rooms, planRooms.map((room) => room.name)) : 0;
|
||
const tierA = !!(canUseSource && tele && roomMatches >= 3);
|
||
const setVac = (patch: Record<string, unknown>) => {
|
||
// HP-1540-01: materialise the marker first — find() alone silently
|
||
// dropped every edit for a vacuum that had never been saved
|
||
const m = this.host._vacEnsureMarker(dev);
|
||
if (!m) return;
|
||
m.vacuum = { ...(m.vacuum || {}), ...patch };
|
||
this.host._regSignature = '';
|
||
// First-use markers are materialised above, so the dialog must rebuild
|
||
// immediately instead of waiting for an unrelated HA state tick.
|
||
this.host._maybeRebuildDevices();
|
||
this._saveConfig();
|
||
this.host.requestUpdate();
|
||
};
|
||
const sameDevice = new Set(dev.entities || []);
|
||
const primaryCandidates = resolution.candidates.filter((candidate) =>
|
||
sameDevice.has(candidate.entityId) || candidate.entityId === v.source);
|
||
const globalCandidates = includeAllCameras
|
||
? resolution.candidates.filter((candidate) => candidate.entityId.startsWith('camera.')
|
||
&& !primaryCandidates.some((entry) => entry.entityId === candidate.entityId))
|
||
: [];
|
||
const capability = (candidate: VacSourceCandidate) => [
|
||
candidate.hasPosition ? this.host._t('vac.cap_position') : '',
|
||
candidate.hasRooms ? this.host._t('vac.cap_rooms_short') : '',
|
||
candidate.hasPath ? this.host._t('vac.cap_path') : '',
|
||
candidate.hasMapId ? this.host._t('vac.cap_map') : '',
|
||
].filter(Boolean).join(' · ') || this.host._t('vac.cap_none');
|
||
const candidateButton = (candidate: VacSourceCandidate) => html`
|
||
<button type="button" class="vacsource ${candidate.entityId === src ? 'on' : ''}"
|
||
@click=${() => setVac({ source: candidate.entityId })}>
|
||
<span><b>${candidate.name}</b><small>${candidate.entityId}</small></span>
|
||
<span class="vacsource-meta">${candidate.platform || this.host._t('vac.platform_unknown')} · ${capability(candidate)}</span>
|
||
</button>`;
|
||
const knownSameDeviceXcme = resolution.candidates.some((candidate) =>
|
||
sameDevice.has(candidate.entityId) && candidate.category === 'known_xcme_incomplete');
|
||
const showXcmeHint = knownSameDeviceXcme
|
||
|| (!!resolution.pinned && !!src?.startsWith('camera.') && !selected?.hasPosition);
|
||
const openPicker = () => {
|
||
const picker = [...this.host.renderRoot.querySelectorAll<HTMLDetailsElement>('details.vacpicker')]
|
||
.find((details) => details.dataset.devId === dev.id);
|
||
if (!picker) return;
|
||
picker.open = true;
|
||
picker.querySelector<HTMLElement>('summary')?.focus();
|
||
};
|
||
return html`
|
||
<label>${this.host._t('vac.section')}</label>
|
||
<div class="bindbox vacbox">
|
||
<div class="vacdiag" role="status">
|
||
<div><span>${this.host._t('vac.diag_source')}</span><b>${src || this.host._t('vac.source_none')}</b></div>
|
||
${selected?.platform ? html`<div><span>${this.host._t('vac.diag_platform')}</span><b>${selected.platform}</b></div>` : nothing}
|
||
<div><span>${this.host._t('vac.diag_status')}</span><b>${this.host._t((`vac.source_status_${resolution.status}`) as any)}</b></div>
|
||
<div><span>${this.host._t('vac.diag_position')}</span><b>${tele?.pos ? this.host._t('common.yes') : this.host._t('common.no')}</b></div>
|
||
<div><span>${this.host._t('vac.diag_rooms')}</span><b>${subst(this.host._t('vac.diag_rooms_value'), {
|
||
total: String(tele?.rooms.length || 0), matched: String(roomMatches),
|
||
readiness: this.host._t(roomMatches >= 3 ? 'vac.autocal_ready' : 'vac.autocal_not_ready'),
|
||
})}</b></div>
|
||
<div><span>${this.host._t('vac.diag_path')}</span><b>${tele?.path.length ? this.host._t('common.yes') : this.host._t('common.no')}</b></div>
|
||
<div><span>${this.host._t('vac.diag_map')}</span><b>${tele?.mapId ?? 'default'}</b></div>
|
||
</div>
|
||
${(resolution.status === 'missing' || resolution.status === 'disabled' || resolution.status === 'unverified')
|
||
? html`<div class="warn vacsource-warning">
|
||
<span>${this.host._t((`vac.source_banner_${resolution.status}`) as any)}</span>
|
||
<button type="button" class="btn ghostbtn" @click=${openPicker}>${this.host._t('vac.choose_source')}</button>
|
||
</div>` : nothing}
|
||
${showXcmeHint ? html`<div class="warn vacxcme">
|
||
<b>${this.host._t('vac.xcme_hint')}</b>
|
||
<pre>attributes:
|
||
- vacuum_position
|
||
- rooms
|
||
- path
|
||
- map_name</pre>
|
||
</div>` : nothing}
|
||
<details class="vacpicker" data-dev-id=${dev.id}>
|
||
<summary class="btn ghostbtn">${this.host._t('vac.choose_source')}</summary>
|
||
<div class="vacsource-list">
|
||
<button type="button" class="vacsource ${!resolution.pinned ? 'on' : ''}"
|
||
@click=${() => setVac({ source: null })}>
|
||
<span><b>${this.host._t('vac.source_auto')}</b><small>${this.host._t('vac.source_auto_hint')}</small></span>
|
||
</button>
|
||
${primaryCandidates.map(candidateButton)}
|
||
<details ?open=${includeAllCameras}
|
||
@toggle=${(event: Event) => {
|
||
const open = (event.currentTarget as HTMLDetailsElement).open;
|
||
if (open) this.host._vacOpenAllCameras(dev);
|
||
else {
|
||
this.host._vacAllCamerasFor = null;
|
||
this.host._vacAllCameraCache = null;
|
||
}
|
||
}}>
|
||
<summary>${this.host._t('vac.all_cameras')}</summary>
|
||
<div class="rhint">${this.host._t('vac.all_cameras_warn')}</div>
|
||
${includeAllCameras
|
||
? (globalCandidates.length ? globalCandidates.map(candidateButton)
|
||
: html`<div class="rhint">${this.host._t('vac.all_cameras_empty')}</div>`)
|
||
: nothing}
|
||
</details>
|
||
</div>
|
||
</details>
|
||
<div class="vacbtns">
|
||
${tierA ? html`<button class="btn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacAutoCalibrate(dev)}>${this.host._t('vac.autocal')}</button>` : nothing}
|
||
${canUseSource
|
||
? html`<button class="btn ghostbtn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacStartFit(dev)}>${this.host._t('vac.fit')}</button>`
|
||
: nothing}
|
||
<a class="btn ghostbtn" href="https://github.com/Matysh/houseplan-card/blob/main/docs/VACUUM.md"
|
||
target="_blank" rel="noopener">${this.host._t('vac.documentation')}</a>
|
||
</div>
|
||
${tele ? html`
|
||
<label class="srcrow">
|
||
${this._boolInput(v.live !== false, (on) => setVac({ live: on ? null : false }))}
|
||
<span>${this.host._t('vac.live')}</span>
|
||
</label>
|
||
<label>${this.host._t('vac.trail')}</label>
|
||
<select class="areasel"
|
||
@change=${(e: Event) => setVac({ trail_mode: (e.target as HTMLSelectElement).value, trail: null })}>
|
||
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
|
||
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this.host._t(('vac.trail_' + mv) as any)}</option>`)}
|
||
</select>
|
||
${renderVacuumMapsSection(this, dev, resolution)}
|
||
` : nothing}
|
||
</div>`;
|
||
}
|
||
|
||
public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._planHass): string {
|
||
// #358: the View card owns map-id resolution — it runs inside willUpdate
|
||
// for every vacuum with telemetry on tabs that never load this runtime.
|
||
return this.host._vacMapId(d, tele, planHass);
|
||
}
|
||
|
||
public _vacSaveMatrix(
|
||
markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
|
||
): Promise<boolean> {
|
||
return saveVacuumMatrix(this, markerId, source, mapId, matrix, routeId);
|
||
}
|
||
|
||
public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
|
||
name: string; cx: number; cy: number;
|
||
}> {
|
||
return (this.host._spaceModelById(spaceId)?.rooms || [])
|
||
// HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class;
|
||
// roomPoly() gives the same outline the renderer uses for them.
|
||
.map((room) => ({ room, poly: roomPoly(room) }))
|
||
.filter(({ room, poly }: any) => room.name && poly)
|
||
.map(({ room, poly }: any) => {
|
||
const centroid = areaCentroid(poly);
|
||
return centroid ? { name: String(room.name), cx: centroid[0], cy: centroid[1] } : null;
|
||
})
|
||
.filter(Boolean) as Array<{ name: string; cx: number; cy: number }>;
|
||
}
|
||
|
||
public async _vacAutoCalibrate(d: DevItem): Promise<void> {
|
||
if (this.host._markerDialog?.busy) return;
|
||
const src = this.host._vacSource(d);
|
||
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
|
||
if (!src || !tele || tele.rooms.length < 3) {
|
||
this.host._showToast(this.host._t('vac.autocal_no_rooms'));
|
||
return;
|
||
}
|
||
const mapId = this._vacMapId(d, tele);
|
||
const target = calibrationTarget(d.id, d.marker?.vacuum ?? null, d.space, src, mapId);
|
||
const res = autoCalibrate(tele.rooms, this._vacPlanRoomAnchors(target.space));
|
||
if (!res) {
|
||
this.host._showToast(this.host._t('vac.autocal_no_match'));
|
||
return;
|
||
}
|
||
const rawSpace = this.host._serverCfg?.spaces?.find((space) => space.id === target.space);
|
||
const rawCellCm = Number(rawSpace?.cell_cm);
|
||
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0 ? rawCellCm : 5;
|
||
const residualCm = vacCalibrationResidualCm(res.residual, this.host._gridPitch, cellCm);
|
||
if (residualCm > VAC_CALIBRATION_WARN_CM) {
|
||
this.host._vacCalConfirm = {
|
||
markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space, matrix: res.matrix,
|
||
rooms: res.matched.length,
|
||
error: formatLength(residualCm, this.host.hass?.config?.unit_system?.length === 'mi'),
|
||
};
|
||
return;
|
||
}
|
||
await saveAutomaticCalibration(this, {
|
||
markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space,
|
||
matrix: res.matrix, rooms: res.matched.length,
|
||
});
|
||
}
|
||
|
||
public _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
|
||
return applyCalibrationProposal(this, manual);
|
||
}
|
||
|
||
public _vacStartFit(d: DevItem, routeId = ''): void {
|
||
if (this.host._markerDialog?.busy) return;
|
||
const src = this.host._vacSource(d);
|
||
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
|
||
if (!src || !tele) {
|
||
this.host._showToast(this.host._t('vac.cal_need_pos'));
|
||
return;
|
||
}
|
||
const mapId = this._vacMapId(d, tele);
|
||
const plan = planVacuumFit(d.id, d.marker?.vacuum ?? null, {
|
||
routeId, source: src, mapId, dockSpace: d.space, rooms: tele.rooms,
|
||
viewBoxOf: (id) => (this.host._spaceModelById(id)?.vb as [number, number, number, number]) ?? null,
|
||
});
|
||
if (!plan) return;
|
||
this.host._markerDialog = null;
|
||
if (plan.space !== this.host._space && !this.host._commitSpace(plan.space)) return;
|
||
this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null };
|
||
}
|
||
|
||
public _vacFitSave(): Promise<void> {
|
||
return saveManualCalibration(this, fitMatrix);
|
||
}
|
||
|
||
public _vacFitTurn(patch: Partial<FitParams>): void {
|
||
const f = this.host._vacFit;
|
||
if (!f || f.busy) return;
|
||
const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes);
|
||
const c = this._vacGhostCentre(tele?.rooms || []);
|
||
const next = { ...f.p, ...patch } as FitParams;
|
||
this.host._vacFit = { ...f, p: reanchorFit(next, f.p, c[0], c[1]) };
|
||
}
|
||
|
||
public _vacGhostCentre(rooms: VacRoom[]): VacPt {
|
||
const xs: number[] = [], ys: number[] = [];
|
||
for (const r of rooms) {
|
||
xs.push(r.x0 ?? r.cx, r.x1 ?? r.cx);
|
||
ys.push(r.y0 ?? r.cy, r.y1 ?? r.cy);
|
||
}
|
||
if (!xs.length) return [0, 0];
|
||
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
|
||
}
|
||
|
||
public _vacDelta(view: { w: number; h: number }, dxPx: number, dyPx: number): VacPt {
|
||
const st = this.host._stageEl;
|
||
const w = st?.clientWidth || 1, h = st?.clientHeight || 1;
|
||
return [(dxPx / w) * view.w, (dyPx / h) * view.h];
|
||
}
|
||
|
||
public _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number; h: number }): void {
|
||
const f = this.host._vacFit;
|
||
if (!f) return;
|
||
ev.stopPropagation();
|
||
if (f.busy) return;
|
||
if (ev.type === 'pointerdown') {
|
||
const t = ev.target as HTMLElement;
|
||
const corner = t.getAttribute?.('data-corner');
|
||
try {
|
||
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||
} catch { /* synthetic pointers (tests) have no active id — capture is a nicety */ }
|
||
this.host._vacFit = { ...f, drag: corner
|
||
? { kind: 'scale', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p },
|
||
fx: Number(corner.split(',')[0]), fy: Number(corner.split(',')[1]) }
|
||
: { kind: 'move', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: 0, fy: 0 } };
|
||
return;
|
||
}
|
||
const d = f.drag;
|
||
if (!d) return;
|
||
if (ev.type === 'pointermove') {
|
||
const [dx, dy] = this._vacDelta(view, ev.clientX - d.sx, ev.clientY - d.sy);
|
||
if (d.kind === 'move') {
|
||
this.host._vacFit = { ...f, p: { ...d.p0, ox: d.p0.ox + dx, oy: d.p0.oy + dy } };
|
||
} else {
|
||
// corner-stretch: uniform scale about the OPPOSITE corner (fx, fy —
|
||
// the fixed corner in robot coords), like a graphics-editor frame
|
||
const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes);
|
||
const c = this._vacGhostCentre(tele?.rooms || []);
|
||
const m0 = fitMatrix(d.p0);
|
||
const [gx, gy] = applyAffine(m0, c[0], c[1]);
|
||
const [px0, py0] = applyAffine(m0, d.fx, d.fy);
|
||
const span0 = Math.hypot(gx - px0, gy - py0) || 1;
|
||
// distance change of the dragged corner (opposite of fixed) from centre
|
||
const [cx0, cy0] = [2 * gx - px0, 2 * gy - py0];
|
||
const span1 = Math.hypot(cx0 + dx * 2 - px0, cy0 + dy * 2 - py0) / 2;
|
||
const k = Math.max(0.05, span1 / span0);
|
||
const next = { ...d.p0, s: d.p0.s * k } as FitParams;
|
||
this.host._vacFit = { ...f, p: reanchorFit(next, d.p0, d.fx, d.fy) };
|
||
}
|
||
return;
|
||
}
|
||
if (ev.type === 'pointerup' || ev.type === 'pointercancel') {
|
||
this.host._vacFit = { ...f, drag: null };
|
||
}
|
||
}
|
||
|
||
public _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||
const f = this.host._vacFit;
|
||
if (!f) return nothing;
|
||
const tele = readVacTelemetry(this.host._renderPlanHass?.states?.[f.source]?.attributes);
|
||
if (!tele) return nothing;
|
||
const m = fitMatrix(f.p);
|
||
const rects: TemplateResult[] = [];
|
||
const xs: number[] = [], ys: number[] = [];
|
||
for (const r of tele.rooms) {
|
||
if (r.x0 == null) continue;
|
||
const cs = [[r.x0!, r.y0!], [r.x1!, r.y0!], [r.x1!, r.y1!], [r.x0!, r.y1!]]
|
||
.map(([x, y]) => applyAffine(m, x, y));
|
||
cs.forEach(([x, y]) => { xs.push(x); ys.push(y); });
|
||
const [lx, ly] = applyAffine(m, r.cx, r.cy);
|
||
rects.push(svg`<polygon points="${cs.map((q) => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join(' ')}"></polygon>
|
||
<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}">${r.name}</text>`);
|
||
}
|
||
let dot: TemplateResult | typeof nothing = nothing;
|
||
if (tele.pos) {
|
||
const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y);
|
||
dot = svg`<circle class="vacfitdot" cx="${dx2.toFixed(1)}" cy="${dy2.toFixed(1)}" r="${(view.w * 0.012).toFixed(1)}"></circle>`;
|
||
}
|
||
// corner handles on the ghost bbox; data-corner carries the FIXED corner
|
||
// (the opposite one) in robot coordinates
|
||
const handles: TemplateResult[] = [];
|
||
if (xs.length) {
|
||
const inv = ((): ((x: number, y: number) => VacPt) => {
|
||
const det = m[0] * m[4] - m[1] * m[3];
|
||
return (x, y) => [
|
||
(m[4] * (x - m[2]) - m[1] * (y - m[5])) / det,
|
||
(-m[3] * (x - m[2]) + m[0] * (y - m[5])) / det,
|
||
];
|
||
})();
|
||
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
||
const y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||
// hit radius (finger-sized: these are grabbed on tablets) and the bead
|
||
// you actually see, a quarter of it — same split as .bdhandle/.bdknob
|
||
const r = view.w * 0.022;
|
||
const kr = r / 4;
|
||
for (const [hx, hy, ox2, oy2] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]] as number[][]) {
|
||
const fixed = inv(ox2, oy2);
|
||
handles.push(svg`<circle class="vacfithandle" data-corner="${fixed[0] + ',' + fixed[1]}"
|
||
cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${r.toFixed(1)}"></circle>
|
||
<circle class="vacfitknob" cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${kr.toFixed(2)}"></circle>`);
|
||
}
|
||
}
|
||
return html`<svg class="vacfit" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||
preserveAspectRatio="none"
|
||
@pointerdown=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||
@pointermove=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||
@pointerup=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||
@pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}</svg>`;
|
||
}
|
||
|
||
public _resetRoomDialogFields(): void {
|
||
this.host._roomEditId = null;
|
||
this.host._roomFill = '';
|
||
this.host._roomCustomFill = null;
|
||
this.host._roomTempMin = ''; this.host._roomTempMax = '';
|
||
this.host._roomTempSrc = '';
|
||
this.host._roomHumSrc = '';
|
||
this.host._roomSrcOpen = null;
|
||
this.host._roomSrcFilter = '';
|
||
this.host._roomNameScale = 1;
|
||
this.host._roomLabelScale = 1;
|
||
}
|
||
|
||
public _openRoomEdit(r: RoomCfg): void {
|
||
if (!r.id) return;
|
||
this.host._roomEditId = r.id;
|
||
this.host._nameSel = r.name || '';
|
||
this.host._areaSel = r.area || '';
|
||
this.host._roomFill = r.settings?.fill_mode === 'glow'
|
||
? ''
|
||
: ((r.settings?.fill_mode as any) || '');
|
||
// #581: a colour stored without the room's own custom mode is an orphan and stays out of the draft
|
||
const rawCustom = this.host._roomFill === 'custom' ? r.settings?.custom_fill : null;
|
||
this.host._roomCustomFill = rawCustom && typeof rawCustom === 'object'
|
||
? customFillOf(rawCustom, spaceDisplayOf(this.host._curSpaceCfg).customFill) : null;
|
||
[this.host._roomTempMin, this.host._roomTempMax] = roomTempThresholdInputValues(r.settings);
|
||
this.host._roomTempSrc = r.settings?.temp_source || '';
|
||
this.host._roomHumSrc = r.settings?.hum_source || '';
|
||
this.host._roomNameScale = clampScale(r.settings?.name_scale);
|
||
this.host._roomLabelScale = clampScale(r.settings?.label_scale);
|
||
this.host._roomSrcOpen = null;
|
||
this.host._roomSrcFilter = '';
|
||
this.host._roomDialog = true;
|
||
rememberRoomBaseline(this.host); // #600 К10: Save только при изменениях
|
||
}
|
||
|
||
public _roomSettingsFromDialog(): RoomCfg['settings'] {
|
||
const st: any = {};
|
||
if (this.host._roomFill) st.fill_mode = this.host._roomFill;
|
||
if (this.host._roomFill === 'custom' && this.host._roomCustomFill) st.custom_fill = this.host._roomCustomFill; // #581
|
||
if (this.host._roomTempSrc) st.temp_source = this.host._roomTempSrc;
|
||
if (this.host._roomHumSrc) st.hum_source = this.host._roomHumSrc;
|
||
applyRoomTempThresholdDraft(st, this.host._roomTempMin, this.host._roomTempMax);
|
||
if (this.host._roomNameScale !== 1) st.name_scale = this.host._roomNameScale;
|
||
if (this.host._roomLabelScale !== 1) st.label_scale = this.host._roomLabelScale;
|
||
return Object.keys(st).length ? st : null;
|
||
}
|
||
|
||
public _saveRoomEdit(): void {
|
||
const sp = this.host._curSpaceCfg;
|
||
const room = sp?.rooms.find((x: any) => x.id === this.host._roomEditId);
|
||
if (!room) {
|
||
this.host._roomDialog = false;
|
||
this.host._roomEditId = null;
|
||
return;
|
||
}
|
||
room.name = this.host._nameSel.trim() || room.name;
|
||
room.area = this.host._areaSel || null;
|
||
// Preserve unknown/future room settings. A legacy fill_mode:'glow' materializes its effective
|
||
// Glow in the same write (an unrelated room edit must not switch the overlay off). #581: the room
|
||
// colour is kept only with the room's own custom mode — "as the space" forgets it, orphans included.
|
||
const previous = room.settings || {};
|
||
const next: any = { ...previous };
|
||
if (!applyRoomTempThresholdDraft(next, this.host._roomTempMin, this.host._roomTempMax)) return;
|
||
if (this.host._roomFill) next.fill_mode = this.host._roomFill;
|
||
else delete next.fill_mode;
|
||
if (this.host._roomFill === 'custom' && this.host._roomCustomFill) next.custom_fill = this.host._roomCustomFill;
|
||
else delete next.custom_fill;
|
||
if (previous.fill_mode === 'glow' && typeof previous.glow !== 'boolean') next.glow = true;
|
||
if (this.host._roomTempSrc) next.temp_source = this.host._roomTempSrc;
|
||
else delete next.temp_source;
|
||
if (this.host._roomHumSrc) next.hum_source = this.host._roomHumSrc;
|
||
else delete next.hum_source;
|
||
if (this.host._roomNameScale !== 1) next.name_scale = this.host._roomNameScale;
|
||
else delete next.name_scale;
|
||
if (this.host._roomLabelScale !== 1) next.label_scale = this.host._roomLabelScale;
|
||
else delete next.label_scale;
|
||
if (Object.keys(next).length) room.settings = next;
|
||
else delete room.settings;
|
||
this._saveConfig();
|
||
this.host._roomDialog = false;
|
||
this.host._roomEditId = null;
|
||
this.host._nameSel = '';
|
||
this.host._areaSel = '';
|
||
this.host._regSignature = '';
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
this.host._showToast(this.host._t('toast.room_updated'));
|
||
}
|
||
|
||
public _roomSrcCandidates(): { value: string; label: string; sub: string }[] {
|
||
const h = this.host._planHass;
|
||
const removed = removedPlanBindings(this.host._markers);
|
||
const q = this.host._roomSrcFilter.trim().toLowerCase();
|
||
const list: { value: string; label: string; sub: string }[] = [];
|
||
for (const dev of Object.values<any>(h.devices)) {
|
||
if (dev.entry_type === 'service') continue;
|
||
if (removed.devices.has(dev.id)) continue;
|
||
const name = (dev.name_by_user || dev.name || dev.id).trim();
|
||
if (q && !name.toLowerCase().includes(q)) continue;
|
||
list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this.host._t('marker.sub_device') });
|
||
}
|
||
for (const [eid, reg] of Object.entries<any>(h.entities)) {
|
||
if (!eid.startsWith('sensor.') || reg.hidden) continue;
|
||
if (isRemovedPlanEntity(h, eid, removed)) continue;
|
||
const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid;
|
||
if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue;
|
||
list.push({ value: 'entity:' + eid, label, sub: eid });
|
||
}
|
||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||
return list.slice(0, 200);
|
||
}
|
||
|
||
public _roomSrcLabel(src: string): string {
|
||
const i = src.indexOf(':');
|
||
const k = src.slice(0, i);
|
||
const ref = src.slice(i + 1);
|
||
if (k === 'device') {
|
||
return this.host._fullRegistryHass.devices[ref]?.name_by_user
|
||
|| this.host._fullRegistryHass.devices[ref]?.name || ref;
|
||
}
|
||
return this.host._fullRegistryHass.entities[ref]?.name
|
||
|| this.host.hass.states[ref]?.attributes?.friendly_name || ref;
|
||
}
|
||
|
||
public _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
||
const saved = this.host._layout['rl_' + (r.id || '')];
|
||
if (saved && saved.s === spaceId) {
|
||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||
}
|
||
// a label nobody has dragged sits at the room's CENTROID, which is not a
|
||
// node for an odd-sized or polygonal room — put it on the nearest one
|
||
const c = this._snap(this.host._roomCenter(r));
|
||
return { x: c[0], y: c[1] };
|
||
}
|
||
|
||
public _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
|
||
if (this.host._mode !== 'plan') return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const p = this._labelPos(r, spaceId);
|
||
this.host._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||
capturePointer(ev);
|
||
this.host._tip = null;
|
||
}
|
||
|
||
public _labelMove(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
|
||
const id = 'rl_' + (r.id || '');
|
||
if (!this.host._drag || this.host._drag.id !== id) return;
|
||
const stage = this.host._stageEl;
|
||
if (!stage) return;
|
||
const space = this.host._spaceModelById(spaceId);
|
||
if (!space) return;
|
||
const vb = space.vb;
|
||
const rect = stage.getBoundingClientRect();
|
||
const v = this.host._viewOr(vb);
|
||
const dx = ((ev.clientX - this.host._drag.sx) / rect.width) * v.w;
|
||
const dy = ((ev.clientY - this.host._drag.sy) / rect.height) * v.h;
|
||
if (Math.abs(ev.clientX - this.host._drag.sx) + Math.abs(ev.clientY - this.host._drag.sy) > 3) this.host._drag.moved = true;
|
||
// DEV-B58-01, and worse than the marker's: this clamped to the space's
|
||
// STORED view_box, which for every existing plan is the old unit square.
|
||
// A room drawn at 2.5 had a name that could not be dragged to its own room.
|
||
const nx = clampCanvasR(this.host._drag.ox + dx);
|
||
const ny = clampCanvasR(this.host._drag.oy + dy);
|
||
this.host._savePos({ id, space: spaceId } as DevItem, nx, ny);
|
||
}
|
||
|
||
public _labelUp(r: RoomCfg): void {
|
||
const id = 'rl_' + (r.id || '');
|
||
if (!this.host._drag || this.host._drag.id !== id) return;
|
||
const moved = this.host._drag.moved;
|
||
this.host._drag = moved ? this.host._drag : null;
|
||
if (moved) window.setTimeout(() => (this.host._drag = null), 0);
|
||
}
|
||
|
||
public _labelScale(r: RoomCfg): number {
|
||
const k = (this.host._layout['rl_' + (r.id || '')] as any)?.k;
|
||
return typeof k === 'number' && Number.isFinite(k) ? Math.min(3, Math.max(0.5, k)) : 1;
|
||
}
|
||
|
||
public _rlResizeDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
|
||
if (this.host._mode !== 'plan') return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const card = (ev.target as HTMLElement).closest('.roomlabel') as HTMLElement | null;
|
||
if (!card) return;
|
||
const b = card.getBoundingClientRect();
|
||
const cx = b.left + b.width / 2;
|
||
const cy = b.top + b.height / 2;
|
||
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
||
this.host._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
||
capturePointer(ev);
|
||
}
|
||
|
||
public _rlResizeMove(ev: PointerEvent): void {
|
||
const rs = this.host._rlResize;
|
||
if (!rs) return;
|
||
ev.stopPropagation();
|
||
const dist = Math.max(8, Math.hypot(ev.clientX - rs.cx, ev.clientY - rs.cy));
|
||
const k = Math.min(3, Math.max(0.5, rs.k0 * (dist / rs.d0)));
|
||
const rec: any = this.host._layout[rs.id];
|
||
if (!rec) {
|
||
// the card was never dragged: pin its current default position first
|
||
const roomId = rs.id.slice(3);
|
||
const sp = this.host._spaceModelById(rs.space);
|
||
if (!sp) return;
|
||
const room = sp.rooms.find((x) => x.id === roomId);
|
||
if (!room) return;
|
||
const p = this._labelPos(room, rs.space);
|
||
|
||
this.host._layout = {
|
||
...this.host._layout,
|
||
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k },
|
||
};
|
||
} else {
|
||
this.host._layout = { ...this.host._layout, [rs.id]: { ...rec, k } };
|
||
}
|
||
this.host._dirtyPos.add(rs.id);
|
||
}
|
||
|
||
public _rlResizeUp(): void {
|
||
if (!this.host._rlResize) return;
|
||
this.host._rlResize = null;
|
||
this.host._persistLayout();
|
||
}
|
||
|
||
public _renderRoomGear(
|
||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number },
|
||
): TemplateResult | typeof nothing {
|
||
if (!r.id) return nothing;
|
||
let c: number[] | null = null;
|
||
if (r.poly) {
|
||
// the VISUAL centre (largest inscribed circle) — interiorPoint only
|
||
// promises "inside", which sat visibly off-centre on an L-shaped room.
|
||
// The model is memoized, so the poly array is a stable cache key.
|
||
c = this.host._gearPtCache.get(r.poly) || null;
|
||
if (!c) { c = poleOfInaccessibility(r.poly); this.host._gearPtCache.set(r.poly, c); }
|
||
} else if (r.x != null && r.y != null) {
|
||
c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2];
|
||
}
|
||
if (!c) return nothing;
|
||
const left = ((c[0] - view.x) / view.w) * 100;
|
||
const top = ((c[1] - view.y) / view.h) * 100;
|
||
return html`<button class="rlgearbtn" data-hp="room-settings" data-room=${r.id}
|
||
style="left:${left}%;top:${top}%"
|
||
title=${this.host._t('room.settings_title')}
|
||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||
<span class="rlgeartext">${this.host._t('room.settings_short')}</span>
|
||
</button>`;
|
||
}
|
||
|
||
public _alignCandidates(): number[][] {
|
||
const out: number[][] = [];
|
||
const spm = this.host._spaceModel();
|
||
if (this.host._markup) {
|
||
if (!spm) return out;
|
||
if (this.host._drag?.id.startsWith('rl_')) {
|
||
// room-card drag: centers of the OTHER room cards
|
||
const dragged = this.host._drag.id.slice(3);
|
||
for (const r of spm.rooms) {
|
||
if (!r.name || r.id === dragged) continue;
|
||
const p = this._labelPos(r, this.host._space);
|
||
out.push([p.x, p.y]);
|
||
}
|
||
return out;
|
||
}
|
||
// drawing: room vertices + current path/split points
|
||
for (const r of spm.rooms) {
|
||
const poly = roomPoly(r);
|
||
if (poly) for (const p of poly) out.push(p);
|
||
}
|
||
if (this.host._tool === 'draw') for (const p of this.host._path) out.push(p);
|
||
if (this.host._tool === 'split' && this.host._splitSel?.pts) for (const p of this.host._splitSel.pts) out.push(p);
|
||
return out;
|
||
}
|
||
if (this.host._mode === 'devices') {
|
||
// other icons of this space only (owner's decision)
|
||
//
|
||
// #400: the dragged marker is excluded by `_deviceDrag`, not `_drag`.
|
||
// Device dragging moved into its own state with #74, and `_drag` is
|
||
// null in this mode — so the marker being moved was listed among its
|
||
// own alignment candidates. Nothing looked wrong because a point always
|
||
// matches itself within tolerance: the guide was drawn from the marker
|
||
// to itself and is indistinguishable from an honest one.
|
||
const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id;
|
||
for (const d of this.host._devices) {
|
||
if (d.space !== this.host._space || d.id === draggedId
|
||
|| d.bindingStatus?.kind === 'ha_disabled') continue;
|
||
const p = this.host._pos(d);
|
||
out.push([p.x, p.y]);
|
||
}
|
||
return out;
|
||
}
|
||
if (this.host._mode === 'decor') {
|
||
const movingId = this.host._decorMove?.id;
|
||
out.push(...this._decorSnapGeometry(movingId).points);
|
||
if (this.host._decorDraft) out.push(this.host._decorDraft.a);
|
||
return out;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
public _renderAlignGuides(): TemplateResult {
|
||
const pt = this.host._alignPoint;
|
||
if (!pt) return svg`` as unknown as TemplateResult;
|
||
// exact node match for grid-snapped things; half a cell for free-moving cards
|
||
const tol = this.host._drag?.id.startsWith('rl_') ? this.host._gridPitch * 0.5 : this.host._gridPitch * 0.05;
|
||
const guides = alignGuides(pt, this._alignCandidates(), tol);
|
||
if (!guides.length) return svg`` as unknown as TemplateResult;
|
||
const g = this.host._gridPitch;
|
||
const over = g * 1.5; // extend a little past the point
|
||
return svg`<g class="alignguides">
|
||
${guides.map((gd: AlignGuide) => {
|
||
const [x1, y1, x2, y2] = gd.axis === 'x'
|
||
? [gd.at, gd.from[1], gd.at, pt[1] + Math.sign(pt[1] - gd.from[1]) * over]
|
||
: [gd.from[0], gd.at, pt[0] + Math.sign(pt[0] - gd.from[0]) * over, gd.at];
|
||
return svg`<line class="alignline" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"></line>
|
||
<circle class="aligndot" cx="${gd.from[0]}" cy="${gd.from[1]}"
|
||
r="${gridVisualUnits(g * 0.18, this.host._cellCm)}"></circle>`;
|
||
})}
|
||
</g>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderOpeningCenterTick(gd: { x: number; y: number; angle: number }): TemplateResult {
|
||
const rad = ((gd.angle + 90) * Math.PI) / 180;
|
||
const half = gridVisualUnits(2.5 * 6, this.host._cellCm);
|
||
return svg`<line class="alignline opcentertick"
|
||
x1="${gd.x - Math.cos(rad) * half}" y1="${gd.y - Math.sin(rad) * half}"
|
||
x2="${gd.x + Math.cos(rad) * half}" y2="${gd.y + Math.sin(rad) * half}"></line>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderOpeningDimensionGuides(measure: OpMeasure): TemplateResult {
|
||
const dimensions = measure.labels.flatMap((label) => label.dimension ? [label.dimension] : []);
|
||
if (!dimensions.length) return svg`` as unknown as TemplateResult;
|
||
const tickHalf = this._cssPxToRender(4);
|
||
return svg`<g class="opening-dimensions" aria-hidden="true" pointer-events="none">
|
||
${dimensions.map((dimension) => {
|
||
const nx = -dimension.axis[1], ny = dimension.axis[0];
|
||
const tick = (point: [number, number], end: string) => svg`<line
|
||
class="opening-dimension-tick" data-end=${end}
|
||
x1=${point[0] - nx * tickHalf} y1=${point[1] - ny * tickHalf}
|
||
x2=${point[0] + nx * tickHalf} y2=${point[1] + ny * tickHalf}></line>`;
|
||
return svg`<g class="opening-dimension" data-source=${dimension.source}
|
||
data-room=${dimension.roomId || nothing}>
|
||
<line class="opening-dimension-line"
|
||
x1=${dimension.from[0]} y1=${dimension.from[1]}
|
||
x2=${dimension.to[0]} y2=${dimension.to[1]}></line>
|
||
${tick(dimension.from, 'from')}${tick(dimension.to, 'to')}
|
||
</g>`;
|
||
})}
|
||
</g>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderOpeningPlacementPreview(): TemplateResult {
|
||
const candidate = this.host._openingPreview;
|
||
if (!candidate) return svg``;
|
||
const visibleSpec: OpeningVisibleSpec = {
|
||
type: candidate.type,
|
||
length: candidate.renderedLength,
|
||
angle: candidate.angle,
|
||
amount: openingAmount(candidate.type, null),
|
||
flipH: candidate.flipH,
|
||
flipV: candidate.flipV,
|
||
base: 'var(--hp-open)',
|
||
tone: 'var(--hp-open)',
|
||
cellCm: this.host._cellCm,
|
||
gridPitch: this.host._gridPitch,
|
||
face: candidate.face,
|
||
};
|
||
const passageGeometry = candidate.type === 'passage'
|
||
? passagePlacementPreviewGeometry(
|
||
candidate, gridVisualUnits(this.host._gridPitch, this.host._cellCm),
|
||
)
|
||
: null;
|
||
return svg`<g class="opening-preview" data-kind=${candidate.type}
|
||
aria-hidden="true" pointer-events="none"
|
||
transform="translate(${candidate.x} ${candidate.y}) rotate(${candidate.angle})">
|
||
${passageGeometry ? svg`
|
||
<rect class="passage-preview-cut" pointer-events="none"
|
||
x=${passageGeometry.rect.x} y=${passageGeometry.rect.y}
|
||
width=${passageGeometry.rect.width} height=${passageGeometry.rect.height}></rect>
|
||
${passageGeometry.boundaries.map((boundary) => svg`
|
||
<line class="passage-preview-boundary" pointer-events="none"
|
||
x1=${boundary.x1} y1=${boundary.y1}
|
||
x2=${boundary.x2} y2=${boundary.y2}></line>`)}
|
||
` : renderOpeningVisibleGeometry(visibleSpec)}
|
||
</g>
|
||
<circle class="opening-preview-dot opghost-dot" aria-hidden="true" pointer-events="none"
|
||
cx=${candidate.x} cy=${candidate.y}
|
||
r=${gridVisualUnits(this.host._gridPitch * 0.18, this.host._cellCm)}></circle>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderOpeningDialog(): TemplateResult {
|
||
const d = this.host._openingDialog!;
|
||
const partitions = this.host._spaceModel()?.partitions || [];
|
||
const previous = d.id
|
||
? this.host._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === d.id)
|
||
: null;
|
||
const dialogOpening: OpeningCfg = {
|
||
id: d.id || 'preview', type: d.type,
|
||
x: d.x / NORM_W, y: d.y / this.host._spaceH,
|
||
angle: d.angle,
|
||
length: previous && !d.lengthTouched
|
||
? previous.length
|
||
: this.host._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
|
||
...(d.host ? { host: d.host } : {}),
|
||
};
|
||
const strict = partitionOpeningNeedsStrictValidation(previous, dialogOpening);
|
||
const hostResolution = d.host
|
||
? strict
|
||
? resolvePartitionOpeningStrict(
|
||
dialogOpening, partitions, NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
)
|
||
: resolvePartitionOpeningCompat(
|
||
dialogOpening, partitions, NORM_W, this.host._cellCm, this.host._gridPitch,
|
||
)
|
||
: null;
|
||
const jambInvalid = hostResolution?.reason === 'does-not-fit-jamb';
|
||
const hostPartition = d.host
|
||
? partitions.find((partition) => partition.id === d.host!.id)
|
||
: null;
|
||
const jambDistance = hostPartition
|
||
? formatLength(hostPartition.cm / 2, this.host._imperial)
|
||
: '';
|
||
const orphan = !!d.host && !hostResolution?.resolved && !jambInvalid;
|
||
const icon = d.type === 'gate' ? 'mdi:gate'
|
||
: d.type === 'window' ? 'mdi:window-closed-variant'
|
||
: d.type === 'passage' ? 'mdi:arch' : 'mdi:door';
|
||
const picker = (kind: 'contact' | 'lock', list: { value: string; label: string }[]) => {
|
||
const cur = kind === 'contact' ? d.contact : d.lock;
|
||
const open = kind === 'contact' ? !!d.contactOpen : !!d.lockOpen;
|
||
const filter = kind === 'contact' ? d.contactFilter || '' : d.lockFilter || '';
|
||
const selected = list.find((candidate) => candidate.value === cur);
|
||
const selectedLabel = selected?.label
|
||
|| this.host.hass.states[cur]?.attributes?.friendly_name
|
||
|| this.host._fullRegistryHass.entities[cur]?.name
|
||
|| cur;
|
||
const filtered = filterOpeningEntityCandidates(list, filter);
|
||
return html`
|
||
<button type="button" class="dropbtn opening-entity-drop ${open ? 'open' : ''}"
|
||
data-opening-picker=${kind} aria-expanded=${open ? 'true' : 'false'}
|
||
@click=${() => this._toggleOpeningEntityPicker(kind)}>
|
||
${cur
|
||
? html`<b>${selectedLabel}</b><span class="ref">${cur}</span>`
|
||
: html`<span class="muted">${this.host._t('opening.none')}</span>`}
|
||
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
|
||
</button>
|
||
${open
|
||
? html`<div class="droppanel opening-entity-panel" data-opening-panel=${kind}>
|
||
<input class="namein opening-entity-search" type="text"
|
||
placeholder=${this.host._t('opening.search_ph')} .value=${filter}
|
||
@input=${(e: Event) => this._filterOpeningEntities(
|
||
kind, (e.target as HTMLInputElement).value,
|
||
)} />
|
||
<div class="candlist">
|
||
<button type="button" class="cand opening-entity-candidate ${cur ? '' : 'sel'}"
|
||
data-opening-entity="" @click=${() => this._selectOpeningEntity(kind, '')}>
|
||
<span class="cl">${this.host._t('opening.none')}</span>
|
||
</button>
|
||
${filtered.map((candidate) => html`
|
||
<button type="button"
|
||
class="cand opening-entity-candidate ${candidate.value === cur ? 'sel' : ''}"
|
||
data-opening-entity=${candidate.value}
|
||
@click=${() => this._selectOpeningEntity(kind, candidate.value)}>
|
||
<span class="cl">${candidate.label}</span>
|
||
<span class="cs">${candidate.value}</span>
|
||
</button>`)}
|
||
${!filtered.length
|
||
? html`<div class="cand muted opening-entity-empty">${this.host._t('marker.nothing_found')}</div>`
|
||
: nothing}
|
||
</div>
|
||
</div>`
|
||
: nothing}`;
|
||
};
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="opening" wide
|
||
.title=${d.id ? this.host._t('opening.edit') : this.host._t('opening.new')} icon=${icon}
|
||
@hp-close=${() => (this.host._openingDialog = null)}>
|
||
<div class="body">
|
||
${d.host ? html`<label>${this.host._t('opening.host_partition')}</label>
|
||
<div class=${orphan || jambInvalid ? 'habindingbanner' : 'rhint'}
|
||
role=${orphan || jambInvalid ? 'status' : nothing}>
|
||
${orphan || jambInvalid ? html`<ha-icon icon="mdi:alert-outline"></ha-icon>` : nothing}
|
||
<span>${orphan
|
||
? this.host._t('opening.partition_orphan')
|
||
: jambInvalid
|
||
? this.host._t('opening.partition_jamb_margin', { distance: jambDistance })
|
||
: this.host._t('opening.host_partition')}</span>
|
||
</div>` : nothing}
|
||
<label>${this.host._t('opening.type_label')}</label>
|
||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
|
||
@change=${() => (this.host._openingDialog = {
|
||
...d, type: 'window', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('window'),
|
||
contactOpen: false, lockOpen: false,
|
||
})} />
|
||
<span>${this.host._t('opening.window')}</span></label>
|
||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
|
||
@change=${() => (this.host._openingDialog = {
|
||
...d, type: 'door', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('door'),
|
||
contactOpen: false, lockOpen: false,
|
||
})} />
|
||
<span>${this.host._t('opening.door')}</span></label>
|
||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'passage'}
|
||
@change=${() => (this.host._openingDialog = {
|
||
...d, type: 'passage', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('passage'),
|
||
contactOpen: false, lockOpen: false,
|
||
})} />
|
||
<span>${this.host._t('opening.passage')}</span></label>
|
||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'gate'}
|
||
@change=${() => (this.host._openingDialog = {
|
||
...d, type: 'gate',
|
||
lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('gate'), flipH: false,
|
||
contactOpen: false, lockOpen: false,
|
||
})} />
|
||
<span>${this.host._t('opening.gate')}</span></label>
|
||
|
||
<label>${this.host._t('opening.length_label')}</label>
|
||
<input class="namein tempin" type="number" min="20" max="600" step="5" .value=${String(d.lengthCm)}
|
||
@input=${(e: Event) => {
|
||
const n = strictNumber((e.target as HTMLInputElement).value);
|
||
if (n != null) this.host._openingDialog = { ...d, lengthCm: n, lengthTouched: true };
|
||
}} />
|
||
|
||
${d.type === 'passage' && (d.contact || d.lock)
|
||
? html`<div class="habindingbanner" role="status" aria-live="polite">
|
||
<ha-icon icon="mdi:alert-outline"></ha-icon>
|
||
<span>${this.host._t('opening.passage_binding_warning')}</span>
|
||
</div>`
|
||
: nothing}
|
||
|
||
${d.type !== 'passage'
|
||
? html`<label>${this.host._t('opening.contact_label')}</label>
|
||
${picker('contact', this._contactCandidates())}
|
||
${d.contact
|
||
? html`<label class="srcrow">${this._boolInput(d.invert, (v) => (this.host._openingDialog = { ...d, invert: v }))}
|
||
<span>${this.host._t('opening.invert')}</span></label>`
|
||
: nothing}`
|
||
: nothing}
|
||
|
||
${d.type === 'door' || d.type === 'gate'
|
||
? html`<label>${this.host._t('opening.lock_label')}</label>
|
||
${picker('lock', this._lockCandidates())}`
|
||
: nothing}
|
||
|
||
${d.type !== 'gate' && d.type !== 'passage'
|
||
? html`<label class="srcrow">${this._boolInput(d.flipH, (v) => (this.host._openingDialog = { ...d, flipH: v }))}
|
||
<span>${this.host._t('opening.flip_h')}</span></label>`
|
||
: nothing}
|
||
${d.type !== 'passage'
|
||
? html`<label class="srcrow">${this._boolInput(d.flipV, (v) => (this.host._openingDialog = { ...d, flipV: v }))}
|
||
<span>${this.host._t('opening.flip_v')}</span></label>`
|
||
: nothing}
|
||
</div>
|
||
<div class="row dialog-action-footer" slot="footer">
|
||
${d.id
|
||
? html`<div class="dialog-action-group dialog-action-danger">
|
||
<button class="btn danger" @click=${() => this._deleteOpening()}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||
</button>
|
||
</div>`
|
||
: nothing}
|
||
${orphan
|
||
? html`<button class="btn ghost" @click=${() => this._rebindPartitionOpening()}>
|
||
${this.host._t('opening.rebind_partition')}
|
||
</button>`
|
||
: nothing}
|
||
<div class="dialog-action-group dialog-action-commit">
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._openingDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveOpening()}>
|
||
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _gridLevels(): { fine: number; coarse: number } | null {
|
||
const stage = this.host._stageEl;
|
||
const v = this.host._viewOr(this.host._baseVb());
|
||
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
|
||
return gridLevels(this.host._gridPitch, px);
|
||
}
|
||
|
||
public _renderMarkupDefs(_vb: number[]): TemplateResult {
|
||
const lv = this._gridLevels();
|
||
if (!lv) return svg`<defs></defs>` as unknown as TemplateResult;
|
||
const g = this.host._gridPitch * lv.fine;
|
||
const G = this.host._gridPitch * lv.coarse;
|
||
const dotR = this.host._gridPitch * lv.fine * 0.14;
|
||
// Two patterns, CAD-style: the fine dots thin out as you zoom away
|
||
// (gridLevels drops whole decades of them) while every coarse node keeps
|
||
// a bigger, darker dot, so the eye never loses the scale reference.
|
||
return svg`<defs>
|
||
<pattern id="hp-grid" x="0" y="0" width="${g}" height="${g}" patternUnits="userSpaceOnUse">
|
||
<circle cx="0" cy="0" r="${dotR}" class="griddot"></circle>
|
||
<circle cx="${g}" cy="0" r="${dotR}" class="griddot"></circle>
|
||
<circle cx="0" cy="${g}" r="${dotR}" class="griddot"></circle>
|
||
<circle cx="${g}" cy="${g}" r="${dotR}" class="griddot"></circle>
|
||
</pattern>
|
||
<pattern id="hp-grid-major" x="0" y="0" width="${G}" height="${G}" patternUnits="userSpaceOnUse">
|
||
<rect width="${G}" height="${G}" fill="url(#hp-grid)"></rect>
|
||
<circle cx="0" cy="0" r="${dotR * 2.1}" class="griddot major"></circle>
|
||
<circle cx="${G}" cy="0" r="${dotR * 2.1}" class="griddot major"></circle>
|
||
<circle cx="0" cy="${G}" r="${dotR * 2.1}" class="griddot major"></circle>
|
||
<circle cx="${G}" cy="${G}" r="${dotR * 2.1}" class="griddot major"></circle>
|
||
</pattern>
|
||
</defs>`;
|
||
}
|
||
|
||
public _renderPhysicalEditorLayer(): TemplateResult {
|
||
const space = this.host._spaceModel();
|
||
if (!space) return svg`` as unknown as TemplateResult;
|
||
const g = this.host._gridPitch;
|
||
const view = this.host._viewOr(this.host._baseVb());
|
||
const stage = this.host._stageEl;
|
||
const unitsPerPx = stage?.clientWidth ? view.w / stage.clientWidth : g / 8;
|
||
const touchStroke = 24;
|
||
const handleR = Math.max(g * 0.22, unitsPerPx * 8);
|
||
const rotateHandleR = Math.max(handleR, unitsPerPx * 12);
|
||
const path = (poly: number[][]) => `M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
|
||
const selected = (kind: string, id: string) =>
|
||
(this.host._physicalSel?.kind === kind && this.host._physicalSel.id === id)
|
||
|| (kind === 'column' && this.host._duplicateColumnId === id);
|
||
const partitions = (space.partitions || []).map((p) => {
|
||
const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch);
|
||
return body
|
||
? svg`<path class="physical-hit ${selected('partition', p.id) ? 'selected' : ''}"
|
||
data-hp="partition" data-kind="partition" data-id=${p.id} d=${path(body)}
|
||
stroke-width=${touchStroke} vector-effect="non-scaling-stroke"
|
||
@pointerdown=${(e: PointerEvent) => this._physicalDown(e, 'partition', p.id)}
|
||
@pointermove=${(e: PointerEvent) => this._physicalMove(e)}
|
||
@pointerup=${(e: PointerEvent) => this._physicalUp(e)}></path>`
|
||
: svg`<line class="physical-hit ${selected('partition', p.id) ? 'selected' : ''}"
|
||
data-hp="partition" data-kind="partition" data-id=${p.id}
|
||
x1=${p.a[0]} y1=${p.a[1]} x2=${p.b[0]} y2=${p.b[1]}
|
||
stroke-width=${touchStroke} vector-effect="non-scaling-stroke"
|
||
@pointerdown=${(e: PointerEvent) => this._physicalDown(e, 'partition', p.id)}
|
||
@pointermove=${(e: PointerEvent) => this._physicalMove(e)}
|
||
@pointerup=${(e: PointerEvent) => this._physicalUp(e)}></line>`;
|
||
});
|
||
const columns = (space.wall_columns || []).map((c) => {
|
||
const shown = this.host._physicalRotate?.id === c.id
|
||
? { ...c, angle: this.host._physicalRotate.angle } as WallColumnCfg : c;
|
||
const body = columnBody(shown, this.host._cellCm, this.host._gridPitch);
|
||
return svg`<path class="physical-hit ${selected('column', c.id) ? 'selected' : ''}"
|
||
data-hp="wall-column" data-kind=${c.shape} data-id=${c.id} d=${path(body)}
|
||
stroke-width=${touchStroke} vector-effect="non-scaling-stroke"
|
||
@pointerdown=${(e: PointerEvent) => this._physicalDown(e, 'column', c.id)}
|
||
@pointermove=${(e: PointerEvent) => this._physicalMove(e)}
|
||
@pointerup=${(e: PointerEvent) => this._physicalUp(e)}></path>`;
|
||
});
|
||
const drag = this.host._physicalDrag;
|
||
const ghost = (() => {
|
||
if (!drag?.moved) return nothing;
|
||
if (drag.kind === 'partition' && Number((drag.base as PartitionCfg).cm) === 0) {
|
||
const partition = drag.base as PartitionCfg;
|
||
return svg`<line class="physical-drag zero"
|
||
x1=${partition.a[0]} y1=${partition.a[1]}
|
||
x2=${partition.b[0]} y2=${partition.b[1]}
|
||
transform="translate(${drag.delta[0]} ${drag.delta[1]})"></line>`;
|
||
}
|
||
const poly = drag.kind === 'partition'
|
||
? partitionBody((drag.base as PartitionCfg).a, (drag.base as PartitionCfg).b,
|
||
(drag.base as PartitionCfg).cm, this.host._cellCm, this.host._gridPitch)
|
||
: columnBody(drag.base as WallColumnCfg, this.host._cellCm, this.host._gridPitch);
|
||
return poly ? svg`<path class="physical-drag" d=${path(poly)}
|
||
transform="translate(${drag.delta[0]} ${drag.delta[1]})"></path>` : nothing;
|
||
})();
|
||
const chrome = (() => {
|
||
const sel = this.host._physicalSel;
|
||
if (!sel) return nothing;
|
||
if (sel.kind === 'partition') {
|
||
const p = space.partitions.find((x) => x.id === sel.id);
|
||
if (!p) return nothing;
|
||
const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch);
|
||
const mx = (p.a[0] + p.b[0]) / 2, my = (p.a[1] + p.b[1]) / 2;
|
||
return svg`<g class="physical-chrome" data-kind="partition-selection">
|
||
${body
|
||
? svg`<path class="frame" d=${path(body)}></path>`
|
||
: svg`<line class="frame" x1=${p.a[0]} y1=${p.a[1]}
|
||
x2=${p.b[0]} y2=${p.b[1]}></line>`}
|
||
<circle class="move-dot" cx=${p.a[0]} cy=${p.a[1]} r=${handleR * 0.55}></circle>
|
||
<circle class="move-dot" cx=${p.b[0]} cy=${p.b[1]} r=${handleR * 0.55}></circle>
|
||
<circle class="move-dot" cx=${mx} cy=${my} r=${handleR}></circle>
|
||
</g>`;
|
||
}
|
||
const base = space.wall_columns.find((x) => x.id === sel.id);
|
||
if (!base) return nothing;
|
||
const c = this.host._physicalRotate?.id === base.id
|
||
? { ...base, angle: this.host._physicalRotate.angle } as WallColumnCfg : base;
|
||
const body = columnBody(c, this.host._cellCm, this.host._gridPitch);
|
||
if (c.shape !== 'square') return svg`<g class="physical-chrome" data-kind="circle-selection">
|
||
<path class="frame" d=${path(body)}></path>
|
||
<circle class="move-dot" cx=${c.center[0]} cy=${c.center[1]} r=${handleR}></circle>
|
||
</g>`;
|
||
const size = wallCmToUnits(c.cm, this.host._cellCm, this.host._gridPitch);
|
||
const angle = canonicalColumnAngle(c.angle) * Math.PI / 180;
|
||
const ux = Math.sin(angle), uy = -Math.cos(angle);
|
||
const edge = [c.center[0] + ux * size / 2, c.center[1] + uy * size / 2];
|
||
const handle = [edge[0] + ux * Math.max(g, unitsPerPx * 24),
|
||
edge[1] + uy * Math.max(g, unitsPerPx * 24)];
|
||
return svg`<g class="physical-chrome" data-kind="square-selection">
|
||
<path class="frame" d=${path(body)}></path>
|
||
<circle class="move-dot" cx=${c.center[0]} cy=${c.center[1]} r=${handleR}></circle>
|
||
<line class="stem" x1=${edge[0]} y1=${edge[1]} x2=${handle[0]} y2=${handle[1]}></line>
|
||
<circle class="rotate-handle" cx=${handle[0]} cy=${handle[1]} r=${rotateHandleR}
|
||
data-kind="rotate" @pointerdown=${(e: PointerEvent) => this._physicalRotateDown(e, base)}
|
||
@pointermove=${(e: PointerEvent) => this._physicalRotateMove(e)}
|
||
@pointerup=${(e: PointerEvent) => this._physicalRotateUp(e)}></circle>
|
||
</g>`;
|
||
})();
|
||
return svg`<g class="physical-editor">${partitions}${columns}${ghost}${chrome}</g>`;
|
||
}
|
||
|
||
public _renderHiddenWallDiagnosticOverlay(): TemplateResult {
|
||
if (!this.host._markup) return svg`` as unknown as TemplateResult;
|
||
const geometry = this._hiddenWallDiagnosticSnapshot().value;
|
||
if (!geometry.segments.length) return svg`` as unknown as TemplateResult;
|
||
const radius = wallCmToUnits(5, this.host._cellCm, this.host._gridPitch);
|
||
return svg`<g class="hidden-wall-diagnostic" data-hp="hidden-wall-diagnostic"
|
||
data-segment-count=${geometry.segments.length}
|
||
data-endpoint-count=${geometry.endpoints.length}
|
||
aria-hidden="true" pointer-events="none">
|
||
${geometry.segments.map((segment) => svg`<line class="hidden-wall-line"
|
||
data-key=${segment.key} data-source-kind=${segment.sourceKind}
|
||
data-source-id=${segment.sourceId}
|
||
x1=${segment.a[0]} y1=${segment.a[1]} x2=${segment.b[0]} y2=${segment.b[1]}
|
||
vector-effect="non-scaling-stroke" pointer-events="none"></line>`)}
|
||
${geometry.endpoints.map((endpoint) => svg`<circle class="hidden-wall-node"
|
||
data-key=${endpoint.key} data-source-kind=${endpoint.sourceKind}
|
||
data-source-id=${endpoint.sourceId}
|
||
cx=${endpoint.point[0]} cy=${endpoint.point[1]} r=${radius}
|
||
pointer-events="none"></circle>`)}
|
||
</g>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderPlanSnapOverlay(): TemplateResult {
|
||
if (!this.host._markup) {
|
||
return svg`` as unknown as TemplateResult;
|
||
}
|
||
const geometry = this._planSnapGeometrySnapshot().value;
|
||
const active = this.host._activePlanSnapCandidate;
|
||
const conflictKeys = new Set(this.host._activePlanSnapConflicts.map((item) => item.key));
|
||
const staticRadius = wallCmToUnits(5, this.host._cellCm, this.host._gridPitch);
|
||
const activeRadius = wallCmToUnits(10, this.host._cellCm, this.host._gridPitch);
|
||
return svg`<g class="plan-snap-overlay" data-hp="plan-snap-overlay"
|
||
data-segment-count=${geometry.segments.length}
|
||
data-endpoint-count=${geometry.endpoints.length}
|
||
aria-hidden="true" pointer-events="none">
|
||
${guard([geometry, staticRadius, [...conflictKeys].sort().join('|')], () => svg`
|
||
${geometry.segments.map((segment) => svg`<line class="plan-snap-line"
|
||
data-key=${segment.key} data-source-kind=${segment.sourceKind}
|
||
x1=${segment.a[0]} y1=${segment.a[1]} x2=${segment.b[0]} y2=${segment.b[1]}
|
||
vector-effect="non-scaling-stroke" pointer-events="none"></line>`)}
|
||
${geometry.endpoints.map((endpoint) => svg`<circle class="plan-snap-node ${conflictKeys.has(endpoint.key) ? 'conflict' : ''}"
|
||
data-kind="endpoint" data-key=${endpoint.key} data-active="false"
|
||
cx=${endpoint.point[0]} cy=${endpoint.point[1]} r=${staticRadius}
|
||
pointer-events="none"></circle>`)}
|
||
`)}
|
||
<circle class="plan-snap-node ${active ? 'active' : ''} ${active?.kind === 'line' ? 'dynamic' : ''}"
|
||
data-hp="plan-snap-active-marker"
|
||
data-kind=${active?.kind ?? nothing} data-key=${active?.key ?? nothing}
|
||
data-active=${active ? 'true' : 'false'}
|
||
cx=${active?.point[0] ?? 0} cy=${active?.point[1] ?? 0} r=${activeRadius}
|
||
visibility=${active ? 'visible' : 'hidden'} pointer-events="none"></circle>
|
||
</g>` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _syncPlanSnapActiveMarker(candidate: PlanSnapCandidate | null): void {
|
||
const marker = this.host.renderRoot?.querySelector<SVGCircleElement>(
|
||
'[data-hp="plan-snap-active-marker"]',
|
||
);
|
||
if (!marker) return;
|
||
marker.setAttribute('class', `plan-snap-node${candidate ? ' active' : ''}${
|
||
candidate?.kind === 'line' ? ' dynamic' : ''
|
||
}`);
|
||
marker.setAttribute('data-active', candidate ? 'true' : 'false');
|
||
marker.setAttribute('visibility', candidate ? 'visible' : 'hidden');
|
||
if (!candidate) {
|
||
marker.removeAttribute('data-kind');
|
||
marker.removeAttribute('data-key');
|
||
return;
|
||
}
|
||
marker.setAttribute('data-kind', candidate.kind);
|
||
marker.setAttribute('data-key', candidate.key);
|
||
marker.setAttribute('cx', String(candidate.point[0]));
|
||
marker.setAttribute('cy', String(candidate.point[1]));
|
||
marker.setAttribute('r', String(wallCmToUnits(10, this.host._cellCm, this.host._gridPitch)));
|
||
}
|
||
|
||
public _syncPlanSnapConflictMarkers(conflicts: readonly PlanSnapEndpoint[]): void {
|
||
const keys = new Set(conflicts.map((item) => item.key));
|
||
for (const marker of this.host.renderRoot?.querySelectorAll<SVGCircleElement>(
|
||
'.plan-snap-node[data-kind="endpoint"]',
|
||
) || []) {
|
||
marker.setAttribute('class', `plan-snap-node${keys.has(marker.dataset.key || '') ? ' conflict' : ''}`);
|
||
}
|
||
}
|
||
|
||
public _planSnapPhysicalSegment(segment: PlanSnapSegment): LinearWallSegment | null {
|
||
let cm = 0;
|
||
const space = this.host._spaceModel();
|
||
if (!space) return null;
|
||
if (segment.sourceKind === 'partition') {
|
||
cm = Number(space.partitions.find((item) => item.id === segment.sourceId)?.cm) || 0;
|
||
} else {
|
||
cm = intervalCmAt(
|
||
space.rooms, this.host._spaceWalls, this.host._openCuts(),
|
||
[segment.a[0], segment.a[1], segment.b[0], segment.b[1]],
|
||
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
|
||
);
|
||
}
|
||
if (!(cm > 0)) return null;
|
||
return {
|
||
a: [...segment.a], b: [...segment.b],
|
||
halfDepth: wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2,
|
||
};
|
||
}
|
||
|
||
public _drawPreviewJoinPatchD(
|
||
points: number[][], halfDepths: number[],
|
||
): string {
|
||
if (points.length < 2) return '';
|
||
const preview: LinearWallSegment[] = [];
|
||
for (let i = 0; i + 1 < points.length; i++) {
|
||
if (!(halfDepths[i] > 0)) continue;
|
||
preview.push({ a: points[i], b: points[i + 1], halfDepth: halfDepths[i] });
|
||
}
|
||
if (!preview.length) return '';
|
||
const eps = this.host._gridPitch * 0.0002;
|
||
const touching = this._planSnapGeometrySnapshot().value.segments
|
||
.filter((segment) => points.some((point) => distToSegment(point, [
|
||
segment.a[0], segment.a[1], segment.b[0], segment.b[1],
|
||
]) <= eps))
|
||
.map((segment) => this._planSnapPhysicalSegment(segment))
|
||
.filter((segment): segment is LinearWallSegment => !!segment);
|
||
const patches = linearWallJoinPatches([...preview, ...touching], eps);
|
||
return patches.map((patch) =>
|
||
`M ${patch.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`).join(' ');
|
||
}
|
||
|
||
public _renderMarkupLayer(vb: number[]): TemplateResult {
|
||
// Derived axes omit zero and positive-body stretches; their dedicated
|
||
// layers paint those segments with the correct style and z-order.
|
||
const openCuts = this.host._openCuts();
|
||
const thickCuts = this.host._thickWallCuts();
|
||
const allCuts = openCuts.concat(thickCuts);
|
||
const segs = allCuts.length
|
||
? cutSegments(this.host._segments, allCuts, this.host._gridPitch * 0.02)
|
||
: this.host._segments;
|
||
const path = this.host._path;
|
||
const g = this.host._gridPitch;
|
||
const view = this.host._viewOr(this.host._baseVb());
|
||
const drawCm = this.host._tool === 'draw' ? this.host._drawWallCm : null;
|
||
const previewPts = (() => {
|
||
if (this.host._tool !== 'draw' || !path.length || drawCm == null) return null;
|
||
if (this.host._contourClosed) return path;
|
||
if (this.host._cursorPt) return [...path, this.host._cursorPt];
|
||
return path.length >= 2 ? path : null;
|
||
})();
|
||
// Превью берёт толщины из того же резолвера, что и запись (#234). Раньше
|
||
// здесь была вторая формула, и расходились они ровно на пропуске: на экране
|
||
// текущее поле, в конфиге — 15 см.
|
||
const previewHalfDepths = previewPts
|
||
? chainSegmentCms(
|
||
previewPts.length - 1,
|
||
this.host._contourClosed
|
||
? [...this.host._wallChainSegmentCms, this.host._closingWallCm ?? undefined]
|
||
: this.host._wallChainSegmentCms,
|
||
drawCm, DRAW_WALL_DEFAULT_CM,
|
||
).map((cm) => wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2)
|
||
: [];
|
||
const previewD = previewPts
|
||
? drawWallPreviewD(
|
||
previewPts,
|
||
wallCmToUnits(drawCm!, this.host._cellCm, this.host._gridPitch) / 2,
|
||
this.host._contourClosed,
|
||
previewHalfDepths,
|
||
)
|
||
: '';
|
||
const previewJoinPatchD = previewPts
|
||
? this._drawPreviewJoinPatchD(previewPts, previewHalfDepths)
|
||
: '';
|
||
const repairPreview = this.host._wallFaceBatch?.candidates[this.host._wallFaceBatch.index]?.repair
|
||
|| this.host._wallRepairDiagnostic;
|
||
return svg`
|
||
${this._gridLevels()
|
||
? svg`<rect x="${view.x}" y="${view.y}" width="${view.w}" height="${view.h}" fill="url(#hp-grid-major)" pointer-events="none"></rect>`
|
||
: nothing}
|
||
${segs.map((s) => svg`<line class="seg" x1="${s[0]}" y1="${s[1]}" x2="${s[2]}" y2="${s[3]}"></line>`)}
|
||
${this._renderPhysicalEditorLayer()}
|
||
${this.host._tool === 'column' && this.host._cursorPt && this.host._drawWallCm
|
||
? svg`<path class="physical-drag" d=${(() => {
|
||
const c: WallColumnCfg = { id: 'preview', shape: 'square', center: this.host._cursorPt!, cm: this.host._drawWallCm! };
|
||
const body = columnBody(c, this.host._cellCm, this.host._gridPitch);
|
||
return `M ${body.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
|
||
})()}></path>`
|
||
: nothing}
|
||
${previewD
|
||
? svg`<path class="drawwall-preview-fill" d="${previewD}"></path>
|
||
<path class="drawwall-preview" d="${previewD}"></path>`
|
||
: nothing}
|
||
${previewPts && drawCm === 0
|
||
? svg`<polyline class="drawwall-zero-preview ${zeroWallStyleOf(this.host._curSpaceCfg)}"
|
||
points=${previewPts.map((point) => point.join(',')).join(' ')}></polyline>`
|
||
: nothing}
|
||
${previewJoinPatchD
|
||
? svg`<path class="drawwall-preview-fill" d="${previewJoinPatchD}"></path>
|
||
<path class="drawwall-preview" style="stroke:none" d="${previewJoinPatchD}"></path>`
|
||
: nothing}
|
||
${repairPreview ? svg`<line class="wall-repair-preview"
|
||
x1=${repairPreview.from[0]} y1=${repairPreview.from[1]}
|
||
x2=${repairPreview.to[0]} y2=${repairPreview.to[1]}
|
||
aria-hidden="true" pointer-events="none"></line>` : nothing}
|
||
${this.host._tool === 'split' && this.host._splitSel?.pts?.length
|
||
? svg`${this.host._splitSel.pts.length > 1
|
||
? svg`<polyline class="pathline" points="${this.host._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
|
||
: nothing}
|
||
${this.host._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
|
||
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this.host._cellCm)}"></circle>`)}
|
||
${this.host._cursorPt
|
||
? svg`<line class="preview" x1="${this.host._splitSel.pts[this.host._splitSel.pts.length - 1][0]}" y1="${this.host._splitSel.pts[this.host._splitSel.pts.length - 1][1]}"
|
||
x2="${this.host._cursorPt[0]}" y2="${this.host._cursorPt[1]}"></line>`
|
||
: nothing}`
|
||
: nothing}
|
||
`;
|
||
}
|
||
|
||
public _renderActiveChainInk(): TemplateResult {
|
||
const path = this.host._path;
|
||
const g = this.host._gridPitch;
|
||
return svg`
|
||
${path.length > 1
|
||
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
|
||
: nothing}
|
||
${path.length && this.host._cursorPt && this.host._tool === 'draw' && !this.host._contourClosed
|
||
? svg`<line class="active-axis" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
|
||
x2="${this.host._cursorPt[0]}" y2="${this.host._cursorPt[1]}" aria-hidden="true"></line>
|
||
${!this.host._activePlanSnapCandidate && !this.host._activePlanSnapConflicts.length
|
||
? svg`<circle class="active-vertex" cx="${this.host._cursorPt[0]}" cy="${this.host._cursorPt[1]}"
|
||
r="${gridVisualUnits(g * 0.22, this.host._cellCm)}" aria-hidden="true"></circle>` : nothing}`
|
||
: nothing}
|
||
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
|
||
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this.host._cellCm)}"></circle>`)}
|
||
` as unknown as TemplateResult;
|
||
}
|
||
|
||
public _renderPartitionDeleteDialog(): TemplateResult {
|
||
const dialog = this.host._partitionDeleteDialog!;
|
||
const imperial = this.host.hass?.config?.unit_system?.length === 'mi';
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="confirm"
|
||
.title=${this.host._t('confirm.delete_partition_openings_title')}
|
||
icon="mdi:wall" dismiss-on-scrim
|
||
@hp-close=${() => (this.host._partitionDeleteDialog = null)}>
|
||
<div class="body">
|
||
<p>${this.host._t('confirm.delete_partition_openings_body', {
|
||
count: dialog.openings.length,
|
||
})}</p>
|
||
<ul aria-label=${this.host._t('confirm.delete_partition_openings_title')}>
|
||
${dialog.openings.map((opening) => html`<li>${this.host._t(
|
||
'confirm.delete_partition_openings_item', {
|
||
type: this.host._t(`opening.${opening.type}` as I18nKey),
|
||
length: formatLength(
|
||
(opening.length * NORM_W / this.host._gridPitch) * this.host._cellCm,
|
||
imperial,
|
||
),
|
||
},
|
||
)}</li>`)}
|
||
</ul>
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._partitionDeleteDialog = null)}>
|
||
${this.host._t('btn.cancel')}
|
||
</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this._confirmPartitionDelete()}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderRoomDeleteDialog(): TemplateResult {
|
||
const dialog = this.host._roomDeleteDialog!;
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="confirm"
|
||
.title=${this.host._t('confirm.delete_room_title', { name: dialog.name })}
|
||
icon="mdi:floor-plan" dismiss-on-scrim
|
||
@hp-close=${() => (this.host._roomDeleteDialog = null)}>
|
||
<div class="body">
|
||
<p>${this.host._t('confirm.delete_room_body')}</p>
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._roomDeleteDialog = null)}>
|
||
${this.host._t('btn.cancel')}
|
||
</button>
|
||
<span class="spacer"></span>
|
||
<button class="btn" data-hp="dialog-confirm" @click=${() => this._confirmRoomDelete(true)}>
|
||
<ha-icon icon="mdi:wall"></ha-icon>${this.host._t('btn.delete_room_keep_walls')}
|
||
</button>
|
||
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this._confirmRoomDelete(false)}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete_room_with_walls')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderPhysicalDialog(): TemplateResult {
|
||
const d = this.host._physicalDialog!;
|
||
const column = d.kind === 'column';
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="physical" wide
|
||
.title=${this.host._t(column ? 'physical.column_properties' : 'physical.partition_properties')}
|
||
icon=${column ? 'mdi:vector-square' : 'mdi:wall'}
|
||
@hp-close=${() => (this.host._physicalDialog = null)}>
|
||
<div class="body">
|
||
${column ? html`<label>${this.host._t('physical.shape')}</label>
|
||
<select class="areasel" @change=${(e: Event) => {
|
||
const shape = (e.target as HTMLSelectElement).value as 'square' | 'circle';
|
||
this.host._physicalDialog = { ...d, shape };
|
||
}}>
|
||
<option value="square" ?selected=${d.shape === 'square'}>${this.host._t('physical.square')}</option>
|
||
<option value="circle" ?selected=${d.shape === 'circle'}>${this.host._t('physical.circle')}</option>
|
||
</select>` : nothing}
|
||
<label>${this.host._t(column
|
||
? d.shape === 'circle' ? 'physical.diameter' : 'physical.side'
|
||
: 'wallthick.field')}</label>
|
||
<div class="row"><input class="namein tempin" type="number"
|
||
min=${cmToField(column ? 1 : 0, this.host._imperial)}
|
||
max=${cmToField(column ? 150 : 100, this.host._imperial)} step="any" .value=${d.cm}
|
||
@input=${(e: Event) => (this.host._physicalDialog = {
|
||
...d, cm: (e.target as HTMLInputElement).value,
|
||
})} />
|
||
<span class="opl">${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span></div>
|
||
${column && d.shape === 'square' ? html`
|
||
<label>${this.host._t('physical.rotation')}</label>
|
||
<input class="namein tempin" type="number" min="0" max="89.999" step="5"
|
||
.value=${d.angle || '0'}
|
||
@input=${(e: Event) => (this.host._physicalDialog = {
|
||
...d, angle: (e.target as HTMLInputElement).value,
|
||
})} />` : nothing}
|
||
${d.length ? html`<div class="muted">${this.host._t('physical.length')}: ${d.length}</div>` : nothing}
|
||
</div>
|
||
<div class="row dialog-action-footer physicalfooter" slot="footer">
|
||
<div class="dialog-action-group dialog-action-danger">
|
||
<button class="btn danger" @click=${() => this._deletePhysicalSelection()}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||
</button>
|
||
</div>
|
||
<div class="dialog-action-group dialog-action-commit">
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._physicalDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._savePhysicalDialog()}>
|
||
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderMarkupBar(): TemplateResult {
|
||
const undoName = this.host._geometryHistory.undoName;
|
||
const redoName = this.host._geometryHistory.redoName;
|
||
const undoTitle = undoName
|
||
? this.host._t('history.undo_named', { name: undoName })
|
||
: this.host._t('history.undo_empty');
|
||
const redoTitle = redoName
|
||
? this.host._t('history.redo_named', { name: redoName })
|
||
: this.host._t('history.redo_empty');
|
||
return html`<div class="editbar planbar" data-hp="toolbar" data-kind="plan">
|
||
<div class="editbar-tools" tabindex="-1" ?inert=${this.host._modeTransitionBusy}>
|
||
<ha-icon icon="mdi:vector-square-edit" class="warn"></ha-icon>
|
||
<span class="wallsgroup">
|
||
<button class="btn ${this.host._tool === 'select' ? 'on' : ''}" data-hp="tool" data-tool="select"
|
||
@click=${() => this._activateMarkupTool('select')}
|
||
title=${this.host._t('title.markup_select')}>
|
||
<ha-icon icon="mdi:cursor-default-outline"></ha-icon>${this.host._t('markup.select')}
|
||
</button>
|
||
<button class="btn ${this.host._tool === 'draw' ? 'on' : ''}" data-hp="tool" data-tool="draw"
|
||
aria-pressed=${this.host._tool === 'draw' ? 'true' : 'false'}
|
||
@click=${() => this._activateMarkupTool('draw')}
|
||
title=${this.host._t('title.markup_add')}>
|
||
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this.host._t('markup.add')}
|
||
</button>
|
||
<button class="btn ${this.host._tool === 'column' ? 'on' : ''}" data-hp="tool" data-tool="column"
|
||
@click=${() => this._activateMarkupTool('column')}
|
||
title=${this.host._t('title.markup_column')}>
|
||
<ha-icon icon="mdi:vector-square"></ha-icon>${this.host._t('markup.column')}
|
||
</button>
|
||
</span>
|
||
<button class="btn ${this.host._tool === 'merge' ? 'on' : ''}" data-hp="tool" data-tool="merge"
|
||
@click=${() => this._activateMarkupTool('merge')}
|
||
title=${this.host._t('title.markup_merge')}>
|
||
<ha-icon icon="mdi:vector-union"></ha-icon>${this.host._t('markup.merge')}
|
||
</button>
|
||
<button class="btn ${this.host._tool === 'split' ? 'on' : ''}" data-hp="tool" data-tool="split"
|
||
@click=${() => this._activateMarkupTool('split')}
|
||
title=${this.host._t('title.markup_split')}>
|
||
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this.host._t('markup.split')}
|
||
</button>
|
||
<button class="btn ${this.host._tool === 'resize' ? 'on' : ''}" data-hp="tool" data-tool="resize"
|
||
@click=${() => this._activateMarkupTool('resize')}
|
||
title=${this.host._t('title.markup_resize')}>
|
||
<ha-icon icon="mdi:arrow-expand-all"></ha-icon>${this.host._t('markup.resize')}
|
||
</button>
|
||
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
|
||
<button class="btn ${this.host._tool === 'wallthick' ? 'on' : ''}" data-hp="tool" data-tool="wall-thickness"
|
||
@click=${() => this._activateMarkupTool('wallthick')}
|
||
title=${this.host._t('title.markup_wallthick')}>
|
||
<ha-icon icon="mdi:wall"></ha-icon>${this.host._t('markup.wallthick')}
|
||
</button>
|
||
<button class="btn ${this.host._tool === 'delroom' ? 'on' : ''}" data-hp="tool" data-tool="delete-room"
|
||
@click=${() => this._activateMarkupTool('delroom')}
|
||
title=${this.host._t('title.markup_delroom')}>
|
||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('markup.delete_room')}
|
||
</button>
|
||
<button class="btn ghost" @click=${() => this._undoGeometry()}
|
||
?disabled=${!undoName}
|
||
title=${undoTitle} aria-label=${undoTitle}>
|
||
<ha-icon icon="mdi:undo-variant" aria-hidden="true"></ha-icon>
|
||
</button>
|
||
<button class="btn ghost" @click=${() => this._redoGeometry()}
|
||
?disabled=${!redoName}
|
||
title=${redoTitle} aria-label=${redoTitle}>
|
||
<ha-icon icon="mdi:redo-variant" aria-hidden="true"></ha-icon>
|
||
</button>
|
||
</div>
|
||
<div class="editbar-end">
|
||
<button class="btn barclose" title=${this.host._t('title.close_editor')} data-hp="editor-close"
|
||
data-editor-navigation="view"
|
||
@click=${() => this._setMode('view')}>
|
||
<ha-icon icon="mdi:close"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
public _renderDevicesBar(): TemplateResult {
|
||
const undoName = this.host._devicePositionHistory.undoName;
|
||
const redoName = this.host._devicePositionHistory.redoName;
|
||
const undoTitle = undoName
|
||
? this.host._t('history.undo_named', { name: undoName })
|
||
: this.host._t('history.undo_empty');
|
||
const redoTitle = redoName
|
||
? this.host._t('history.redo_named', { name: redoName })
|
||
: this.host._t('history.redo_empty');
|
||
return html`<div class="editbar devbar" data-hp="toolbar" data-kind="device">
|
||
<div class="editbar-tools" tabindex="-1" ?inert=${this.host._modeTransitionBusy}>
|
||
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
|
||
<button class="btn" data-hp="tool" data-tool="add-device" @click=${() => this._openMarkerDialog()}
|
||
title=${this.host._t('title.add_device')}>
|
||
<ha-icon icon="mdi:plus-box-outline"></ha-icon>${this.host._t('devbar.add')}
|
||
</button>
|
||
<button class="btn ${this.host._showAll ? 'on' : ''}" data-hp="tool" data-tool="device-inbox" @click=${() => this._openDeviceInbox()}
|
||
title=${this.host._t('device_inbox.title')}>
|
||
<ha-icon icon="mdi:devices"></ha-icon>${this.host._t('device_inbox.button')}
|
||
</button>
|
||
<button class="btn" data-hp="tool" data-tool="icon-rules" @click=${() => this._openRulesDialog()} title=${this.host._t('title.icon_rules')}>
|
||
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this.host._t('devbar.rules')}
|
||
</button>
|
||
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
|
||
</div>
|
||
<div class="editbar-end">
|
||
<button class="btn ghost" data-device-position-history="undo"
|
||
@click=${() => this.host._undoDevicePosition()}
|
||
?disabled=${this.host._devicePositionBusy || !undoName}
|
||
title=${undoTitle} aria-label=${undoTitle}>
|
||
<ha-icon icon="mdi:undo-variant" aria-hidden="true"></ha-icon>
|
||
</button>
|
||
<button class="btn ghost" data-device-position-history="redo"
|
||
@click=${() => this.host._redoDevicePosition()}
|
||
?disabled=${this.host._devicePositionBusy || !redoName}
|
||
title=${redoTitle} aria-label=${redoTitle}>
|
||
<ha-icon icon="mdi:redo-variant" aria-hidden="true"></ha-icon>
|
||
</button>
|
||
<button class="btn barclose" title=${this.host._t('title.close_editor')} data-hp="editor-close"
|
||
data-editor-navigation="view"
|
||
@click=${() => this._setMode('view')}>
|
||
<ha-icon icon="mdi:close"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** #44: drafts merged over the stored settings — the single truth the
|
||
* preview, the chips and Save all read. */
|
||
public _discoveryFilterState(dialog: DeviceInboxDialogState): {
|
||
groupLights: boolean; excluded: string[]; usesProductList: boolean; dirty: boolean;
|
||
} {
|
||
const settings = this.host._settings as { group_lights?: boolean; exclude_integrations?: string[] };
|
||
const groupLights = dialog.draftGroupLights !== undefined
|
||
? dialog.draftGroupLights : settings.group_lights !== false;
|
||
const stored = settings.exclude_integrations;
|
||
const excluded = dialog.draftExcluded !== undefined
|
||
? (dialog.draftExcluded === null ? [...EXCLUDED_DOMAINS] : dialog.draftExcluded)
|
||
: (stored ? [...stored] : [...EXCLUDED_DOMAINS]);
|
||
const usesProductList = dialog.draftExcluded !== undefined
|
||
? dialog.draftExcluded === null : !stored;
|
||
const dirty = dialog.draftGroupLights !== undefined || dialog.draftExcluded !== undefined;
|
||
return { groupLights, excluded: excluded.sort(), usesProductList, dirty };
|
||
}
|
||
|
||
/** Integrations really present in the HA registry (platforms + device
|
||
* identifier domains), for the exclusion search. */
|
||
private _registryIntegrations(): string[] {
|
||
const found = new Set<string>();
|
||
// Реестр HA нетипизирован на своей стороне, но здесь читаются ровно два
|
||
// поля и оба защищённо, поэтому хватает минимальной структурной формы —
|
||
// она честнее `any`: описывает то, на что код действительно опирается.
|
||
const full = this.host._fullRegistryHass as {
|
||
entities?: Record<string, { platform?: unknown } | undefined>;
|
||
devices?: Record<string, { identifiers?: unknown[] } | undefined>;
|
||
} | null | undefined;
|
||
for (const reg of Object.values(full?.entities || {})) {
|
||
if (reg?.platform) found.add(String(reg.platform));
|
||
}
|
||
for (const device of Object.values(full?.devices || {})) {
|
||
const first = device?.identifiers?.[0];
|
||
const domain = Array.isArray(first) ? first[0] : null;
|
||
if (domain) found.add(String(domain));
|
||
}
|
||
return [...found].sort();
|
||
}
|
||
|
||
private _discoveryPreviewMemo: { key: string; value: { appear: number; hide: number; lights: number } } | null = null;
|
||
|
||
/** #44 AC6: the preview runs the SAME buildDevices the plan runs — draft
|
||
* settings in, binding sets compared. Memoised so hass ticks while the
|
||
* section is open do not redo discovery. */
|
||
public _discoveryFilterPreview(dialog: DeviceInboxDialogState): { appear: number; hide: number; lights: number } {
|
||
const draft = this._discoveryFilterState(dialog);
|
||
const key = [
|
||
draft.groupLights ? 1 : 0, draft.usesProductList ? 'p' : draft.excluded.join(','),
|
||
this.host._cfgEpoch, this.host._regSignature,
|
||
].join('|');
|
||
if (this._discoveryPreviewMemo?.key === key) return this._discoveryPreviewMemo.value;
|
||
const ctx = {
|
||
hass: this.host.hass,
|
||
registry: this.host._haRegistry,
|
||
areaToSpace: Object.fromEntries(
|
||
Object.entries(this.host._areaToSpace).map(([a, v]) => [a, v.space]),
|
||
),
|
||
markers: this.host._markers,
|
||
showAll: this.host._showAll,
|
||
firstSpaceId: this.host._model[0]?.id || '',
|
||
loc: (k: string) => this.host._t(k as never),
|
||
iconRules: this.host._iconRules,
|
||
};
|
||
// Two shared builders, zero copies of the filter logic: the exclusion
|
||
// materialises through seedHiddenBindings (hidden markers), the grouping
|
||
// changes the buildDevices candidate set — the preview diffs BOTH.
|
||
const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
|
||
seedHiddenBindings({ ...ctx, settings, excluded } as never));
|
||
const candidatesOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
|
||
buildDevices({ ...ctx, settings, excluded } as never)
|
||
.map((device) => device.bindingRef || device.id));
|
||
const currentExcluded = effectiveExcludedIntegrations(this.host._settings);
|
||
const draftSettings = { ...this.host._settings, group_lights: draft.groupLights };
|
||
const draftExcluded = new Set(draft.excluded);
|
||
const seededNow = seededOf(this.host._settings, currentExcluded);
|
||
const seededNext = seededOf(draftSettings, draftExcluded);
|
||
const candidatesNow = candidatesOf(this.host._settings, currentExcluded);
|
||
const candidatesNext = candidatesOf(draftSettings, draftExcluded);
|
||
let appear = 0, hide = 0, lights = 0;
|
||
const isLight = (binding: string) => binding.includes('light.');
|
||
for (const binding of seededNow) if (!seededNext.has(binding)) { appear++; if (isLight(binding)) lights++; }
|
||
for (const binding of seededNext) if (!seededNow.has(binding)) { hide++; if (isLight(binding)) lights++; }
|
||
for (const binding of candidatesNext) if (!candidatesNow.has(binding)) { appear++; if (isLight(binding)) lights++; }
|
||
for (const binding of candidatesNow) if (!candidatesNext.has(binding)) { hide++; if (isLight(binding)) lights++; }
|
||
const value = { appear, hide, lights };
|
||
this._discoveryPreviewMemo = { key, value };
|
||
return value;
|
||
}
|
||
|
||
/** #44: one settings write per Save; defaults are stored as ABSENCE. */
|
||
public async _saveDiscoveryFilters(dialog: DeviceInboxDialogState): Promise<void> {
|
||
const cfg = this.host._serverCfg;
|
||
if (!cfg) return;
|
||
const draft = this._discoveryFilterState(dialog);
|
||
const settings: Record<string, unknown> = { ...(cfg.settings as object) };
|
||
if (draft.groupLights) delete settings.group_lights;
|
||
else settings.group_lights = false;
|
||
if (draft.usesProductList) delete settings.exclude_integrations;
|
||
else settings.exclude_integrations = draft.excluded;
|
||
this.host._serverCfg = { ...cfg, settings } as typeof cfg;
|
||
this.host._deviceInboxMemo = null;
|
||
this._discoveryPreviewMemo = null;
|
||
this.host._regSignature = ''; // force the device rebuild to see new filters
|
||
if (this.host._deviceInbox) {
|
||
this.host._deviceInbox = {
|
||
...dialog, draftGroupLights: undefined, draftExcluded: undefined,
|
||
};
|
||
}
|
||
this._saveConfig();
|
||
this.host._maybeRebuildDevices();
|
||
this.host.requestUpdate();
|
||
}
|
||
|
||
private _renderDiscoveryFilters(dialog: DeviceInboxDialogState): TemplateResult {
|
||
const draft = this._discoveryFilterState(dialog);
|
||
const patch = (delta: Partial<DeviceInboxDialogState>) =>
|
||
(this.host._deviceInbox = { ...dialog, ...delta });
|
||
const preview = dialog.filtersOpen && draft.dirty ? this._discoveryFilterPreview(dialog) : null;
|
||
const known = this._registryIntegrations().filter((name) => !draft.excluded.includes(name));
|
||
return html`<details class="device-inbox-discovery" ?open=${dialog.filtersOpen}
|
||
@toggle=${(event: Event) => patch({ filtersOpen: (event.target as HTMLDetailsElement).open })}>
|
||
<summary>${this.host._t('device_inbox.filters_title' as never)}</summary>
|
||
<label class="srcrow">
|
||
<input type="checkbox" .checked=${draft.groupLights}
|
||
@change=${(event: Event) => patch({
|
||
draftGroupLights: (event.target as HTMLInputElement).checked,
|
||
})} />
|
||
${this.host._t('device_inbox.filters_group_lights' as never)}
|
||
</label>
|
||
<div class="device-inbox-excluded">
|
||
<span>${this.host._t('device_inbox.filters_excluded' as never)}</span>
|
||
<div class="device-inbox-chips">
|
||
${draft.excluded.map((name) => html`<span class="chip">${name}
|
||
<button type="button" aria-label="×"
|
||
@click=${() => patch({ draftExcluded: draft.excluded.filter((x) => x !== name) })}>×</button>
|
||
</span>`)}
|
||
</div>
|
||
<input type="text" list="hp-discovery-integrations"
|
||
placeholder=${this.host._t('device_inbox.filters_search_ph' as never)}
|
||
@change=${(event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const name = input.value.trim();
|
||
if (name && !draft.excluded.includes(name)) {
|
||
patch({ draftExcluded: [...draft.excluded, name] });
|
||
}
|
||
input.value = '';
|
||
}} />
|
||
<datalist id="hp-discovery-integrations">
|
||
${known.map((name) => html`<option value=${name}></option>`)}
|
||
</datalist>
|
||
<button type="button" class="btn ghost" ?disabled=${draft.usesProductList}
|
||
@click=${() => patch({ draftExcluded: null })}>
|
||
${this.host._t('device_inbox.filters_reset' as never)}
|
||
</button>
|
||
</div>
|
||
${preview ? html`<div class="device-inbox-preview">
|
||
${this.host._t('device_inbox.filters_preview_appear' as never, { count: String(preview.appear) })}
|
||
· ${this.host._t('device_inbox.filters_preview_hide' as never, { count: String(preview.hide) })}
|
||
· ${this.host._t('device_inbox.filters_preview_lights' as never, { count: String(preview.lights) })}
|
||
</div>` : nothing}
|
||
<button type="button" class="btn on" ?disabled=${!draft.dirty}
|
||
@click=${() => this._saveDiscoveryFilters(dialog)}>
|
||
${this.host._t('device_inbox.filters_save' as never)}
|
||
</button>
|
||
</details>`;
|
||
}
|
||
|
||
public _renderDeviceInbox(): TemplateResult {
|
||
const dialog = this.host._deviceInbox!;
|
||
const rows = this._deviceInboxRows();
|
||
const counts = Object.fromEntries((['on_plan', 'available', 'hidden', 'readd'] as const)
|
||
.map((category) => [category, rows.filter((row) => row.category === category).length]));
|
||
const filtered = filterDeviceInbox(
|
||
rows, dialog.tab, dialog.search, dialog.tab === 'on_plan' && dialog.onlyNew,
|
||
);
|
||
const visible = filtered.slice(0, dialog.limit);
|
||
const tabLabel = (tab: DeviceInboxCategory) => this.host._t(`device_inbox.tab_${tab}` as I18nKey);
|
||
const emptyKey = `device_inbox.empty_${dialog.tab}` as I18nKey;
|
||
const openVirtual = () => {
|
||
this.host._deviceInboxReturn = { ...dialog };
|
||
this.host._deviceInbox = null;
|
||
this._openMarkerDialog();
|
||
if (!this.host._markerDialog) this._closeMarkerDialog();
|
||
};
|
||
return html`<hp-dialog class="device-inbox-dialog" .hass=${this.host.hass}
|
||
data-kind="device-inbox"
|
||
.title=${this.host._t('device_inbox.title')} icon="mdi:devices" wide
|
||
@hp-close=${() => (this.host._deviceInbox = null)}>
|
||
<div class="device-inbox" ?inert=${!!dialog.busy}>
|
||
<div class="device-inbox-head">
|
||
<input class="device-inbox-search" type="search" autofocus
|
||
placeholder=${this.host._t('device_inbox.search')} .value=${dialog.search}
|
||
@input=${(event: Event) => (this.host._deviceInbox = {
|
||
...dialog, search: (event.target as HTMLInputElement).value, limit: 100,
|
||
})} />
|
||
<button type="button" class="btn" @click=${openVirtual}>
|
||
<ha-icon icon="mdi:map-marker-plus-outline"></ha-icon>
|
||
${this.host._t('device_inbox.add_virtual')}
|
||
</button>
|
||
</div>
|
||
<div class="device-inbox-tabs" role="tablist" @keydown=${(event: KeyboardEvent) => this._deviceInboxTabKey(event)}>
|
||
${(['on_plan', 'available', 'hidden', 'readd'] as DeviceInboxCategory[]).map((tab) => html`
|
||
<button type="button" role="tab" aria-selected=${dialog.tab === tab ? 'true' : 'false'}
|
||
class=${dialog.tab === tab ? 'on' : ''}
|
||
@click=${() => (this.host._deviceInbox = { ...dialog, tab, limit: 100, onlyNew: false })}>
|
||
${tabLabel(tab)} <span>${counts[tab]}</span>
|
||
</button>`)}
|
||
</div>
|
||
<div class="device-inbox-filters">
|
||
${dialog.tab === 'on_plan' ? html`<label>
|
||
<input type="checkbox" .checked=${dialog.onlyNew}
|
||
@change=${(event: Event) => (this.host._deviceInbox = {
|
||
...dialog, onlyNew: (event.target as HTMLInputElement).checked, limit: 100,
|
||
})} />${this.host._t('device_inbox.only_new')}
|
||
</label>` : nothing}
|
||
${dialog.tab === 'available' ? html`<label>
|
||
<input type="checkbox" .checked=${dialog.showEntities}
|
||
@change=${(event: Event) => {
|
||
this.host._deviceInboxMemo = null;
|
||
this.host._deviceInbox = {
|
||
...dialog, showEntities: (event.target as HTMLInputElement).checked, limit: 100,
|
||
};
|
||
}} />${this.host._t('device_inbox.show_entities')}
|
||
</label>` : nothing}
|
||
<span class="device-inbox-filter-help">
|
||
<label>
|
||
<input type="checkbox" .checked=${this.host._showAll}
|
||
@change=${(event: Event) => {
|
||
this.host._showHidden = (event.target as HTMLInputElement).checked;
|
||
this.host._deviceInboxMemo = null;
|
||
this.host.requestUpdate();
|
||
}} />${this.host._t('device_inbox.show_hidden')}
|
||
</label>
|
||
${this._help('device_inbox.show_hidden.help')}
|
||
</span>
|
||
</div>
|
||
${dialog.tab === 'available' ? this._renderDiscoveryFilters(dialog) : nothing}
|
||
<div class="device-inbox-results" aria-live="polite">
|
||
${visible.length ? visible.map((row) => {
|
||
const primary = row.category === 'on_plan'
|
||
? row.canFind ? html`<button type="button" class="btn" @click=${() => this._findInboxDevice(row)}>
|
||
<ha-icon icon="mdi:crosshairs-gps"></ha-icon>${this.host._t('device_inbox.find')}</button>`
|
||
: html`<button type="button" class="btn" @click=${() => this._openInboxMarker(row)}
|
||
?disabled=${!row.canEdit}>${this.host._t('device_inbox.edit')}</button>`
|
||
: row.category === 'hidden'
|
||
? html`<button type="button" class="btn" @click=${() => this._setInboxHidden(row, false)}
|
||
title=${row.canShow ? '' : this.host._t('device_inbox.show_disabled')}
|
||
?disabled=${!row.canShow}>${this.host._t('device_inbox.show')}</button>`
|
||
: html`<button type="button" class="btn" @click=${() => this._openInboxMarker(row, true)}
|
||
?disabled=${!row.canAdd}>${this.host._t(row.category === 'readd'
|
||
? 'device_inbox.readd' : 'device_inbox.add')}</button>`;
|
||
const status = row.status.kind === 'active' ? ''
|
||
: this.host._t(`device_inbox.status_${row.status.kind}` as I18nKey);
|
||
return html`<article class="device-inbox-row" data-binding=${row.binding}
|
||
data-category=${row.category} data-status=${row.status.kind}>
|
||
<ha-icon class="device-inbox-icon" .icon=${row.icon}></ha-icon>
|
||
<div class="device-inbox-copy">
|
||
<div class="device-inbox-name"><b>${row.name}</b>
|
||
${row.isNew ? html`<span class="device-inbox-new">${this.host._t('device_inbox.new')}</span>` : nothing}
|
||
</div>
|
||
<div class="device-inbox-meta">
|
||
${[row.model, row.integration, row.spaceName, row.areaName].filter(Boolean).join(' · ')}
|
||
</div>
|
||
<div class="device-inbox-reason">
|
||
${row.reason === 'excluded_integration' && row.integration
|
||
? this.host._t('device_inbox.reason_excluded_integration',
|
||
{ integration: row.integration })
|
||
: this.host._t(`device_inbox.reason_${row.reason}` as I18nKey)}
|
||
${status ? html`<span class="device-inbox-status">${status}</span>` : nothing}
|
||
</div>
|
||
<code>${row.binding}</code>
|
||
</div>
|
||
<div class="device-inbox-actions">
|
||
${primary}
|
||
${row.canEdit || row.canHide || row.category === 'available'
|
||
|| row.category === 'hidden' || this.host._bindingHasHaPage(row.binding) ? html`
|
||
<details class="device-inbox-menu">
|
||
<summary class="btn ghost" aria-label=${this.host._t('device_inbox.more_actions')}
|
||
title=${this.host._t('device_inbox.more_actions')}>
|
||
<ha-icon icon="mdi:dots-vertical"></ha-icon>
|
||
</summary>
|
||
<div class="device-inbox-menu-items">
|
||
${row.canEdit && !(row.category === 'on_plan' && !row.canFind)
|
||
? html`<button type="button" class="btn ghost" @click=${() => this._openInboxMarker(row)}>
|
||
${this.host._t('device_inbox.edit')}</button>` : nothing}
|
||
${row.canHide ? html`<button type="button" class="btn ghost"
|
||
@click=${() => this._setInboxHidden(row, true)}>${this.host._t('device_inbox.hide')}</button>` : nothing}
|
||
${row.category === 'available' ? html`<button type="button" class="btn ghost"
|
||
@click=${() => this._setInboxHidden(row, true)}>${this.host._t('device_inbox.hide_available')}</button>` : nothing}
|
||
${row.category === 'hidden' ? html`<button type="button" class="btn ghost"
|
||
title=${row.canFind ? '' : this.host._t('device_inbox.find_hidden_hint')}
|
||
?disabled=${!row.canFind} @click=${() => this._findInboxDevice(row)}>
|
||
<ha-icon icon="mdi:crosshairs-gps"></ha-icon>${this.host._t('device_inbox.find')}</button>` : nothing}
|
||
${this.host._bindingHasHaPage(row.binding) ? html`<button type="button" class="btn ghost"
|
||
@click=${() => this.host._openBindingInHa(row.binding)}>${this.host._t('btn.open_in_ha')}</button>` : nothing}
|
||
</div>
|
||
</details>` : nothing}
|
||
</div>
|
||
</article>`;
|
||
}) : html`<div class="device-inbox-empty">${this.host._t(emptyKey)}</div>`}
|
||
</div>
|
||
${filtered.length > visible.length ? html`<button type="button" class="btn device-inbox-more"
|
||
@click=${() => (this.host._deviceInbox = { ...dialog, limit: dialog.limit + 100 })}>
|
||
${this.host._t('device_inbox.show_more')} (${filtered.length - visible.length})
|
||
</button>` : nothing}
|
||
</div>
|
||
<div slot="footer" class="row">
|
||
<button type="button" class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._deviceInbox = null)}>
|
||
${this.host._t('btn.close')}</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _markerValueBadgeFields(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): Pick<Marker, 'value_badge'> | Record<string, never> {
|
||
return valueBadgeWriteFields({
|
||
touched: d.valueBadgeTouched,
|
||
originalHas: d.originalHasValueBadge,
|
||
original: d.originalValueBadge,
|
||
enabled: d.valueBadgeEnabled,
|
||
source: d.valueBadgeSource,
|
||
position: d.valueBadgePosition,
|
||
});
|
||
}
|
||
|
||
public _markerValueSourceFields(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): Pick<Marker, 'value_source'> | Record<string, never> {
|
||
return valueSourceWriteFields({
|
||
touched: d.valueSourceTouched,
|
||
originalHas: d.originalHasValueSource,
|
||
original: d.originalValueSource,
|
||
source: d.valueSource,
|
||
});
|
||
}
|
||
|
||
public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): Marker | null {
|
||
if (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual')) return null;
|
||
const roomRef = parseRoomRef(d.room);
|
||
const id = markerIdForBinding(d.binding, d.devId, () => '__hp_device_preview__');
|
||
const previous = this.host._markers.find((marker) => marker.id === id || marker.id === d.devId);
|
||
const controls = persistedExternalControls(
|
||
d.binding, d.controls, this.host._bindingEntities(d.binding),
|
||
);
|
||
const effectiveTapAction = this._effectiveStoredTapAction(d);
|
||
const marker: Marker = {
|
||
id,
|
||
binding: d.binding,
|
||
name: d.name.trim() || null,
|
||
icon: d.icon || null,
|
||
display: d.display !== 'badge' ? d.display : null,
|
||
ripple_color: d.display === 'icon_ripple' && d.rippleColor ? d.rippleColor : null,
|
||
ripple_size: d.display === 'icon_ripple' && d.rippleSize !== 1.5 ? d.rippleSize : null,
|
||
size: d.size !== 1 ? d.size : null,
|
||
angle: d.angle || null,
|
||
...this._markerTapActionFields(d),
|
||
...this._markerToggleEntityFields(d),
|
||
tap_target: effectiveTapAction === 'run' ? d.tapTarget || null : null,
|
||
tap_confirm: d.tapConfirm ? true : null,
|
||
controls: controls.length ? controls : null,
|
||
...this._markerLightFields(d),
|
||
...this._markerValueBadgeFields(d),
|
||
...this._markerValueSourceFields(d),
|
||
use_climate_temp: d.useClimateTemp ? true : null,
|
||
glow_radius_cm: (() => {
|
||
const value = strictNumber(d.glowRadius);
|
||
if (value == null || value <= 0) return null;
|
||
return Math.round(this.host._imperial ? value * 30.48 : value * 100);
|
||
})(),
|
||
model: d.model.trim() || null,
|
||
link: d.link.trim() || null,
|
||
description: d.description.trim() || null,
|
||
pdfs: d.pdfs,
|
||
hidden: d.hideFromPlan,
|
||
vacuum: previous?.vacuum || null,
|
||
};
|
||
const previousExplicit = !!previous && (
|
||
(typeof previous.area === 'string' && previous.area.length > 0)
|
||
|| (previous.area === null && !!previous.space && !!previous.room_id)
|
||
);
|
||
if (d.binding === 'virtual' || d.roomTouched) {
|
||
marker.space = roomRef?.space || (d.binding === 'virtual' ? this.host._space : null);
|
||
marker.area = roomRef?.area || null;
|
||
marker.room_id = roomRef?.roomId || null;
|
||
} else if (previousExplicit) {
|
||
marker.space = previous!.space;
|
||
marker.area = previous!.area;
|
||
marker.room_id = previous!.room_id;
|
||
}
|
||
return marker;
|
||
}
|
||
|
||
public _markerPreviewDevice(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): DevItem | null {
|
||
const marker = this._markerDraft(d);
|
||
if (!marker) return null;
|
||
const key = `${this.host._haRegistry.revision}\n${contentFingerprint(this.host._markers)}\n${JSON.stringify(marker)}`;
|
||
if (this.host._markerPreviewMemo?.key === key) return this.host._markerPreviewMemo.device;
|
||
const device = deviceFromMarkerDraft({
|
||
hass: this.host.hass,
|
||
registry: this.host._haRegistry,
|
||
areaToSpace: Object.fromEntries(
|
||
Object.entries(this.host._areaToSpace).map(([area, value]) => [area, value.space]),
|
||
),
|
||
marker,
|
||
siblingMarkers: this.host._markers,
|
||
settings: this.host._settings,
|
||
excluded: this.host._excluded,
|
||
showAll: this.host._showAll,
|
||
firstSpaceId: this.host._model[0]?.id || this.host._space,
|
||
loc: (key) => this.host._t(key),
|
||
iconRules: this.host._iconRules,
|
||
});
|
||
this.host._markerPreviewMemo = { key, device };
|
||
return device;
|
||
}
|
||
|
||
public _markerPreviewDevices(preview: DevItem): readonly DevItem[] {
|
||
const memo = this.host._markerPreviewDevicesMemo;
|
||
if (memo?.base === this.host._devices && memo.preview === preview) return memo.devices;
|
||
const devices = [...this.host._devices.filter((item) => item.id !== preview.id), preview];
|
||
this.host._markerPreviewDevicesMemo = { base: this.host._devices, preview, devices };
|
||
return devices;
|
||
}
|
||
|
||
public _toggleIntent(
|
||
device: DevItem,
|
||
devices: readonly DevItem[] = this.host._devices,
|
||
): ResolvedToggleIntent | null {
|
||
// #357: the View card owns toggle resolution — a plain tap must work on a
|
||
// cold tab that never loaded this runtime. Editor consumers delegate back.
|
||
return this.host._toggleIntent(device, devices);
|
||
}
|
||
|
||
public _toggleIntentForDialog(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): ResolvedToggleIntent | null {
|
||
const preview = this._markerPreviewDevice(d);
|
||
if (!preview) return null;
|
||
const devices = this._markerPreviewDevices(preview);
|
||
return this._toggleIntent(preview, devices);
|
||
}
|
||
|
||
public _toggleStateText(entityId: string, fallback: string): string {
|
||
return this.host._toggleStateText(entityId, fallback);
|
||
}
|
||
|
||
public _toggleConfirmationStateText(target: ResolvedToggleTarget): string {
|
||
return this.host._toggleConfirmationStateText(target);
|
||
}
|
||
|
||
public _toggleConfirmationLines(intent: ResolvedToggleIntent): string[] {
|
||
return this.host._toggleConfirmationLines(intent);
|
||
}
|
||
|
||
public _toggleHintLines(intent: ResolvedToggleIntent | null): string[] {
|
||
if (!intent) return [];
|
||
const effect = (value: ToggleNextEffect): string =>
|
||
this.host._t(`marker.toggle_effect_${value.replace('-', '_')}` as I18nKey);
|
||
const skipReason = (value: ToggleSkipReason): string =>
|
||
this.host._t(`marker.toggle_skip_${value.replace('-', '_')}` as I18nKey);
|
||
return formatToggleIntent(intent, {
|
||
single: (target) => {
|
||
if ('via' in target && target.via === 'virtual-light') {
|
||
return this.host._t('marker.virtual_light_target', { name: target.name });
|
||
}
|
||
const entityId = target.entityId || ('ref' in target ? target.ref : '');
|
||
const name = target.name || entityId;
|
||
return this.host._t('marker.toggle_hint_single', { name, id: entityId });
|
||
},
|
||
group: (targets) => this.host._t('marker.toggle_hint_group', {
|
||
count: targets.length,
|
||
names: targets.map((target) => `${target.name} (${target.entityId})`).join(', '),
|
||
}),
|
||
currentNext: (target, next) => target.via === 'virtual-light'
|
||
? this.host._t('marker.virtual_light_current', {
|
||
state: this.host._t(target.state === 'on'
|
||
? 'marker.virtual_light_state_on' : 'marker.virtual_light_state_off'),
|
||
effect: effect(next),
|
||
})
|
||
: this.host._t('marker.toggle_hint_current', {
|
||
state: this._toggleStateText(target.entityId, target.state),
|
||
effect: effect(next),
|
||
}),
|
||
groupCurrentNext: (targets, next) => this.host._t('marker.toggle_hint_group_current', {
|
||
on: targets.filter((target) => target.state === 'on').length,
|
||
count: targets.length,
|
||
effect: effect(next),
|
||
}),
|
||
skipped: (targets) => this.host._t('marker.toggle_hint_skipped', {
|
||
count: targets.length,
|
||
targets: targets.map((target) => {
|
||
const id = target.entityId || target.ref;
|
||
return `${target.name || id} (${id}: ${skipReason(target.reason)})`;
|
||
}).join(', '),
|
||
}),
|
||
none: (reason: ToggleNoneReason) =>
|
||
this.host._t(`marker.toggle_none_${reason.replaceAll('-', '_')}` as I18nKey),
|
||
});
|
||
}
|
||
|
||
public _effectiveStoredTapAction(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>, primaryDomain?: string,
|
||
): string {
|
||
return d.tapActionTouched
|
||
? d.tapAction
|
||
: projectedTapAction(
|
||
d.originalHasTapAction ? d.originalTapAction : null,
|
||
primaryDomain,
|
||
);
|
||
}
|
||
|
||
public _effectiveMarkerTapAction(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
preview = this._markerPreviewDevice(d),
|
||
): string {
|
||
return this._effectiveStoredTapAction(d, preview?.primary?.split('.')[0]);
|
||
}
|
||
|
||
public _announceToggleDraft(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
): NonNullable<HouseplanEditorHostPort['_markerDialog']> {
|
||
const preview = this._markerPreviewDevice(d);
|
||
const next = !d.tapActionTouched
|
||
? { ...d, tapAction: this._effectiveMarkerTapAction(d, preview) }
|
||
: d;
|
||
const text = next.tapAction === 'toggle'
|
||
? this._toggleHintLines(this._toggleIntentForDialog(next)).join(' ')
|
||
: '';
|
||
return { ...next, tapHintAnnouncement: text };
|
||
}
|
||
|
||
public _valueBadgeForBinding(
|
||
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>, binding: string,
|
||
): Pick<NonNullable<HouseplanEditorHostPort['_markerDialog']>,
|
||
'valueBadgeEnabled' | 'valueBadgeSource' | 'valueBadgeTouched'> {
|
||
const draft = {
|
||
...d, binding,
|
||
valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true,
|
||
};
|
||
const device = this._markerPreviewDevice(draft);
|
||
if (!device) return { valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true };
|
||
const lightDevices = [...this.host._devices.filter((item) => item.id !== device.id), device];
|
||
const candidates = valueBadgeCandidates(this.host._planHass, device, lightDevices);
|
||
const source = recommendedValueBadgeSource(this.host._planHass, device, candidates);
|
||
return {
|
||
valueBadgeEnabled: d.valueBadgeEnabled && !!source,
|
||
valueBadgeSource: source,
|
||
valueBadgeTouched: true,
|
||
};
|
||
}
|
||
|
||
public _markerSpatialSource(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>) {
|
||
const device = this._markerPreviewDevice(d);
|
||
if (!device) return null;
|
||
const preview = { ...device, hidden: false };
|
||
return selectSpatialGlowSource(resolvedLightSources(this.host._planHass, [
|
||
...this.host._devices.filter((item) => item.id !== preview.id), preview,
|
||
], null, this.host._virtualLights).filter((source) => source.device.id === preview.id));
|
||
}
|
||
|
||
public _markerAutoHasSpatialSource(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): boolean {
|
||
const autoDraft = { ...d, lightRole: 'auto' as const, lightRoleTouched: true };
|
||
const device = this._markerPreviewDevice(autoDraft);
|
||
return !!device && hasOwnSpatialSource(this.host._planHass, { ...device, hidden: false });
|
||
}
|
||
|
||
public _setMarkerLightRole(role: 'auto' | 'always' | 'never'): void {
|
||
const d = this.host._markerDialog;
|
||
if (!d) return;
|
||
this.host._markerDialog = { ...d, lightRole: role, lightRoleTouched: true };
|
||
}
|
||
|
||
public _controlRefInfo(ref: string): { label: string; sub: string; icon: string; warning: boolean } {
|
||
if (!ref.startsWith('marker:')) {
|
||
return {
|
||
label: this.host.hass.states[ref]?.attributes?.friendly_name || ref,
|
||
sub: ref,
|
||
icon: ref.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch',
|
||
warning: !this.host._planEntityAvailable(ref),
|
||
};
|
||
}
|
||
const id = ref.slice('marker:'.length);
|
||
const marker = this.host._markers.find((item) => item.id === id);
|
||
const device = this.host._devices.find((item) => item.id === id);
|
||
if (!marker || marker.removed || marker.is_light !== true) {
|
||
return {
|
||
label: this.host._t('marker.control_missing_label'),
|
||
sub: `${this.host._t('marker.control_broken')} (${ref})`,
|
||
icon: 'mdi:alert-outline', warning: true,
|
||
};
|
||
}
|
||
const space = this.host._serverCfg?.spaces.find((item) => item.id === (device?.space || marker.space));
|
||
const stateful = !!device && ownControllableEntities(device).length > 0;
|
||
return {
|
||
label: marker.name || device?.name || id,
|
||
sub: [space?.title || device?.space || marker.space, marker.room_id || device?.area,
|
||
stateful ? '' : this.host._t('marker.control_passive')].filter(Boolean).join(' · '),
|
||
icon: marker.icon || device?.icon || 'mdi:lightbulb-outline',
|
||
warning: marker.hidden === true || device?.hidden === true,
|
||
};
|
||
}
|
||
|
||
public _valueBadgeCandidateLabel(candidate: ValueBadgeCandidate): string {
|
||
const source = candidate.source;
|
||
if (source.kind === 'derived_lqi') return this.host._t('marker.value_badge_lqi');
|
||
if (source.kind === 'derived_marker_state') {
|
||
return this.host._t('marker.value_badge_marker_state', { name: candidate.label });
|
||
}
|
||
if (source.kind === 'entity_state') {
|
||
return this.host._t('marker.value_badge_state', { name: candidate.label });
|
||
}
|
||
const entityName = this.host.hass.states[source.entity_id]?.attributes?.friendly_name
|
||
|| this.host._fullRegistryHass.entities[source.entity_id]?.name || source.entity_id;
|
||
return this.host._t(`marker.value_badge_attr_${source.attribute}` as I18nKey, { name: entityName });
|
||
}
|
||
|
||
public _controlCandidates(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): {
|
||
value: string; label: string; sub: string; icon: string;
|
||
}[] {
|
||
const currentId = this._markerDraft(d)?.id || d.devId || '';
|
||
const plan: { value: string; label: string; sub: string; icon: string; search: string }[] = [];
|
||
const coveredEntities = new Set<string>();
|
||
for (const marker of this.host._markers) {
|
||
if (marker.id === currentId || marker.removed || marker.hidden || marker.is_light !== true) continue;
|
||
if (marker.binding !== 'virtual' && this.host._bindingStatus(marker.binding).kind !== 'active') continue;
|
||
const device = this.host._devices.find((item) => item.id === marker.id);
|
||
if (!device || device.hidden) continue;
|
||
const info = this._controlRefInfo(`marker:${marker.id}`);
|
||
for (const eid of ownControllableEntities(device)) coveredEntities.add(eid);
|
||
plan.push({
|
||
value: `marker:${marker.id}`, label: info.label, sub: info.sub, icon: info.icon,
|
||
search: `${info.label} ${info.sub} ${marker.id} ${ownControllableEntities(device).join(' ')}`.toLowerCase(),
|
||
});
|
||
}
|
||
const ha = Object.keys(this.host.hass.states || {})
|
||
.filter((eid) => isControllable(eid) && !coveredEntities.has(eid)
|
||
&& effectiveMarkerControls(d.binding, [eid], this.host._bindingEntities(d.binding)).length > 0
|
||
&& this.host._planEntityAvailable(eid))
|
||
.map((eid) => {
|
||
const info = this._controlRefInfo(eid);
|
||
return { value: eid, label: info.label, sub: eid, icon: info.icon,
|
||
search: `${info.label} ${eid}`.toLowerCase() };
|
||
});
|
||
const q = d.controlsFilter.trim().toLowerCase();
|
||
return [...plan, ...ha]
|
||
.filter((item) => !d.controls.includes(item.value) && (!q || item.search.includes(q)))
|
||
.slice(0, 12)
|
||
.map(({ search: _search, ...item }) => item);
|
||
}
|
||
|
||
public _addControlRef(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>, ref: string): void {
|
||
if (ref.startsWith('marker:')) {
|
||
const controllerId = this._markerDraft(d)?.id || d.devId || '';
|
||
if (!controllerId) {
|
||
this.host._showToast(this.host._t('toast.marker_binding_required'));
|
||
return;
|
||
}
|
||
const targetId = ref.slice('marker:'.length);
|
||
const draft = this._markerDraft(d);
|
||
const graph = draft
|
||
? [...this.host._markers.filter((marker) => marker.id !== controllerId), draft]
|
||
: this.host._markers;
|
||
if (markerControlWouldCycle(graph, controllerId, targetId)) {
|
||
this.host._showToast(this.host._t('toast.marker_control_cycle'));
|
||
return;
|
||
}
|
||
}
|
||
this.host._markerDialog = this._announceToggleDraft({
|
||
...d, controls: [...d.controls, ref], controlsFilter: '',
|
||
});
|
||
}
|
||
|
||
public _setMarkerGlowMode(mode: 'auto' | 'color' | 'fixed'): void {
|
||
const d = this.host._markerDialog;
|
||
if (!d) return;
|
||
if (mode === 'auto') {
|
||
this.host._markerDialog = { ...d, glowMode: mode, glowTouched: true };
|
||
return;
|
||
}
|
||
const source = this._markerSpatialSource(d);
|
||
const values = resolveGlowValues(
|
||
source ? this.host._planHass.states[source.eid] : undefined,
|
||
null,
|
||
this.host._fillColors.glow_light.c,
|
||
);
|
||
const needColor = !d.glowColorDrafted;
|
||
const needBrightness = mode === 'fixed' && !d.glowBrightnessDrafted;
|
||
this.host._markerDialog = {
|
||
...d,
|
||
glowMode: mode,
|
||
glowColor: needColor ? values.c : d.glowColor,
|
||
glowBrightness: needBrightness ? Math.max(1, Math.round(values.bri * 100)) : d.glowBrightness,
|
||
glowColorDrafted: true,
|
||
glowBrightnessDrafted: d.glowBrightnessDrafted || mode === 'fixed',
|
||
glowTouched: true,
|
||
};
|
||
}
|
||
|
||
public _renderRadarSection(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>, device: DevItem | null, placement: 'main' | 'additional' = 'main'): TemplateResult {
|
||
return renderRadarSection({
|
||
dialog: d,
|
||
device,
|
||
registryHass: this.host._fullRegistryHass,
|
||
planHass: this.host._planHass,
|
||
hass: this.host.hass,
|
||
spaceModelById: (id) => this.host._spaceModelById(id),
|
||
currentSpace: () => this.host._spaceModel(),
|
||
position: (item) => this.host._pos(item),
|
||
updateDialog: (patch) => {
|
||
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, ...patch };
|
||
},
|
||
dialogDeviceId: () => this.host._markerDialog?.devId,
|
||
t: (key, vars) => this.host._t(key, vars),
|
||
help: (key) => this._help(key),
|
||
boolInput: (value, change) => this._boolInput(value, change),
|
||
toast: (message) => this.host._showToast(message),
|
||
setup: this._radarSetup,
|
||
configRev: this.host._cfgRev,
|
||
}, placement);
|
||
}
|
||
|
||
public _renderMarkerDialog(): TemplateResult { return renderMarkerDialog.call(this); }
|
||
|
||
public _renderSpaceDialog(): TemplateResult { return renderSpaceSettingsDialog.call(this); }
|
||
|
||
public _renderMergeDialog(): TemplateResult {
|
||
const d = this.host._mergeDialog!;
|
||
const rooms = this.host._spaceModel()?.rooms || [];
|
||
const opt = (id: string, key: 'a' | 'b') => {
|
||
const r = rooms.find((x) => x.id === id);
|
||
const area = r?.area ? this.host.hass.areas[r.area]?.name : null;
|
||
return html`<label class="srcrow">
|
||
<input type="radio" name="mergekeep" .checked=${d.pick === key}
|
||
@change=${() => (this.host._mergeDialog = { ...d, pick: key })} />
|
||
<span>${r?.name || ''} <span class="muted">· ${area || this.host._t('merge.no_area')}</span></span>
|
||
</label>`;
|
||
};
|
||
return html`<hp-dialog .hass=${this.host.hass} data-kind="room" .title=${this.host._t('merge.header')} icon="mdi:vector-union"
|
||
@hp-close=${() => (this.host._mergeDialog = null)}>
|
||
<div class="body">
|
||
<p class="muted">${this.host._t('merge.hint')}</p>
|
||
<label>${this.host._t('merge.keep')}</label>
|
||
${opt(d.aId, 'a')}
|
||
${opt(d.bId, 'b')}
|
||
</div>
|
||
<div class="row" slot="footer">
|
||
<span class="spacer"></span>
|
||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._mergeDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._commitMerge()}>
|
||
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
|
||
</button>
|
||
</div>
|
||
</hp-dialog>`;
|
||
}
|
||
|
||
public _renderRoomDialog(): TemplateResult { return renderRoomSettingsDialog.call(this); }
|
||
}
|