mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
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
@@ -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,
|
||||
|
||||
+140
-138
File diff suppressed because one or more lines are too long
Vendored
+140
-138
File diff suppressed because one or more lines are too long
+50
-10
@@ -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
@@ -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
@@ -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
@@ -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[]>;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
Reference in New Issue
Block a user