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:
Matysh
2026-07-31 10:38:49 +03:00
parent 11186371a9
commit 1eabfeeee8
11 changed files with 573 additions and 157 deletions
File diff suppressed because one or more lines are too long
+54 -32
View File
@@ -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
+67 -22
View File
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Отразить"
}
+34
View File
@@ -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
View File
@@ -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 };
}
+27
View File
@@ -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);
});