mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
// #582: a day-cycle outline on a physically ordinary plan stored at
|
||
// 1 cm/grid-point must allocate in screen pixels, not in the 4700×4200 local
|
||
// SVG coordinate range. HA Companion loses fixed tiles when Chromium creates
|
||
// that oversized filter/overlap graph during pinch.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch(
|
||
{ width: 820, height: 760 }, 1, [], { hasTouch: true },
|
||
);
|
||
const cdp = await page.context().newCDPSession(page);
|
||
let latestLayers = [];
|
||
cdp.on('LayerTree.layerTreeDidChange', ({ layers }) => { latestLayers = layers; });
|
||
await cdp.send('LayerTree.enable');
|
||
const presentedFrames = [];
|
||
cdp.on('Page.screencastFrame', (event) => {
|
||
if (presentedFrames.length < 90) presentedFrames.push(event.data);
|
||
void cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
||
});
|
||
|
||
const settle = () => page.evaluate(async () => {
|
||
const card = window.__card;
|
||
await card.updateComplete;
|
||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||
});
|
||
|
||
await page.evaluate(async () => {
|
||
const card = window.__card;
|
||
const large = {
|
||
id: 'hp582-large-daycycle',
|
||
title: 'Synthetic large-coordinate floor',
|
||
plan_url: null,
|
||
view_box: [0, 0, 5, 4.5],
|
||
cell_cm: 1,
|
||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: '#435468' },
|
||
rooms: [{
|
||
id: 'hp582-room', name: 'Large room', area: null,
|
||
poly: [[0.1, 0.1], [4.7, 0.1], [4.7, 4.2], [0.1, 4.2]],
|
||
}],
|
||
wall_segments: [], walls: [], partitions: [], wall_columns: [], openings: [], decor: [],
|
||
};
|
||
card._serverCfg = {
|
||
...card._serverCfg,
|
||
settings: { ...(card._serverCfg.settings || {}), bg_mode: 'daynight', glow_enabled: false },
|
||
spaces: [large],
|
||
};
|
||
card._space = large.id;
|
||
card._view = null;
|
||
card._zoom = 1;
|
||
card._cfgRev = (card._cfgRev || 0) + 1;
|
||
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
|
||
entity_id: 'sun.sun', state: 'above_horizon',
|
||
attributes: { azimuth: 180, elevation: 40, rising: false },
|
||
} } };
|
||
card.requestUpdate();
|
||
await card.updateComplete;
|
||
});
|
||
await settle();
|
||
await cdp.send('Page.startScreencast', {
|
||
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
|
||
});
|
||
await page.waitForTimeout(80);
|
||
|
||
const describeContentLayers = async () => {
|
||
const content = latestLayers.filter((layer) => layer.drawsContent);
|
||
const described = [];
|
||
for (const layer of content) {
|
||
let className = '';
|
||
if (layer.backendNodeId) {
|
||
try {
|
||
const { node } = await cdp.send('DOM.describeNode', { backendNodeId: layer.backendNodeId });
|
||
const attrs = node.attributes || [];
|
||
const classIndex = attrs.indexOf('class');
|
||
className = classIndex >= 0 ? attrs[classIndex + 1] : '';
|
||
} catch { /* detached between LayerTree event and inspection */ }
|
||
}
|
||
let reasons = [];
|
||
try {
|
||
({ compositingReasons: reasons } = await cdp.send('LayerTree.compositingReasons', {
|
||
layerId: layer.layerId,
|
||
}));
|
||
} catch { /* root/retired layers may have no reason record */ }
|
||
described.push({
|
||
className, width: layer.width, height: layer.height,
|
||
pixels: layer.width * layer.height, reasons,
|
||
});
|
||
}
|
||
return described;
|
||
};
|
||
|
||
const layerSnapshot = async () => {
|
||
await page.waitForTimeout(80);
|
||
const stage = await page.evaluate(() => {
|
||
const root = window.__card.renderRoot;
|
||
const rect = root.querySelector('.stage').getBoundingClientRect();
|
||
const outline = root.querySelector('.hp-paper-outline-svg');
|
||
const paper = root.querySelector('.hp-paperg');
|
||
return {
|
||
width: rect.width,
|
||
height: rect.height,
|
||
safe: root.querySelector('.stage').classList.contains('hp-safe-daycycle-outline'),
|
||
outline: outline ? {
|
||
viewBox: outline.getAttribute('viewBox'),
|
||
filter: getComputedStyle(outline).filter,
|
||
willChange: getComputedStyle(outline).willChange,
|
||
pointerEvents: getComputedStyle(outline).pointerEvents,
|
||
visibility: getComputedStyle(outline).visibility,
|
||
} : null,
|
||
paper: paper ? {
|
||
bbox: (() => { const box = paper.getBBox(); return [box.width, box.height]; })(),
|
||
filter: getComputedStyle(paper).filter,
|
||
willChange: getComputedStyle(paper).willChange,
|
||
} : null,
|
||
};
|
||
});
|
||
return { stage, layers: await describeContentLayers() };
|
||
};
|
||
|
||
await page.evaluate(() => {
|
||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||
const rect = stage.getBoundingClientRect();
|
||
const y = rect.top + rect.height / 2;
|
||
const cx = rect.left + rect.width / 2;
|
||
stage.setPointerCapture = () => {};
|
||
stage.releasePointerCapture = () => {};
|
||
window.__hp582Touch = (type, pointerId, x, buttons = 1) => stage.dispatchEvent(new PointerEvent(type, {
|
||
bubbles: true, composed: true, pointerId, pointerType: 'touch',
|
||
isPrimary: pointerId === 5821, button: type === 'pointerdown' ? 0 : -1,
|
||
buttons, clientX: x, clientY: y,
|
||
}));
|
||
window.__hp582Pinch = { cx };
|
||
window.__hp582Touch('pointerdown', 5821, cx - 70);
|
||
window.__hp582Touch('pointerdown', 5822, cx + 70);
|
||
});
|
||
|
||
for (const distance of [78, 92, 66, 104, 72, 98]) {
|
||
await page.evaluate((distance) => {
|
||
const { cx } = window.__hp582Pinch;
|
||
window.__hp582Touch('pointermove', 5821, cx - distance);
|
||
window.__hp582Touch('pointermove', 5822, cx + distance);
|
||
}, distance);
|
||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||
await page.waitForTimeout(25);
|
||
}
|
||
|
||
const active = await layerSnapshot();
|
||
await page.evaluate(() => {
|
||
const { cx } = window.__hp582Pinch;
|
||
window.__hp582Touch('pointerup', 5821, cx - 98, 0);
|
||
window.__hp582Touch('pointerup', 5822, cx + 98, 0);
|
||
delete window.__hp582Touch;
|
||
delete window.__hp582Pinch;
|
||
});
|
||
await settle();
|
||
await page.waitForTimeout(100);
|
||
await cdp.send('Page.stopScreencast');
|
||
const settled = await layerSnapshot();
|
||
|
||
const frameMetrics = await page.evaluate(async ({ encoded, stage }) => {
|
||
const decode = (data) => new Promise((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = reject;
|
||
image.src = `data:image/png;base64,${data}`;
|
||
});
|
||
const out = [];
|
||
for (const data of encoded) {
|
||
const image = await decode(data);
|
||
const scaleX = image.width / innerWidth;
|
||
const scaleY = image.height / innerHeight;
|
||
const sx = Math.floor(stage.x * scaleX);
|
||
const sy = Math.floor(stage.y * scaleY);
|
||
const sw = Math.max(1, Math.floor(stage.width * scaleX));
|
||
const sh = Math.max(1, Math.floor(stage.height * scaleY));
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = sw;
|
||
canvas.height = sh;
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
context.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh);
|
||
const pixels = context.getImageData(0, 0, sw, sh).data;
|
||
let sampled = 0;
|
||
let nearWhite = 0;
|
||
for (let y = Math.floor(sh * 0.25); y < sh * 0.75; y += 3) {
|
||
for (let x = Math.floor(sw * 0.25); x < sw * 0.75; x += 3) {
|
||
const offset = (y * sw + x) * 4;
|
||
sampled++;
|
||
if (pixels[offset] >= 245 && pixels[offset + 1] >= 245 && pixels[offset + 2] >= 245) {
|
||
nearWhite++;
|
||
}
|
||
}
|
||
}
|
||
out.push({ nearWhiteRatio: nearWhite / Math.max(1, sampled) });
|
||
}
|
||
return out;
|
||
}, {
|
||
encoded: presentedFrames,
|
||
stage: await page.evaluate(() => {
|
||
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
|
||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||
}),
|
||
});
|
||
|
||
// Contract item 6: the non-interactive houseplan-space-card never needs a
|
||
// gesture to become safe. Its day-cycle silhouette is stage-bounded from its
|
||
// first frame, while the visible paper group owns no filter layer.
|
||
const staticStage = await page.evaluate(async () => {
|
||
await customElements.whenDefined('houseplan-space-card');
|
||
const full = window.__card;
|
||
const fallbackCallWS = full.hass.callWS?.bind(full.hass);
|
||
const hass = { ...full.hass, callWS: async (message) => {
|
||
if (message.type === 'houseplan/config/get') {
|
||
return { config: full._serverCfg, rev: full._cfgRev || 1 };
|
||
}
|
||
if (message.type === 'houseplan/layout/get') return { layout: {} };
|
||
return fallbackCallWS ? fallbackCallWS(message) : {};
|
||
} };
|
||
const host = document.createElement('div');
|
||
host.style.cssText = 'width:820px';
|
||
document.body.appendChild(host);
|
||
const card = document.createElement('houseplan-space-card');
|
||
card.setConfig({
|
||
type: 'custom:houseplan-space-card', space: 'hp582-large-daycycle',
|
||
title: '', show_button: false,
|
||
});
|
||
card.hass = hass;
|
||
host.appendChild(card);
|
||
const deadline = Date.now() + 6000;
|
||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() < deadline) {
|
||
await new Promise((done) => setTimeout(done, 60));
|
||
}
|
||
await card.updateComplete;
|
||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||
full.style.display = 'none';
|
||
const stage = card.renderRoot?.querySelector('.hp-static-stage');
|
||
const outline = stage?.querySelector(':scope > .hp-paper-outline-svg');
|
||
const plan = stage?.querySelector(':scope > .hp-static-plan-svg');
|
||
const paper = plan?.querySelector('.hp-paperg');
|
||
const rect = stage?.getBoundingClientRect();
|
||
return {
|
||
width: rect?.width || 0,
|
||
height: rect?.height || 0,
|
||
outlinePresent: !!outline,
|
||
outlineFilter: outline ? getComputedStyle(outline).filter : 'none',
|
||
outlineWillChange: outline ? getComputedStyle(outline).willChange : 'auto',
|
||
outlineVisibility: outline ? getComputedStyle(outline).visibility : 'hidden',
|
||
outlineZ: outline ? getComputedStyle(outline).zIndex : 'auto',
|
||
planZ: plan ? getComputedStyle(plan).zIndex : 'auto',
|
||
paperFilter: paper ? getComputedStyle(paper).filter : 'missing',
|
||
paperWillChange: paper ? getComputedStyle(paper).willChange : 'missing',
|
||
};
|
||
});
|
||
await page.waitForTimeout(180);
|
||
const staticLayers = await describeContentLayers();
|
||
|
||
const checks = {};
|
||
const outlineLayers = active.layers.filter((layer) => layer.className.includes('hp-paper-outline-svg'));
|
||
const outlineLayer = outlineLayers[0];
|
||
const settledPlanLayer = settled.layers.find((layer) => layer.className.includes('plan-svg'));
|
||
const oversized = active.layers.filter((layer) => layer.width > 4096 || layer.height > 4096);
|
||
const activePixels = active.layers.reduce((sum, layer) => sum + layer.pixels, 0);
|
||
const screenPixels = active.stage.width * active.stage.height;
|
||
const staticOutlineLayers = staticLayers.filter(
|
||
(layer) => layer.className.includes('hp-paper-outline-svg'),
|
||
);
|
||
const staticOutlineLayer = staticOutlineLayers[0];
|
||
const staticOversized = staticLayers.filter(
|
||
(layer) => layer.width > 4096 || layer.height > 4096,
|
||
);
|
||
|
||
checks.largeCoordinateFixture = active.stage.paper?.bbox?.[0] > 4096
|
||
&& active.stage.paper?.bbox?.[1] > 4000;
|
||
checks.separateFilteredOutline = !!active.stage.outline
|
||
&& /drop-shadow/.test(active.stage.outline.filter)
|
||
&& active.stage.outline.pointerEvents === 'none'
|
||
&& active.stage.outline.visibility === 'visible';
|
||
checks.cameraActivatesSafeFallback = active.stage.safe === true;
|
||
checks.oneOutlineLayerOnly = outlineLayers.length === 1;
|
||
checks.visiblePaperUnfiltered = active.stage.paper?.filter === 'none'
|
||
&& !/filter/.test(active.stage.paper?.willChange || '');
|
||
checks.outlineLayerIsStageBounded = !!outlineLayer
|
||
&& outlineLayer.width <= active.stage.width * 1.5 + 64
|
||
&& outlineLayer.height <= active.stage.height * 1.5 + 64;
|
||
checks.no4096ContentLayer = oversized.length === 0;
|
||
// Browser chrome/root layers add a small constant. The broken graph measured
|
||
// 78.7M px against a ~0.6M px stage; the fixed graph must stay screen-order.
|
||
checks.totalContentLayerAreaIsScreenOrder = activePixels <= screenPixels * 16;
|
||
checks.terminalFrameKeepsBudget = settled.layers.every(
|
||
(layer) => layer.width <= 4096 && layer.height <= 4096,
|
||
);
|
||
checks.settledPlanLayerIsExplicitAndBounded = !!settledPlanLayer
|
||
&& settledPlanLayer.width <= active.stage.width * 1.5 + 64
|
||
&& settledPlanLayer.height <= active.stage.height * 1.5 + 64
|
||
&& settledPlanLayer.reasons.some((reason) => reason.includes('will-change: transform'))
|
||
&& settledPlanLayer.reasons.every((reason) => !reason.includes('Overlaps other composited content'));
|
||
checks.capturedPresentedPinchFrames = frameMetrics.length >= 3;
|
||
checks.presentedFramesHaveNoWhiteTile = frameMetrics.every(
|
||
(frame) => frame.nearWhiteRatio < 0.01,
|
||
);
|
||
checks.staticCardUsesSeparateFilteredOutline = staticStage.outlinePresent
|
||
&& staticStage.outlineVisibility === 'visible'
|
||
&& /drop-shadow/.test(staticStage.outlineFilter)
|
||
&& /filter/.test(staticStage.outlineWillChange)
|
||
&& staticStage.outlineZ === '0'
|
||
&& staticStage.planZ === '1';
|
||
checks.staticCardPaperStaysUnfiltered = staticStage.paperFilter === 'none'
|
||
&& !/filter/.test(staticStage.paperWillChange);
|
||
checks.staticCardHasOneOutlineLayer = staticOutlineLayers.length === 1;
|
||
checks.staticCardOutlineLayerIsStageBounded = !!staticOutlineLayer
|
||
&& staticOutlineLayer.width <= staticStage.width * 1.5 + 64
|
||
&& staticOutlineLayer.height <= staticStage.height * 1.5 + 64;
|
||
checks.staticCardHasNo4096ContentLayer = staticOversized.length === 0;
|
||
|
||
console.log(JSON.stringify({
|
||
stage: active.stage,
|
||
activePixels,
|
||
screenPixels,
|
||
ratio: Number((activePixels / screenPixels).toFixed(2)),
|
||
oversized,
|
||
activeLayers: active.layers,
|
||
settledLayers: settled.layers,
|
||
presentedFrameMetrics: frameMetrics,
|
||
staticStage,
|
||
staticLayers,
|
||
staticOversized,
|
||
}, null, 2));
|
||
checkAll(checks);
|
||
await cdp.send('LayerTree.disable');
|
||
await finish(browser, checks);
|