mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
Vendored
+290
-248
File diff suppressed because one or more lines are too long
+260
-25
@@ -113,7 +113,7 @@ import {
|
||||
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
|
||||
directionalOccluders, draftBodies, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
|
||||
} from './physical-geometry';
|
||||
import {
|
||||
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
@@ -172,6 +172,16 @@ import {
|
||||
ModeTransitionController, viewportFromViewBox,
|
||||
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
|
||||
} from './mode-transition';
|
||||
import {
|
||||
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
|
||||
} from './labs';
|
||||
import {
|
||||
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, projectedFrame,
|
||||
unprojectFloorPoint, type ScenePoint, type ViewRect,
|
||||
} from './iso-projection';
|
||||
import {
|
||||
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoWallGeometry,
|
||||
} from './iso-walls';
|
||||
import {
|
||||
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
|
||||
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
|
||||
@@ -278,6 +288,9 @@ const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void
|
||||
type WarmViewport = {
|
||||
space: string;
|
||||
mode: 'view' | 'plan' | 'devices' | 'decor';
|
||||
projection: 'flat' | 'iso';
|
||||
activeLabsIso: boolean;
|
||||
logicalCenter: { x: number; y: number } | null;
|
||||
zoom: number;
|
||||
view: { x: number; y: number; w: number; h: number } | null;
|
||||
/** the view-mode viewport an editor was entered from (_viewModeSnap) */
|
||||
@@ -417,6 +430,7 @@ const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
const LS_NAV = 'houseplan_card_nav_v1'; // last space only; editor sessions never survive page navigation
|
||||
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
|
||||
const LS_VIEW = 'houseplan_card_view_v1'; // presentation preference per space, Labs-only
|
||||
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. */
|
||||
@@ -1138,6 +1152,8 @@ class HouseplanCard extends LitElement {
|
||||
key: string;
|
||||
value: ReturnType<typeof wallBodiesUnionPath>;
|
||||
} | null = null;
|
||||
private _isoGeometryCache = new Map<string, { geometry: IsoWallGeometry; frame: Rect }>();
|
||||
private _isoFallback = new Set<string>();
|
||||
private _openingTunnelCache: {
|
||||
key: string;
|
||||
value: Array<OpeningTunnelGeometry | null>;
|
||||
@@ -1492,6 +1508,10 @@ class HouseplanCard extends LitElement {
|
||||
private _warmSlot: WarmEntry | null = null;
|
||||
private _hashApplied = false;
|
||||
private _navApplied = false; // the saved space was restored (or the user navigated)
|
||||
private _labs: LabsSnapshot = { active: Object.freeze([]), space: '' };
|
||||
private _labsUnsub?: () => void;
|
||||
private _viewPreference: Record<string, 'flat' | 'iso'> = {};
|
||||
private _renderProjection: 'flat' | 'iso' = 'flat';
|
||||
// ---- kiosk (wall device) mode ----
|
||||
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
|
||||
private _kioskDialog = false;
|
||||
@@ -1603,10 +1623,63 @@ class HouseplanCard extends LitElement {
|
||||
private _cyclePausedUntil = 0;
|
||||
private _swipeStart: { x: number; y: number; id: number } | null = null;
|
||||
private _lastTap = 0;
|
||||
private get _labsIso(): boolean {
|
||||
return this._labs.active.includes('iso');
|
||||
}
|
||||
|
||||
private get _desiredProjection(): 'flat' | 'iso' {
|
||||
return this._mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso'
|
||||
? 'iso' : 'flat';
|
||||
}
|
||||
|
||||
private _saveViewPreference(): void {
|
||||
try { localStorage.setItem(LS_VIEW, JSON.stringify(this._viewPreference)); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
private _logicalViewCenter(projection: 'flat' | 'iso'): { x: number; y: number } | null {
|
||||
const view = this._view;
|
||||
if (!view) return null;
|
||||
const center: ScenePoint = [view.x + view.w / 2, view.y + view.h / 2];
|
||||
const point = projection === 'iso' ? unprojectFloorPoint(center) : center;
|
||||
return { x: point[0], y: point[1] };
|
||||
}
|
||||
|
||||
private _convertProjectionView(from: 'flat' | 'iso', to: 'flat' | 'iso'): void {
|
||||
if (from === to) return;
|
||||
const logical = this._logicalViewCenter(from);
|
||||
this._view = null;
|
||||
const target = logical
|
||||
? to === 'iso' ? projectPlanPoint([logical.x, logical.y], 0) : [logical.x, logical.y] as ScenePoint
|
||||
: null;
|
||||
this._applyView(this._zoom, target?.[0], target?.[1]);
|
||||
this._warmPatch({ vp: this._warmViewportState() });
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _setProjection(projection: 'flat' | 'iso'): void {
|
||||
if (!this._labsIso || this._mode !== 'view') return;
|
||||
const from = this._effectiveProjection();
|
||||
this._viewPreference = { ...this._viewPreference, [this._space]: projection };
|
||||
if (projection === 'iso') {
|
||||
const retry = this._isoSceneKey();
|
||||
if (retry) this._isoFallback.delete(retry);
|
||||
}
|
||||
this._saveViewPreference();
|
||||
const to = this._effectiveProjection();
|
||||
this._convertProjectionView(from, to);
|
||||
}
|
||||
|
||||
private _onLabsSnapshot = (next: LabsSnapshot): void => {
|
||||
const from = this._effectiveProjection();
|
||||
this._labs = next;
|
||||
const to = this._effectiveProjection();
|
||||
this._convertProjectionView(from, to);
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
|
||||
private _hashSpace(): string {
|
||||
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
return hashSpace(window.location.hash || '');
|
||||
}
|
||||
private _onHashChange = (): void => {
|
||||
const id = this._hashSpace();
|
||||
@@ -1747,6 +1820,8 @@ class HouseplanCard extends LitElement {
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
}
|
||||
window.addEventListener('hashchange', this._onHashChange);
|
||||
this._labsUnsub?.();
|
||||
this._labsUnsub = subscribeLabs(CARD_VERSION, this._onLabsSnapshot);
|
||||
// AUD-1552-01: the boot-veil timers die in disconnectedCallback, so a
|
||||
// disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
|
||||
// switch remounts the card) used to strand _booting=true with no watcher
|
||||
@@ -1832,6 +1907,8 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
this._labsUnsub?.();
|
||||
this._labsUnsub = undefined;
|
||||
clearTimeout(this._holdTimer);
|
||||
this._roViewport?.disconnect();
|
||||
this._roViewport = undefined;
|
||||
@@ -2175,6 +2252,14 @@ class HouseplanCard extends LitElement {
|
||||
} catch {
|
||||
this._zoomBySpace = {};
|
||||
}
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(LS_VIEW) || '{}') || {};
|
||||
this._viewPreference = Object.fromEntries(Object.entries(stored)
|
||||
.filter((entry): entry is [string, 'flat' | 'iso'] => entry[1] === 'flat' || entry[1] === 'iso'));
|
||||
} catch {
|
||||
this._viewPreference = {};
|
||||
}
|
||||
this._labs = currentLabs(CARD_VERSION);
|
||||
try {
|
||||
const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null');
|
||||
this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) };
|
||||
@@ -2381,8 +2466,16 @@ class HouseplanCard extends LitElement {
|
||||
// pending intent is REPLACED here, never merely added to.
|
||||
this._pendingNavMode = vp.mode !== 'view' && !this._canEdit && !config.kiosk ? vp.mode : null;
|
||||
this._zoom = vp.zoom;
|
||||
this._view = vp.view ? { ...vp.view } : null;
|
||||
this._viewModeSnap = vp.snap ? { ...vp.snap } : null;
|
||||
const projection = this._effectiveProjection();
|
||||
const sameProjection = projection === vp.projection && this._labsIso === vp.activeLabsIso;
|
||||
this._view = sameProjection && vp.view ? { ...vp.view } : null;
|
||||
this._viewModeSnap = sameProjection && vp.snap ? { ...vp.snap } : null;
|
||||
if (!sameProjection && vp.logicalCenter) {
|
||||
const center = projection === 'iso'
|
||||
? projectPlanPoint([vp.logicalCenter.x, vp.logicalCenter.y], 0)
|
||||
: [vp.logicalCenter.x, vp.logicalCenter.y] as ScenePoint;
|
||||
this._applyView(vp.zoom, center[0], center[1]);
|
||||
}
|
||||
this._tool = normalizeMarkupTool(vp.tool);
|
||||
this._decorTool = vp.decorTool;
|
||||
this._showHidden = vp.showHidden;
|
||||
@@ -2428,9 +2521,13 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _warmViewportState(): WarmViewport {
|
||||
const projection = this._effectiveProjection();
|
||||
return {
|
||||
space: this._space,
|
||||
mode: this._mode,
|
||||
projection,
|
||||
activeLabsIso: this._labsIso,
|
||||
logicalCenter: this._logicalViewCenter(projection),
|
||||
zoom: this._zoom,
|
||||
view: this._view ? { ...this._view } : null,
|
||||
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
|
||||
@@ -4321,7 +4418,87 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */
|
||||
private _isoSource(): { key: string; build: () => any } {
|
||||
const space = this._spaceModel();
|
||||
const walls = this._spaceWalls;
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((opening: any) => ({
|
||||
x: Number(opening.x) * NORM_W,
|
||||
y: Number(opening.y) * NORM_W,
|
||||
angle: Number(opening.angle) || 0,
|
||||
length: (Number(opening.length) > 0 ? Number(opening.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const key = `${space.id}|${isoGeometryFingerprint({
|
||||
rooms: space.rooms, walls, openCuts, openings,
|
||||
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
|
||||
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
|
||||
})}`;
|
||||
return {
|
||||
key,
|
||||
build: () => {
|
||||
const extras = physicalBodies(space, this._cellCm, this._gridPitch);
|
||||
const united = walls.length || extras.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
)
|
||||
: { geom: [] };
|
||||
if (!united) throw new Error('wall boolean geometry failed');
|
||||
return united.geom;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _isoSceneKey(): string | null {
|
||||
if (!this._labsIso || this._mode !== 'view') return null;
|
||||
try { return this._isoSource().key; } catch { return `${this._space}|invalid`; }
|
||||
}
|
||||
|
||||
private _isoScene(): { key: string; geometry: IsoWallGeometry; frame: Rect } {
|
||||
const source = this._isoSource();
|
||||
const cached = this._isoGeometryCache.get(source.key);
|
||||
if (cached) return { key: source.key, ...cached };
|
||||
const flat = this._frameOf().rect;
|
||||
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
|
||||
const geometry = buildIsoWallGeometry(source.build());
|
||||
const value = { geometry, frame };
|
||||
lruWrite(this._isoGeometryCache, source.key, value, 8);
|
||||
return { key: source.key, ...value };
|
||||
}
|
||||
|
||||
private _effectiveProjection(): 'flat' | 'iso' {
|
||||
if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat';
|
||||
try {
|
||||
const scene = this._isoScene();
|
||||
return isoEffectiveView('iso', scene.key, this._isoFallback);
|
||||
} catch (error) {
|
||||
const key = this._isoSceneKey() || `${this._space}|invalid`;
|
||||
if (!this._isoFallback.has(key)) {
|
||||
this._isoFallback.add(key);
|
||||
const parts = key.split('|');
|
||||
console.warn(`HOUSEPLAN ISO FALLBACK: #89, space ${this._space}, fingerprint ${parts[parts.length - 1]}, ${error instanceof Error ? error.message : 'renderer error'}`);
|
||||
}
|
||||
return 'flat';
|
||||
}
|
||||
}
|
||||
|
||||
private _scenePoint(point: readonly [number, number]): ScenePoint {
|
||||
return this._renderProjection === 'iso' ? projectPlanPoint(point, 0) : point;
|
||||
}
|
||||
|
||||
private _floorView(view: { x: number; y: number; w: number; h: number }): ViewRect {
|
||||
if (this._renderProjection !== 'iso') return view;
|
||||
const start = unprojectFloorPoint([view.x, view.y]);
|
||||
const end = unprojectFloorPoint([view.x + view.w, view.y + view.h]);
|
||||
return { x: start[0], y: start[1], w: end[0] - start[0], h: end[1] - start[1] };
|
||||
}
|
||||
|
||||
private _baseVb(): number[] {
|
||||
if (this._effectiveProjection() === 'iso') {
|
||||
const frame = this._isoScene().frame;
|
||||
return [frame.x, frame.y, frame.w, frame.h];
|
||||
}
|
||||
const r = this._frameOf().rect;
|
||||
return [r.x, r.y, r.w, r.h];
|
||||
}
|
||||
@@ -4366,7 +4543,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._booting) return nothing;
|
||||
const v = this._view;
|
||||
if (!v || !v.w || !v.h) return nothing;
|
||||
const f = this._frameOf().rect;
|
||||
const base = this._baseVb();
|
||||
const f = { x: base[0], y: base[1], w: base[2], h: base[3] };
|
||||
const gone = f.x + f.w <= v.x || f.x >= v.x + v.w || f.y + f.h <= v.y || f.y >= v.y + v.h;
|
||||
if (!gone) return nothing;
|
||||
const ang = Math.atan2((f.y + f.h / 2) - (v.y + v.h / 2), (f.x + f.w / 2) - (v.x + v.w / 2));
|
||||
@@ -5310,6 +5488,7 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
const previousMode = this._mode;
|
||||
const previousProjection = this._effectiveProjection();
|
||||
const capturedVisual = this._currentModeVisual(previousMode);
|
||||
const retargeting = this._modeTransitionBusy;
|
||||
// The target toolbar replaces the old editor markup during the hidden
|
||||
@@ -5346,9 +5525,17 @@ class HouseplanCard extends LitElement {
|
||||
this._viewModeSnap = {
|
||||
space: this._space,
|
||||
zoom: this._zoom,
|
||||
cx: v ? v.x + v.w / 2 : undefined,
|
||||
cy: v ? v.y + v.h / 2 : undefined,
|
||||
cx: v ? this._logicalViewCenter(previousProjection)?.x : undefined,
|
||||
cy: v ? this._logicalViewCenter(previousProjection)?.y : undefined,
|
||||
};
|
||||
if (previousProjection === 'iso') {
|
||||
const logical = this._logicalViewCenter('iso');
|
||||
targetCenterX = logical?.x;
|
||||
targetCenterY = logical?.y;
|
||||
this._view = null;
|
||||
this._mode = mode;
|
||||
this._applyView(this._zoom, logical?.x, logical?.y);
|
||||
}
|
||||
if (baseChanges) {
|
||||
targetZoom = 1;
|
||||
targetCenterX = undefined;
|
||||
@@ -5371,8 +5558,12 @@ class HouseplanCard extends LitElement {
|
||||
// restore the snapshot only for the space it was taken in
|
||||
if (snap && snap.space === this._space) {
|
||||
targetZoom = snap.zoom;
|
||||
targetCenterX = snap.cx;
|
||||
targetCenterY = snap.cy;
|
||||
const targetProjection = this._labsIso && this._viewPreference[this._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
|
||||
@@ -5395,6 +5586,10 @@ class HouseplanCard extends LitElement {
|
||||
this._modeTransitionTargetZoom = targetZoom;
|
||||
this._modeTransitionTargetCenterX = targetCenterX;
|
||||
this._modeTransitionTargetCenterY = targetCenterY;
|
||||
if (previousProjection === 'iso'
|
||||
|| (mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso')) {
|
||||
this._modeTransitionForceAtomic = true;
|
||||
}
|
||||
const request = ++this._modeTransitionRequest;
|
||||
if (!animate) {
|
||||
// The only non-animated caller is route departure; keep the guard local
|
||||
@@ -9799,7 +9994,8 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _renderSvgRoomLabels(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
|
||||
if (space.bg || disp.showNames || this._markup) return svg`` as unknown as TemplateResult;
|
||||
if (this._renderProjection === 'iso' || space.bg || disp.showNames || this._markup)
|
||||
return svg`` as unknown as TemplateResult;
|
||||
return svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {
|
||||
const center = this._roomCenter(room);
|
||||
return svg`<text class="rlabel" data-hp="room-label"
|
||||
@@ -9809,6 +10005,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _renderWallBodies(disp: SpaceDisplay): TemplateResult {
|
||||
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
|
||||
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
|
||||
return svg`` as unknown as TemplateResult;
|
||||
const walls = this._spaceWalls;
|
||||
@@ -9852,6 +10049,18 @@ class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderIsoWalls(): TemplateResult {
|
||||
if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
if (!disp.showBorders) return svg`` as unknown as TemplateResult;
|
||||
const scene = this._isoScene();
|
||||
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
|
||||
<g class="iso-wall-sides">${scene.geometry.sides.map((face) =>
|
||||
svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>`)}</g>
|
||||
<path class="iso-wall-top" d=${scene.geometry.topPath} fill-rule="evenodd"></path>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* View-only clean-floor perimeter above real wall bodies.
|
||||
*
|
||||
@@ -13630,6 +13839,10 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const space = this._spaceModel();
|
||||
const vb = space.vb;
|
||||
const projection = this._effectiveProjection();
|
||||
this._renderProjection = projection;
|
||||
if (this._labs.active.length) noteLabsRender(CARD_VERSION);
|
||||
const iso = projection === 'iso';
|
||||
// hidden devices render ONLY in the device editor with "show hidden" on
|
||||
// (ghosted); everywhere else the flag removes them from sight — but not
|
||||
// from the build, so room LQI still counts them (docs/FILTERING.md)
|
||||
@@ -13642,7 +13855,8 @@ class HouseplanCard extends LitElement {
|
||||
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
const view = this._viewOr(vb);
|
||||
const view = this._viewOr(this._baseVb());
|
||||
const floorView = this._floorView(view);
|
||||
// Background around the plan (view/kiosk; editors keep their own canvas).
|
||||
// Both settings dialogs preview their pending value live.
|
||||
const stageBg = this._editing ? '' : this._stageBg(disp);
|
||||
@@ -13737,6 +13951,15 @@ class HouseplanCard extends LitElement {
|
||||
: nothing}
|
||||
<span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
|
||||
<span class="spacer"></span>
|
||||
${this._labsIso && this._mode === 'view' && !this._kiosk
|
||||
? html`<button class="btn projection-toggle ${iso ? 'on' : ''}"
|
||||
data-hp="projection-toggle" aria-pressed=${iso ? 'true' : 'false'}
|
||||
aria-label=${this._t('view.volumetric')}
|
||||
title=${this._t(iso ? 'view.flat' : 'view.volumetric')}
|
||||
@click=${() => this._setProjection(iso ? 'flat' : 'iso')}>
|
||||
<ha-icon icon=${iso ? 'mdi:view-grid-outline' : 'mdi:cube-outline'}></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
<div class="zoomctl">
|
||||
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
|
||||
${''/* docs/CANVAS.md §8: this IS «вписать всё» — the old "reset
|
||||
@@ -13783,7 +14006,8 @@ class HouseplanCard extends LitElement {
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
|
||||
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
<svg viewBox="${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}"
|
||||
preserveAspectRatio=${iso ? 'none' : 'xMidYMid meet'}>
|
||||
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
|
||||
2026-08-04). Opaque shapes stop the scene background —
|
||||
bg_color or the 'daynight' sky — from bleeding through the
|
||||
@@ -13983,6 +14207,10 @@ class HouseplanCard extends LitElement {
|
||||
${this._renderBackdropFrame(view)}
|
||||
${this._renderTextFrame(view)}
|
||||
</svg>
|
||||
${iso ? svg`<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoWalls()}
|
||||
</svg>` : nothing}
|
||||
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
|
||||
scales with it when you zoom — the behaviour the card always
|
||||
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
|
||||
@@ -13998,7 +14226,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._renderVacuums(devs, view)}
|
||||
${this._renderVacFit(view)}
|
||||
${this._renderOpeningLocks(view)}
|
||||
${disp.showNames || this._markup
|
||||
${disp.showNames || (iso && !space.bg) || this._markup
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
: nothing}
|
||||
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
|
||||
@@ -14842,7 +15070,8 @@ class HouseplanCard extends LitElement {
|
||||
if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) {
|
||||
const pts = srvPrev.points.map(([x, y]: number[]) => {
|
||||
const [cx2, cy2] = applyAffine(matrix, x, y);
|
||||
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
|
||||
const point = this._scenePoint([cx2, cy2]);
|
||||
return point[0].toFixed(1) + ',' + point[1].toFixed(1);
|
||||
}).join(' ');
|
||||
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
|
||||
}
|
||||
@@ -14857,7 +15086,8 @@ class HouseplanCard extends LitElement {
|
||||
if (raw.length) {
|
||||
const pathD = raw.map((segment) => segment.map(([x, y], index) => {
|
||||
const [cx, cy] = applyAffine(matrix, x, y);
|
||||
return `${index ? 'L' : 'M'} ${cx.toFixed(1)} ${cy.toFixed(1)}`;
|
||||
const point = this._scenePoint([cx, cy]);
|
||||
return `${index ? 'L' : 'M'} ${point[0].toFixed(1)} ${point[1].toFixed(1)}`;
|
||||
}).join(' ')).join(' ');
|
||||
// A single SVG path with several M commands keeps gaps literal.
|
||||
trails.push(svg`<path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path>`);
|
||||
@@ -14865,15 +15095,17 @@ class HouseplanCard extends LitElement {
|
||||
if (moving && last?.length >= 2) {
|
||||
const anchor = last[last.length - 1];
|
||||
const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]);
|
||||
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
|
||||
const point = this._scenePoint([ax, ay]);
|
||||
const a1 = point[0].toFixed(1), a2 = point[1].toFixed(1);
|
||||
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moving || !tele.pos) continue;
|
||||
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
|
||||
const left = ((cx - view.x) / view.w) * 100;
|
||||
const top = ((cy - view.y) / view.h) * 100;
|
||||
const point = this._scenePoint([cx, cy]);
|
||||
const left = ((point[0] - view.x) / view.w) * 100;
|
||||
const top = ((point[1] - view.y) / view.h) * 100;
|
||||
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
|
||||
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
|
||||
pucks.push(html`<div
|
||||
@@ -14895,8 +15127,9 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
|
||||
const pos = this._pos(d);
|
||||
const left = ((pos.x - view.x) / view.w) * 100;
|
||||
const top = ((pos.y - view.y) / view.h) * 100;
|
||||
const point = this._scenePoint([pos.x, pos.y]);
|
||||
const left = ((point[0] - view.x) / view.w) * 100;
|
||||
const top = ((point[1] - view.y) / view.h) * 100;
|
||||
const presentation = this._devicePresentation(d, showLqi);
|
||||
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
|
||||
const disabledReason = presentation.disabledReason;
|
||||
@@ -15267,8 +15500,9 @@ class HouseplanCard extends LitElement {
|
||||
// editor — that is where you name them (field report, 2026-07-27)
|
||||
if (!r.name && !this._markup) return nothing;
|
||||
const p = this._labelPos(r, space.id);
|
||||
const left = ((p.x - view.x) / view.w) * 100;
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
const point = this._scenePoint([p.x, p.y]);
|
||||
const left = ((point[0] - view.x) / view.w) * 100;
|
||||
const top = ((point[1] - view.y) / view.h) * 100;
|
||||
const op = Math.min(1, disp.opacity + 0.25);
|
||||
const k = this._labelScale(r);
|
||||
// Optional metrics row. Light sources may use an explicit room_id even
|
||||
@@ -15650,8 +15884,9 @@ class HouseplanCard extends LitElement {
|
||||
const off = gateFace ? -16 * gateFace.side : 16 * (o.flip_v ? -1 : 1);
|
||||
const px = o.rx + Math.cos(rad) * off;
|
||||
const py = o.ry + Math.sin(rad) * off;
|
||||
const left = ((px - view.x) / view.w) * 100;
|
||||
const top = ((py - view.y) / view.h) * 100;
|
||||
const point = this._scenePoint([px, py]);
|
||||
const left = ((point[0] - view.x) / view.w) * 100;
|
||||
const top = ((point[1] - view.y) / view.h) * 100;
|
||||
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
|
||||
style="left:${left}%;top:${top}%"
|
||||
@click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
|
||||
|
||||
@@ -505,6 +505,8 @@
|
||||
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
|
||||
"marker.pick_ph": "Choose a device…",
|
||||
"room.open_area": "Open the HA area",
|
||||
"view.volumetric": "Volumetric view",
|
||||
"view.flat": "Flat view",
|
||||
"kiosk.title": "This screen's sizes",
|
||||
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
|
||||
"kiosk.icon_scale": "Device icon size",
|
||||
|
||||
@@ -505,6 +505,8 @@
|
||||
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
|
||||
"marker.pick_ph": "Выберите устройство…",
|
||||
"room.open_area": "Открыть зону в HA",
|
||||
"view.volumetric": "Объёмный вид",
|
||||
"view.flat": "Плоский вид",
|
||||
"kiosk.title": "Размеры на этом экране",
|
||||
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
|
||||
"kiosk.icon_scale": "Размер значков устройств",
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface IsoWallGeometry {
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
export function isoEffectiveView(
|
||||
desired: 'flat' | 'iso', fingerprint: string, failed: ReadonlySet<string>,
|
||||
): 'flat' | 'iso' {
|
||||
return desired === 'iso' && fingerprint && !failed.has(fingerprint) ? 'iso' : 'flat';
|
||||
}
|
||||
|
||||
const pointText = (point: ScenePoint): string =>
|
||||
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
|
||||
|
||||
|
||||
+4
-2
@@ -183,7 +183,8 @@ export function subscribeLabs(
|
||||
version: string, subscriber: (value: LabsSnapshot) => void,
|
||||
): () => void {
|
||||
subscribers.add(subscriber);
|
||||
subscriber(ensureBrowserLabs(version));
|
||||
const current = ensureBrowserLabs(version);
|
||||
if (typeof window === 'undefined') subscriber(current);
|
||||
return () => { subscribers.delete(subscriber); };
|
||||
}
|
||||
|
||||
@@ -193,7 +194,8 @@ export function currentLabs(version: string): LabsSnapshot {
|
||||
|
||||
/** Diagnostics are emitted only when an active Labs frame is actually rendered. */
|
||||
export function noteLabsRender(version: string): void {
|
||||
const current = currentLabs(version);
|
||||
if (!configuredVersion) ensureBrowserLabs(version);
|
||||
const current = snapshot;
|
||||
const signature = current.active.join(',');
|
||||
if (!signature || signature === loggedSignature) return;
|
||||
loggedSignature = signature;
|
||||
|
||||
@@ -383,6 +383,12 @@ export const cardStyles = css`
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.projection-toggle {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
/* docs/CANVAS.md §4.1: objects an order of magnitude away from the plan
|
||||
do not decide the opening view — one quiet chip says so and offers to
|
||||
take them in. Never a modal (owner). */
|
||||
@@ -463,6 +469,26 @@ export const cardStyles = css`
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.iso-walls-svg {
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
}
|
||||
.iso-wall-side {
|
||||
fill: #a8acae;
|
||||
stroke: #92989b;
|
||||
stroke-width: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-wall-top {
|
||||
fill: #f3f3f1;
|
||||
stroke: #d7d9d8;
|
||||
stroke-width: 0.8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.iso-wall-side { fill: #3f474c; stroke: #333a3e; }
|
||||
.iso-wall-top { fill: #596166; stroke: #747d82; }
|
||||
}
|
||||
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
|
||||
shows ONLY around the plan, never through it. The colour is the
|
||||
pre-bg_color canvas — the theme card background under an image plan,
|
||||
@@ -1654,6 +1680,7 @@ export const cardStyles = css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
.dev {
|
||||
position: absolute;
|
||||
|
||||
+11
-1
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildIsoWallGeometry, isoGeometryFingerprint } from '../test-build/iso-walls.js';
|
||||
import {
|
||||
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
|
||||
} from '../test-build/iso-walls.js';
|
||||
|
||||
const closed = (points) => [...points, points[0]];
|
||||
const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
@@ -42,3 +44,11 @@ test('fingerprint is content based, stable and sensitive to in-place geometry ed
|
||||
isoGeometryFingerprint({ rooms: value.rooms, showBorders: false }));
|
||||
});
|
||||
|
||||
test('latched renderer failures stay flat until explicit retry or a new fingerprint', () => {
|
||||
const failed = new Set(['space|old']);
|
||||
assert.equal(isoEffectiveView('iso', 'space|old', failed), 'flat');
|
||||
assert.equal(isoEffectiveView('iso', 'space|new', failed), 'iso');
|
||||
assert.equal(isoEffectiveView('flat', 'space|new', failed), 'flat');
|
||||
failed.delete('space|old');
|
||||
assert.equal(isoEffectiveView('iso', 'space|old', failed), 'iso');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8');
|
||||
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
|
||||
const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('Labs iso is presentation-only and absent from the secondary space card', () => {
|
||||
assert.match(card, /subscribeLabs\(CARD_VERSION/);
|
||||
assert.match(card, /data-hp="projection-toggle"/);
|
||||
assert.match(card, /this\._labsIso && this\._mode === 'view' && !this\._kiosk/);
|
||||
assert.doesNotMatch(spaceCard, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
|
||||
assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
|
||||
});
|
||||
|
||||
test('renderer uses SVG geometry without a CSS 3D context or new window light', () => {
|
||||
assert.match(card, /<svg class="iso-walls-svg"/);
|
||||
assert.match(card, /class="iso-wall-top"/);
|
||||
assert.match(card, /class="iso-wall-side"/);
|
||||
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
|
||||
assert.doesNotMatch(card, /iso-window|window-light|vertical-door/);
|
||||
});
|
||||
|
||||
test('all current floor/live layers remain in the one main scene', () => {
|
||||
for (const renderer of [
|
||||
'_renderDecorLayer()', '_renderRoomHoverFill(roomHover)', '_renderGlowLayer(space, disp)',
|
||||
'_renderSunRays(space)', '_renderOpenings(disp)', '_renderVacuums(devs, view)',
|
||||
]) assert.ok(card.includes(renderer), `missing ${renderer}`);
|
||||
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
|
||||
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user