fix: polish vacuum map routes

Treat explicit empty route lists as authoritative, preserve them through single-space export, group deleted-space routes, and render vacuums from the immutable vacuum-only snapshot subset.

Issue: #443
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 23:24:08 +03:00
parent 83201da15d
commit cebd20a9b5
42 changed files with 553 additions and 292 deletions
+56 -36
View File
@@ -70,7 +70,7 @@ export function renderVacuumMapsSection(
const t = (key: string, vars?: Record<string, string>) => supportT(
lang, key as SupportI18nKey, vars);
const vacuum = dev.marker?.vacuum || {};
const explicit = Array.isArray(vacuum.map_routes) && vacuum.map_routes.length > 0;
const explicit = Array.isArray(vacuum.map_routes);
const rootSource: string = host._vacSource(dev) || '';
const routes: VacuumMapRoute[] = effectiveRoutes(dev.id, vacuum, dev.space, rootSource);
const spaces: Array<{ id: string; name: string }> = (host._serverCfg?.spaces || [])
@@ -192,11 +192,19 @@ export function renderVacuumMapsSection(
await writeRoutes((current) => removeRoute(current, route.id));
};
// Space order, then map id, then source: a stable reading order that never
// depends on the order the routes happen to be stored in.
// Valid spaces keep their configured order. Missing-space routes form one
// explicit group with a fully deterministic identity order (#443).
const order = new Map(spaces.map((space, index) => [space.id, index]));
const rows = [...routes].sort((a, b) => (order.get(a.space) ?? 1e6) - (order.get(b.space) ?? 1e6)
|| a.map_id.localeCompare(b.map_id) || a.source.localeCompare(b.source));
const compareIdentity = (a: VacuumMapRoute, b: VacuumMapRoute): number =>
a.map_id.localeCompare(b.map_id)
|| a.source.localeCompare(b.source)
|| a.id.localeCompare(b.id);
const validRows = routes.filter((route) => spaceIds.has(route.space))
.sort((a, b) => (order.get(a.space) ?? 0) - (order.get(b.space) ?? 0)
|| compareIdentity(a, b));
const missingRows = routes.filter((route) => !spaceIds.has(route.space))
.sort(compareIdentity);
const rows = [...validRows, ...missingRows];
const statusOf = (route: VacuumMapRoute): string => {
if (!spaceIds.has(route.space)) return t('vac.route_status_missing_space');
@@ -254,6 +262,37 @@ export function renderVacuumMapsSection(
host.requestUpdate();
};
const renderRoute = (route: VacuumMapRoute): TemplateResult => html`
<li class="vacroute ${route.id === activeId ? 'on' : ''}" data-route-id=${route.id}>
<div class="vacroute-head">
<b>${route.map_id || t('vac.route_map_default')}</b>
${route.id === activeId
? html`<span class="vacroute-active">${t('vac.route_active')}</span>` : nothing}
</div>
<small>${route.source}</small>
<div class="vacroute-status">${statusOf(route)}</div>
<label class="srcrow">
<span>${t('vac.route_space')}</span>
<select class="areasel" @change=${(event: Event) => {
const select = event.target as HTMLSelectElement;
const next = select.value;
select.value = route.space;
void retarget(route, next);
}}>
<option value="" ?selected=${!route.space}>${t('vac.route_space_none')}</option>
${spaces.map((space) => html`
<option value=${space.id} ?selected=${space.id === route.space}>${space.name}</option>`)}
</select>
</label>
<div class="vacbtns">
<button type="button" class="btn ghostbtn"
@click=${() => runtime._vacStartFit(dev, route.id)}>${host._t('vac.fit')}</button>
<button type="button" class="btn ghostbtn danger"
@click=${() => void drop(route)}>${t('vac.route_delete')}</button>
</div>
</li>`;
const missingGroupTitleId = `vacroute-missing-${dev.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
return html`
<label>${t('vac.routes_section')}</label>
<div class="bindbox vacroutes">
@@ -262,7 +301,7 @@ export function renderVacuumMapsSection(
? t('vac.routes_no_map') : (currentMapId || t('vac.route_map_default'))}</b></div>
<div><span>${t('vac.routes_status')}</span><b>${t(`vac.routes_state_${resolution.kind}`)}</b></div>
</div>
${rows.length || draft ? html`<ul class="vacroute-list">
${validRows.length || draft ? html`<ul class="vacroute-list">
${draft ? html`
<li class="vacroute pending">
<div class="vacroute-head"><b>${draft.mapId || t('vac.route_map_default')}</b></div>
@@ -284,36 +323,17 @@ export function renderVacuumMapsSection(
@click=${() => void confirmDraft()}>${host._t('btn.save')}</button>
</div>
</li>` : nothing}
${rows.map((route) => html`
<li class="vacroute ${route.id === activeId ? 'on' : ''}">
<div class="vacroute-head">
<b>${route.map_id || t('vac.route_map_default')}</b>
${route.id === activeId
? html`<span class="vacroute-active">${t('vac.route_active')}</span>` : nothing}
</div>
<small>${route.source}</small>
<div class="vacroute-status">${statusOf(route)}</div>
<label class="srcrow">
<span>${t('vac.route_space')}</span>
<select class="areasel" @change=${(event: Event) => {
const select = event.target as HTMLSelectElement;
const next = select.value;
select.value = route.space;
void retarget(route, next);
}}>
<option value="" ?selected=${!route.space}>${t('vac.route_space_none')}</option>
${spaces.map((space) => html`
<option value=${space.id} ?selected=${space.id === route.space}>${space.name}</option>`)}
</select>
</label>
<div class="vacbtns">
<button type="button" class="btn ghostbtn"
@click=${() => runtime._vacStartFit(dev, route.id)}>${host._t('vac.fit')}</button>
<button type="button" class="btn ghostbtn danger"
@click=${() => void drop(route)}>${t('vac.route_delete')}</button>
</div>
</li>`)}
</ul>` : html`<div class="rhint">${t('vac.routes_empty')}</div>`}
${validRows.map(renderRoute)}
</ul>` : nothing}
${missingRows.length ? html`
<section class="vacroute-missing-group" data-hp="vacuum-route-missing-group"
aria-labelledby=${missingGroupTitleId}>
<h4 class="vacroute-group-title" id=${missingGroupTitleId}>
${t('vac.route_missing_space_group')}
</h4>
<ul class="vacroute-list">${missingRows.map(renderRoute)}</ul>
</section>` : nothing}
${!rows.length && !draft ? html`<div class="rhint">${t('vac.routes_empty')}</div>` : nothing}
<div class="vacbtns">
<button type="button" class="btn vacroute-add-current" ?disabled=${!canAddCurrent || !!draft}
@click=${addCurrent}>${t('vac.route_add_current')}</button>
+7 -3
View File
@@ -4861,6 +4861,11 @@ export class HouseplanCard extends LitElement {
return this._renderDeviceSnapshot?.devices || this._devices;
}
/** Normal frames reuse the vacuum-only subset captured with their facts. */
private get _renderVacuumDevices(): readonly DevItem[] {
return this._renderDeviceSnapshot?.vacuumDevices || this._devices;
}
/**
* Full registry metadata for diagnostics and action safety checks. Entity
* states/services for actions always come from the active `_planHass`;
@@ -11791,7 +11796,7 @@ export class HouseplanCard extends LitElement {
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderVacuums(this._renderDevices, view, space.id)}
${this._renderVacuums(this._renderVacuumDevices, view, space.id)}
${this._renderVacFit(view)}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
@@ -12383,8 +12388,7 @@ export class HouseplanCard extends LitElement {
rootSource: d.marker?.vacuum?.source ?? null,
serverCurrent: srv0?.current ?? null,
serverPrevious: srv0?.previous ?? null,
explicitRoutes: Array.isArray(d.marker?.vacuum?.map_routes)
&& (d.marker?.vacuum?.map_routes?.length ?? 0) > 0,
explicitRoutes: Array.isArray(d.marker?.vacuum?.map_routes),
});
if (!plan.live && !plan.previous) continue;
const matrix = plan.live;
+1
View File
@@ -62,6 +62,7 @@
"vac.route_status_needs_calibration": "Kalibrierung nötig",
"vac.route_status_ambiguous": "mehrdeutig",
"vac.route_status_missing_space": "Raum gelöscht",
"vac.route_missing_space_group": "Gelöschter Bereich",
"vac.route_add_current": "Aktuelle Karte hinzufügen",
"vac.route_delete": "Karte entfernen",
"vac.route_delete_title": "Karte von der Etage entfernen?",
+1
View File
@@ -62,6 +62,7 @@
"vac.route_status_needs_calibration": "needs calibration",
"vac.route_status_ambiguous": "ambiguous",
"vac.route_status_missing_space": "space deleted",
"vac.route_missing_space_group": "Deleted space",
"vac.route_add_current": "Add current map",
"vac.route_delete": "Remove map",
"vac.route_delete_title": "Remove the map from its floor?",
+1
View File
@@ -62,6 +62,7 @@
"vac.route_status_needs_calibration": "calibrage requis",
"vac.route_status_ambiguous": "ambiguë",
"vac.route_status_missing_space": "espace supprimé",
"vac.route_missing_space_group": "Espace supprimé",
"vac.route_add_current": "Ajouter la carte actuelle",
"vac.route_delete": "Supprimer la carte",
"vac.route_delete_title": "Retirer la carte de son étage ?",
+1
View File
@@ -62,6 +62,7 @@
"vac.route_status_needs_calibration": "нужна калибровка",
"vac.route_status_ambiguous": "неоднозначна",
"vac.route_status_missing_space": "пространство удалено",
"vac.route_missing_space_group": "Пространство удалено",
"vac.route_add_current": "Добавить текущую карту",
"vac.route_delete": "Удалить карту",
"vac.route_delete_title": "Удалить карту с этажа?",
+7 -1
View File
@@ -15,6 +15,8 @@ export interface RenderDeviceSnapshot {
themes?: any;
}>;
readonly devices: readonly DevItem[];
/** Devices with captured vacuum facts, sharing the same cloned roster rows. */
readonly vacuumDevices: readonly DevItem[];
readonly positions: ReadonlyMap<string, Readonly<{ x: number; y: number }>>;
/** Two variants per device: `${id}:1` with LQI and `${id}:0` without it. */
readonly presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
@@ -131,11 +133,15 @@ export function createRenderDeviceSnapshot(options: {
locale: cloneFact(options.hass?.locale),
themes: cloneFact(options.hass?.themes),
});
const devices = cloneFact([...options.devices]);
const vacuumDevices = Object.freeze(devices.filter((device) =>
options.facts?.has(`vacuum:${device.id}`)));
return Object.freeze({
sourceSequence: options.sourceSequence,
capturedAt: options.capturedAt ?? Date.now(),
hass,
devices: cloneFact([...options.devices]),
devices,
vacuumDevices,
positions: readonlyMap([...(options.positions || [])].map(([id, point]) =>
[id, Object.freeze({ x: point.x, y: point.y })] as const)),
presentations: readonlyMap([...options.presentations].map(([key, presentation]) =>
+12
View File
@@ -657,6 +657,18 @@ export const dialogsStyles = css`
.vacdiag > div { display: flex; justify-content: space-between; gap: var(--sp-5); }
.vacdiag > div > span { color: var(--secondary-text-color); }
.vacdiag > div > b { text-align: right; overflow-wrap: anywhere; }
.vacroute-missing-group {
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--hp-line);
}
.vacroute-group-title {
margin: 0 0 var(--sp-3);
color: var(--secondary-text-color);
font-size: var(--fs-m);
font-weight: 600;
}
.vacroute-missing-group .vacroute-list { margin-top: 0; }
.vacpicker { margin: var(--sp-3) 0; }
.vacsource-warning { display: grid; gap: 8px; }
.vacsource-warning .btn { justify-self: start; }
+1 -1
View File
@@ -133,7 +133,7 @@ export function effectiveRoutes(
discoveredSource?: string | null,
): VacuumMapRoute[] {
const explicit = marker?.map_routes;
if (Array.isArray(explicit) && explicit.length) {
if (Array.isArray(explicit)) {
return explicit
.filter((route) => route && typeof route.id === 'string' && route.id)
.map((route) => ({