mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 14:19:04 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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": "Удалить карту с этажа?",
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user