Merge issue/802-zigbee-hover-labels into dev

Owner-authorized local review; task CI and WSL acceptance are green.

Issue: #802
User-Visible: no
This commit is contained in:
Matysh
2026-10-05 21:42:00 +03:00
26 changed files with 1095 additions and 25 deletions
+449
View File
@@ -0,0 +1,449 @@
// #802: real pointer + public HA/config inputs. No private card writes: the
// fixture delivers registry events and the integration's normal topology feed.
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const artifacts = new URL('../artifacts/zigbee-topology-802/', import.meta.url);
mkdirSync(artifacts, { recursive: true });
const out = {};
const evidence = [];
const ieee = (n) => `00124b000000000${n}`;
const remoteName = 'Landing <router> & "night light"';
const nodes = [
['d_light1', 1, 1, 'Router'], ['d_lamp', 2, 0, 'Coordinator'],
['d_mower', 3, 3, 'EndDevice'], ['d_temp', 4, 4, 'EndDevice'],
['not_on_plan', 6, 6, 'Router'], ['d_kettle', 7, 7, 'EndDevice'],
];
const z2m = {
nodes: nodes.map(([id, n, nwk, type]) => ({ ieeeAddr: ieee(n), networkAddress: nwk, type,
friendlyName: id === 'not_on_plan' ? 'Unplaced <router> & target' : 'Provider target' })),
links: [
{ source: ieee(2), target: ieee(1), linkquality: 130, routes: [
{ destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' },
] },
{ source: ieee(1), target: ieee(3), relationship: 'Parent', linkquality: 140 },
{ source: ieee(6), target: ieee(4), relationship: 'Parent', linkquality: 90 },
{ source: ieee(2), target: ieee(6), linkquality: 110, routes: [
{ destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' },
] },
],
};
const zha = nodes.map(([id, n, nwk, type]) => ({ ieee: ieee(n), nwk, device_reg_id: id,
device_type: type, name: id === 'not_on_plan' ? 'Unplaced <router> & target' : 'Provider target',
neighbors: n === 3 ? [{ ieee: ieee(1), relationship: 'Parent', lqi: 140 }]
: n === 4 ? [{ ieee: ieee(6), relationship: 'Parent', lqi: 90 }] : [],
...(n === 1 || n === 6 ? { routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }] } : {}),
}));
for (const provider of ['z2m', 'zha']) {
const { page, browser } = await launch({ width: 1100, height: 900 });
const root = page.locator('#host > houseplan-card');
const marker = (id) => root.locator(`[data-hp="device"][data-id="${id}"]`);
const bubble = root.locator('[data-hp="zigbee-topology-parent-bubble"]');
const settle = () => page.evaluate(() => window.__hpTest.settled());
let pointer;
const hover = async (id, vertical = 0.5) => {
await page.mouse.move(2, 2);
const box = await marker(id).boundingBox();
if (!box) throw new Error(`missing marker ${id}`);
pointer = { x: box.x + box.width / 2, y: box.y + box.height * vertical };
await page.mouse.move(pointer.x, pointer.y);
await settle();
};
const sample = async (id) => page.evaluate((id) => {
const card = window.__card;
const root = card.shadowRoot;
const topology = root.querySelector('hp-zigbee-topology-overlay');
const tip = root.querySelector('[data-hp-live-tip]');
const rect = (element) => {
const r = element.getBoundingClientRect();
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height };
};
const visible = (element) => !!element && !element.hidden && element.getBoundingClientRect().width > 0
&& getComputedStyle(element).display !== 'none' && getComputedStyle(element).visibility !== 'hidden';
const stage = rect(root.querySelector('.stage'));
const bounds = { left: Math.max(stage.left, 0), top: Math.max(stage.top, 0),
right: Math.min(stage.right, innerWidth), bottom: Math.min(stage.bottom, innerHeight) };
const source = rect(root.querySelector(`[data-hp="device"][data-id="${id}"]`));
const badges = [...(topology?.shadowRoot?.querySelectorAll('.parent-bubble,.remote,.route-status') || [])]
.filter(visible).map((element) => ({ rect: rect(element), text: element.textContent.trim(),
kind: element.className, pointerEvents: getComputedStyle(element).pointerEvents }));
const tipRect = tip ? rect(tip) : null;
const overlap = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left))
* Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
const tipVisible = visible(tip);
const badgesDisjoint = badges.every((badge, i) => badges.slice(i + 1)
.every((other) => overlap(badge.rect, other.rect) < 0.5));
const inside = (r) => r.left >= bounds.left - 1 && r.top >= bounds.top - 1
&& r.right <= bounds.right + 1 && r.bottom <= bounds.bottom + 1;
const sourceElement = root.querySelector(`[data-hp="device"][data-id="${id}"]`);
const floor = sourceElement.getAttribute('data-hp-iso-floor')?.split(',').map(Number);
const visual = sourceElement.getAttribute('data-hp-iso-visual')?.split(',').map(Number);
return { source, bounds, badges, tip: tipRect, tipVisible,
projectionIso: root.querySelector('.stage').classList.contains('projection-iso'),
isoWalls: !!root.querySelector('[data-hp="iso-walls"]'),
sourceRaised: floor?.length === 2 && visual?.length === 2
&& Math.hypot(floor[0] - visual[0], floor[1] - visual[1]) > 0.1,
tipText: tip?.textContent || '', badgesDisjoint,
clean: tipVisible && badges.length > 0 && badges.every((badge) => overlap(tipRect, badge.rect) < 0.5)
&& overlap(tipRect, source) < 0.5 && inside(tipRect),
badgeInside: badges.every((badge) => inside(badge.rect)),
pointerTransparent: !!topology && getComputedStyle(topology).pointerEvents === 'none'
&& badges.every((badge) => badge.pointerEvents === 'none'),
safeText: !topology?.shadowRoot?.querySelector('router,img,script'),
endpoint: root.querySelector(`[data-hp="device"][data-id="${id}"]`)?.hasAttribute('data-hp-zigbee-topology-endpoint'),
lines: [...(topology?.shadowRoot?.querySelectorAll('line,polygon') || [])].map((element) =>
['x1', 'x2', 'y1', 'y2', 'points'].map((name) => element.getAttribute(name)).join(',')),
calls: { ...window.__tooltipFixture.calls },
};
}, id);
const witness = async (name, id, { hidden = false } = {}) => {
const value = await sample(id);
evidence.push({ provider, name, ...value });
out[`${provider}_${name}`] = value.badgesDisjoint
&& (hidden ? !value.tipVisible && value.badges.length > 0 : value.clean);
return value;
};
const deliver = async (options = {}) => page.evaluate((options) => {
const fixture = window.__tooltipFixture;
if (options.name !== undefined) {
fixture.devices.d_light1 = { ...fixture.devices.d_light1,
name_by_user: options.name, name: options.registryName ?? '' };
window.__card.hass = { ...window.__card.hass, devices: { ...fixture.devices } };
for (const callback of fixture.registryCallbacks) callback({ action: 'update', device_id: 'd_light1' });
}
if (options.clear) fixture.result = null;
else if (options.restore) fixture.result = fixture.originalResult;
if (options.providerName !== undefined && fixture.result) {
fixture.result = structuredClone(fixture.result);
fixture.result.nodes[0].friendlyName = options.providerName;
}
fixture.phase = options.phase || 'ready';
fixture.stale = options.stale === true;
for (const callback of fixture.callbacks) callback(fixture.event());
}, options);
try {
// A delayed overlay chunk makes the first hover occur before upgrade. The
// source pointer stays still while the public backend reset supplies data.
let releaseOverlay;
let overlayWasDelayed = false;
const overlayReady = new Promise((resolve) => { releaseOverlay = resolve; });
if (provider === 'z2m') await page.route('**/*hp-zigbee-topology-overlay*.js', async (route) => {
overlayWasDelayed = true;
await overlayReady; await route.fallback();
});
await page.evaluate(async ({ provider, nodes, z2m, zha, remoteName }) => {
const card = window.__card;
const original = card.hass;
const fixture = { devices: structuredClone(original.devices), callbacks: new Set(), registryCallbacks: new Set(),
calls: { zha: 0, start: 0, subscriptions: 0 }, revision: 0, phase: 'ready', stale: false,
result: provider === 'z2m' ? z2m : null, originalResult: z2m, zha };
for (const [id, n] of nodes) if (fixture.devices[id]) fixture.devices[id].identifiers = [['zha', `00124b000000000${n}`]];
fixture.devices.d_light1 = { ...fixture.devices.d_light1, name_by_user: remoteName };
fixture.event = () => fixture.result ? {
kind: 'state', session_id: 'tooltip-802', revision: ++fixture.revision,
provider: { topic: 'zigbee2mqtt', job_id: 'fixture', phase: fixture.phase, elapsed_ms: 1200,
obtained_at: Date.now() - (fixture.stale ? 360000 : 0), stale: fixture.stale,
...(fixture.phase === 'error' ? { error: 'provider' } : {}), result: fixture.result },
} : { kind: 'removed', session_id: 'tooltip-802', revision: ++fixture.revision, topic: 'zigbee2mqtt' };
const connection = { ...original.connection,
subscribeEvents: async (callback, type) => {
if (type === 'device_registry_updated') fixture.registryCallbacks.add(callback);
const release = await original.connection.subscribeEvents(callback, type);
return () => { fixture.registryCallbacks.delete(callback); release(); };
},
subscribeMessage: async (callback, message) => {
if (message.type !== 'houseplan/zigbee/subscribe') return original.connection.subscribeMessage(callback, message);
fixture.calls.subscriptions++;
fixture.callbacks.add(callback);
callback({ kind: 'reset', session_id: 'tooltip-802', revision: fixture.revision,
topics: fixture.result ? ['zigbee2mqtt'] : [] });
if (fixture.result) callback(fixture.event());
return () => fixture.callbacks.delete(callback);
},
};
card.hass = { ...original, devices: fixture.devices, connection,
callWS: async (message) => {
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'zha/devices') { fixture.calls.zha++; return fixture.zha; }
if (message.type === 'houseplan/zigbee/start') { fixture.calls.start++; return fixture.event(); }
const response = await original.callWS(message);
return message.type === 'houseplan/config/get' ? { ...response, zigbee_scan_api: 1 } : response;
},
};
window.__tooltipFixture = fixture;
document.getElementById('host').style.width = '900px';
await window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...cfg.settings, zigbee_topology: { enabled: true, z2m_base_topics: ['zigbee2mqtt'] } };
cfg.spaces = cfg.spaces.map((space) => ({ ...space,
settings: { ...space.settings, show_borders: true, show_names: true },
}));
return cfg;
});
await window.__hpTest.setLayout((layout) => ({ ...layout,
d_kettle: { s: 'f1', x: 0.50, y: 0.40 }, d_temp: { s: 'f1', x: 0.22, y: 0.62 },
d_light1: { s: 'f1', x: 0.25, y: 0.27 }, d_mower: { s: 'garden', x: 0.50, y: 0.48 },
}));
}, { provider, nodes, z2m, zha, remoteName });
if (provider === 'zha') {
await page.evaluate(() => window.__hpTest.setMode('plan'));
await root.locator('[data-hp="settings"]').click();
await root.locator('hp-zigbee-topology-settings').getByRole('button', { name: /Read ZHA/ }).click();
await page.waitForFunction(() => window.__tooltipFixture.calls.zha === 1);
await page.evaluate(() => window.__hpTest.close());
await page.evaluate(() => window.__hpTest.setMode('view'));
}
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
await hover('d_mower');
if (provider === 'z2m') out.mouseReallyPrecedesLazyUpgrade = overlayWasDelayed && await page.evaluate(() =>
!customElements.get('hp-zigbee-topology-overlay')
&& !window.__card.shadowRoot.querySelector('[data-hp-live-tip]').hidden);
releaseOverlay();
await bubble.waitFor({ state: 'visible' });
await settle();
out[`${provider}_remoteCaption`] = (await bubble.innerText()).trim() === `Ground floor (${remoteName})`;
const remote = await witness(provider === 'z2m' ? 'firstHoverAfterLazyUpgrade' : 'firstMouseHover', 'd_mower');
out[`${provider}_safeTextAndPointerTransparent`] = remote.safeText && remote.pointerTransparent;
await page.screenshot({ path: fileURLToPath(new URL(`${provider}-remote-caption.png`, artifacts)) });
if (provider === 'z2m') {
for (const [name, registryName, providerName, expected] of [
[' ', 'Registry target', 'Provider target', 'Ground floor (Registry target)'],
[' ', ' ', 'Provider target', 'Ground floor (Provider target)'],
[' ', ' ', ' ', 'Ground floor'],
]) {
await deliver({ name, registryName, providerName });
await page.waitForTimeout(300);
await hover('d_mower');
await page.waitForFunction((expected) => window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay')
?.shadowRoot?.querySelector('.parent-bubble')?.textContent.trim() === expected, expected);
out[`${provider}_nameFallback_${expected}`] = (await bubble.innerText()).trim() === expected;
}
await deliver({ name: remoteName });
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').title = ''; return cfg;
}));
await hover('d_mower');
out.z2m_missingSpaceTitle = (await bubble.innerText()).trim() === `another space (${remoteName})`;
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').title = 'Ground floor'; return cfg;
}));
for (const iso of [false, true]) for (const theme of ['light', 'dark']) {
await page.evaluate(async ({ iso, theme }) => {
await window.__hpTest.setVolumetricView(iso);
const card = window.__card;
card.hass = { ...card.hass, themes: { ...card.hass.themes, darkMode: theme === 'dark' } };
for (const [key, value] of Object.entries(theme === 'light' ? {
'--card-background-color': '#ffffff', '--ha-card-background': '#ffffff',
'--primary-text-color': '#202020', '--secondary-text-color': '#606060',
} : { '--card-background-color': '#1c2530', '--ha-card-background': '#1c2530',
'--primary-text-color': '#e1e1e1', '--secondary-text-color': '#9aa4ad' })) card.style.setProperty(key, value);
}, { iso, theme });
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
for (const [id, name] of [['d_temp', 'unplaced'], ['d_light1', 'remote-count'], ['d_kettle', 'status-only']]) {
await hover(id, 0.25);
await root.locator('.route-status').waitFor({ state: 'visible' });
const value = await witness(`${iso ? 'iso' : 'flat'}_${theme}_${name}`, id);
out[`actualProjection_${iso}_${theme}_${name}`] = value.projectionIso === iso
&& (!iso || (value.isoWalls && value.sourceRaised));
if (name === 'remote-count') out[`remoteCount_${iso}_${theme}`] = value.badges.some((badge) => /1/.test(badge.text) && badge.kind === 'remote');
if (name === 'status-only') {
out[`statusOnlyHasNoEndpoint_${iso}_${theme}`] = !value.endpoint && value.lines.length === 0
&& value.badges.some((badge) => /No route data/.test(badge.text));
await page.screenshot({ path: fileURLToPath(new URL(`status-only-${iso ? 'iso' : 'flat'}-${theme}.png`, artifacts)) });
if (!iso && theme === 'light') {
// Recreate only the old display position, not product state. The
// same intersection oracle must reject the original defect.
const negative = await page.evaluate(({ x, y }) => {
const tip = window.__card.shadowRoot.querySelector('[data-hp-live-tip]');
const before = tip.getAttribute('style');
tip.style.left = `${Math.min(innerWidth - tip.offsetWidth - 8, x + 12)}px`;
tip.style.top = `${Math.min(innerHeight - tip.offsetHeight - 8, y + 12)}px`;
return before;
}, pointer);
const oldPlacement = await sample(id);
evidence.push({ provider, name: 'negative-control-cursor-plus-12', ...oldPlacement });
out.oldPlacementNegativeControlFails = !oldPlacement.clean && oldPlacement.tipVisible
&& oldPlacement.badges.some(({ rect }) => Math.max(0, Math.min(rect.right, oldPlacement.tip.right)
- Math.max(rect.left, oldPlacement.tip.left)) * Math.max(0, Math.min(rect.bottom, oldPlacement.tip.bottom)
- Math.max(rect.top, oldPlacement.tip.top)) > 10);
await page.screenshot({ path: fileURLToPath(new URL('negative-control-old-placement.png', artifacts)) });
await root.locator('[data-hp-live-tip]').evaluate((tip, style) => tip.setAttribute('style', style), negative);
}
}
}
}
// Live provider status arrives while the mouse remains stationary.
await deliver({ phase: 'error', stale: true });
await page.waitForFunction(() => /Stale data/i.test(window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay')
?.shadowRoot?.querySelector('.route-status')?.textContent || ''));
await settle();
const changed = await witness('runtimeErrorStaleNoPointerMove', 'd_kettle');
out.runtimeIncludesPartialStaleError = changed.badges.some((badge) => /Incomplete data/.test(badge.text)
&& /Stale data/i.test(badge.text) && /could not be loaded/i.test(badge.text));
await deliver();
await page.evaluate(() => window.__hpTest.setVolumetricView(false));
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
const longName = ('Long landing router <safe> & ' + 'north corridor diagnostic lighting '.repeat(5)).trim();
await deliver({ name: longName });
await page.waitForTimeout(300);
await hover('d_mower');
const long = await witness('longName', 'd_mower');
out.longNameWrapsInsideStage = long.badgeInside && long.safeText
&& long.badges.some((badge) => badge.text.includes(longName) && badge.rect.height > 25);
await page.screenshot({ path: fileURLToPath(new URL('long-remote-caption.png', artifacts)) });
// Freeze the fixture header/stage height so horizontal container resize
// cannot move the marker away and create an unrelated native pointerout.
await deliver({ name: remoteName });
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = '.head{height:100px!important;min-height:100px!important;max-height:100px!important;overflow:hidden}.stage{height:400px!important;min-height:400px!important;max-height:400px!important}';
window.__card.shadowRoot.append(style);
});
await page.waitForTimeout(150);
await hover('d_mower');
const beforeResize = await sample('d_mower');
await page.evaluate(() => { document.getElementById('host').style.width = '95px'; });
await page.waitForTimeout(150);
await witness('narrowHidesOnlyOrdinaryTip', 'd_mower', { hidden: true });
await page.screenshot({ path: fileURLToPath(new URL('narrow-priority.png', artifacts)) });
await deliver({ clear: true }); await settle();
const narrowWithoutBadges = await sample('d_mower');
out.removingBadgesRestoresHiddenTipWithoutNewHover = narrowWithoutBadges.tipVisible
&& narrowWithoutBadges.badges.length === 0;
await deliver({ restore: true }); await settle();
await witness('returningBadgesReappliesNarrowFallback', 'd_mower', { hidden: true });
await page.evaluate(() => { document.getElementById('host').style.width = '900px'; });
await page.waitForTimeout(150);
const restored = await witness('resizeRestoresWithoutNewHover', 'd_mower');
out.resizePreservesSourceAndRoute = JSON.stringify(restored.source) === JSON.stringify(beforeResize.source)
&& JSON.stringify(restored.lines) === JSON.stringify(beforeResize.lines);
await deliver({ clear: true });
await settle();
const removed = await sample('d_mower');
out.removingDiagnosticsRestoresOrdinaryTip = removed.tipVisible && removed.badges.length === 0;
await deliver({ restore: true, name: remoteName });
await hover('d_mower');
const networkBefore = JSON.stringify((await sample('d_mower')).calls);
for (let i = 0; i < 12; i++) await page.mouse.move(pointer.x + i % 3, pointer.y + i % 2);
out.pointerMotionNeverFetches = JSON.stringify((await sample('d_mower')).calls) === networkBefore;
await page.mouse.move(2, 2); await settle();
const left = await sample('d_mower');
out.pointerLeaveClearsBoth = !left.tipVisible && left.badges.length === 0;
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
await hover('d_tv');
const ordinary = await sample('d_tv');
out.nonZigbeeRetainsOrdinaryTooltip = ordinary.tipVisible && ordinary.badges.length === 0;
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
await page.evaluate(() => window.__hpTest.setLayout((layout) => ({ ...layout,
d_mower: { s: 'garden', x: 0.94, y: 0.12 },
})));
await hover('d_mower');
const edge = await witness('edgeCaption', 'd_mower');
out.edgeCaptionInsideWorkingArea = edge.badgeInside;
await page.mouse.wheel(0, -180);
await page.waitForTimeout(450);
const zoomed = await witness('cameraZoomWithoutNewHover', 'd_mower');
out.cameraActuallyChangesGeometry = Math.abs(edge.source.width - zoomed.source.width) > 0.1
|| Math.abs(edge.source.left - zoomed.source.left) > 0.1;
await page.screenshot({ path: fileURLToPath(new URL('edge-zoom.png', artifacts)) });
// A second production card shares the HA connection/runtime but owns its
// own pointer/focus tip and layout observers.
await page.evaluate(() => {
const second = document.createElement('houseplan-card');
second.id = 'second-tooltip-card';
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
second.hass = window.__card.hass;
second.style.cssText = 'position:fixed;left:5px;top:5px;width:350px;z-index:100';
document.body.append(second);
});
const second = page.locator('#second-tooltip-card');
await second.locator('[data-hp="device"]').first().waitFor({ state: 'visible' });
await second.locator('[data-hp="device"]').first().focus();
await page.keyboard.press('Tab');
await settle();
out.secondCardKeyboardTipDoesNotActivateTopology = await second.evaluate((card) => {
const root = card.shadowRoot;
return root.activeElement?.matches('[data-hp="device"]:focus-visible')
&& !root.querySelector('[data-hp-live-tip]').hidden
&& !root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.querySelector('.parent-bubble,.remote,.route-status');
});
await hover('d_mower');
const firstWithSecond = await sample('d_mower');
await second.evaluate((card) => card.remove());
await settle();
const afterSecondRemoved = await sample('d_mower');
out.secondCardUnmountDoesNotClearFirstHover = firstWithSecond.clean && afterSecondRemoved.clean
&& JSON.stringify(firstWithSecond.badges) === JSON.stringify(afterSecondRemoved.badges);
for (const pointerType of ['touch', 'pen']) {
await marker('d_mower').dispatchEvent('pointerdown', { pointerType, pointerId: 82,
bubbles: true, composed: true, clientX: pointer.x, clientY: pointer.y });
await settle();
const cleared = await sample('d_mower');
out[`${pointerType}ClearsTooltipAndTopology`] = !cleared.tipVisible && cleared.badges.length === 0;
await hover('d_mower');
await witness(`mouseRestoresAfter_${pointerType}`, 'd_mower');
}
await page.mouse.move(2, 2);
await page.keyboard.press('Tab');
await marker('d_mower').focus();
await settle();
const keyboardOnly = await sample('d_mower');
out.focusAloneShowsOrdinaryTipWithoutTopology = keyboardOnly.tipVisible && keyboardOnly.badges.length === 0
&& await marker('d_mower').evaluate((element) => element.matches(':focus-visible'));
await hover('d_mower');
await witness('focusedDeviceMouseDiagnostics', 'd_mower');
await page.mouse.move(2, 2); await settle();
const focusRestored = await sample('d_mower');
out.pointerLeaveRestoresKeyboardTipAfterDiagnostics = focusRestored.tipVisible
&& focusRestored.tipText === keyboardOnly.tipText && focusRestored.badges.length === 0;
await marker('d_mower').evaluate((element) => element.blur());
// A transformed HA/dashboard ancestor establishes the containing block
// for a position:fixed tooltip. Measure the actual screen result, not
// the CSS left/top values, before and after document scrolling.
await hover('d_mower');
const beforeAncestorTransform = await sample('d_mower');
await page.evaluate(() => {
document.body.style.minHeight = '1800px';
const host = document.getElementById('host');
host.style.transformOrigin = '0 0';
host.style.transform = 'translate(35px, 125px) scale(0.82)';
});
await hover('d_mower');
const transformed = await witness('transformedContainingAncestor', 'd_mower');
out.ancestorScaleReallyAppliesToFixedTooltip = Math.abs(
transformed.source.width / beforeAncestorTransform.source.width - 0.82,
) < 0.02 && await root.locator('[data-hp-live-tip]').evaluate((tip) =>
getComputedStyle(tip).position === 'fixed');
out.transformedBadgesInsideVisibleStage = transformed.badgeInside;
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform.png', artifacts)) });
// Six CSS pixels keep the pointer inside the marker, so this check also
// witnesses the scroll observer without a new pointer event/hover.
await page.evaluate(() => window.scrollTo(0, 6));
await settle();
const smallScroll = await witness('ancestorScrollWithoutNewHover', 'd_mower');
out.scrollActuallyMovesScreenGeometry = Math.abs(transformed.source.top - smallScroll.source.top - 6) < 1;
// A larger document scroll moves the source away from the old pointer;
// re-enter the still-visible marker with a real mouse.
await page.evaluate(() => window.scrollTo(0, 180));
await hover('d_mower');
const scrolled = await witness('transformedAncestorAfterPageScroll', 'd_mower');
out.largePageScrollKeepsBadgesInsideVisibleStage = scrolled.badgeInside
&& Math.abs(transformed.source.top - scrolled.source.top - 180) < 1
&& await page.evaluate(() => window.scrollY === 180);
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform-scrolled.png', artifacts)) });
}
} catch (error) {
out[`${provider}_completed`] = false;
console.error(error);
await page.screenshot({ path: fileURLToPath(new URL(`${provider}-failure.png`, artifacts)) });
} finally {
// Keep the shared smoke page-error guard authoritative for both browsers.
await finish(browser);
}
}
writeFileSync(new URL('layout-evidence.json', artifacts), JSON.stringify({ out, evidence }, null, 2));
checkAll(out);
await finish(null, out);
+7 -2
View File
@@ -343,8 +343,9 @@ const result = await page.evaluate(async () => {
?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'), 'remote parent bubble');
let activeOverlay = root().querySelector('hp-zigbee-topology-overlay');
const firstTitle = card._serverCfg.spaces.find((space) => space.id === 'f1')?.title;
const remoteParentName = card.hass.devices.d_light1.name_by_user || card.hass.devices.d_light1.name;
out.remoteParentBubble = activeOverlay.shadowRoot
.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === firstTitle
.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === `${firstTitle} (${remoteParentName})`
&& activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-bubble"]')
?.getAttribute('data-kind') === 'remote-space'
&& !!activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]')
@@ -961,7 +962,11 @@ for (const scale of [0.5, 1, 2]) {
const root = card.shadowRoot || card.renderRoot;
window.__zigbeeOutlineProbeStyle.textContent = `[data-hp-zigbee-outline-probe] {
transform: scale(${scale}) !important; transform-origin: 0 0 !important; background: #fff !important;
}`;
}
/* #802: isolate route ink. At scale=.5 the ordinary pointer tooltip can
cover the target arrow; its dark background is not outline coverage.
Real tooltip/badge coexistence is witnessed in smoke_zigbee_tooltip_layout. */
[data-hp-live-tip] { display: none !important; }`;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const overlay = root.querySelector('hp-zigbee-topology-overlay');
const svg = overlay.shadowRoot.querySelector('svg');
+10
View File
@@ -634,6 +634,16 @@ Unknown/partial/stale captions never trigger network work; one expiry timer
updates age without requiring another HA event. Persistence and privacy:
[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md).
Zigbee caption layout (#802) notifies the lazy live-hover runtime after the
overlay's DOM update and coalesced active-hover resize/scroll/camera changes.
Only the matching pointer device tooltip avoids actual text-caption rectangles
and its source marker, in screen coordinates inside stage∩viewport. A pure
candidate search finds the closest feasible full rectangle or hides the tooltip
until space returns. It does not move markers, start scans or render the card.
Keyboard tooltips retain their own path. Pointer departure/disconnect disposes
the active layout observer/frame; remote caption wrapping updates its short
arrow endpoint in the same post-render pass.
## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579)
Rewriting the SVG `viewBox` re-rasterises the whole scene, so per-frame writes
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Zigbee links to another space now name the destination device too. Device
tooltips move around network badges and give them priority only when the card
is too small to show both ([#802](https://github.com/Matysh/houseplan-card/issues/802)).
## v1.80.0-beta.3 — 2026-10-05
- Zigbee2MQTT map scans now continue in Home Assistant when settings or the
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Подписи Zigbee-связей в другое пространство теперь содержат имя устройства.
Обычная подсказка обходит сетевые бейджи и уступает им место только тогда,
когда карточка слишком мала для обеих подсказок
([#802](https://github.com/Matysh/houseplan-card/issues/802)).
## v1.80.0-beta.3 — 2026-10-05
- Сканирование карты Zigbee2MQTT теперь продолжается в Home Assistant после
+6
View File
@@ -30,6 +30,12 @@ topology benchmark must use confirmed route evidence, not an empty graph.
Neighbour-only raw fixtures remain negative controls; do not add guessed
routes to make them draw. Ordinary LQI badges keep their separate palette.
For tooltip layout (#802), also run `node demo/smoke_zigbee_tooltip_layout.mjs`:
it uses real mouse movement and screen-space overlap oracles, including a
negative control at the previous cursor-relative position. DOM presence alone
does not prove either caption is readable. A small-card fallback must restore
without requiring a new pointerover; use the actual lazy-overlay update path.
For warm-remount changes, `node demo/smoke_warm_mode_adoption.mjs` records
intermediate camera frames and screen-space points, observes every memo size
publication, and exercises delayed permission/runtime races and a touch floor
+1
View File
@@ -30,6 +30,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
| Current local cycle | **v1.80.0-beta.3 candidate** — #800 background Zigbee2MQTT scans passed owner-authorized independent local review and are merged into `dev`. Full exact-SHA Validate and artifact verification precede publication. `main` remains on stable v1.79.0. |
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
| Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. |
| Zigbee caption layout | #802 adds remote destination names and keeps the matching pointer tooltip clear of diagnostic text; only an unplaceable tooltip yields in a small card. Focus/touch/actions and provider transport remain unchanged. |
| Zigbee scan lifecycle | #800 replaces #799's 10-minute total budget with integration-owned background jobs: closing the browser does not stop waiting; after 10 minutes an exact-job cancel becomes available. MQTT setup/publish caps and the retained-info check remain bounded. Tests cover 15-minute success, cross-client restore, cancel races, stale-cache retention and reload on a live HA connection. |
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
+5 -1
View File
@@ -371,7 +371,11 @@ that scales with the plan. Device LQI badges and room colours are unchanged.
The whole layer is pointer-transparent, so device
and room actions continue to work normally.
If the next step is in another space, a short bubble names that space. If the
If the next step is in another space, its bubble names the space and destination
device, for example **Upstairs (Hallway socket)**; an unknown device name is
omitted. The ordinary device tooltip moves into free space around network
badges. Only when the card cannot fit both is it temporarily hidden, returning
when space becomes available. No extra setting is needed. If the
needed router or coordinator is not placed on the plan, the bubble says so;
an unplaced device's available name is included in parentheses. “No route data”
means the outgoing route is unknown or conflicting, not that the device is
+7 -2
View File
@@ -398,8 +398,13 @@ LQI: 0 — красный, 128 — жёлтый, 255 — зелёный. Без
прозрачен для указателя, поэтому действия комнат и устройств работают как
раньше.
Если следующий шаг находится в другом пространстве, рядом с устройством
появляется его название. Если нужный роутер или координатор не размещён на
Если следующий шаг находится в другом пространстве, подпись содержит его
название и имя устройства в скобках: **Второй этаж (Розетка коридора)**.
Если имя неизвестно, показывается только пространство. Обычная подсказка
устройства перемещается в свободное место, не закрывая сетевые бейджи.
Только когда места для обеих подсказок недостаточно, она временно скрывается;
при освобождении места возвращается. Новая настройка для этого не нужна.
Если нужный роутер или координатор не размещён на
плане, короткая подпись сообщает об этом; доступное имя отсутствующего
устройства добавляется в скобках. «Нет данных о маршруте» означает отсутствие
однозначных сведений об исходящей связи, а не недоступность устройства.
+7
View File
@@ -147,6 +147,13 @@ and bubbles are above room names and unrelated device markers; the complete
source and locally connected marker shells stay above the topology. The layer
remains pointer-transparent and therefore does not change any existing action.
The outgoing remote-space caption includes the destination device name in
parentheses when known (#802). While actual diagnostic text badges are visible,
the matching device's pointer tooltip moves to a free part of the visible stage;
it hides only if no full non-overlapping placement fits and returns when space
becomes available. Marker positions and local routes do not move. Focus-only
tooltips, rooms and devices without diagnostic text retain their usual behaviour.
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
+74
View File
@@ -0,0 +1,74 @@
# Локальное код-ревью #802 — r1
Вердикт: зелёный · High: 0 · Medium: 0 · маршрут: merge.
## Материал и полномочия
- Дата: 2026-10-05; ветка `issue/802-zigbee-hover-labels`.
- База: `7feda5b6c82b34f3b42f87a1f0c1fac5bf590265`.
- Продукт: `db887cd7f0612267e74d06700ae5fe8deb14a561`.
- Итоговый материал тестов: `077e9c1b1be693757009f748bfccb1c77bb39aee`.
- ТЗ: тело [#802](https://github.com/Matysh/houseplan-card/issues/802), раздел
`## ТЗ`, default Q1 принят. [Исключение владельца](https://github.com/Matysh/houseplan-card/issues/802#issuecomment-6000425421)
разрешает саморевью, ручное слияние и следующую бету вместо недоступной
ревью-модели. Это не вердикт штатного автоматического ревьюера.
Автор прочитал весь diff; дополнительно отдельный локальный агент проверил
продуктовый материал и повторно проверил исправление своей находки. Итог
обоих прочтений — без High/Medium. Изначально длинная подпись адресата могла
перекрыть сетевой status-бейдж; это исправлено тем же измеряемым размещением,
с переносом текста и присоединением стрелки к фактическому краю подписи.
## Приёмка
| Контракт | Проверка |
| --- | --- |
| AC1: адресат | ZHA/Z2M, имя из текущего маппинга, fallback, длинные строки и специальные символы; Lit выводит текст, входящий счётчик не изменён. |
| AC2: читаемость | Реальный pointer, remote/unplaced/status-only, partial/stale/error, края, Flat и настоящий 2.5D, обе темы; измеряются пересечения DOM-прямоугольников. |
| AC3: тесная карточка | 13 unit-тестов чистого расчёта, включая независимый перебор 200 малых раскладок; browser hide/restore без нового hover. |
| AC4: обновления | Lazy adoption уже наведённого устройства, runtime, resize и camera, отсутствие новых запросов от pointer; измерения после render объединены через rAF. |
| AC5: input/lifecycle | Keyboard restore, mouse/touch/pen, несколько карточек и unmount; соседние input/render smokes. Наблюдатели отключаются при clear/disconnect. |
| AC6: визуал | Просмотрены кадры длинной подписи, status-only 2.5D, узкой карточки и transformed/scrolled ancestor. Эталоны не переписываются вслепую. |
## Исполненные проверки
- WSL Ubuntu, Node 22.23.2: `npm run gate:small` — PASS, 0 упавших гейтов.
Нативный Windows не использован как доказательство полного unit-набора:
платформенные shell-тесты проверены в WSL.
- `node demo/smoke_zigbee_tooltip_layout.mjs` — 77/77 PASS на Windows с pinned
Node и повторно 77/77 PASS в WSL на итоговом материале. Дополнительные семь проверяют
реальный `position:fixed` при `translate + scale` предка, scroll без нового
hover и повторное наведение после большой прокрутки.
- WSL: `smoke_zigbee_topology_hover`, `smoke_household_journeys`,
`smoke_render_invalidation`, `smoke_touch_tips` — PASS.
- WSL: полный `npm run golden:verify` — PASS; ни один эталон не изменён.
- [Validate 37357451243](https://github.com/Matysh/houseplan-card/actions/runs/37357451243)
на `077e9c1b1be693757009f748bfccb1c77bb39aee` — success. После него
добавлен только этот отчёт/индекс; повторный WSL `gate:small` также PASS.
- Отрицательный контроль старого cursor-position даёт 1596 CSS px² перекрытия;
новый layout устраняет его. Это не DOM-count и не поиск строки в исходнике.
- Изолированный raster-oracle обводки временно скрывает обычную подсказку
только внутри тестовой сцены, иначе её тёмный фон закрывает измеряемую стрелку
при scale 0.5. Все пороги и проверки масштабирования сохранены; совместное
отображение подсказок защищено отдельным новым smoke.
- `git diff --check`, process gate с живыми issue-метками,
`node scripts/mutation-gate.mjs --check` — PASS. Два мутанта размещения
зарегистрированы; их исполнение оставлено ночному гейту по процессу.
Smoke-select перечисляет много потребителей общего live-hover; локально выбран
Zigbee, household actions, invalidation и touch, поскольку новая ветка активна
только для pointer-tooltip с совпадающим владельцем реально показанных
Zigbee-бейджей. Остальные режимы ранним return сохраняют старое поведение.
Полная CI-матрица остаётся обязательной на точном SHA беты.
## Ограничения и заключение
В физически слишком маленькой области невозможно гарантировать размещение
сколь угодно длинного диагностического текста. Текст не обрезается до потери
имени, обычный tooltip уступает место; новая настройка не добавлена.
Изменения не затрагивают маршруты, LQI, transport, сохранённые данные или
действия устройств. Рост bundleBytes на 8229 байт обоснован в issue; прочие
монолитные метрики и initial-load budget не расширены.
Разрешено ручное слияние после зелёных проверок ветки. Этот документ сам по
себе не подтверждает готовность точного release-кандидата или публикацию.
+3 -1
View File
@@ -1,12 +1,14 @@
# Индекс ревью
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 305, issue: 152. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 307, issue: 153. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|---|---|---|---|---:|---:|---|---|
| бета v1.80.0-beta.1 | [SHIP-REVIEW-v1.80.0-beta.1.md](SHIP-REVIEW-v1.80.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| бета v1.79.0-beta.2 | [SHIP-REVIEW-v1.79.0-beta.2.md](SHIP-REVIEW-v1.79.0-beta.2.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| бета v1.79.0-beta.1 | [SHIP-REVIEW-v1.79.0-beta.1.md](SHIP-REVIEW-v1.79.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| #802 | [SPEC-REVIEW-802-r1.md](SPEC-REVIEW-802-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #802 | [CODE-REVIEW-802-r1.md](CODE-REVIEW-802-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #800 | [SPEC-REVIEW-800-r1.md](SPEC-REVIEW-800-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #800 | [CODE-REVIEW-800-r1.md](CODE-REVIEW-800-r1.md) | code · r1 | 🟡 жёлтый | 0 | 3 | живой браузер остаётся на умершем job после reload интеграции; AC1 не имеет свидетеля, чувствительного к прежнему 600-секундному deadline; материал не проходит обязательный gate:small; English guide всё ещё обещает прекращение ожидания через 10 минут | `custom_components/houseplan/zigbee_topology.py` `scripts/mutation-registry.mjs` `test/data-hp-contract.test.mjs` `test/reviews-index.test.mjs` `PREFLIGHT-SPEC-800.md` `test/smoke-select.test.mjs` `docs/USER-GUIDE.md` |
| #800 | [CODE-REVIEW-800-r2.md](CODE-REVIEW-800-r2.md) | code · r2 | 🟡 жёлтый | 0 | 1 | временный not_ready во время reload навсегда блокирует явный retry | `src/zigbee-topology-runtime.ts` |
+23
View File
@@ -0,0 +1,23 @@
# Локальное ревью ТЗ #802 — r1
Вердикт: зелёный · High: 0 · Medium: 0.
Дата: 2026-10-05. Материал: тело issue #802, раздел `## ТЗ`, редакция с
подтверждённым default Q1; база `7feda5b6c82b34f3b42f87a1f0c1fac5bf590265`.
По прямому исключению владельца ревью выполняется локально, без недоступной
ревью-модели. Дополнительно независимый локальный агент прочёл ТЗ и не нашёл
High/Medium. Это не отчёт CI и не подтверждение готовности к выпуску.
## Проверка
- Скоуп J7; одна задача объединяет подпись удалённого адресата и дефект перекрытия.
- Q1 закрыт владельцем. DoR, AC1–AC6, тесты, i18n, отсутствие миграции,
performance/input-риски и откат зафиксированы.
- Сохраняется pointer-only диагностика; обычный focus-tooltip не меняется.
- Скрытие — только при невозможности разместить tooltip, не по одному факту
включённой настройки или наличию линии. Восстановление без нового hover.
- Пересечение проверяется реальной мышью и прямоугольниками, не DOM-count.
- Отдельные риски реализации: lazy upgrade без движения мыши; screen-space
измерения при transformed ancestors; resize/runtime/lifecycle cleanup.
Проверено чтением, не исполнением: продуктовая реализация ещё не написана.
@@ -35,9 +35,9 @@ that transition events alone prove disposal.
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
| Paint, cascade and layer composition | 40 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Responsive DOM layout | 39 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **238 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
| **Total** | **239 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -195,6 +195,8 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o
### Responsive DOM layout
- `zigbee-tooltip-collision-bypass`
The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size.
- `dialog-ha-disconnected-reject-reopen-enabled`
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 5067,
"portPrivates": 103,
"harnessPrivates": 100,
"bundleBytes": 2697591
"bundleBytes": 2705820
}
+24 -2
View File
@@ -9657,8 +9657,8 @@ const MUTANT_DEFINITIONS = [
+ 'as local routes instead of becoming permanently grey (#798)',
patches: [{
file: 'src/hp-zigbee-topology-overlay.ts',
find: " ${bubbles.map((bubble) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true))}",
replace: " ${bubbles.map((bubble) => this._route(origin, bubble.point, undefined, 'toward-neighbor', bubble.arrow, true))}",
find: " ${bubbles.map((bubble, index) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true, index))}",
replace: " ${bubbles.map((bubble, index) => this._route(origin, bubble.point, undefined, 'toward-neighbor', bubble.arrow, true, index))}",
}],
},
{
@@ -9799,6 +9799,28 @@ const MUTANT_DEFINITIONS = [
replace: ' } else if (isOutgoing) {\n if (other) remote.add(identity);',
}],
},
{
id: 'zigbee-tooltip-collision-bypass',
guard: 'node demo/smoke_zigbee_tooltip_layout.mjs',
because: 'screen-space overlap of independently rendered shadow-DOM surfaces and '
+ 'late lazy paint needs a real mouse/browser witness; DOM presence or pure layout '
+ 'cannot prove runtime avoidance (#802 AC2/AC4)',
patches: [{
file: 'src/live-hover.ts',
find: ' avoidTopologyCaptions(root, element, tip);',
replace: ' void tip; // mutant: keep the old overlapping cursor-relative tooltip',
}],
},
{
id: 'zigbee-tooltip-forgets-obstacles',
guard: 'node --test test/live-tip-placement.test.mjs',
because: 'free placement must avoid diagnostic captions and the source marker (#802 AC3)',
patches: [{
file: 'src/live-tip-placement.ts',
find: ' const forbidden = blockers',
replace: ' const forbidden = blockers.slice(0, 0)',
}],
},
{
id: 'zigbee-provider-inactive-route-accepted',
guard: 'node --test --test-name-pattern="only ACTIVE status" test/zigbee-provider-routes.test.mjs',
+1 -1
View File
@@ -11249,7 +11249,7 @@ export class HouseplanCard extends LitElement {
: nothing}
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
${''/* #649: one floor-shadow layer (z below every tile), after the markers in DOM order */}${iso ? html`<div class="iso-tile-shadows" aria-hidden="true">${repeat(devs, (d) => d.id, (d) => this._renderDevice(d, view, showLqi, isoOverlays?.devices.get(d.id), true))}${this._renderOpeningLocks(view, isoOverlays?.locks, true)}</div>` : nothing}
${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, zoom: this._zoom, view: this._mode === 'view', kiosk: this._kiosk })}
${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, zoom: this._zoom, view: this._mode === 'view', kiosk: this._kiosk, onLayout: () => this._liveRt?.hover() })}
</div>
<div data-hp-live-editor-html></div>
${this._wallDialog
+134 -10
View File
@@ -14,6 +14,7 @@ import {
} from './zigbee-topology-runtime';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { DevItem } from './types';
import { placeDeviceTooltip, type TipRect } from './live-tip-placement';
const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} };
const ENDPOINT_ATTRIBUTE = 'data-hp-zigbee-topology-endpoint';
@@ -28,6 +29,7 @@ export class HpZigbeeTopologyOverlay extends LitElement {
spaces: { attribute: false },
viewKey: { attribute: false },
zoom: { type: Number },
onLayout: { attribute: false },
};
hass!: ZigbeeTopologyHass;
@@ -37,6 +39,7 @@ export class HpZigbeeTopologyOverlay extends LitElement {
spaces?: readonly { id?: unknown; title?: unknown }[];
viewKey: unknown;
zoom = 1;
onLayout?: () => void;
private _runtime = EMPTY_RUNTIME;
private _hovered = '';
private _staleTimer?: ReturnType<typeof setTimeout>;
@@ -46,6 +49,8 @@ export class HpZigbeeTopologyOverlay extends LitElement {
private _parent?: HTMLElement;
private _hoverGateObserver?: MutationObserver;
private _markerObserver?: MutationObserver;
private _layoutFrame = 0;
private _layoutResize?: ResizeObserver;
private _desiredEndpointIds = new Set<string>();
private _endpointSetDirty = false;
private _endpointElements = new Set<HTMLElement>();
@@ -71,6 +76,7 @@ export class HpZigbeeTopologyOverlay extends LitElement {
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
.parent-bubble { width: max-content; white-space: normal; overflow-wrap: anywhere; box-sizing: border-box; border-radius: 12px; }
.parent-bubble.right { transform: translate(0, -50%); }
.parent-bubble.left { transform: translate(-100%, -50%); }
.route-status { transform: translate(-50%, 12px); max-width: 240px; white-space: normal; text-align: center; }
@@ -106,6 +112,9 @@ export class HpZigbeeTopologyOverlay extends LitElement {
this._hoverGateObserver = undefined;
this._hovered = '';
this._desiredEndpointIds.clear();
this.removeAttribute('data-tooltip-owner');
this._stopLayout();
this.onLayout?.();
super.disconnectedCallback();
}
@@ -137,6 +146,100 @@ export class HpZigbeeTopologyOverlay extends LitElement {
}
this._connectParent();
if (this._endpointSetDirty || markerInputsChanged) this._syncEndpointOwnership();
this.toggleAttribute('data-tooltip-owner', !!this._hovered);
if (this._hovered) this.setAttribute('data-tooltip-owner', this._hovered);
this._scheduleLayout();
if (!this._hovered) this.onLayout?.();
}
private _stopLayout(): void {
cancelAnimationFrame(this._layoutFrame);
this._layoutFrame = 0;
this._layoutResize?.disconnect();
this._layoutResize = undefined;
window.removeEventListener('resize', this._scheduleLayout);
window.removeEventListener('scroll', this._scheduleLayout, true);
}
private _scheduleLayout = (): void => {
if (!this.isConnected || !this._hovered) return;
if (!this._layoutResize && typeof ResizeObserver !== 'undefined') {
this._layoutResize = new ResizeObserver(this._scheduleLayout);
this._layoutResize.observe(this);
const stage = (this.getRootNode() as ShadowRoot).querySelector('.stage');
if (stage) this._layoutResize.observe(stage);
window.addEventListener('resize', this._scheduleLayout);
window.addEventListener('scroll', this._scheduleLayout, true);
}
if (this._layoutFrame) return;
this._layoutFrame = requestAnimationFrame(() => {
this._layoutFrame = 0;
if (!this.isConnected || !this._hovered) return;
this._fitParentCaptions();
this.onLayout?.();
});
};
/** DOM-only layout, not a card render or network refresh. */
private _fitParentCaptions(): void {
const stage = (this.getRootNode() as ShadowRoot).querySelector('.stage')?.getBoundingClientRect();
if (!stage) return;
const layer = this.getBoundingClientRect();
const scaleX = this.clientWidth ? layer.width / this.clientWidth : 1;
const scaleY = this.clientHeight ? layer.height / this.clientHeight : 1;
if (scaleX <= 0 || scaleY <= 0) return;
const left = Math.max(0, stage.left) + 8;
const right = Math.min(window.innerWidth, stage.right) - 8;
const top = Math.max(0, stage.top) + 8;
const bottom = Math.min(window.innerHeight, stage.bottom) - 8;
const marker = this._marker(this._hovered)?.getBoundingClientRect();
const blockers: TipRect[] = [...this.renderRoot.querySelectorAll<HTMLElement>('.remote,.route-status')]
.map((node) => node.getBoundingClientRect());
if (marker) blockers.push(marker);
for (const caption of this.renderRoot.querySelectorAll<HTMLElement>('.parent-bubble')) {
caption.style.marginLeft = '0px';
caption.style.marginTop = '0px';
caption.style.maxWidth = `${Math.max(1, Math.min(300, (right - left) / scaleX))}px`;
let rect = caption.getBoundingClientRect();
const placement = () => placeDeviceTooltip({
preferred: { x: rect.left, y: rect.top },
size: rect, bounds: { left, top, right, bottom, width: right - left, height: bottom - top },
blockers, edge: 0, gap: 8,
});
let position = placement();
if (!position && right > left) {
caption.style.maxWidth = `${(right - left) / scaleX}px`;
rect = caption.getBoundingClientRect();
position = placement();
}
const dx = (position?.left ?? Math.max(left, Math.min(rect.left, right - rect.width))) - rect.left;
const dy = (position?.top ?? Math.max(top, Math.min(rect.top, bottom - rect.height))) - rect.top;
caption.style.marginLeft = `${dx / scaleX}px`;
caption.style.marginTop = `${dy / scaleY}px`;
blockers.push({ left: rect.left + dx, right: rect.right + dx,
top: rect.top + dy, bottom: rect.bottom + dy, width: rect.width, height: rect.height });
// Keep the short outgoing arrow attached to the nearest label edge after
// a long caption wraps/shifts at the stage boundary.
const line = this.renderRoot.querySelector<SVGLineElement>(
`[data-parent-index="${caption.dataset.parentIndex}"][data-hp="zigbee-topology-parent-line"]`,
);
if (!line) continue;
const origin = { x: Number(line.getAttribute('x1')), y: Number(line.getAttribute('y1')) };
const screenOrigin = { x: layer.left + origin.x * scaleX, y: layer.top + origin.y * scaleY };
const point = {
x: (Math.max(rect.left + dx, Math.min(screenOrigin.x, rect.right + dx)) - layer.left) / scaleX,
y: (Math.max(rect.top + dy, Math.min(screenOrigin.y, rect.bottom + dy)) - layer.top) / scaleY,
};
for (const part of this.renderRoot.querySelectorAll<SVGLineElement>(
`line[data-parent-index="${caption.dataset.parentIndex}"]`,
)) { part.setAttribute('x2', String(point.x)); part.setAttribute('y2', String(point.y)); }
const arrow = zigbeeArrowGeometry(origin, point,
marker ? Math.max(marker.width, marker.height) * .61 + 3 : 0, 0, 'toward-neighbor');
const polygon = this.renderRoot.querySelector<SVGPolygonElement>(
`polygon[data-parent-index="${caption.dataset.parentIndex}"]`,
);
if (polygon) polygon.setAttribute('points', arrow ? this._points(arrow.points) : '');
}
}
private _acceptRuntime(next: ZigbeeTopologyRuntimeSnapshot): void {
@@ -174,10 +277,26 @@ export class HpZigbeeTopologyOverlay extends LitElement {
parent.addEventListener('pointerout', this._pointerOut);
parent.addEventListener('pointerdown', this._pointerDown, true);
if (typeof MutationObserver !== 'undefined') {
this._markerObserver = new MutationObserver(() => this._syncEndpointOwnership());
this._markerObserver.observe(parent, { childList: true });
this._markerObserver = new MutationObserver((records) => {
if (records.some((record) => record.type === 'childList')) this._syncEndpointOwnership();
if (records.some((record) => record.attributeName === 'data-hp-device-hover')) this._adoptPointerHover();
this._scheduleLayout();
});
this._markerObserver.observe(parent, { childList: true, attributes: true, subtree: true,
attributeFilter: ['style', 'data-hp-device-hover'] });
}
this._syncEndpointOwnership();
this._adoptPointerHover();
}
private _adoptPointerHover(): void {
const host = (this.getRootNode() as ShadowRoot).host;
const id = host?.hasAttribute('data-pointer-hover')
? this._parent?.querySelector<HTMLElement>('[data-hp-device-hover]')?.dataset.id || '' : '';
if (id === this._hovered) return;
if (!id) { this._clear(); return; }
this._hovered = id;
this.requestUpdate();
}
private _disconnectParent(): void {
@@ -223,6 +342,8 @@ export class HpZigbeeTopologyOverlay extends LitElement {
};
private _clear(): void {
this.removeAttribute('data-tooltip-owner');
this._stopLayout();
this._setDesiredEndpointIds([]);
this._clearEndpointOwnership();
this._endpointSetDirty = false;
@@ -286,8 +407,11 @@ export class HpZigbeeTopologyOverlay extends LitElement {
private _targetText(target: ZigbeeParentTarget): string {
if (target.kind === 'remote-space') {
const title = this.spaces?.find((space) => space.id === target.spaceId)?.title;
return (typeof title === 'string' && title.trim())
const space = (typeof title === 'string' && title.trim())
|| topologyT(langOf(this.hass), 'route_other_space');
return target.deviceName?.trim()
? topologyT(langOf(this.hass), 'route_other_space_named', { space, name: target.deviceName.trim() })
: space;
}
if (target.kind === 'unplaced-coordinator') return topologyT(langOf(this.hass), 'route_coordinator_not_on_plan');
return target.deviceName
@@ -305,19 +429,19 @@ export class HpZigbeeTopologyOverlay extends LitElement {
private _route(origin: ZigbeePixelPoint, point: ZigbeePixelPoint, lqi: number | undefined,
direction: 'toward-neighbor' | 'toward-origin',
arrow: ReturnType<typeof zigbeeArrowGeometry>, parent = false) {
arrow: ReturnType<typeof zigbeeArrowGeometry>, parent = false, parentIndex?: number) {
const color = zigbeeLinkColor(lqi);
const outline = Number.isFinite(this.zoom) && this.zoom > 0 ? this.zoom : 1;
return svg`${lqi === undefined ? svg`<line class="link-casing" data-hp="zigbee-topology-line-casing"
x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
data-parent-index=${parentIndex ?? nothing} x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
stroke="#000000" stroke-width=${2 + 2 * outline} data-direction=${direction}></line>` : nothing}
<line class=${parent ? 'parent-route' : 'link-core'}
data-hp=${parent ? 'zigbee-topology-parent-line' : 'zigbee-topology-line'}
x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
data-parent-index=${parentIndex ?? nothing} x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
stroke=${color} stroke-width=${lqi === undefined ? 2 : 2.2} data-direction=${direction}></line>
${arrow ? svg`<polygon class="route-arrow"
data-hp=${parent ? 'zigbee-topology-parent-arrow' : 'zigbee-topology-arrow'}
data-direction=${direction} points=${this._points(arrow.points)} fill=${color}
data-parent-index=${parentIndex ?? nothing} data-direction=${direction} points=${this._points(arrow.points)} fill=${color}
stroke=${lqi === undefined ? '#000000' : nothing} stroke-width=${lqi === undefined ? 2 * outline : nothing}
stroke-linejoin="round" paint-order="stroke fill"></polygon>` : nothing}`;
}
@@ -396,12 +520,12 @@ export class HpZigbeeTopologyOverlay extends LitElement {
${lines.length || bubbles.length ? svg`<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${lines.map((line) => this._route(origin, line.point!, line.lqi, line.routeDirection, line.arrow))}
${bubbles.map((bubble) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true))}
${bubbles.map((bubble, index) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true, index))}
</svg>` : nothing}
${lines.map((line) => html`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${line.neighborMarkerId} style="left:${line.point!.x}px;top:${line.point!.y}px;width:${line.point!.width * 1.22}px;height:${line.point!.height * 1.22}px"></div>`)}
${bubbles.map((bubble) => html`<div class="parent-bubble ${placeLeft ? 'left' : 'right'}"
data-hp="zigbee-topology-parent-bubble" data-kind=${bubble.target.kind}
${bubbles.map((bubble, index) => html`<div class="parent-bubble ${placeLeft ? 'left' : 'right'}"
data-parent-index=${index} data-hp="zigbee-topology-parent-bubble" data-kind=${bubble.target.kind}
style="left:${bubble.point.x}px;top:${bubble.point.y}px">${this._targetText(bubble.target)}</div>`)}
${hover.remoteCount ? html`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${origin.x}px;top:${origin.y}px">${topologyT(
+1
View File
@@ -41,6 +41,7 @@
"route_device_not_on_plan": "Gerät ist nicht im Plan",
"route_coordinator_not_on_plan": "Koordinator ist nicht im Plan",
"route_other_space": "anderer Bereich",
"route_other_space_named": "{space} ({name})",
"route_device_not_on_plan_named": "Gerät ist nicht im Plan ({name})",
"route_unknown": "Keine Routendaten",
"route_stale": "Veraltete Daten",
+1
View File
@@ -41,6 +41,7 @@
"route_device_not_on_plan": "Device is not on the plan",
"route_coordinator_not_on_plan": "coordinator is not on the plan",
"route_other_space": "another space",
"route_other_space_named": "{space} ({name})",
"route_device_not_on_plan_named": "Device is not on the plan ({name})",
"route_unknown": "No route data",
"route_stale": "Stale data",
+1
View File
@@ -41,6 +41,7 @@
"route_device_not_on_plan": "appareil absent du plan",
"route_coordinator_not_on_plan": "coordinateur absent du plan",
"route_other_space": "autre espace",
"route_other_space_named": "{space} ({name})",
"route_device_not_on_plan_named": "Appareil absent du plan ({name})",
"route_unknown": "Aucune donnée de route",
"route_stale": "Données anciennes",
+1
View File
@@ -41,6 +41,7 @@
"route_device_not_on_plan": "Устройства нет на плане",
"route_coordinator_not_on_plan": "координатора нет на плане",
"route_other_space": "другое пространство",
"route_other_space_named": "{space} ({name})",
"route_device_not_on_plan_named": "Устройства нет на плане ({name})",
"route_unknown": "Нет данных о маршруте",
"route_stale": "Данные устарели",
+42 -2
View File
@@ -2,6 +2,8 @@ import { lqiColor } from './logic';
import type { I18nKey } from './i18n';
import type { ResolvedDevicePresentation } from './device-presentation';
import type { DevItem } from './types';
import { placeDeviceTooltip } from './live-tip-placement';
import { floatingViewport } from './floating-surface';
export interface LiveTip {
x: number;
@@ -40,7 +42,6 @@ interface DeviceTipHost extends LiveHoverHost {
_spaceDisplayForRender: () => { showLqi?: boolean };
_devicePresentation: (device: DevItem, showLqi: boolean) => ResolvedDevicePresentation;
_notePointer: (event: PointerEvent) => void;
_showTip: (event: PointerEvent, title: string, meta: string) => void;
_t: LiveHoverHost['_t'] & ((key: I18nKey) => string);
}
@@ -70,7 +71,8 @@ export function showDevicePointerTip(value: object, event: PointerEvent, device:
}
const tip = deviceTipContent(host, device);
host._deviceHits.hover(host.renderRoot, device.id);
host._showTip(event, tip.title, tip.meta);
host._tip = { x: event.clientX, y: event.clientY, ...tip, source: 'pointer', deviceId: device.id };
syncHouseplanHover(host);
}
export function showDeviceFocusTip(value: object, target: HTMLElement | null, device: DevItem): void {
@@ -160,6 +162,44 @@ const syncTip = (host: LiveHoverHost, root: ParentNode): void => {
const maxTop = Math.max(margin, window.innerHeight - box.height - margin);
element.style.left = `${Math.min(maxLeft, Math.max(margin, tip.x + gap))}px`;
element.style.top = `${Math.min(maxTop, Math.max(margin, tip.y + gap))}px`;
avoidTopologyCaptions(root, element, tip);
};
/** Both layers are measured after paint in screen coordinates, including 2.5D. */
const avoidTopologyCaptions = (root: ParentNode, element: HTMLElement, tip: LiveTip): void => {
if (tip.source !== 'pointer' || tip.room || !tip.deviceId) return;
const overlay = root.querySelector<HTMLElement>('hp-zigbee-topology-overlay');
if (overlay?.getAttribute('data-tooltip-owner') !== tip.deviceId) return;
const stage = root.querySelector<HTMLElement>('.stage');
if (!stage || !overlay.shadowRoot) return;
const viewport = floatingViewport(window);
const area = stage.getBoundingClientRect();
const left = Math.max(viewport.left, area.left);
const top = Math.max(viewport.top, area.top);
const right = Math.min(viewport.left + viewport.width, area.right);
const bottom = Math.min(viewport.top + viewport.height, area.bottom);
const captions = [...overlay.shadowRoot.querySelectorAll<HTMLElement>(
'.parent-bubble,.remote,.route-status',
)].map((node) => node.getBoundingClientRect()).filter((rect) => rect.width > 0 && rect.height > 0
&& rect.right > left && rect.left < right && rect.bottom > top && rect.top < bottom);
if (!captions.length) return;
const marker = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((node) => node.dataset.id === tip.deviceId);
const blockers = marker ? [...captions, marker.getBoundingClientRect()] : captions;
const box = element.getBoundingClientRect();
const position = placeDeviceTooltip({
preferred: { x: tip.x + 12, y: tip.y + 12 },
size: { width: box.width, height: box.height },
bounds: { left, top, right, bottom, width: right - left, height: bottom - top },
blockers,
});
if (!position) { element.hidden = true; return; }
// A fixed tooltip may still have a transformed containing block in a HA card.
// Correct from its measured position rather than treating CSS left as clientX.
const scaleX = element.offsetWidth ? box.width / element.offsetWidth : 1;
const scaleY = element.offsetHeight ? box.height / element.offsetHeight : 1;
element.style.left = `${parseFloat(element.style.left) + (position.left - box.left) / scaleX}px`;
element.style.top = `${parseFloat(element.style.top) + (position.top - box.top) / scaleY}px`;
};
const setRoomPath = (root: ParentNode, selector: string, d: string): void => {
+85
View File
@@ -0,0 +1,85 @@
/** Screen-space rectangles shared by the tooltip and its measured obstacles. */
export type TipRect = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
};
export type DeviceTooltipPlacementInput = {
preferred: { x: number; y: number };
size: { width: number; height: number };
/** Intersection of the visible stage and viewport, in the same coordinates. */
bounds: TipRect;
/** Visible diagnostic badges and the source marker; never route lines. */
blockers: readonly TipRect[];
gap?: number;
edge?: number;
};
const validRect = (rect: TipRect): boolean =>
[rect.left, rect.top, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)
&& rect.right >= rect.left && rect.bottom >= rect.top && rect.width >= 0 && rect.height >= 0;
/**
* Find the closest complete, unobscured tooltip position without changing its size.
* Null means that no placement satisfies the bounds and clearance, or measurements
* are not ready. Nothing is retained: a later resize/badge update can restore it.
*/
export function placeDeviceTooltip(input: DeviceTooltipPlacementInput): { left: number; top: number } | null {
const { preferred, size, bounds, blockers, gap = 8, edge = 8 } = input;
if (![preferred.x, preferred.y, size.width, size.height, gap, edge].every(Number.isFinite)
|| size.width <= 0 || size.height <= 0 || gap < 0 || edge < 0
|| !validRect(bounds) || blockers.some((rect) => !validRect(rect))) return null;
const minLeft = bounds.left + edge;
const maxLeft = bounds.right - edge - size.width;
const minTop = bounds.top + edge;
const maxTop = bounds.bottom - edge - size.height;
if (![minLeft, maxLeft, minTop, maxTop].every(Number.isFinite)
|| maxLeft < minLeft || maxTop < minTop) return null;
// Expand each obstacle into the open rectangle forbidden to the tooltip's
// top-left corner. Touching its boundary leaves exactly the requested gap.
const forbidden = blockers
.filter((rect) => rect.right > rect.left && rect.bottom > rect.top)
.map((rect) => ({
left: rect.left - gap - size.width,
right: rect.right + gap,
top: rect.top - gap - size.height,
bottom: rect.bottom + gap,
}));
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
const xs = new Set([minLeft, maxLeft, clamp(preferred.x, minLeft, maxLeft)]);
const ys = new Set([minTop, maxTop, clamp(preferred.y, minTop, maxTop)]);
for (const rect of forbidden) {
xs.add(clamp(rect.left, minLeft, maxLeft));
xs.add(clamp(rect.right, minLeft, maxLeft));
ys.add(clamp(rect.top, minTop, maxTop));
ys.add(clamp(rect.bottom, minTop, maxTop));
}
// Feasible regions have axis-aligned edges at these coordinates. Their nearest
// point is a projected preferred coordinate or an edge intersection, so the
// cross product also finds pockets that four directional guesses would miss.
const columns = [...xs].sort((a, b) => a - b);
const rows = [...ys].sort((a, b) => a - b);
let best: { left: number; top: number } | null = null;
let bestDistance = Infinity;
for (const top of rows) {
for (const left of columns) {
if (forbidden.some((rect) => left > rect.left && left < rect.right
&& top > rect.top && top < rect.bottom)) continue;
const distance = Math.hypot(left - preferred.x, top - preferred.y);
// Sorted coordinates break exact ties topmost, then leftmost, independently
// of DOM/blocker order. Keep subpixels so rounding cannot reintroduce overlap.
if (!best || distance < bestDistance) {
best = { left, top };
bestDistance = distance;
}
}
}
return best;
}
+2 -1
View File
@@ -15,11 +15,12 @@ export function renderZigbeeTopologyOverlay(input: {
zoom: number;
view: boolean;
kiosk: boolean;
onLayout?: () => void;
}): TemplateResult | typeof nothing {
if (!input.view || input.kiosk || input.hass?.user?.is_admin !== true
|| !zigbeeTopologySettingsOf(input.settings).enabled) return nothing;
void import('./hp-zigbee-topology-overlay');
return html`<hp-zigbee-topology-overlay aria-hidden="true" .hass=${input.hass} .devices=${input.devices}
.registry=${input.registry} .currentSpace=${input.currentSpace} .spaces=${input.spaces}
.viewKey=${input.viewKey} .zoom=${input.zoom}></hp-zigbee-topology-overlay>`;
.viewKey=${input.viewKey} .zoom=${input.zoom} .onLayout=${input.onLayout}></hp-zigbee-topology-overlay>`;
}
+197
View File
@@ -0,0 +1,197 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { placeDeviceTooltip } from '../test-build/live-tip-placement.js';
const rect = (left, top, width, height) => ({
left, top, width, height, right: left + width, bottom: top + height,
});
const input = (overrides = {}) => ({
preferred: { x: 200, y: 150 },
size: { width: 160, height: 60 },
bounds: rect(0, 0, 640, 480),
blockers: [],
...overrides,
});
const assertFits = (placement, data) => {
assert.ok(placement, 'a complete free position exists');
const { left, top } = placement;
const { width, height } = data.size;
const edge = data.edge ?? 8;
const gap = data.gap ?? 8;
assert.ok(left >= data.bounds.left + edge);
assert.ok(top >= data.bounds.top + edge);
assert.ok(left + width <= data.bounds.right - edge);
assert.ok(top + height <= data.bounds.bottom - edge);
for (const blocker of data.blockers) {
if (!blocker.width || !blocker.height) continue;
assert.ok(left + width + gap <= blocker.left || left >= blocker.right + gap
|| top + height + gap <= blocker.top || top >= blocker.bottom + gap,
`tooltip ${JSON.stringify(placement)} covers ${JSON.stringify(blocker)}`);
}
};
test('tooltip preserves the preferred position when no obstacle occupies it', () => {
for (const blockers of [[], [rect(400, 100, 120, 40)], [rect(180, 100, 20, 20)]]) {
const data = input({ blockers });
assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 });
}
});
test('tooltip moves below a naturally colliding status badge, clear of the source marker', () => {
const data = input({
preferred: { x: 310, y: 160 },
size: { width: 190, height: 72 },
blockers: [rect(286, 136, 24, 24), rect(308, 160, 240, 30)],
});
const placement = placeDeviceTooltip(data);
assert.deepEqual(placement, { left: 310, top: 198 });
assertFits(placement, data);
});
test('tooltip avoids every badge and the source, not only the closest diagnostic text', () => {
const data = input({
blockers: [rect(190, 100, 30, 30), rect(200, 145, 220, 35),
rect(200, 180, 150, 40), rect(430, 120, 70, 30)],
});
const placement = placeDeviceTooltip(data);
assertFits(placement, data);
assert.deepEqual(placement, { left: 200, top: 228 });
});
test('tooltip reports no space in a tight card without shrinking the tooltip or moving blockers', () => {
const blockers = [Object.freeze(rect(75, 20, 80, 45))];
const data = Object.freeze(input({
preferred: Object.freeze({ x: 90, y: 40 }),
size: Object.freeze({ width: 120, height: 70 }),
bounds: Object.freeze(rect(0, 0, 180, 110)),
blockers: Object.freeze(blockers),
}));
assert.equal(placeDeviceTooltip(data), null);
assert.deepEqual(blockers[0], rect(75, 20, 80, 45));
});
test('recalculation restores a hidden tooltip after expanding the bounds or removing a badge', () => {
const data = input({ size: { width: 120, height: 70 }, bounds: rect(0, 0, 180, 110),
blockers: [rect(75, 20, 80, 45)] });
assert.equal(placeDeviceTooltip(data), null);
const expanded = { ...data, bounds: rect(0, 0, 640, 480) };
assertFits(placeDeviceTooltip(expanded), expanded);
const cleared = { ...data, blockers: [] };
assertFits(placeDeviceTooltip(cleared), cleared);
assert.equal(placeDeviceTooltip(data), null, 'no placement state leaks between calls');
});
test('tooltip clamps all four edges without obstacles and accepts an exact safe-area fit', () => {
const bounds = rect(20, 30, 300, 200);
const size = { width: 120, height: 60 };
for (const [preferred, expected] of [
[{ x: -500, y: -500 }, { left: 28, top: 38 }],
[{ x: 1000, y: 1000 }, { left: 192, top: 162 }],
[{ x: 120, y: 1000 }, { left: 120, top: 162 }],
[{ x: -500, y: 120 }, { left: 28, top: 120 }],
]) assert.deepEqual(placeDeviceTooltip(input({ bounds, size, preferred })), expected);
assert.deepEqual(placeDeviceTooltip(input({ bounds,
size: { width: 284, height: 184 } })), { left: 28, top: 38 });
});
test('negative origins, subpixels and long tooltip sizes remain in screen coordinates', () => {
const data = input({
bounds: rect(-300.5, -100.25, 1100, 360),
preferred: { x: 500, y: 160.5 },
size: { width: 800.25, height: 100.5 },
blockers: [rect(-250, -60, 80, 70)],
});
const placement = placeDeviceTooltip(data);
assert.deepEqual(placement, { left: -8.75, top: 151.25 });
assertFits(placement, data);
});
test('cross-product search finds an interior pocket unavailable to four directional guesses', () => {
// Four strips leave only the central 30 x 30 notch. Staying on either of the
// preferred axes, even at a bounds edge, would incorrectly claim no space.
const data = input({
bounds: rect(0, 0, 100, 100), size: { width: 20, height: 20 },
preferred: { x: 10, y: 10 }, gap: 0, edge: 0,
blockers: [rect(0, 0, 100, 40), rect(0, 70, 100, 30),
rect(0, 40, 40, 30), rect(70, 40, 30, 30)],
});
const placement = placeDeviceTooltip(data);
assert.deepEqual(placement, { left: 40, top: 40 });
assertFits(placement, data);
assert.equal(placeDeviceTooltip({ ...data, blockers: [...data.blockers, rect(40, 40, 30, 30)] }), null);
});
test('clearance is exact, configurable, and is never rounded back into a badge', () => {
const data = input({
size: { width: 40.5, height: 30.25 }, preferred: { x: 150.25, y: 120.5 },
blockers: [rect(150.25, 120.5, 90.75, 45.5)],
});
assert.deepEqual(placeDeviceTooltip(data), { left: 150.25, top: 82.25 });
assertFits(placeDeviceTooltip(data), data);
assert.deepEqual(placeDeviceTooltip({ ...data, gap: 0 }), { left: 150.25, top: 90.25 });
assert.deepEqual(placeDeviceTooltip({ ...data, gap: 12.5 }), { left: 150.25, top: 77.75 });
});
test('equal-distance placements are deterministic regardless of blocker order', () => {
const data = input({ preferred: { x: 100, y: 100 }, size: { width: 20, height: 20 },
blockers: [rect(100, 100, 20, 20), rect(300, 300, 20, 20)] });
assert.deepEqual(placeDeviceTooltip(data), { left: 100, top: 72 });
assert.deepEqual(placeDeviceTooltip({ ...data, blockers: [...data.blockers].reverse() }),
placeDeviceTooltip(data));
});
test('empty and off-stage blockers do not invent a collision', () => {
const data = input({ blockers: [rect(180, 100, 0, 160), rect(180, 160, 180, 0),
rect(1000, 1000, 400, 400), rect(-1000, -1000, 50, 50)] });
assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 });
});
test('closest feasible result agrees with exhaustive placement across 200 small layouts', () => {
let seed = 802;
const next = (limit) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed % limit;
};
for (let scene = 0; scene < 200; scene++) {
const data = input({ bounds: rect(-5, -4, 32, 28),
size: { width: 3 + next(10), height: 2 + next(9) },
preferred: { x: next(50) - 10, y: next(40) - 8 }, gap: next(4), edge: next(4),
blockers: Array.from({ length: 1 + next(5) }, () =>
rect(next(40) - 8, next(36) - 8, next(13), next(13))),
});
let expected = null;
let bestDistance = Infinity;
// All input edges and preferred coordinates are integral, so an integer
// closest point exists. This oracle scans the area, not obstacle candidates.
for (let top = data.bounds.top + data.edge;
top + data.size.height <= data.bounds.bottom - data.edge; top++) {
for (let left = data.bounds.left + data.edge;
left + data.size.width <= data.bounds.right - data.edge; left++) {
if (data.blockers.some((b) => b.width && b.height
&& left < b.right + data.gap && left + data.size.width + data.gap > b.left
&& top < b.bottom + data.gap && top + data.size.height + data.gap > b.top)) continue;
const distance = (left - data.preferred.x) ** 2 + (top - data.preferred.y) ** 2;
if (distance < bestDistance) {
expected = { left, top };
bestDistance = distance;
}
}
}
assert.deepEqual(placeDeviceTooltip(data), expected, `layout ${scene}: ${JSON.stringify(data)}`);
}
});
test('invalid, unmeasurable and oversized inputs fail closed without throwing', () => {
for (const overrides of [
{ preferred: { x: NaN, y: 10 } }, { preferred: { x: 10, y: Infinity } },
{ size: { width: 0, height: 20 } }, { size: { width: -20, height: 20 } },
{ size: { width: 20, height: NaN } }, { size: { width: 20, height: -1 } },
{ size: { width: 20, height: Infinity } }, { size: { width: 1000, height: 20 } },
{ size: { width: 20, height: 1000 } }, { bounds: rect(0, 0, 0, 0) },
{ bounds: rect(0, 0, -100, 100) }, { bounds: rect(Infinity, 0, 100, 100) },
{ blockers: [rect(10, 10, -10, 10)] }, { blockers: [rect(10, NaN, 10, 10)] },
{ gap: -1 }, { gap: Infinity }, { edge: -1 }, { edge: NaN },
]) assert.equal(placeDeviceTooltip(input(overrides)), null, JSON.stringify(overrides));
});