fix(radar): show the room contour in the on-plan setup (#774)

The on-plan radar setup multiplied SpaceModel room contours by 1000 a
second time. They are render units already, so the outline was drawn at
40000… far outside the fixed "0 0 1000 1000" viewBox, and the
administrator placed mount, heading and references on an empty field.
Dropping the multiplier alone is not enough: the fixed board also cut
every room drawn outside the historical square, and the pointer mapping
ignored the letterbox and clamped presses to [0,1].

The setup now frames the plan with the main card's content frame
(docs/CANVAS.md §4, the same items through the editor host port). It
opens on core and switches to all when core would cut the configured
room; the stored view_box remains the empty-content fallback. The frame
is computed once per session. One pure projection serves every layer:
the contour is drawn as is, mount, heading, pending/reference marks and
the live trail go through plan units x NORM_W, and a press maps back
through xMidYMid meet to unclamped plan units, a press in the letterbox
placing nothing. Stored radar data, the calibration solve, Apply/Save
and View are unchanged.

Tests: a unit table for the projection and the frame (offset frame,
letterbox, negative and >1 plan units, outlier core -> all, empty-content
fallback) and an extended smoke_radar_setup (a room outside [0,1] with
view_box [0,0,1,1], vertices and bbox, known presses -> draft and DOM,
frame stability across presses, live trail and resize, Apply, Cancel,
Escape and reopen, a room without contour, an unknown room). Five
unit-guarded mutants are registered.

Issue: #774
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-06 23:16:55 +00:00
committed by claude[bot]
parent de6cc44b89
commit e4b4b0e6cd
9 changed files with 719 additions and 23 deletions
+349 -1
View File
@@ -96,4 +96,352 @@ const out = await page.evaluate(async () => {
};
});
await finish(browser, checkAll(out));
// #774: the on-plan setup shows the real contour of the selected room, and the
// contour, mount, heading, pending/reference marks and the live trail share ONE
// projection: content frame (docs/CANVAS.md §4) → SVG viewBox → xMidYMid meet.
// Plan units below are the stored ones (render = ×1000). Every expected number
// is computed here, from the fixture and the measured SVG rectangle — never by
// asking the product code.
const POLY = {
// an ordinary non-rectangular room inside the historical square
r_l: [[0.1, 0.2], [0.7, 0.2], [0.7, 0.45], [0.4, 0.45], [0.4, 0.6], [0.1, 0.6]],
// a room entirely outside [0,1], negative on both axes
r_out: [[-0.55, -0.4], [-0.15, -0.4], [-0.15, -0.1], [-0.35, -0.1], [-0.55, -0.25]],
// a room entirely past 1 on both axes
r_far: [[1.1, 0.8], [1.45, 0.8], [1.45, 1.15], [1.1, 1.15]],
};
const SAVED_774 = {
version: 1, enabled: true, show_live: false, profile: 'cartesian_v1',
sources: { slots: [{ id: 'target_1', x_entity: 'sensor.living_temp',
y_entity: 'sensor.living_temp', unit: 'cm' }] },
room_id: 'r_l',
mount: { installation_id: 'smoke-774', x: 0.2, y: 0.3, heading_deg: 45, range_cm: 600, fov_deg: 120 },
calibration: { method: 'two_point', mirror: false, cell_cm: 5, rms_cm: 4,
refs: [{ plan: { x: 0.3, y: 0.3 }, local_cm: { x: 0, y: 100 } },
{ plan: { x: 0.25, y: 0.4 }, local_cm: { x: 80, y: 60 } }] },
};
const FIXTURE_774 = {
spaces: [{
id: 'rs', title: 'Radar setup', view_box: [0, 0, 1, 1], cell_cm: 5, plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r_l', name: 'L room', area: 'zx_l', poly: POLY.r_l },
{ id: 'r_out', name: 'Outside', area: 'zx_out', poly: POLY.r_out },
{ id: 'r_far', name: 'Far', area: 'zx_far', poly: POLY.r_far },
// a legacy rectangle: a room WITHOUT a contour polygon
{ id: 'r_rect', name: 'No contour', area: 'zx_rect', x: 0.75, y: 0.25, w: 0.15, h: 0.15 },
],
}],
// made-up areas: no demo device lands here, the radar is the only marker
markers: [{ id: 'm_radar', binding: 'device:d_motion', area: 'zx_l', radar: SAVED_774 }],
settings: { filter_seeded: true },
};
// The radar's icon stands outside every room, so a frame built from the rooms
// alone (a truncated set of inputs) is NOT the frame the card opens on.
const LAYOUT_774 = { m_radar: { s: 'rs', x: -0.6, y: 1.2 } };
// Content bbox: x -600..1450 (2050), y -400..1200 (1600); 5 % of 2050 = 102.5.
// Five items, no outlier (docs/CANVAS.md §4.1) → core = all.
const FRAME_774 = [-702.5, -502.5, 2255, 1805];
const out774 = await page.evaluate(async ({ fixture, layout, poly, frame, saved }) => {
const hp = window.__hpTest;
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const result = {};
const savedRadar = () => JSON.stringify(card._serverCfg.markers.find((m) => m.id === 'm_radar')?.radar);
const setup = () => root().querySelector('.radarsetup');
const svgOf = () => setup()?.querySelector('svg') || null;
const viewBoxOf = (svg) => (svg?.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
const pointsOf = (value) => (value || '').trim().split(/\s+/).filter(Boolean)
.map((pair) => pair.split(',').map(Number));
const contourOf = (svg) => pointsOf(svg?.querySelector('polygon.room')?.getAttribute('points'));
const render = (pts) => pts.map(([x, y]) => [x * 1000, y * 1000]);
const samePts = (a, b, eps = 1e-6) => a.length === b.length
&& a.every((p, i) => Math.abs(p[0] - b[i][0]) <= eps && Math.abs(p[1] - b[i][1]) <= eps);
const near = (a, b, eps) => !!a && !!b && Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
const within = (pts, vb) => pts.length > 0 && pts.every(([x, y]) => x >= vb[0] && y >= vb[1]
&& x <= vb[0] + vb[2] && y <= vb[1] + vb[3]);
const sameFrame = (a, b) => a.length === 4 && a.every((v, i) => Math.abs(v - b[i]) < 1e-9);
// xMidYMid meet, written out independently of src/radar-setup.ts
const screen = (svg) => {
const rect = svg.getBoundingClientRect();
const [vx, vy, vw, vh] = viewBoxOf(svg);
const k = Math.min(rect.width / vw, rect.height / vh);
const ox = rect.left + (rect.width - vw * k) / 2;
const oy = rect.top + (rect.height - vh * k) / 2;
return { rect, k, ox, oy, client: ([px, py]) => [ox + (px * 1000 - vx) * k, oy + (py * 1000 - vy) * k] };
};
const press = (svg, [x, y]) => svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: x, clientY: y, pointerId: 1, isPrimary: true,
}));
const centre = (el) => {
const r = el?.getBoundingClientRect();
return r ? [r.left + r.width / 2, r.top + r.height / 2] : null;
};
const markOf = (cls) => svgOf()?.querySelector(`g.${cls} circle`) || null;
const marksAt = (cls) => [...(svgOf()?.querySelectorAll(`g.${cls}`) || [])]
.map((g) => g.getAttribute('transform'));
const choose = async (select, value) => {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
await hp.settled();
};
const roomSelect = () => root().querySelector('#radar-room');
const configure = async () => {
root().querySelector('.radargroup button ha-icon[icon="mdi:map-marker-radius"]')
?.closest('button')?.click();
await hp.settled();
return svgOf();
};
const toast = () => root().querySelector('[data-hp="toast"]')?.textContent?.trim() || '';
const markerDialog = () => root().querySelector('[data-hp="dialog"][data-kind="marker"]');
const acceptConfirm = async (confirm) => {
const buttons = [...(confirm?.querySelectorAll('.danger-confirm-footer button')
|| confirm?.shadowRoot?.querySelectorAll('.danger-confirm-footer button') || [])];
buttons.at(-1)?.click();
await hp.settled();
};
const closeMarker = async () => {
if (!markerDialog()) return false;
const { confirm } = await hp.close(markerDialog(), { via: 'escape' });
if (confirm) await acceptConfirm(confirm);
// a second confirmation (the dialog's own) may follow the setup's
const again = root().querySelector('[data-hp="dialog"][data-kind="confirm"]');
if (again) await acceptConfirm(again);
return !markerDialog();
};
// Live diagnostics: the demo's subscribeMessage is a no-op; this one hands the
// setup subscription's callback to the smoke so it can push real snapshots.
const connection = card.hass.connection;
const realSubscribe = connection.subscribeMessage;
const live = { push: null, subscribed: 0, released: 0 };
connection.subscribeMessage = async (callback, message) => {
if (message?.type !== 'houseplan/radar/setup/subscribe') return () => {};
live.subscribed += 1;
live.push = callback;
return () => { live.released += 1; };
};
const realNow = Date.now;
let skew = 0;
Date.now = () => realNow.call(Date) + skew;
// Every config write the card sends, as the radar block it would persist. The
// editor may write unrelated housekeeping on its own; only a changed radar
// block before the ordinary Save is a leak (AC4).
const hass = card.hass;
const realCallWS = hass.callWS;
const radarWrites = [];
hass.callWS = async (message) => {
if (message?.type === 'houseplan/config/set') {
radarWrites.push(JSON.stringify(message.config?.markers?.find((m) => m.id === 'm_radar')?.radar));
}
return realCallWS(message);
};
try {
await hp.setServerConfig(fixture);
await hp.setLayout(layout);
if (card._space !== 'rs') await hp.switchSpace('rs');
await hp.setMode('devices');
await hp.openMarkerDialog('m_radar');
const saved0 = savedRadar();
const persisted = () => savedRadar() === saved0 && radarWrites.every((radar) => radar === saved0);
result.fixtureLoaded = saved0 === JSON.stringify(saved) && !!roomSelect();
// -------------------------------------------------- AC1: room outside [0,1]
await choose(roomSelect(), 'r_out');
let svg = await configure();
result.setupOpensForOutsideRoom = !!svg;
const vb = viewBoxOf(svg);
result.frameIsContentDerived = sameFrame(vb, frame);
// the main card frames the same space with the very same numbers
const cardFrame = card._frameOf().rect;
result.frameMatchesMainCard = sameFrame(vb, [cardFrame.x, cardFrame.y, cardFrame.w, cardFrame.h]);
const contourOut = contourOf(svg);
result.outsideContourScaledOnce = samePts(contourOut, render(poly.r_out));
result.outsideContourFitsViewBox = within(contourOut, vb);
// -------------------------------------------------- AC2/AC3: one projection
let view = screen(svg);
result.squareSvgLetterboxes = Math.abs(view.rect.width - view.rect.height) < 1
&& view.oy - view.rect.top > 10;
press(svg, [view.rect.left + view.rect.width / 2, view.rect.top + (view.oy - view.rect.top) / 2]);
await hp.settled();
result.letterboxPressIgnored = !markOf('mount')
&& card._editorRuntime._radarSetup.active?.mount === null;
const MOUNT = [-0.3, -0.25]; // inside r_out: negative plan units
const AHEAD = [0.5, -0.25]; // due east of the mount → heading 90°
press(svg, view.client(MOUNT));
await hp.settled();
press(svg, view.client(AHEAD));
await hp.settled();
const state = () => card._editorRuntime._radarSetup.active;
result.mountInPlanUnits = near(state()?.mount, MOUNT, 1e-9)
&& state()?.draft.mountX === '-300' && state()?.draft.mountY === '-250'
&& state()?.draft.heading === '90';
result.mountMarkOnClick = near(centre(markOf('mount')), view.client(MOUNT), 1);
const line = svgOf().querySelector('line.heading');
result.headingLineInSceneUnits = !!line
&& near([+line.getAttribute('x1'), +line.getAttribute('y1')], [-300, -250], 1e-6)
&& near([+line.getAttribute('x2'), +line.getAttribute('y2')], [500, -250], 1e-6);
const polygonBox = svgOf().querySelector('polygon.room').getBoundingClientRect();
const outMin = view.client([-0.55, -0.4]);
const outMax = view.client([-0.15, -0.1]);
result.contourOnScreenWhereProjected = Math.abs(polygonBox.left - outMin[0]) < 1.5
&& Math.abs(polygonBox.top - outMin[1]) < 1.5 && Math.abs(polygonBox.right - outMax[0]) < 1.5
&& Math.abs(polygonBox.bottom - outMax[1]) < 1.5;
await sleep(0);
result.coordinateProfileSubscribed = live.subscribed === 1 && typeof live.push === 'function';
// live trail: 600 cm ahead, then 60 cm to its right (heading 90, cell 5 → 1200 cm/unit)
live.push?.({ local_targets: [{ slot: 'target_1', x_cm: 0, y_cm: 600 }], frame: { health: 'ok' } });
live.push?.({ local_targets: [{ slot: 'target_1', x_cm: 60, y_cm: 600 }], frame: { health: 'ok' } });
await hp.settled();
const trail = pointsOf(svgOf().querySelector('polyline.trail')?.getAttribute('points'));
result.trailInSameProjection = samePts(trail, [[200, -250], [200, -200]], 1e-6);
result.frameStableAfterClicksAndLive = sameFrame(viewBoxOf(svgOf()), frame);
// reference 1 at x > 1, y < 0: exactly 1800 cm ahead of the mount
const REF1 = [1.2, -0.25];
press(svgOf(), view.client(REF1));
await hp.settled();
result.pendingMarkOnClick = near(state()?.pendingPlan, REF1, 1e-9)
&& near(centre(markOf('pending')), view.client(REF1), 1);
const capture = async (local) => {
setup().querySelector('.row button.btn:not(.ghost):not(.on)')?.click();
await hp.settled();
skew += 10_500; // countdown over
const t0 = Date.now() / 1000;
for (const dt of [0, 1.2, 2.4]) {
live.push?.({ local_targets: [{ slot: 'target_1', x_cm: local[0], y_cm: local[1],
reported_at: t0 + dt }], frame: { health: 'ok' } });
}
skew += 5_000; // capture window over
await sleep(450); // the 200 ms setup tick closes it
await hp.settled();
};
await capture([0, 1800]);
result.referenceOneCaptured = state()?.phase === 'reference_2'
&& near(state()?.refs?.[0]?.plan, REF1, 1e-9)
&& near(centre(markOf('reference')), view.client(REF1), 1);
// resize: only the screen matrix changes; the scene and the stored points do not
const before = { vb: svgOf().getAttribute('viewBox'), marks: [...marksAt('mount'), ...marksAt('reference')] };
svgOf().style.width = '300px';
await hp.settled();
view = screen(svgOf());
result.resizeChangedScreen = Math.abs(view.rect.width - 300) < 1;
result.resizeKeepsFrameAndScene = svgOf().getAttribute('viewBox') === before.vb
&& JSON.stringify([...marksAt('mount'), ...marksAt('reference')]) === JSON.stringify(before.marks);
result.resizeMovesMarksWithMatrix = near(centre(markOf('mount')), view.client(MOUNT), 1)
&& near(centre(markOf('reference')), view.client(REF1), 1);
// reference 2 placed through the NEW matrix: 1200 cm right and 1200 cm ahead
const REF2 = [0.7, 0.75];
press(svgOf(), view.client(REF2));
await hp.settled();
result.clickAfterResizeLands = near(state()?.pendingPlan, REF2, 1e-9)
&& near(centre(markOf('pending')), view.client(REF2), 1);
await capture([1200, 1200]);
result.twoPointSolved = state()?.phase === 'solved' && state()?.solved?.mirror === false
&& Math.abs(state()?.solved?.headingDeg - 90) < 1e-6;
result.frameStableThroughSession = sameFrame(viewBoxOf(svgOf()), frame);
// -------------------------------------------------- AC4: Apply ≠ Save
setup().querySelector('.row button.btn.on')?.click();
await hp.settled();
const applied = card._markerDialog?.radar;
const refs = applied?.calibrationOverride?.refs || [];
result.applyReturnsPlanUnits = !setup() && applied?.mountX === '-300' && applied?.mountY === '-250'
&& applied?.heading === '90' && applied?.mirror === false
&& applied?.calibrationOverride?.method === 'two_point' && refs.length === 2
&& near([refs[0].plan.x, refs[0].plan.y], REF1, 1e-9)
&& near([refs[1].plan.x, refs[1].plan.y], REF2, 1e-9)
&& refs[0].local_cm.x === 0 && refs[0].local_cm.y === 1800;
result.applyReleasesSubscription = live.released === 1;
result.applyDoesNotPersist = persisted();
result.closeWithoutSaveDiscards = await closeMarker() && persisted();
// -------------------------------------------------- reopen: saved calibration untouched
await hp.openMarkerDialog('m_radar');
result.reopenShowsSavedCalibration = JSON.stringify(card._markerDialog?.radar?.original) === saved0
&& roomSelect()?.value === 'r_l';
// AC1: the ordinary non-rectangular room, same frame
svg = await configure();
const contourL = contourOf(svg);
result.ordinaryContourScaledOnce = samePts(contourL, render(poly.r_l));
result.ordinaryContourFitsViewBox = within(contourL, viewBoxOf(svg));
result.ordinaryRoomSameFrame = sameFrame(viewBoxOf(svg), frame);
// Escape with a dirty setup: confirmation, nothing written
view = screen(svg);
press(svg, view.client([0.2, 0.3]));
await hp.settled();
result.escapeWithDirtySetupDiscards = !!markOf('mount') && await closeMarker() && persisted();
// -------------------------------------------------- AC5: no contour / unknown room
await hp.openMarkerDialog('m_radar');
await choose(roomSelect(), 'r_rect');
svg = await configure();
result.noContourWarns = toast() === card._t('radar.no_contour');
result.noContourNoInventedOutline = !!svg && contourOf(svg).length === 0
&& sameFrame(viewBoxOf(svg), frame);
setup().querySelector('.radarsetup-head .iconbtn')?.click();
await hp.settled();
result.untouchedCancelCloses = !setup();
await closeMarker();
await hp.setServerConfig((cfg) => {
cfg.markers.find((m) => m.id === 'm_radar').radar.room_id = 'r_gone';
return cfg;
});
await hp.openMarkerDialog('m_radar');
svg = await configure();
result.unknownRoomIsAnError = !svg && toast() === card._t('radar.invalid');
await closeMarker();
// -------------------------------------------------- AC2: stored view_box does not frame content
await hp.setServerConfig((cfg) => {
cfg.spaces[0].view_box = [5, 5, 2, 2];
cfg.markers.find((m) => m.id === 'm_radar').radar.room_id = 'r_l';
return cfg;
});
await hp.openMarkerDialog('m_radar');
svg = await configure();
result.storedViewBoxIgnoredWithContent = sameFrame(viewBoxOf(svg), frame);
setup().querySelector('.radarsetup-head .iconbtn')?.click();
await hp.settled();
await closeMarker();
// -------------------------------------------------- AC1: the outlier room (core → all)
await hp.setServerConfig((cfg) => {
cfg.spaces[0].rooms.push({ id: 'r_remote', name: 'Remote', area: 'zx_remote',
poly: [[40, 40], [40.5, 40], [40.5, 40.5], [40, 40.5]] });
return cfg;
});
await hp.openMarkerDialog('m_radar');
svg = await configure();
result.mainMassRoomKeepsCore = sameFrame(viewBoxOf(svg), frame);
setup().querySelector('.radarsetup-head .iconbtn')?.click();
await hp.settled();
await choose(roomSelect(), 'r_remote');
svg = await configure();
const remote = contourOf(svg);
// all: x -600..40500 (41100), y -400..40500 (40900); 5 % of 41100 = 2055
result.outlierRoomOpensOnAll = sameFrame(viewBoxOf(svg), [-2655, -2455, 45210, 45010])
&& samePts(remote, [[40000, 40000], [40500, 40000], [40500, 40500], [40000, 40500]])
&& within(remote, viewBoxOf(svg));
setup().querySelector('.radarsetup-head .iconbtn')?.click();
await hp.settled();
await closeMarker();
result.scenarioCompleted = true;
} catch (error) {
// keep the named results gathered so far; the failure itself is reported too
result.scenarioCompleted = `stopped: ${error?.message || error}`;
} finally {
Date.now = realNow;
hass.callWS = realCallWS;
connection.subscribeMessage = realSubscribe;
if (markerDialog()) await closeMarker().catch(() => {});
await hp.setMode('view').catch(() => {});
}
return result;
}, { fixture: FIXTURE_774, layout: LAYOUT_774, poly: POLY, frame: FRAME_774, saved: SAVED_774 });
await finish(browser, checkAll({ ...out, ...out774 }));
+6
View File
@@ -206,6 +206,12 @@ not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms.
One notion of "the plan", or a stray the frame had just thrown out came back
as icons ninety times too big (audit DEV-2C947-03).
The radar **Configure on plan** setup (#774) frames the plan with the same
items and opens on `core`, with one exception: when `core` would cut the
contour of the room being configured it opens on `all`, so the vote can never
hide the room the user is placing the sensor in. The frame is fixed for the
setup session; the stored `view_box` still counts only when nothing is drawn.
### §4.3 The frame in an editor
Inside an editor the frame only ever **grows** (`unionRect` with the previous
+7
View File
@@ -11,6 +11,13 @@
- In kiosk mode, holding the plan to open **This screen's sizes** no longer
downloads the editor, and a poor connection no longer shows a "Could not load
the editor" notice there ([#763](https://github.com/Matysh/houseplan-card/issues/763)).
- Presence radar: **Configure on plan** now shows the outline of the selected
room. It used to be drawn far outside the setup area, so the mount point,
direction and reference positions were placed on an empty field. The area
now frames the plan the same way the card does, including rooms drawn outside
the original square, and every mark lands exactly where it is clicked
relative to the outline. Saved radar settings are unchanged
([#774](https://github.com/Matysh/houseplan-card/issues/774)).
## v1.80.0-beta.6 — 2026-10-06
+7
View File
@@ -17,6 +17,13 @@
- В режиме киоска долгое нажатие, открывающее **«Размеры на этом экране»**,
больше не загружает редактор, а при плохой сети не показывает сообщение
«Не удалось загрузить редактор» ([#763](https://github.com/Matysh/houseplan-card/issues/763)).
- Радар присутствия: в **«Настроить на плане»** теперь виден контур выбранной
комнаты. Раньше он рисовался далеко за пределами области настройки, и точку
установки, направление и опорные позиции приходилось ставить на пустом поле.
Теперь область показывает план так же, как карточка, включая комнаты за
пределами исходного квадрата, а каждая отметка ложится ровно туда, куда
нажали относительно контура. Сохранённые настройки радаров не меняются
([#774](https://github.com/Matysh/houseplan-card/issues/774)).
## v1.80.0-beta.6 — 2026-10-06
+57
View File
@@ -719,6 +719,63 @@ const MUTANT_DEFINITIONS = [
replace: ' && JSON.stringify(original.sources) === JSON.stringify(sources)',
}],
},
{
id: 'radar-setup-contour-scaled-twice',
guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs',
because: '#774 AC1: SpaceModel room contours are render units already; multiplying them by '
+ '1000 again draws the outline at 40000… far outside the setup viewBox, so the field '
+ 'looks empty while mount and heading still appear',
patches: [{
file: 'src/radar-setup.ts',
find: "map((point) => `${point[0]},${point[1]}`)",
replace: "map((point) => `${point[0] * 1000},${point[1] * 1000}`)",
}],
},
{
id: 'radar-setup-pointer-clamped-to-unit-square',
guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs',
because: '#774 AC2: the canvas has no edges; clamping a press to [0,1] silently moves a mount '
+ 'or reference placed in a room outside the historical square and changes the physical '
+ 'meaning of the stored calibration',
patches: [{
file: 'src/radar-setup.ts',
find: ' ? [x / NORM_W, y / NORM_W] : null;',
replace: ' ? [Math.max(0, Math.min(1, x / NORM_W)), Math.max(0, Math.min(1, y / NORM_W))] : null;',
}],
},
{
id: 'radar-setup-frame-from-stored-view-box',
guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs',
because: '#774 AC1/AC2 (SPEC-REVIEW-774-r1 H1): view_box is only the empty-content hint; '
+ 'framing the setup with it hides a room drawn outside [0,1] although its contour is right',
patches: [{
file: 'src/radar-setup.ts',
find: ' const frame = !core || !all ? spaceFrame(space)',
replace: ' const frame = !core || !all || space.vb ? spaceFrame({ ...space, rooms: [], stairs: [], bg: null })',
}],
},
{
id: 'radar-setup-room-left-to-outlier-vote',
guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs',
because: '#774 AC1: the outlier vote may drop the very room being configured from core; '
+ 'without the switch to all its contour is cut off the setup frame',
patches: [{
file: 'src/radar-setup.ts',
find: ' && core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? all : core;',
replace: ' && core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? core : core;',
}],
},
{
id: 'radar-setup-trail-in-plan-units',
guard: 'node --test --test-name-pattern="#774" test/radar-setup.test.mjs',
because: '#774 AC3: a layer outside the shared projection (the live trail in raw plan units) '
+ 'sits 1000× off the contour and marks it is meant to be compared with',
patches: [{
file: 'src/radar-setup.ts',
find: "trail.map((entry) => view.scene(entry.point).join(','))",
replace: "trail.map((entry) => entry.point.join(','))",
}],
},
{
id: 'household-marker-drops-keyboard-reach',
guard: 'node demo/smoke_household_journeys.mjs',
+6 -1
View File
@@ -7,7 +7,8 @@ import {
recognizeRadar,
type RadarEditorDraft, type RadarHassLike,
} from '../radar-editor';
import type { RadarSetupController } from '../radar-setup';
import { radarSetupFrame, type RadarSetupController } from '../radar-setup';
import type { ContentItem } from '../space-geometry';
import type { DevItem, SpaceModel } from '../types';
import { toggleRow } from './form-kit';
@@ -35,6 +36,9 @@ export interface RadarSectionOptions {
hass: RadarHass;
spaceModelById(id: string | null | undefined): SpaceModel | null | undefined;
currentSpace(): SpaceModel | null | undefined;
/** The main card's content-frame items of a space (docs/CANVAS.md §4): the
* on-plan setup frames the same plan, without drawing those layers (#774). */
contentItems(space: SpaceModel): ReadonlyArray<ContentItem>;
position(device: DevItem): { x: number; y: number };
updateDialog(patch: Partial<{
radar: RadarEditorDraft | null;
@@ -197,6 +201,7 @@ export function renderRadarSection(
}
if (!options.setup.begin(
device.marker?.id || device.id, draft, room, space.cellCm || 5, options.configRev,
radarSetupFrame(space, room, options.contentItems(space)),
)) options.toast(options.t('radar.invalid'));
};
const changeInstallation = () => configureOnPlan({
+3
View File
@@ -509,6 +509,8 @@ export interface HouseplanEditorHostPort {
_confirmDanger: (request: HpConfirmRequest) => Promise<boolean>;
_cancelDangerConfirm: () => void;
_config: CardConfig | undefined;
/** #774: the content-frame items the card frames a space with (docs/CANVAS.md §4). */
_contentItems: (space: SpaceModel) => import('./space-geometry').ContentItem[];
_contourClosed: boolean;
_curSpaceCfg: any;
_currentModeVisual: (mode?: HouseplanMode) => ModeVisualState | null;
@@ -11986,6 +11988,7 @@ public _renderRadarSection(d: NonNullable<HouseplanEditorHostPort['_markerDialog
hass: this.host.hass,
spaceModelById: (id) => this.host._spaceModelById(id),
currentSpace: () => this.host._spaceModel(),
contentItems: (space) => this.host._contentItems(space),
position: (item) => this.host._pos(item),
updateDialog: (patch) => {
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, ...patch };
+68 -16
View File
@@ -7,6 +7,9 @@ import {
projectRadarLocal, radarMedianSample, solveRadarTwoPoint,
} from './radar-geometry';
import type { RadarEditorDraft } from './radar-editor';
import {
NORM_W, contentFrame, itemOf, spaceFrame, type ContentItem, type Rect,
} from './space-geometry';
type RadarDiscardKey = 'radar.discard_setup_title' | 'radar.discard_setup' | 'btn.close' | 'btn.cancel';
@@ -15,7 +18,7 @@ export const radarDiscardRequest = (t: (key: RadarDiscardKey) => string) => ({
title: t('radar.discard_setup_title'), message: t('radar.discard_setup'),
confirmLabel: t('btn.close'), cancelLabel: t('btn.cancel'),
});
import type { MarkerRadar, RoomCfg } from './types';
import type { MarkerRadar, RoomCfg, SpaceModel } from './types';
const COUNTDOWN_MS = 10_000;
const CAPTURE_MS = 5_000;
@@ -47,6 +50,7 @@ interface ActiveSetup {
markerId: string;
draft: RadarEditorDraft;
room: RoomCfg;
projection: RadarSetupProjection;
cellCm: number;
configRev: number;
phase: SetupPhase;
@@ -74,13 +78,55 @@ export interface RadarSetupHost {
confirmDiscard(): Promise<boolean>;
}
function pointFromEvent(event: PointerEvent): Point | null {
const rect = (event.currentTarget as SVGSVGElement).getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) return null;
return [
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
];
/**
* The wizard's camera (#774, docs/CANVAS.md §4): the content frame the main
* card opens on, from the same items (`items` is the card's own content list,
* a superset of the space model's). `core` normally; `all` when the core would
* cut the contour of the room being configured, so an outlier vote can never
* hide it. The stored view_box is read only through `spaceFrame` — i.e. only
* when nothing at all is drawn. Absolute render units, fixed for the session.
*/
export function radarSetupFrame(
space: SpaceModel, room: RoomCfg, items: ReadonlyArray<ContentItem>,
): Rect {
const { core, all } = contentFrame(items);
const own = room.poly && room.poly.length >= 3 ? itemOf(room.poly) : null;
const frame = !core || !all ? spaceFrame(space)
: own && !(core.x <= own.minX && core.y <= own.minY
&& core.x + core.w >= own.maxX && core.y + core.h >= own.maxY) ? all : core;
return [frame.x, frame.y, frame.w, frame.h].every(Number.isFinite) && frame.w > 0 && frame.h > 0
? frame : { x: 0, y: 0, w: NORM_W, h: NORM_W };
}
export type RadarSetupProjection = ReturnType<typeof radarSetupProjection>;
/**
* The ONE projection of every wizard layer (#774). Plan units (what is stored)
* times NORM_W are absolute render units, and those ARE the SVG user units,
* because the viewBox is the frame itself. Room contours are render units
* already and are drawn untouched. The screen side mirrors
* preserveAspectRatio="xMidYMid meet"; a press in the letterbox is no point.
* Never clamped: the canvas has no edges (docs/CANVAS.md).
*/
export function radarSetupProjection(frame: Rect) {
return {
frame,
viewBox: `${frame.x} ${frame.y} ${frame.w} ${frame.h}`,
/** Glyph scale that keeps marker sizes what they were on the 1000-unit board. */
glyph: Math.max(frame.w, frame.h) / NORM_W,
scene: (point: Point): Point => [point[0] * NORM_W, point[1] * NORM_W],
plan(
clientX: number, clientY: number,
rect: { left: number; top: number; width: number; height: number },
): Point | null {
const k = Math.min(rect.width / frame.w, rect.height / frame.h);
if (!(k > 0 && Number.isFinite(k))) return null;
const x = frame.x + (clientX - rect.left - (rect.width - frame.w * k) / 2) / k;
const y = frame.y + (clientY - rect.top - (rect.height - frame.h * k) / 2) / k;
return x >= frame.x && x <= frame.x + frame.w && y >= frame.y && y <= frame.y + frame.h
? [x / NORM_W, y / NORM_W] : null;
},
};
}
function headingBetween(mount: Point, target: Point): number {
@@ -105,13 +151,16 @@ export class RadarSetupController {
public constructor(private readonly host: RadarSetupHost) {}
/** `frame` is `radarSetupFrame(...)`, computed once by the caller at open. */
public begin(
markerId: string, draft: RadarEditorDraft, room: RoomCfg, cellCm: number, configRev: number,
frame: Rect,
): boolean {
if (!this.host.configFromDraft(draft, cellCm)) return false;
this.reset();
this.active = {
markerId, draft, room, cellCm, configRev, phase: 'mount', mount: null,
markerId, draft, room, projection: radarSetupProjection(frame),
cellCm, configRev, phase: 'mount', mount: null,
headingPoint: null, pendingPlan: null, refs: [], samples: [], ambiguous: false, trail: [],
};
this.host.requestUpdate();
@@ -155,7 +204,8 @@ export class RadarSetupController {
private choosePoint(event: PointerEvent): void {
const state = this.active;
const point = pointFromEvent(event);
const point = state?.projection.plan(event.clientX, event.clientY,
(event.currentTarget as Element).getBoundingClientRect());
if (!state || !point || event.isPrimary === false
|| state.phase === 'capture' || state.phase === 'checking') return;
state.error = undefined;
@@ -390,7 +440,9 @@ export class RadarSetupController {
public render(): TemplateResult | typeof nothing {
const state = this.active;
if (!state) return nothing;
const polygon = (state.room.poly || []).map((point) => `${point[0] * 1000},${point[1] * 1000}`).join(' ');
const view = state.projection;
// SpaceModel contours are render units already (space-geometry.ts): drawn as is.
const polygon = (state.room.poly || []).map((point) => `${point[0]},${point[1]}`).join(' ');
const markers = [
...(state.mount ? [{ point: state.mount, cls: 'mount', label: 'R' }] : []),
...state.refs.map((ref, index) => ({ point: ref.plan, cls: 'reference', label: String(index + 1) })),
@@ -406,17 +458,17 @@ export class RadarSetupController {
<button class="iconbtn" type="button" title=${this.host.t('btn.cancel')}
@click=${() => { void this.cancel(); }}><ha-icon icon="mdi:close"></ha-icon></button>
</div>
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet"
<svg viewBox=${view.viewBox} preserveAspectRatio="xMidYMid meet"
@pointerdown=${(event: PointerEvent) => this.choosePoint(event)}
@pointercancel=${() => this.interrupt()}>
<polygon class="room" points=${polygon}></polygon>
${trailGroups.map((trail) => trail.length > 1 ? svg`<polyline class="trail"
points=${trail.map((entry) => `${entry.point[0] * 1000},${entry.point[1] * 1000}`).join(' ')}></polyline>` : nothing)}
points=${trail.map((entry) => view.scene(entry.point).join(',')).join(' ')}></polyline>` : nothing)}
${state.mount && state.headingPoint ? svg`<line class="heading"
x1=${state.mount[0] * 1000} y1=${state.mount[1] * 1000}
x2=${state.headingPoint[0] * 1000} y2=${state.headingPoint[1] * 1000}></line>` : nothing}
x1=${view.scene(state.mount)[0]} y1=${view.scene(state.mount)[1]}
x2=${view.scene(state.headingPoint)[0]} y2=${view.scene(state.headingPoint)[1]}></line>` : nothing}
${markers.map((marker) => svg`<g class=${marker.cls}
transform="translate(${marker.point[0] * 1000} ${marker.point[1] * 1000})">
transform="translate(${view.scene(marker.point).join(' ')}) scale(${view.glyph})">
<circle r="18"></circle><text y="1">${marker.label}</text></g>`)}
</svg>
<p class="muted">${this.host.t('radar.desktop_hint')}</p>
+216 -5
View File
@@ -2,7 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { radarConfigFromDraft } from '../test-build/radar-editor.js';
import { RadarSetupController } from '../test-build/radar-setup.js';
import {
RadarSetupController, radarSetupFrame, radarSetupProjection,
} from '../test-build/radar-setup.js';
import { contentFrame, itemOf } from '../test-build/space-geometry.js';
/** The historical 1000-unit board: plan units ×1000, no offset. */
const BOARD = { x: 0, y: 0, w: 1000, h: 1000 };
const draft = () => ({
original: null, enabled: true, showLive: true, profile: 'presence_v1', roomId: 'living',
@@ -27,7 +33,7 @@ test('on-plan installation changes only the editor draft until ordinary Save', (
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1], [0, 1]],
}, 5, 7), true);
}, 5, 7, BOARD), true);
assert.equal(controller.isDirty(), false, 'an untouched setup may close synchronously');
controller.choosePoint(pointer(250, 400));
assert.equal(controller.isDirty(), true, 'placing the mount makes the setup discard-sensitive');
@@ -50,7 +56,7 @@ test('cancelling calibration releases its draft subscription exactly once', asyn
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
}, 5, 7), true);
}, 5, 7, BOARD), true);
let calls = 0;
controller.unsubscribe = () => { calls += 1; };
assert.equal(await controller.cancel(), true);
@@ -68,7 +74,7 @@ test('dirty calibration stays open when discard confirmation is rejected', async
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
}, 5, 7), true);
}, 5, 7, BOARD), true);
controller.choosePoint(pointer(250, 400));
let cleanups = 0;
controller.unsubscribe = () => { cleanups += 1; };
@@ -86,7 +92,7 @@ test('calibration reports bad reference placement separately from a measurement
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
}, 5, 7), true);
}, 5, 7, BOARD), true);
const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200];
controller.active.mount = [.5, .5];
controller.active.refs = [
@@ -103,3 +109,208 @@ test('calibration reports bad reference placement separately from a measurement
controller.solve();
assert.equal(controller.active.error, 'radar.bad_fit');
});
// ---------------------------------------------------------------------------
// #774: one projection for the on-plan setup. The oracles below are computed
// by hand from the frame, the SVG rectangle and xMidYMid meet — never by the
// production function — so an offset, a scale, a clamp or a frame-relative
// normalisation slipping into the code changes a number here.
const near = (actual, expected, message) => {
assert.ok(actual, `${message}: expected a point, got ${actual}`);
assert.ok(Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9,
`${message}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
};
/** Values of a lit template (recursively) whose static part ends with `suffix`. */
const templateValues = (result, suffix) => {
const found = [];
const walk = (node) => {
if (Array.isArray(node)) { node.forEach(walk); return; }
if (!node || typeof node !== 'object' || !Array.isArray(node.strings)) return;
node.values.forEach((value, index) => {
if (node.strings[index].replace(/\s+/g, ' ').endsWith(suffix)) found.push(value);
walk(value);
});
};
walk(result);
return found;
};
// Offset, non-square frame in a square 400 px SVG at (10, 20): the scale is
// 400 / 2000 = 0.2, the frame is 200 px tall, so 100 px of letterbox lie above
// (client y 20..120) and below (320..420) the projected viewBox.
const WIDE = { x: -500, y: 200, w: 2000, h: 1000 };
const SQUARE = { left: 10, top: 20, width: 400, height: 400 };
test('#774 projection: screen → absolute render units → plan units, letterbox honoured', () => {
const view = radarSetupProjection(WIDE);
assert.equal(view.viewBox, '-500 200 2000 1000');
assert.equal(view.glyph, 2, 'marker glyphs keep their 1000-unit board size');
const table = [
// client point expected plan units (null = ignored)
[[60, 220], [-0.25, 0.7], 'negative x inside an offset frame'],
[[390, 300], [1.4, 1.1], 'both axes past 1, never clamped'],
[[10, 120], [-0.5, 0.2], 'top-left corner of the projected viewBox is inside'],
[[410, 320], [1.5, 1.2], 'bottom-right corner of the projected viewBox is inside'],
[[200, 60], null, 'upper letterbox'],
[[200, 380], null, 'lower letterbox'],
[[200, 119], null, 'one pixel above the projected viewBox'],
[[5, 200], null, 'left of the SVG rectangle'],
];
for (const [[x, y], expected, label] of table) {
const actual = view.plan(x, y, SQUARE);
if (expected) near(actual, expected, label);
else assert.equal(actual, null, label);
}
// The historical board is the identity case, and a wide rectangle letterboxes sideways.
const board = radarSetupProjection(BOARD);
near(board.plan(250, 400, { left: 0, top: 0, width: 1000, height: 1000 }), [0.25, 0.4], 'board');
const wideRect = { left: 0, top: 0, width: 600, height: 300 };
near(board.plan(150, 0, wideRect), [0, 0], 'left edge of a sideways letterbox');
near(board.plan(300, 150, wideRect), [0.5, 0.5], 'centre of a sideways letterbox');
assert.equal(board.plan(100, 150, wideRect), null, 'sideways letterbox');
for (const rect of [
{ left: 0, top: 0, width: 0, height: 400 },
{ left: 0, top: 0, width: 400, height: 0 },
{ left: 0, top: 0, width: Number.NaN, height: 400 },
]) assert.equal(view.plan(100, 100, rect), null, `degenerate rect ${JSON.stringify(rect)}`);
assert.deepEqual(view.scene([-0.25, 1.4]), [-250, 1400], 'plan → render is ×NORM_W, unclamped');
});
const L_ROOM = { id: 'r_l', name: 'L', poly: [
[100, 200], [700, 200], [700, 450], [400, 450], [400, 600], [100, 600]] };
const OUT_ROOM = { id: 'r_out', name: 'Out', poly: [
[-550, -400], [-150, -400], [-150, -100], [-350, -100], [-550, -250]] };
const spaceOf = (rooms, vb = [0, 0, 1000, 1000]) => ({
id: 's', title: 'S', cellCm: 5, vb, bg: null, rooms, stairs: [],
});
const itemsOf = (rooms, devices = []) => [
...rooms.map((room) => itemOf(room.poly)),
...devices.map(([x, y]) => ({ minX: x, minY: y, maxX: x, maxY: y })),
];
test('#774 frame: the content frame, not the stored view_box, frames a room outside [0,1]', () => {
const rooms = [L_ROOM, OUT_ROOM];
const items = itemsOf(rooms, [[300, 300]]);
// bbox x -550..700 (1250), y -400..600 (1000); 5 % of the longer side = 62.5
const expected = { x: -612.5, y: -462.5, w: 1375, h: 1125 };
for (const room of rooms) {
assert.deepEqual(radarSetupFrame(spaceOf(rooms), room, items), expected, room.id);
}
const frame = radarSetupFrame(spaceOf(rooms), OUT_ROOM, items);
const own = itemOf(OUT_ROOM.poly);
assert.ok(frame.x <= own.minX && frame.y <= own.minY
&& frame.x + frame.w >= own.maxX && frame.y + frame.h >= own.maxY,
'the whole contour of the room outside [0,1] fits the frame');
assert.ok(own.maxX < 0 && own.maxY < 0, 'premise: the stored unit view_box would cut it entirely');
for (const vb of [[5000, 5000, 2000, 2000], [-3, 7, 0.5, 9]]) {
assert.deepEqual(radarSetupFrame(spaceOf(rooms, vb), L_ROOM, items), expected,
`replacing the stored view_box ${vb} does not move a content frame`);
}
});
test('#774 frame: the configured room is never left to the outlier vote (core → all)', () => {
const square = (id, x, y, side = 100) => ({ id, name: id, poly: [
[x, y], [x + side, y], [x + side, y + side], [x, y + side]] });
const near4 = [square('a', 400, 400), square('b', 500, 400), square('c', 400, 500),
square('d', 500, 500)];
const far = square('far', 40000, 40000, 500);
const rooms = [...near4, far];
const items = itemsOf(rooms);
const { core, all, outliers } = contentFrame(items);
assert.equal(outliers, 1, 'premise: the vote rejects the far room');
assert.deepEqual(core, { x: 390, y: 390, w: 220, h: 220 });
assert.deepEqual(all, { x: -1605, y: -1605, w: 44110, h: 44110 });
assert.ok(core.x + core.w < 40000, 'core excludes the far room');
assert.deepEqual(radarSetupFrame(spaceOf(rooms), far, items), all, 'the far room opens on all');
assert.deepEqual(radarSetupFrame(spaceOf(rooms), near4[0], items), core,
'a room inside the main mass keeps the ordinary opening frame');
});
test('#774 frame: the stored view_box is only the empty-content fallback', () => {
const room = { id: 'gone', name: 'No contour' };
assert.deepEqual(radarSetupFrame(spaceOf([], [300, -200, 1500, 900]), room, []),
{ x: 300, y: -200, w: 1500, h: 900 }, 'a valid offset hint is used verbatim');
for (const vb of [[0, 0, 0, 1000], [0, 0, -5, 10], undefined]) {
assert.deepEqual(radarSetupFrame(spaceOf([], vb), room, []), BOARD,
`invalid hint ${JSON.stringify(vb)} → the standard unit frame`);
}
});
const coordinateDraft = () => ({
...draft(), profile: 'cartesian_v1', roomId: 'r_out',
xEntities: ['sensor.x'], yEntities: ['sensor.y'], swapXY: [false], xSigns: [1], ySigns: [1],
slotPresenceEntities: [''], distanceEntities: [''], angleEntities: [''],
});
const press = (x, y, rect = SQUARE) => ({
clientX: x, clientY: y, currentTarget: { getBoundingClientRect: () => rect },
});
test('#774 every layer goes through the one projection; the frame stays put', () => {
let applied = null;
const controller = new RadarSetupController({
hass: () => ({}), requestUpdate() {}, t: (key) => key,
configFromDraft: radarConfigFromDraft, apply: (next) => { applied = next; },
confirmDiscard: async () => true,
});
assert.equal(controller.begin('radar', coordinateDraft(), OUT_ROOM, 5, 7, WIDE), true);
const frame = controller.active.projection.frame;
controller.choosePoint(press(200, 60));
assert.equal(controller.active.mount, null, 'a press in the letterbox places nothing');
assert.equal(controller.active.phase, 'mount');
controller.choosePoint(press(60, 220));
near(controller.active.mount, [-0.25, 0.7], 'mount');
controller.choosePoint(press(390, 220));
near(controller.active.headingPoint, [1.4, 0.7], 'heading point');
assert.equal(controller.active.draft.mountX, '-250');
assert.equal(controller.active.draft.mountY, '700');
assert.equal(controller.active.draft.heading, '90');
assert.equal(controller.active.phase, 'reference_1');
controller.choosePoint(press(60, 300));
near(controller.active.pendingPlan, [-0.25, 1.1], 'pending reference');
// Live diagnostics: 120 cm straight ahead (east) of the mount, then 60 cm to its right.
controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 0, y_cm: 120 }] });
controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 60, y_cm: 120 }] });
assert.equal(controller.active.projection.frame, frame, 'clicks and live data keep the session frame');
const view = controller.render();
assert.deepEqual(templateValues(view, '<svg viewBox='), ['-500 200 2000 1000']);
assert.deepEqual(templateValues(view, 'class="room" points='),
['-550,-400 -150,-400 -150,-100 -350,-100 -550,-250'], 'the contour is drawn once, untouched');
assert.deepEqual(templateValues(view, 'x1='), [-250]);
assert.deepEqual(templateValues(view, 'y1='), [700]);
assert.deepEqual(templateValues(view, 'x2='), [1400]);
assert.deepEqual(templateValues(view, 'y2='), [700]);
assert.deepEqual(templateValues(view, 'transform="translate('), ['-250 700', '-250 1100'],
'mount and pending marker in the same render units');
assert.deepEqual(templateValues(view, ') scale('), [2, 2]);
const trail = templateValues(view, 'class="trail" points=');
assert.equal(trail.length, 1);
const points = trail[0].split(' ').map((pair) => pair.split(',').map(Number));
near(points[0], [-150, 700], 'trail sample 1 (120 cm ahead = +0.1 plan)');
near(points[1], [-150, 750], 'trail sample 2 (60 cm right = +0.05 plan)');
// Presence profile: Apply hands back plain plan units, unclamped, and nothing else.
assert.equal(controller.begin('radar', draft(), OUT_ROOM, 5, 7, WIDE), true);
controller.choosePoint(press(60, 220));
controller.choosePoint(press(390, 220));
controller.apply();
assert.equal(applied.mountX, '-250');
assert.equal(applied.mountY, '700');
assert.equal(applied.heading, '90');
assert.deepEqual(radarConfigFromDraft(applied, 5).mount.x, -0.25);
assert.deepEqual(radarConfigFromDraft(applied, 5).mount.y, 0.7);
});
test('#774 a room without a contour draws no invented outline', () => {
const controller = new RadarSetupController({
hass: () => ({}), requestUpdate() {}, t: (key) => key,
configFromDraft: radarConfigFromDraft, apply() {}, confirmDiscard: async () => true,
});
const room = { id: 'living', name: 'Legacy', x: 100, y: 100, w: 300, h: 200 };
const space = spaceOf([room]);
const frame = radarSetupFrame(space, room, [itemOf([[100, 100], [400, 300]])]);
assert.equal(controller.begin('radar', draft(), room, 5, 7, frame), true);
assert.deepEqual(templateValues(controller.render(), 'class="room" points='), [''],
'the camera frame is not substituted for the missing polygon');
});