mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
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:
+349
-1
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user