zoom-out, device-aware content frame, and the editor no longer shifts the plan

Three owner reports:

- The content frame behind the default zoom only looked at rooms, and devices
  are allowed to stand outside every one of them — a gate sensor by the fence
  was left outside the opening view. contentBounds() takes the marker positions
  now, and they count as content even on a space with no rooms at all.

- Entering an editor 'strangely shifted' the plan. The stage height was
  100dvh minus a hard-coded 118px of header, and the editor header is ~90px
  taller than that: the whole scene slid down by the difference and its bottom
  went below the fold. The card now measures where the stage actually starts
  (HA toolbar, margins and our header included) and gives it the rest of the
  viewport; the measurement is deferred a frame because setting state straight
  from a ResizeObserver callback trips the 'undelivered notifications' error,
  which smoke_dialog_zombie rightly counts as a page error.

- Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the
  clamp centres the content instead of pinning it to the top-left corner —
  with the view larger than the plan there is nothing to clamp against.

New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport,
0.4 floor, centring, the device-stretched frame); a unit test for the extra
points of contentBounds. Not released — the next release goes out after the
v1.49.0 audit.
This commit is contained in:
Matysh
2026-07-28 23:40:39 +03:00
parent 9b180c5917
commit 6c90e03427
6 changed files with 146 additions and 24 deletions
+64
View File
@@ -0,0 +1,64 @@
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
// entering an editor keeps the plan inside the viewport;
// - zoom < 1 centres the content instead of pinning it to a corner;
// - the content frame (default zoom) includes devices standing outside rooms.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// -- editor entry keeps the stage inside the viewport --------------------
const stageBox = () => page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const b = sr.querySelector('.stage').getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
});
const vh = await page.evaluate(() => window.innerHeight);
const inView = await stageBox();
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(400);
const inPlan = await stageBox();
out.viewFitsViewport = inView.bottom <= vh + 2;
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
await page.evaluate(() => window.__card._setMode('view'));
await page.waitForTimeout(300);
// -- zoom out below the base fit -----------------------------------------
out.zoomOut = await page.evaluate(() => {
const c = window.__card;
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
const v = { ...c._view };
const base = c._baseVb();
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
return {
zoom: c._zoom,
wider: v.w > fit.w * 1.9, // actually zoomed out
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
};
});
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
c._serverCfg = cfg; c._model = null;
const before = c._baseVb();
// walk one lamp far outside every room
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
const after = c._baseVb();
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+8 -8
View File
File diff suppressed because one or more lines are too long
+45 -7
View File
@@ -245,6 +245,8 @@ class HouseplanCard extends LitElement {
private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false;
private _roViewport?: ResizeObserver;
private _roHdr?: ResizeObserver;
private _hdrH = 118; // measured px above the stage (see the observer in updated())
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
@@ -359,6 +361,7 @@ class HouseplanCard extends LitElement {
private _holdFired = false;
static properties = {
_hdrH: { state: true },
hass: { attribute: false },
_config: { state: true },
_space: { state: true },
@@ -431,6 +434,8 @@ class HouseplanCard extends LitElement {
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
this._roHdr?.disconnect();
this._roHdr = undefined;
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
@@ -724,6 +729,24 @@ class HouseplanCard extends LitElement {
this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport.observe(stage);
}
// The stage fills the rest of the viewport. What sits above it — the HA
// toolbar, card margins, our own header — DEPENDS ON THE MODE: the editor
// bars used to be billed against a hard-coded 118px, so entering an editor
// pushed the plan down by the difference and cut its bottom off below the
// fold. Measure where the stage actually starts instead of assuming.
const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null;
if (hdr && stage && !this._roHdr) {
const measure = () => {
const t = Math.round(stage.getBoundingClientRect().top + (window.scrollY || 0));
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
};
// a frame later: setting state straight from the observer callback makes
// the browser report "ResizeObserver loop completed with undelivered
// notifications" — the render it triggers resizes the stage again
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
this._roHdr.observe(hdr);
measure();
}
if (stage && !this._view) this._refitView();
// onboarding: on an empty server config, open the space dialog right away
if (
@@ -1235,7 +1258,11 @@ class HouseplanCard extends LitElement {
private _baseVb(): number[] {
const m = this._spaceModel();
if (m.bg) return m.vb;
const b = contentBounds(m);
// devices are content too — they may stand outside every room
const pts = this._devices
.filter((d) => d.space === m.id)
.map((d) => { const p = this._pos(d); return [p.x, p.y] as const; });
const b = contentBounds(m, 0.05, pts);
return b ? [b.x, b.y, b.w, b.h] : m.vb;
}
@@ -1259,7 +1286,14 @@ class HouseplanCard extends LitElement {
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
}
/** Clamp the view to the fit bounds (the content always covers the scene). */
/** Zoom limits: 8× in, 0.4× out (zoomed out, the plan floats centred). */
private static readonly ZOOM_MAX = 8;
private static readonly ZOOM_MIN = 0.4;
/** Clamp the view to the fit bounds (the content always covers the scene).
* Zoomed OUT the view is larger than the content on an axis — then there is
* nothing to clamp against and the content is centred on that axis instead
* of being pinned to a corner. */
private _clampView(
v: { x: number; y: number; w: number; h: number },
fit: { x: number; y: number; w: number; h: number },
@@ -1267,8 +1301,12 @@ class HouseplanCard extends LitElement {
return {
w: v.w,
h: v.h,
x: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
y: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
x: v.w >= fit.w
? fit.x + (fit.w - v.w) / 2
: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
y: v.h >= fit.h
? fit.y + (fit.h - v.h) / 2
: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
};
}
@@ -1276,7 +1314,7 @@ class HouseplanCard extends LitElement {
private _applyView(zoom: number, cx?: number, cy?: number): void {
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, zoom));
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom));
const w = fit.w / z, h = fit.h / z;
const cur = this._viewOr(vb);
const ccx = cx ?? cur.x + cur.w / 2;
@@ -1299,7 +1337,7 @@ class HouseplanCard extends LitElement {
if (!stage) return;
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(8, Math.max(1, newZoom));
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
const w = stage.clientWidth, h = stage.clientHeight;
const pt = this._screenToVb(sx, sy);
const nw = fit.w / z, nh = fit.h / z;
@@ -3878,7 +3916,7 @@ class HouseplanCard extends LitElement {
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
+4 -1
View File
@@ -69,7 +69,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05,
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const add = (x: number, y: number) => {
@@ -86,6 +86,9 @@ export function contentBounds(
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
if (minX > maxX || minY > maxY) return null;
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
+17
View File
@@ -106,3 +106,20 @@ test('contentBounds: fits what is drawn, with a 5% margin', () => {
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
assert.equal(contentBounds(empty), null);
});
test('contentBounds: devices outside every room stretch the frame', () => {
const one = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
// a gate sensor far to the right of the room
const b = contentBounds(one, 0.05, [[900, 500]]);
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
// devices alone are content enough — an empty yard with two cameras
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
assert.equal(Math.round(only.w), 220);
// and junk coordinates are ignored, not spread across the canvas
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
});