mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
The fit panel replaces the three-point wizard
Calibration is now a direct-manipulation overlay: the robot's rooms as a dashed translucent ghost over the plan, dragged into place and stretched by four corner handles (uniform scale about the opposite corner). Quarter-turn and mirror buttons re-anchor about the ghost centre; mirror defaults on because every robot map seen so far flips Y versus the screen — measured on the owner's X50. Everything folds into the same stored 6-number matrix, and legacy matrices reopen in the panel with rotation snapped to a quarter. The park-the-robot-three- times wizard is deleted outright: it was the most fragile part of the feature (owner: «плохо работает»). fitMatrix/fitFromMatrix/initialFit/ reanchorFit are pure and unit-tested; the smoke drives the panel end to end — drag, corner-stretch, rotate, save, puck on the new matrix.
This commit is contained in:
File diff suppressed because one or more lines are too long
+54
-32
@@ -103,39 +103,59 @@ const out = await page.evaluate(async () => {
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- the three-point wizard end to end ----
|
||||
// ---- the fit panel (drag + corner-stretch) end to end ----
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
|
||||
o.wizardDevFound = !!dev;
|
||||
// robot parked at known coords on map m2 (uncalibrated so far)
|
||||
const putRobot = async (x, y) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x, y, a: 0 }, map_name: 'm2' } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await putRobot(200, 200);
|
||||
c._vacStartWizard(dev); await c.updateComplete;
|
||||
o.wizardBanner = !!sr().querySelector('.vaccalbar');
|
||||
// three clicks; the stage click handler converts client → canvas via
|
||||
// _svgPoint, so click through the real stage at chosen client points
|
||||
const stage = sr().querySelector('.stage');
|
||||
const sb = stage.getBoundingClientRect();
|
||||
const click = async (fx, fy) => {
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: sb.left + sb.width * fx, clientY: sb.top + sb.height * fy }));
|
||||
await c.updateComplete;
|
||||
};
|
||||
await click(0.2, 0.2);
|
||||
await putRobot(1800, 200);
|
||||
await click(0.8, 0.2);
|
||||
await putRobot(1800, 1800);
|
||||
await click(0.8, 0.8);
|
||||
o.fitDevFound = !!dev;
|
||||
// map m2 with rooms (bboxes) and a parked robot
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
|
||||
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
|
||||
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
|
||||
await c.updateComplete;
|
||||
c._vacStartFit(dev); await c.updateComplete;
|
||||
o.fitOverlay = !!sr().querySelector('.vacfit');
|
||||
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
|
||||
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
|
||||
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
|
||||
o.fitBar = !!sr().querySelector('.vaccalbar');
|
||||
o.fitMirrorDefault = c._vacFit.p.mir === true;
|
||||
// drag: the ghost centre must follow; rotate: the centre must NOT move
|
||||
const before = { ...c._vacFit.p };
|
||||
const svgEl = sr().querySelector('.vacfit');
|
||||
const sb2 = svgEl.getBoundingClientRect();
|
||||
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
|
||||
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
|
||||
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
await c.updateComplete;
|
||||
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
|
||||
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
|
||||
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
|
||||
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
|
||||
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
|
||||
// corner-stretch scales
|
||||
const s0 = c._vacFit.p.s;
|
||||
const handle = sr().querySelector('.vacfithandle');
|
||||
const hb = handle.getBoundingClientRect();
|
||||
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
|
||||
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
await c.updateComplete;
|
||||
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
||||
// save -> matrix stored for m2, panel closed, puck appears while cleaning
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
||||
o.wizardSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.wizardClosed = !c._vacCal;
|
||||
// the freshly calibrated map now shows the puck while cleaning
|
||||
await putRobot(1000, 1000);
|
||||
o.puckAfterWizard = !!sr().querySelector('.vacpuck');
|
||||
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
||||
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
|
||||
rooms: {} } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
o.puckAfterFit = !!sr().querySelector('.vacpuck');
|
||||
|
||||
return o;
|
||||
});
|
||||
@@ -148,7 +168,9 @@ checkAll(out, {
|
||||
trailCasing: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailLingers: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
wizardDevFound: true, wizardBanner: true, wizardSavedMatrix: true,
|
||||
wizardClosed: true, puckAfterWizard: true,
|
||||
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
|
||||
fitHandles: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
|
||||
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
|
||||
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+67
-22
File diff suppressed because one or more lines are too long
+12
-5
@@ -49,11 +49,18 @@ per map; an active map without a matrix falls back to Tier C.
|
||||
expose the robot's room list with coordinates; our rooms carry HA
|
||||
area bindings. Match by name, take centroids of ≥3 matches, solve,
|
||||
show a live preview («робот сейчас здесь») — one click to confirm.
|
||||
- **Path 2, manual 3-point wizard (Tier B or when auto fails):** pause
|
||||
the robot somewhere recognisable → drag the ghost puck to the true
|
||||
spot → next; three points in different corners, live preview, done.
|
||||
- Residual over ~40 cm → warn and ask for a fourth point. Mirrored maps
|
||||
are covered by the affine automatically (negative determinant).
|
||||
- **Path 2, the fit panel (replaced the 3-point wizard, owner call
|
||||
2026-07-31):** the robot's rooms render as a translucent dashed ghost
|
||||
over the plan; the user DRAGS the ghost into place and stretches it by
|
||||
its four corner handles (uniform scale about the opposite corner, like
|
||||
a graphics-editor frame). Rotation is quarter-turn buttons, mirror is
|
||||
one toggle — both re-anchor about the ghost centre. Mirror defaults ON:
|
||||
every robot map seen so far has Y flipped versus the screen. No numeric
|
||||
fields; the old park-the-robot-three-times wizard is gone entirely —
|
||||
it was the most fragile part of the feature.
|
||||
- The panel folds into the same stored 6-number matrix
|
||||
(S·R(rot)·mirror + offset); legacy matrices reopen in the panel with
|
||||
the rotation snapped to the nearest quarter.
|
||||
|
||||
## Puck behaviour
|
||||
|
||||
|
||||
+156
-42
@@ -28,6 +28,7 @@ import { ContentSigner } from './signing';
|
||||
import {
|
||||
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
|
||||
autoCalibrate, pushTrailPoint, isVacMoving, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
|
||||
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
|
||||
VAC_TRAIL_LINGER_MS, Pt as VacPt,
|
||||
} from './vacuum';
|
||||
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
|
||||
@@ -359,8 +360,9 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
private _vacCal: { markerId: string; source: string; mapId: string;
|
||||
pairs: Array<[VacPt, VacPt]> } | null = null;
|
||||
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
|
||||
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
|
||||
fx: number; fy: number } } | null = null;
|
||||
private _kioskDots = false;
|
||||
private _kioskDotsTimer?: number;
|
||||
private _kioskHoldTimer?: number;
|
||||
@@ -415,7 +417,7 @@ class HouseplanCard extends LitElement {
|
||||
_decorSel: { state: true },
|
||||
_decorTextDialog: { state: true },
|
||||
_kioskDialog: { state: true },
|
||||
_vacCal: { state: true },
|
||||
_vacFit: { state: true },
|
||||
_kioskDots: { state: true },
|
||||
_areaSel: { state: true },
|
||||
_nameSel: { state: true },
|
||||
@@ -485,8 +487,8 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && this._vacCal) {
|
||||
this._vacCal = null;
|
||||
if (e.key === 'Escape' && this._vacFit) {
|
||||
this._vacFit = null;
|
||||
this._showToast(this._t('vac.cal_cancelled'));
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -1539,6 +1541,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerDown(ev: PointerEvent): void {
|
||||
if (this._vacFit) return; // no pan/swipe while fitting the robot map
|
||||
if (this._kiosk) {
|
||||
this._cyclePausedUntil = Date.now() + 60000;
|
||||
if (this._pointers.size === 0) {
|
||||
@@ -1972,7 +1975,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _markupClick(ev: MouseEvent): void {
|
||||
if (this._vacCal) { this._vacCalClick(ev); return; }
|
||||
if (this._vacFit) return; // the fit overlay owns all pointer input
|
||||
if (!this._markup) return;
|
||||
// a pan or pinch just happened — the synthesized click is not a draw
|
||||
if (this._suppressClick) return;
|
||||
@@ -4257,6 +4260,7 @@ class HouseplanCard extends LitElement {
|
||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
|
||||
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
|
||||
${this._renderVacuums(devs, view)}
|
||||
${this._renderVacFit(view)}
|
||||
${this._renderOpeningLocks(view)}
|
||||
${disp.showNames || this._markup
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
@@ -4301,9 +4305,12 @@ class HouseplanCard extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._kioskDialog ? this._renderKioskDialog() : nothing}
|
||||
${this._vacCal ? html`<div class="vaccalbar">
|
||||
<span>${subst(this._t('vac.cal_point'), { n: String(this._vacCal.pairs.length + 1) })}</span>
|
||||
<button class="btn ghostbtn" @click=${() => { this._vacCal = null; }}>${this._t('btn.cancel')}</button>
|
||||
${this._vacFit ? html`<div class="vaccalbar">
|
||||
<span>${this._t('vac.fit_hint')}</span>
|
||||
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button>
|
||||
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
|
||||
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
|
||||
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
|
||||
</div>` : nothing}
|
||||
${this._tapConfirm
|
||||
? html`<div class="menuwrap dialogwrap" @click=${() => (this._tapConfirm = null)}>
|
||||
@@ -4424,7 +4431,7 @@ class HouseplanCard extends LitElement {
|
||||
${tele ? html`
|
||||
<div class="vacbtns">
|
||||
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this._t('vac.autocal')}</button>` : nothing}
|
||||
<button class="btn ghostbtn" @click=${() => this._vacStartWizard(dev)}>${this._t('vac.wizard')}</button>
|
||||
<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this._t('vac.fit')}</button>
|
||||
</div>
|
||||
<label class="srcrow">
|
||||
<input type="checkbox" .checked=${v.live !== false}
|
||||
@@ -4495,49 +4502,156 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
|
||||
}
|
||||
|
||||
/** «Калибровка по трём точкам»: arm the wizard and leave the dialog. */
|
||||
private _vacStartWizard(d: DevItem): void {
|
||||
/** «Подогнать вручную»: open the fit overlay and leave the dialog. */
|
||||
private _vacStartFit(d: DevItem): void {
|
||||
const src = this._vacSource(d);
|
||||
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
|
||||
if (!src || !tele?.pos) {
|
||||
if (!src || !tele) {
|
||||
this._showToast(this._t('vac.cal_need_pos'));
|
||||
return;
|
||||
}
|
||||
const mapId = this._vacMapId(d, tele);
|
||||
const existing = d.marker?.vacuum?.calibration?.[mapId] as Affine | undefined;
|
||||
const sp = this._spaceModel(d.space);
|
||||
const vb = (sp?.vb || [0, 0, NORM_W, NORM_W]) as [number, number, number, number];
|
||||
const p = (existing && existing.length === 6 && fitFromMatrix(existing))
|
||||
|| initialFit(tele.rooms, vb);
|
||||
this._markerDialog = null;
|
||||
this._vacCal = { markerId: d.id, source: src, mapId: this._vacMapId(d, tele), pairs: [] };
|
||||
if (d.space !== this._space) this._space = d.space;
|
||||
this._vacFit = { markerId: d.id, source: src, mapId, p, drag: null };
|
||||
}
|
||||
|
||||
private _vacCalClick(ev: MouseEvent): void {
|
||||
const cal = this._vacCal;
|
||||
if (!cal) return;
|
||||
const tele = readVacTelemetry(this.hass?.states[cal.source]?.attributes);
|
||||
if (!tele?.pos) {
|
||||
this._showToast(this._t('vac.cal_need_pos'));
|
||||
return;
|
||||
}
|
||||
const pt = this._svgPoint(ev);
|
||||
const pairs = [...cal.pairs, [[tele.pos.x, tele.pos.y], [pt[0], pt[1]]] as [VacPt, VacPt]];
|
||||
if (pairs.length < 3) {
|
||||
this._vacCal = { ...cal, pairs };
|
||||
return;
|
||||
}
|
||||
const matrix = solveAffine(pairs);
|
||||
if (!matrix) {
|
||||
// collinear or repeated points — drop the last one and ask again
|
||||
this._vacCal = { ...cal, pairs: pairs.slice(0, -1) };
|
||||
this._showToast(this._t('vac.cal_degenerate'));
|
||||
return;
|
||||
}
|
||||
if (affineResidual(matrix, pairs) > NORM_W * 0.05 && pairs.length < 4) {
|
||||
this._vacCal = { ...cal, pairs };
|
||||
this._showToast(this._t('vac.cal_res_warn'));
|
||||
return;
|
||||
}
|
||||
this._vacSaveMatrix(cal.markerId, cal.source, cal.mapId, matrix);
|
||||
this._vacCal = null;
|
||||
private _vacFitSave(): void {
|
||||
const f = this._vacFit;
|
||||
if (!f) return;
|
||||
this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p));
|
||||
this._vacFit = null;
|
||||
this._showToast(this._t('vac.cal_done'));
|
||||
}
|
||||
|
||||
/** Rotate/mirror around the ghost centre so the map does not fly away. */
|
||||
private _vacFitTurn(patch: Partial<FitParams>): void {
|
||||
const f = this._vacFit;
|
||||
if (!f) return;
|
||||
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
|
||||
const c = this._vacGhostCentre(tele?.rooms || []);
|
||||
const next = { ...f.p, ...patch } as FitParams;
|
||||
this._vacFit = { ...f, p: reanchorFit(next, f.p, c[0], c[1]) };
|
||||
}
|
||||
|
||||
private _vacGhostCentre(rooms: VacRoom[]): VacPt {
|
||||
const xs: number[] = [], ys: number[] = [];
|
||||
for (const r of rooms) {
|
||||
xs.push(r.x0 ?? r.cx, r.x1 ?? r.cx);
|
||||
ys.push(r.y0 ?? r.cy, r.y1 ?? r.cy);
|
||||
}
|
||||
if (!xs.length) return [0, 0];
|
||||
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
|
||||
}
|
||||
|
||||
/** px→canvas-units for a pointer delta, via the current stage size. */
|
||||
private _vacDelta(view: { w: number; h: number }, dxPx: number, dyPx: number): VacPt {
|
||||
const st = this._stageEl;
|
||||
const w = st?.clientWidth || 1, h = st?.clientHeight || 1;
|
||||
return [(dxPx / w) * view.w, (dyPx / h) * view.h];
|
||||
}
|
||||
|
||||
private _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number; h: number }): void {
|
||||
const f = this._vacFit;
|
||||
if (!f) return;
|
||||
ev.stopPropagation();
|
||||
if (ev.type === 'pointerdown') {
|
||||
const t = ev.target as HTMLElement;
|
||||
const corner = t.getAttribute?.('data-corner');
|
||||
try {
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
} catch { /* synthetic pointers (tests) have no active id — capture is a nicety */ }
|
||||
this._vacFit = { ...f, drag: corner
|
||||
? { kind: 'scale', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p },
|
||||
fx: Number(corner.split(',')[0]), fy: Number(corner.split(',')[1]) }
|
||||
: { kind: 'move', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: 0, fy: 0 } };
|
||||
return;
|
||||
}
|
||||
const d = f.drag;
|
||||
if (!d) return;
|
||||
if (ev.type === 'pointermove') {
|
||||
const [dx, dy] = this._vacDelta(view, ev.clientX - d.sx, ev.clientY - d.sy);
|
||||
if (d.kind === 'move') {
|
||||
this._vacFit = { ...f, p: { ...d.p0, ox: d.p0.ox + dx, oy: d.p0.oy + dy } };
|
||||
} else {
|
||||
// corner-stretch: uniform scale about the OPPOSITE corner (fx, fy —
|
||||
// the fixed corner in robot coords), like a graphics-editor frame
|
||||
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
|
||||
const c = this._vacGhostCentre(tele?.rooms || []);
|
||||
const m0 = fitMatrix(d.p0);
|
||||
const [gx, gy] = applyAffine(m0, c[0], c[1]);
|
||||
const [px0, py0] = applyAffine(m0, d.fx, d.fy);
|
||||
const span0 = Math.hypot(gx - px0, gy - py0) || 1;
|
||||
// distance change of the dragged corner (opposite of fixed) from centre
|
||||
const [cx0, cy0] = [2 * gx - px0, 2 * gy - py0];
|
||||
const span1 = Math.hypot(cx0 + dx * 2 - px0, cy0 + dy * 2 - py0) / 2;
|
||||
const k = Math.max(0.05, span1 / span0);
|
||||
const next = { ...d.p0, s: d.p0.s * k } as FitParams;
|
||||
this._vacFit = { ...f, p: reanchorFit(next, d.p0, d.fx, d.fy) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'pointerup' || ev.type === 'pointercancel') {
|
||||
this._vacFit = { ...f, drag: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** The translucent robot map over the plan while fitting. */
|
||||
private _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
const f = this._vacFit;
|
||||
if (!f) return nothing;
|
||||
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
|
||||
if (!tele) return nothing;
|
||||
const m = fitMatrix(f.p);
|
||||
const rects: TemplateResult[] = [];
|
||||
const xs: number[] = [], ys: number[] = [];
|
||||
for (const r of tele.rooms) {
|
||||
if (r.x0 == null) continue;
|
||||
const cs = [[r.x0!, r.y0!], [r.x1!, r.y0!], [r.x1!, r.y1!], [r.x0!, r.y1!]]
|
||||
.map(([x, y]) => applyAffine(m, x, y));
|
||||
cs.forEach(([x, y]) => { xs.push(x); ys.push(y); });
|
||||
const [lx, ly] = applyAffine(m, r.cx, r.cy);
|
||||
rects.push(svg`<polygon points="${cs.map((q) => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join(' ')}"></polygon>
|
||||
<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}">${r.name}</text>`);
|
||||
}
|
||||
let dot: TemplateResult | typeof nothing = nothing;
|
||||
if (tele.pos) {
|
||||
const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y);
|
||||
dot = svg`<circle class="vacfitdot" cx="${dx2.toFixed(1)}" cy="${dy2.toFixed(1)}" r="${(view.w * 0.012).toFixed(1)}"></circle>`;
|
||||
}
|
||||
// corner handles on the ghost bbox; data-corner carries the FIXED corner
|
||||
// (the opposite one) in robot coordinates
|
||||
const handles: TemplateResult[] = [];
|
||||
if (xs.length) {
|
||||
const inv = ((): ((x: number, y: number) => VacPt) => {
|
||||
const det = m[0] * m[4] - m[1] * m[3];
|
||||
return (x, y) => [
|
||||
(m[4] * (x - m[2]) - m[1] * (y - m[5])) / det,
|
||||
(-m[3] * (x - m[2]) + m[0] * (y - m[5])) / det,
|
||||
];
|
||||
})();
|
||||
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
||||
const y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||
const r = view.w * 0.014;
|
||||
for (const [hx, hy, ox2, oy2] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]] as number[][]) {
|
||||
const fixed = inv(ox2, oy2);
|
||||
handles.push(svg`<circle class="vacfithandle" data-corner="${fixed[0] + ',' + fixed[1]}"
|
||||
cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${r.toFixed(1)}"></circle>`);
|
||||
}
|
||||
}
|
||||
return html`<svg class="vacfit" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="none"
|
||||
@pointerdown=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||||
@pointermove=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||||
@pointerup=${(e: PointerEvent) => this._vacFitPointer(e, view)}
|
||||
@pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}</svg>`;
|
||||
}
|
||||
|
||||
/** Puck + trail for every live vacuum of the space. */
|
||||
private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
if (this._markup || this._mode === 'decor') return nothing;
|
||||
|
||||
+5
-5
@@ -360,7 +360,6 @@
|
||||
"vac.status_found": "Position source found: {name}",
|
||||
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
|
||||
"vac.autocal": "Set up automatically",
|
||||
"vac.wizard": "Three-point calibration",
|
||||
"vac.live": "Live position on the plan",
|
||||
"vac.trail": "Show the cleaning trail",
|
||||
"vac.cal_maps": "Calibrated maps: {maps}",
|
||||
@@ -368,10 +367,11 @@
|
||||
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — use point calibration",
|
||||
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine with points if needed",
|
||||
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
|
||||
"vac.cal_point": "Point {n} of 3: click the spot on the plan where the robot is standing right now",
|
||||
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
|
||||
"vac.cal_degenerate": "Points are too close or collinear — pick another spot",
|
||||
"vac.cal_res_warn": "The fit is imprecise — add a fourth point in another corner",
|
||||
"vac.cal_done": "Calibration saved. Start a cleanup and check",
|
||||
"vac.cal_cancelled": "Calibration cancelled"
|
||||
"vac.cal_cancelled": "Calibration cancelled",
|
||||
"vac.fit": "Fit manually",
|
||||
"vac.fit_hint": "Drag the robot map into place, stretch by the corners",
|
||||
"vac.fit_rotate": "Rotate 90°",
|
||||
"vac.fit_mirror": "Mirror"
|
||||
}
|
||||
|
||||
+5
-5
@@ -360,7 +360,6 @@
|
||||
"vac.status_found": "Источник координат найден: {name}",
|
||||
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
|
||||
"vac.autocal": "Настроить автоматически",
|
||||
"vac.wizard": "Калибровка по трём точкам",
|
||||
"vac.live": "Живая позиция на плане",
|
||||
"vac.trail": "Показывать след уборки",
|
||||
"vac.cal_maps": "Откалиброваны карты: {maps}",
|
||||
@@ -368,10 +367,11 @@
|
||||
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — используйте калибровку по точкам",
|
||||
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откалибруйте по точкам",
|
||||
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
|
||||
"vac.cal_point": "Точка {n} из 3: кликните на плане место, где робот стоит сейчас",
|
||||
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
|
||||
"vac.cal_degenerate": "Точки слишком близко или на одной линии — выберите другое место",
|
||||
"vac.cal_res_warn": "Привязка неточная — добавьте четвёртую точку в другом углу",
|
||||
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
|
||||
"vac.cal_cancelled": "Калибровка отменена"
|
||||
"vac.cal_cancelled": "Калибровка отменена",
|
||||
"vac.fit": "Подогнать вручную",
|
||||
"vac.fit_hint": "Перетащите карту робота на место, растяните за уголки",
|
||||
"vac.fit_rotate": "Повернуть 90°",
|
||||
"vac.fit_mirror": "Отразить"
|
||||
}
|
||||
|
||||
@@ -1306,6 +1306,40 @@ export const cardStyles = css`
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
.vacbox .vacbtns { display: flex; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
|
||||
.vacfit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 12;
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.vacfit:active { cursor: grabbing; }
|
||||
.vacfit polygon {
|
||||
fill: color-mix(in srgb, var(--hp-accent) 16%, transparent);
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.vacfit text {
|
||||
fill: var(--hp-accent);
|
||||
font-size: 26px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.vacfitdot { fill: var(--hp-accent); pointer-events: none; }
|
||||
.vacfithandle {
|
||||
fill: var(--hp-bg);
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.vaccalbar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
|
||||
+79
-2
@@ -67,7 +67,8 @@ export function affineResidual(m: Affine, pairs: Array<[Pt, Pt]>): number {
|
||||
|
||||
// ---------------- telemetry adapters ----------------
|
||||
|
||||
export interface VacRoom { id: string; name: string; cx: number; cy: number }
|
||||
export interface VacRoom { id: string; name: string; cx: number; cy: number;
|
||||
x0?: number; y0?: number; x1?: number; y1?: number }
|
||||
export interface VacTelemetry {
|
||||
pos: { x: number; y: number; a: number | null } | null;
|
||||
path: Pt[] | null; // integration-provided full path, vacuum coords
|
||||
@@ -123,7 +124,15 @@ export function readVacTelemetry(attrs: Record<string, any> | null | undefined):
|
||||
// Tasshack dreame-vacuum: the room centre is plain x/y (verified against
|
||||
// a live X50 Master; x/y sits within its own x0..x1 bbox)
|
||||
if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
|
||||
if (name && cx != null && cy != null) rooms.push({ id, name, cx, cy });
|
||||
if (name && cx != null && cy != null) {
|
||||
const room: VacRoom = { id, name, cx, cy };
|
||||
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
|
||||
if (x0 != null && y0 != null && x1 != null && y1 != null) {
|
||||
room.x0 = Math.min(x0, x1); room.y0 = Math.min(y0, y1);
|
||||
room.x1 = Math.max(x0, x1); room.y1 = Math.max(y0, y1);
|
||||
}
|
||||
rooms.push(room);
|
||||
}
|
||||
}
|
||||
const mapId = String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
|
||||
if (!pos && !rooms.length && !path) return null;
|
||||
@@ -213,3 +222,71 @@ export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
|
||||
export function isVacMoving(state: string | undefined): boolean {
|
||||
return state === 'cleaning' || state === 'returning' || state === 'on';
|
||||
}
|
||||
|
||||
// ---------------- the fit panel (drag + corner-stretch calibration) ----------------
|
||||
|
||||
/** What the user manipulates; folds into the same stored 6-number matrix. */
|
||||
export interface FitParams {
|
||||
ox: number; oy: number; // translation, canvas units
|
||||
s: number; // uniform scale, canvas units per robot unit
|
||||
rot: 0 | 90 | 180 | 270; // whole-quarter rotation
|
||||
mir: boolean; // mirror (robots' Y usually grows the other way)
|
||||
}
|
||||
|
||||
const ROT_CS: Record<number, [number, number]> = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
|
||||
|
||||
/** target = S · R(rot) · diag(mir ? −1 : 1, 1) · source + (ox, oy) */
|
||||
export function fitMatrix(p: FitParams): Affine {
|
||||
const [c, s_] = ROT_CS[p.rot] || [1, 0];
|
||||
const mx = p.mir ? -1 : 1;
|
||||
return [p.s * c * mx, -p.s * s_, p.ox, p.s * s_ * mx, p.s * c, p.oy];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a stored matrix back into panel params. Rotation snaps to the
|
||||
* nearest quarter — a legacy 3-point matrix reopens as an editable start,
|
||||
* not verbatim, and that is fine: the ghost shows the result live.
|
||||
*/
|
||||
export function fitFromMatrix(m: Affine): FitParams | null {
|
||||
const det = m[0] * m[4] - m[1] * m[3];
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
|
||||
const mir = det < 0;
|
||||
const s = Math.sqrt(Math.abs(det));
|
||||
// the second column (b, e) = S·(−sin, cos) is mirror-free
|
||||
let ang = Math.atan2(-m[1], m[4]) * 180 / Math.PI;
|
||||
ang = ((Math.round(ang / 90) * 90) % 360 + 360) % 360;
|
||||
return { ox: m[2], oy: m[5], s, rot: ang as FitParams['rot'], mir };
|
||||
}
|
||||
|
||||
/** A sane opening position: the robot map centred over the plan at 60% size. */
|
||||
export function initialFit(
|
||||
rooms: VacRoom[],
|
||||
vb: [number, number, number, number],
|
||||
): FitParams {
|
||||
const bx: number[] = [], by: number[] = [];
|
||||
for (const r of rooms) {
|
||||
if (r.x0 != null) { bx.push(r.x0, r.x1!); by.push(r.y0!, r.y1!); }
|
||||
else { bx.push(r.cx); by.push(r.cy); }
|
||||
}
|
||||
// no rooms at all: an arbitrary honest guess the user will drag anyway
|
||||
if (!bx.length) return { ox: vb[0] + vb[2] / 2, oy: vb[1] + vb[3] / 2, s: vb[2] / 10000, rot: 0, mir: true };
|
||||
const minX = Math.min(...bx), maxX = Math.max(...bx);
|
||||
const minY = Math.min(...by), maxY = Math.max(...by);
|
||||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||||
const s = (Math.min(vb[2], vb[3]) * 0.6) / span;
|
||||
// mirror on by default: every robot map seen so far has Y flipped vs screen
|
||||
const p: FitParams = { ox: 0, oy: 0, s, rot: 0, mir: true };
|
||||
const m = fitMatrix(p);
|
||||
const [ccx, ccy] = applyAffine(m, (minX + maxX) / 2, (minY + maxY) / 2);
|
||||
p.ox = vb[0] + vb[2] / 2 - ccx;
|
||||
p.oy = vb[1] + vb[3] / 2 - ccy;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Re-anchor params so the source point (sx, sy) stays at the same target spot. */
|
||||
export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: number): FitParams {
|
||||
const [px, py] = applyAffine(fitMatrix(prev), sx, sy);
|
||||
const trial = fitMatrix({ ...p, ox: 0, oy: 0 });
|
||||
const [qx, qy] = applyAffine(trial, sx, sy);
|
||||
return { ...p, ox: px - qx, oy: py - qy };
|
||||
}
|
||||
|
||||
@@ -108,3 +108,30 @@ test('isVacMoving', () => {
|
||||
assert.ok(!isVacMoving('idle'));
|
||||
assert.ok(!isVacMoving(undefined));
|
||||
});
|
||||
|
||||
test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => {
|
||||
const { fitMatrix, fitFromMatrix, initialFit, reanchorFit } = await import('../test-build/vacuum.js');
|
||||
for (const rot of [0, 90, 180, 270]) for (const mir of [false, true]) {
|
||||
const p = { ox: 123.4, oy: -55.5, s: 0.083, rot, mir };
|
||||
const q = fitFromMatrix(fitMatrix(p));
|
||||
assert.equal(q.rot, rot, `rot ${rot} mir ${mir}`);
|
||||
assert.equal(q.mir, mir);
|
||||
assert.ok(Math.abs(q.s - p.s) < 1e-9 && Math.abs(q.ox - p.ox) < 1e-9 && Math.abs(q.oy - p.oy) < 1e-9);
|
||||
}
|
||||
// the real X50 matrix shape: X forward, Y flipped == mir + 180? decompose sanity
|
||||
const q = fitFromMatrix([0.08, 0, 590, 0, -0.08, 677]);
|
||||
assert.equal(q.mir, true);
|
||||
// initialFit centres the map bbox on the canvas
|
||||
const rooms = [{ id: '1', name: 'A', cx: 500, cy: 500, x0: 0, y0: 0, x1: 1000, y1: 1000 }];
|
||||
const f = initialFit(rooms, [0, 0, 1000, 1000]);
|
||||
const m = fitMatrix(f);
|
||||
const c = applyAffine(m, 500, 500);
|
||||
assert.ok(Math.abs(c[0] - 500) < 1e-6 && Math.abs(c[1] - 500) < 1e-6);
|
||||
assert.ok(Math.abs(1000 * f.s - 600) < 1e-6); // 60% of the canvas
|
||||
assert.equal(f.mir, true);
|
||||
// reanchor keeps the chosen source point fixed through a rotation
|
||||
const p2 = { ...f, rot: 90 };
|
||||
const r2 = reanchorFit(p2, f, 500, 500);
|
||||
const c2 = applyAffine(fitMatrix(r2), 500, 500);
|
||||
assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user