// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square // renders whole, devices can be placed and saved out there, the opening view // follows the content, one far stray neither breaks the view nor hides itself, // zoom-out stops at 3x the content and the icons keep their proportion to it. import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { launch, check, checkAll, finish } from './serve.mjs'; const SHOTS = process.env.HP_SHOTS || ''; const { page, browser } = await launch({ width: 900, height: 820 }, 1); const out = {}; const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await page.evaluate(() => { window.__waitForCamera = async (card = window.__card) => { const started = performance.now(); while (card?._cameraTransition?.active) { if (performance.now() - started > 1000) throw new Error('camera transition did not settle'); await new Promise((r) => requestAnimationFrame(r)); } await card?.updateComplete; await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await card?.updateComplete; }; }); // A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old // canvas. Before the infinite canvas the -25%..125% envelope threw every one // of these vertices away and the card framed empty space. const FAR_CFG = { spaces: [{ id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null, rooms: [ { id: 'r1', name: 'Living room', area: 'living_room', poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] }, { id: 'r2', name: 'Kitchen', area: 'kitchen', poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] }, { id: 'r3', name: 'Bedroom', area: 'bedroom', poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] }, { id: 'r4', name: 'Hallway', area: 'hallway', poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] }, ], openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }], decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }], }, { id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1], rooms: [{ id: 'g1', name: 'Garden', area: 'garden', poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }], }], markers: [], settings: {}, }; const FAR_LAYOUT = { d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 }, d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 }, d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 }, d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 }, d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 }, }; const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => { const c = window.__card; c._serverCfg = JSON.parse(JSON.stringify(c2)); c._layout = JSON.parse(JSON.stringify(l2)); c._modelCache = null; c._frame = null; c._showFar = false; c._space = 'f1'; c._maybeRebuildDevices?.(); c._defPos = c._defaultPositions(); c._view = null; c._zoom = 1; c.requestUpdate(); await c.updateComplete; c._resetZoom(); await window.__waitForCamera(c); }, [cfg, layout]); await loadFar(FAR_CFG, FAR_LAYOUT); await raf(); // ---- (b) the plan past the old square renders WHOLE ---------------------- Object.assign(out, await page.evaluate(() => { const c = window.__card; const o = {}; const b = c._baseVb(); // the content is 1500..3000 x 1500..2700 render units, plus decor/openings o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750; o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more const v = c._viewOr(b); o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000; // every room polygon is inside the painted viewBox const sr = c.shadowRoot || c.renderRoot; const polys = [...sr.querySelectorAll('polygon.room')]; o.allRoomsDrawn = polys.length === 4; const st = sr.querySelector('.stage').getBoundingClientRect(); o.roomsOnScreen = polys.every((p) => { const r = p.getBoundingClientRect(); return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2; }); // and the markers landed on the plan, not in a corner of a phantom canvas const devs = [...sr.querySelectorAll('.devlayer .dev')]; o.markersOnPlan = devs.length >= 10 && devs.every((d) => { const r = d.getBoundingClientRect(); return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2; }); return o; })); // ---- a device can be PLACED past the old square, and it saves ------------- const ws = await page.evaluate(async () => { const c = window.__card; const sent = []; const base = c.hass.callWS; c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } }; // drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas const far = { s: 'f1', x: 3.4, y: 2.9 }; c._layout = { ...c._layout, d_gate: far }; c._dirtyPos.add('d_gate'); await c._persistLayout?.(); // and a room extended even further out, saved through the config writer const cfg = JSON.parse(JSON.stringify(c._serverCfg)); cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop', poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] }); c._serverCfg = cfg; c._modelCache = null; c._frame = null; await c._saveConfig?.(); await new Promise((r) => setTimeout(r, 700)); return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) }; }); out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9; const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop(); const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop(); out.wsCarriedFarGeometry = !!cfgMsg && JSON.stringify(cfgMsg.config).includes('3.8'); out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4'); // the payload the card sends must pass the REAL backend schema (docs/CANVAS.md // §3: ±5000). Skipped, not failed, where voluptuous is not installed. out.backendAcceptsFarPayload = (() => { if (!cfgMsg) return false; const directory = mkdtempSync(resolve(tmpdir(), 'hp-infinite-')); const f = resolve(directory, 'payload.json'); writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout })); try { try { execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' }); } catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; } const code = [ 'import json,sys', 'sys.path.insert(0, "custom_components/houseplan")', 'import validation as v', `d=json.load(open(${JSON.stringify(f)}))`, 'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])', 'print("VALID")', ].join('\n'); try { return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID'); } catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; } } finally { rmSync(directory, { recursive: true, force: true }); } })(); // ---- an OUTLIER: the view ignores it, the hint offers it ------------------ await loadFar({ ...FAR_CFG, spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)), }, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } }); await raf(); Object.assign(out, await page.evaluate(() => { const c = window.__card; const o = {}; o.outlierReported = c._outliers === 1; const b = c._baseVb(); o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000; const sr = c.shadowRoot || c.renderRoot; const hint = sr.querySelector('.farhint'); o.hintShown = !!hint && !!hint.querySelector('button'); o.hintIsNotAModal = !sr.querySelector('hp-dialog'); return o; })); // «Показать» / «Вписать всё» takes the stray in Object.assign(out, await page.evaluate(async () => { const c = window.__card; const sr = c.shadowRoot || c.renderRoot; sr.querySelector('.farhint button').click(); await window.__waitForCamera(c); const o = {}; const v = c._viewOr(c._baseVb()); o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000; o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500; await c.updateComplete; o.hintGoneAfterShow = !sr.querySelector('.farhint'); return o; })); // ---- zoom-out stops at 3x, zoom-in unchanged ----------------------------- await loadFar(FAR_CFG, FAR_LAYOUT); await raf(); Object.assign(out, await page.evaluate(async () => { const c = window.__card; const o = {}; c._resetZoom(); await window.__waitForCamera(c); const fit = { ...c._viewOr(c._baseVb()) }; c._applyView(0.001); o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9 && Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02; c._applyView(50); o.zoomInUnchanged = c._zoom === 8; c._resetZoom(); await window.__waitForCamera(c); return o; })); // ---- pan has slack, and "home is that way" brings you back --------------- Object.assign(out, await page.evaluate(async () => { const c = window.__card; const sr = c.shadowRoot || c.renderRoot; const o = {}; c._resetZoom(); await window.__waitForCamera(c); const fit = { ...c._view }; // pan most of a screen to the right: nothing pins the content over the scene c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit); o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5; // walk right off the plan: the frame is entirely outside the view now c._view = { ...fit, x: fit.x + fit.w * 1.6 }; c.requestUpdate(); await c.updateComplete; const arrow = sr.querySelector('.homearrow'); o.homeArrowAppears = !!arrow; if (arrow) { arrow.click(); await window.__waitForCamera(c); o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2; await c.updateComplete; o.homeArrowGone = !sr.querySelector('.homearrow'); } // and it is NOT there while the plan is on screen c._resetZoom(); await window.__waitForCamera(c); o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow'); return o; })); // ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ---------- // The infinite canvas briefly made --icon-size a percentage of the viewport, // so a marker kept its pixel size at every zoom. The owner looked at it and // asked for the original contract back (2026-08-03): an icon is a percentage // of the PLAN, it grows when you zoom in and shrinks when you zoom out, like // everything else drawn on the plan. What the infinite canvas contributes is // only the base unit the percentage is taken OF: `iconUnit` instead of the // stored `vb.w`, checked right below on this very plan — which is drawn // entirely outside the old square. const farIcon = await page.evaluate(async () => { const c = window.__card; const sr = c.shadowRoot || c.renderRoot; // Measure the package core: shell stroke/shadow are decoration, while the // saved icon geometry must still scale with the plan. const size = () => { const el = sr.querySelector('.devlayer .dev .device-core'); return Math.round(el.getBoundingClientRect().width * 100) / 100; }; const o = {}; c._resetZoom(); await window.__waitForCamera(c); const at1 = size(); c._applyView(4); await new Promise((resolve) => requestAnimationFrame(resolve)); const at4 = size(); c._applyView(1 / 3); await new Promise((resolve) => requestAnimationFrame(resolve)); const atOut = size(); c._resetZoom(); await window.__waitForCamera(c); o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05; o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02; if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut); return { o, at1 }; }); Object.assign(out, farIcon.o); // ---- an OLD plan (small, with a view_box) opens exactly as before --------- Object.assign(out, await page.evaluate(async () => { const c = window.__card; const o = {}; const cfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null, rooms: [{ id: 'r1', name: 'Living', area: 'living_room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] }, { id: 'r2', name: 'Kitchen', area: 'kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }], markers: [], settings: {}, }; c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } }; c._modelCache = null; c._frame = null; c._showFar = false; c._view = null; c._maybeRebuildDevices?.(); c._defPos = c._defaultPositions(); // the auto grid follows the new rooms c.requestUpdate(); await c.updateComplete; c._resetZoom(); await window.__waitForCamera(c); const b = c._baseVb(); // identical to what contentBounds produced before: the content bbox // (40..960 x 140..580) plus 5% of the longer side (920 -> 46) o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1 && Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1; o.legacyNoHint = c._outliers === 0; // an EMPTY space falls back to the stored view_box hint (§4) c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [], settings: {} }; // a space with NOTHING in it at all — not even a marker parked in it c._layout = {}; c._modelCache = null; c._frame = null; c._devices = []; c.requestUpdate(); await c.updateComplete; const e = c._baseVb(); o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]'; return o; })); // ---- and the icon does not DEGENERATE on the runaway plan ---------------- // The catch of "a percentage of the plan" on an unbounded canvas: keep the // old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide // divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor // and keeps shrinking the further out the plan is drawn. `iconUnit` grows // with the plan, so the marker on the far plan measured above is the same // number of pixels as on this ordinary 0..1 one. out.farIconNotDegenerate = await page.evaluate(async (farPx) => { const c = window.__card; const sr = c.shadowRoot || c.renderRoot; // the small plan from the block above, with its devices back c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null, rooms: [{ id: 'r1', name: 'Living', area: 'living_room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] }, { id: 'r2', name: 'Kitchen', area: 'kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }], markers: [], settings: {} }; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } }; c._modelCache = null; c._frame = null; c._showFar = false; c._view = null; c._maybeRebuildDevices?.(); c._defPos = c._defaultPositions(); c.requestUpdate(); await c.updateComplete; c._resetZoom(); await window.__waitForCamera(c); const el = sr.querySelector('.devlayer .dev'); const px = el.getBoundingClientRect().width - 2; if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx); return px > 4 && Math.abs(px / farPx - 1) < 0.3; }, farIcon.at1); // ---- screenshots --------------------------------------------------------- if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await loadFar(FAR_CFG, FAR_LAYOUT); await raf(); await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` }); // fit-all with a stray far away await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } }); await raf(); await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` }); await page.evaluate(async () => { const c = window.__card; (c.shadowRoot || c.renderRoot).querySelector('.farhint button').click(); await window.__waitForCamera(c); }); await raf(); await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` }); // adaptive grid, zoomed far out in the plan editor await loadFar(FAR_CFG, FAR_LAYOUT); await page.evaluate(async () => { const c = window.__card; c._setMode('plan'); await c.updateComplete; c._resetZoom(); await window.__waitForCamera(c); c._applyView(1 / 3); await c.updateComplete; }); await raf(); await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` }); await page.evaluate(async () => { const c = window.__card; c._resetZoom(); await window.__waitForCamera(c); c._applyView(4); await c.updateComplete; }); await raf(); await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` }); await page.evaluate(async () => { window.__card._setMode('view'); }); } await finish(browser, checkAll(out));