Trail modes + the tip grows glued to the icon

«Показывать путь робота» is a three-way choice now: never / while
cleaning (the default — the line hides the moment the run ends) /
always (the only mode that also shows the faded previous run).
trail_mode rides next to the legacy bool, which still maps in.

The last segment no longer pops in when the next telemetry point
arrives: a rAF sampler drags a tip line's endpoint to the puck's
animated centre every frame, so the path visually pours out strictly
from under the icon. The sampler runs only while pucks exist and stops
itself.
This commit is contained in:
Matysh
2026-07-31 11:30:17 +03:00
parent 089c5ef462
commit 75524d9d85
11 changed files with 522 additions and 431 deletions
File diff suppressed because one or more lines are too long
@@ -282,6 +282,9 @@ MARKER_SCHEMA = vol.Schema(
vol.Schema({
vol.Optional("live"): vol.Any(bool, None),
vol.Optional("trail"): vol.Any(bool, None),
vol.Optional("trail_mode"): vol.Any(
None, vol.In(["never", "cleaning", "always"])
),
vol.Optional("room_highlight"): vol.Any(bool, None),
vol.Optional("source"): vol.Any(str, None),
# one 6-number affine per robot map; numbers must be finite
+20 -2
View File
@@ -87,7 +87,8 @@ const out = await page.evaluate(async () => {
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
o.trailLingers = !!sr().querySelector('.vactrail polyline');
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
o.trailHiddenAfterDock = !sr().querySelector('.vactrail polyline');
// hidden marker renders neither puck nor trail
c.hass = { ...c.hass, states: { ...c.hass.states,
@@ -104,6 +105,7 @@ const out = await page.evaluate(async () => {
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 1, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
@@ -123,6 +125,21 @@ const out = await page.evaluate(async () => {
// 4 server points, the live tail trimmed while moving -> 3 rendered
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
// the growing tip is glued to the puck by the rAF sampler
const tip = sr().querySelector('line.tip');
o.tipExists = !!tip;
await new Promise((r) => setTimeout(r, 250));
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
const svgT = sr().querySelector('.vactrail');
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
// 'never' kills every line including the previous run
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.neverHidesAll = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
c._vacSrvTrails = {};
// ---- the fit panel (drag + corner-stretch) end to end ----
@@ -202,9 +219,10 @@ checkAll(out, {
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
trailCasing: true, trailToggleOff: true,
puckGoneWhenDocked: true, trailLingers: true, hiddenNoPuck: true,
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
File diff suppressed because one or more lines are too long
+140 -138
View File
File diff suppressed because one or more lines are too long
+50 -10
View File
@@ -27,7 +27,7 @@ import {
import { ContentSigner } from './signing';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
autoCalibrate, pushTrailPoint, isVacMoving, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
@@ -353,6 +353,8 @@ class HouseplanCard extends LitElement {
space switch) or a tab return must TELEPORT the puck animating left/top
through a viewport change reads as «едет через весь план» (owner). */
private _vacViewKey = '';
private _vacLastView: { x: number; y: number; w: number; h: number } | null = null;
private _vacRaf = 0;
/** server-recorded runs per marker: {current, previous} in raw robot coords */
private _vacSrvTrails: Record<string, any> = {};
private _unsubTrail?: () => void;
@@ -458,6 +460,7 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
@@ -4391,7 +4394,7 @@ class HouseplanCard extends LitElement {
this._vacRt.set(d.id, rt);
}
if (moving && !rt.moving) { rt.trail = []; rt.lastPos = null; } // a fresh run
const wantTrail = d.marker?.vacuum?.trail !== false && !tele?.path;
const wantTrail = vacTrailMode(d.marker?.vacuum) !== 'never' && !tele?.path;
if (!moving && rt.moving) {
rt.endedTs = Date.now();
// the run is over: the puck has arrived everywhere it was going
@@ -4455,11 +4458,12 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => setVac({ live: (e.target as HTMLInputElement).checked ? null : false })} />
<span>${this._t('vac.live')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${v.trail !== false}
@change=${(e: Event) => setVac({ trail: (e.target as HTMLInputElement).checked ? null : false })} />
<span>${this._t('vac.trail')}</span>
</label>
<label>${this._t('vac.trail')}</label>
<select class="areasel"
@change=${(e: Event) => setVac({ trail_mode: (e.target as HTMLSelectElement).value, trail: null })}>
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this._t(('vac.trail_' + mv) as any)}</option>`)}
</select>
${cals.length ? html`<div class="rhint">${subst(this._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
` : nothing}
</div>`;
@@ -4669,6 +4673,29 @@ class HouseplanCard extends LitElement {
@pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}</svg>`;
}
/** Every frame: glue the growing tip segment to the animated puck centre. */
private _vacRafLoop(): void {
this._vacRaf = requestAnimationFrame(() => {
const sr = this.renderRoot as ShadowRoot;
const stage = this._stageEl;
const view = this._vacLastView;
const pucks = sr?.querySelectorAll?.('.vacpuck') || [];
if (!stage || !view || !pucks.length) { this._vacRaf = 0; return; }
const sb = stage.getBoundingClientRect();
for (const puck of pucks as any) {
const mid = puck.getAttribute('data-mid');
const pb = puck.getBoundingClientRect();
const cx = view.x + ((pb.left + pb.width / 2 - sb.left) / sb.width) * view.w;
const cy = view.y + ((pb.top + pb.height / 2 - sb.top) / sb.height) * view.h;
for (const line of sr.querySelectorAll(`line.tip[data-mid="${mid}"]`)) {
line.setAttribute('x2', cx.toFixed(1));
line.setAttribute('y2', cy.toFixed(1));
}
}
this._vacRafLoop();
});
}
/** 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;
@@ -4688,14 +4715,17 @@ class HouseplanCard extends LitElement {
if (!matrix || matrix.length !== 6) continue;
const rt = this._vacRt.get(d.id);
const moving = rt?.moving ?? false;
const lingering = !moving && rt?.endedTs && Date.now() - rt.endedTs < VAC_TRAIL_LINGER_MS;
const tmode = vacTrailMode(d.marker?.vacuum);
// owner 2026-07-31: hide when the cleanup is over (default), unless the
// mode says always; the previous run only ever shows in 'always'
const showCur = tmode === 'always' || (tmode === 'cleaning' && moving);
const srv = this._vacSrvTrails[d.id];
const mapNow = this._vacMapId(d, tele);
const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null;
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
// the PREVIOUS run stays visible even at rest: users compare where the
// robot has been against where it has not (owner call 2026-07-31)
if (d.marker?.vacuum?.trail !== false && srvPrev && srvPrev.points.length > 1) {
if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) {
const pts = srvPrev.points.map(([x, y]: number[]) => {
const [cx2, cy2] = applyAffine(matrix, x, y);
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
@@ -4704,7 +4734,7 @@ class HouseplanCard extends LitElement {
}
// trail source order: server current run (survives reloads, shared by
// every screen) → integration path → the local live buffer
if (d.marker?.vacuum?.trail !== false && (moving || lingering || srvCur)) {
if (showCur && (moving || srvCur)) {
// any server/integration path ends at the CURRENT target — trim the
// live tail while moving so the line never runs ahead of the puck
const full: VacPt[] = (srvCur?.points as VacPt[]) || tele.path || rt?.trail || [];
@@ -4719,6 +4749,13 @@ class HouseplanCard extends LitElement {
// visible over ANY room fill — blend modes all have a blind
// luminance where the line vanishes (owner request 2026-07-31).
trails.push(svg`<polyline class="case" points="${ptsStr}"></polyline><polyline class="core" points="${ptsStr}"></polyline>`);
// the LAST segment grows glued to the icon (owner: «след появлялся
// строго за иконкой»): a rAF sampler drags x2/y2 to the puck centre
if (moving) {
const [ax, ay] = applyAffine(matrix, raw[raw.length - 1][0], raw[raw.length - 1][1]);
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
}
}
if (!moving || !tele.pos) continue;
@@ -4728,6 +4765,7 @@ class HouseplanCard extends LitElement {
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
pucks.push(html`<div
data-mid="${d.id}"
class="vacpuck ${rt?.jump || jumpAll ? 'jump' : ''} ${stale ? 'stale' : ''}"
style="left:${left}%;top:${top}%"
title=${d.name}
@@ -4735,6 +4773,8 @@ class HouseplanCard extends LitElement {
<ha-icon .icon=${icon}></ha-icon>
</div>`);
}
this._vacLastView = view;
if (pucks.length && !this._vacRaf) this._vacRafLoop();
if (!pucks.length && !trails.length) return nothing;
return html`
${trails.length ? svg`<svg class="vactrail" viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none">${trails}</svg>` : nothing}
+5 -2
View File
@@ -361,7 +361,7 @@
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
"vac.autocal": "Set up automatically",
"vac.live": "Live position on the plan",
"vac.trail": "Show the cleaning trail",
"vac.trail": "Show the robot's path",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — use point calibration",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — use point calibration",
@@ -373,5 +373,8 @@
"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"
"vac.fit_mirror": "Mirror",
"vac.trail_never": "Never",
"vac.trail_cleaning": "While cleaning",
"vac.trail_always": "Always"
}
+5 -2
View File
@@ -361,7 +361,7 @@
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
"vac.autocal": "Настроить автоматически",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать след уборки",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — используйте калибровку по точкам",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — используйте калибровку по точкам",
@@ -373,5 +373,8 @@
"vac.fit": "Подогнать вручную",
"vac.fit_hint": "Перетащите карту робота на место, растяните за уголки",
"vac.fit_rotate": "Повернуть 90°",
"vac.fit_mirror": "Отразить"
"vac.fit_mirror": "Отразить",
"vac.trail_never": "Не показывать никогда",
"vac.trail_cleaning": "Во время уборки",
"vac.trail_always": "Показывать всегда"
}
+2 -1
View File
@@ -58,7 +58,8 @@ export interface Marker {
/** live robot vacuum (docs/VACUUM.md); absent on non-vacuum markers */
vacuum?: {
live?: boolean | null;
trail?: boolean | null;
trail?: boolean | null; // legacy bool; trail_mode wins
trail_mode?: 'never' | 'cleaning' | 'always' | null;
room_highlight?: boolean | null;
source?: string | null;
calibration?: Record<string, number[]>;
+10
View File
@@ -290,3 +290,13 @@ export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: numbe
const [qx, qy] = applyAffine(trial, sx, sy);
return { ...p, ox: px - qx, oy: py - qy };
}
export type VacTrailMode = 'never' | 'cleaning' | 'always';
/** marker.vacuum → display mode; legacy bool maps in (false = never). */
export function vacTrailMode(v: { trail?: boolean | null; trail_mode?: string | null } | null | undefined): VacTrailMode {
const m = v?.trail_mode;
if (m === 'never' || m === 'cleaning' || m === 'always') return m;
if (v?.trail === false) return 'never';
return 'cleaning';
}
+7
View File
@@ -956,3 +956,10 @@ class TestVacuum:
import pytest
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"teleport": True}))
def test_trail_mode_bounded(self):
import pytest
for ok in ("never", "cleaning", "always", None):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))