mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Перезапись `viewBox` — это не сдвиг, а инвалидация растеризации всей сцены. Кадр жеста делал её каждый раз: в профиле владельца (Firefox 155, 144 Гц) кадр доезжал до экрана 200 мс, а драйвер пропускал 124–144 тика в секунду с пометкой «ждём краску». Теперь `paintLiveViewport` держит якорь — кадр, чей `viewBox` записан в DOM, и момент записи. Кадр жеста двигает узлы сцены тем же проективным преобразованием, которым уже двигались HTML-слои, а `viewBox` переписывается по бюджету: 100 мс либо 15 % сдвига/масштаба. Ни атрибут, ни стиль не пишутся, если строка не изменилась. Issue: #531 User-Visible: yes
117 lines
6.1 KiB
JavaScript
117 lines
6.1 KiB
JavaScript
// #531: панорама двигает сцену трансформом, а не перерисовывает её каждый кадр.
|
||
//
|
||
// Перезапись атрибута `viewBox` — это инвалидация растеризации всего плана.
|
||
// В профиле владельца (Firefox 155, 144 Гц) кадр панорамы уходил на экран за
|
||
// 200 мс при незагруженном GPU, а драйвер пропускал 124–144 тика в секунду с
|
||
// пометкой «ждём краску». Свидетель судит причину, а не миллисекунды: считает
|
||
// перезаписи `viewBox` во время жеста и проверяет, что план при этом реально
|
||
// едет за курсором.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch({ width: 1100, height: 800 });
|
||
const out = await page.evaluate(async () => {
|
||
const result = {};
|
||
const card = window.__card;
|
||
const root = card.shadowRoot || card.renderRoot;
|
||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||
const settle = async () => { await card.updateComplete; await frame(); await frame(); };
|
||
card._setMode('view');
|
||
await settle();
|
||
const stage = root.querySelector('.stage');
|
||
const scene = root.querySelector('[data-hp-live-viewbox]');
|
||
const layer = root.querySelector('[data-hp-live-layer="camera"]');
|
||
result.sceneExists = !!scene && !!layer && !!stage;
|
||
if (!result.sceneExists) return result;
|
||
|
||
// Наблюдаем ровно за атрибутом, ради которого всё затевалось.
|
||
let viewBoxWrites = 0;
|
||
const observer = new MutationObserver((records) => {
|
||
for (const record of records) if (record.attributeName === 'viewBox') viewBoxWrites += 1;
|
||
});
|
||
for (const node of root.querySelectorAll('[data-hp-live-viewbox]')) {
|
||
observer.observe(node, { attributes: true, attributeFilter: ['viewBox'] });
|
||
}
|
||
|
||
const rect = stage.getBoundingClientRect();
|
||
const at = (dx, dy) => ({
|
||
clientX: rect.left + rect.width / 2 + dx,
|
||
clientY: rect.top + rect.height / 2 + dy,
|
||
});
|
||
const send = (type, dx, dy) => stage.dispatchEvent(new PointerEvent(type, {
|
||
bubbles: true, composed: true, cancelable: true,
|
||
pointerId: 31, isPrimary: true, pointerType: 'mouse', button: 0, buttons: 1, ...at(dx, dy),
|
||
}));
|
||
const viewBoxOf = () => scene.getAttribute('viewBox');
|
||
|
||
const boxBefore = viewBoxOf();
|
||
const marker = root.querySelector('[data-hp="device"]');
|
||
// Нужен видимый узел самой сцены, а не заготовка из `<defs>`: у неё нет
|
||
// экранного прямоугольника, и сравнивать её смещение не с чем.
|
||
const room = root.querySelector('[data-hp-live-viewbox] [data-hp="room"]');
|
||
result.parityTargetsExist = !!marker && !!room;
|
||
const centre = (node) => {
|
||
const box = node.getBoundingClientRect();
|
||
return [box.left + box.width / 2, box.top + box.height / 2];
|
||
};
|
||
const markerBefore = marker ? centre(marker) : null;
|
||
const roomBefore = room ? centre(room) : null;
|
||
|
||
stage.setPointerCapture = () => {}; stage.releasePointerCapture = () => {};
|
||
send('pointerdown', 0, 0);
|
||
await frame();
|
||
const startedAt = performance.now();
|
||
let frames = 0;
|
||
let framesWithSceneTransform = 0;
|
||
let worstParity = 0;
|
||
for (let step = 1; step <= 20; step += 1) {
|
||
send('pointermove', -step * 6, -step * 3);
|
||
await frame();
|
||
frames += 1;
|
||
const transform = getComputedStyle(scene).transform;
|
||
if (transform && transform !== 'none') framesWithSceneTransform += 1;
|
||
if (marker && room) {
|
||
// #451: слой устройств и сцена обязаны ехать как одно целое. Сравниваются
|
||
// не формулы, а экранные смещения одной и той же пары объектов.
|
||
const markerNow = centre(marker); const roomNow = centre(room);
|
||
const dx = Math.abs((markerNow[0] - markerBefore[0]) - (roomNow[0] - roomBefore[0]));
|
||
const dy = Math.abs((markerNow[1] - markerBefore[1]) - (roomNow[1] - roomBefore[1]));
|
||
worstParity = Math.max(worstParity, dx, dy);
|
||
}
|
||
}
|
||
const elapsed = performance.now() - startedAt;
|
||
const writesDuringGesture = viewBoxWrites;
|
||
send('pointerup', -120, -60);
|
||
await settle();
|
||
await frame();
|
||
observer.disconnect();
|
||
|
||
// AC5: за 20 кадров жеста сцена перерисовывается считаные разы, а не каждый кадр.
|
||
// Порог считается от реально прошедшего времени: бюджет — одна перезапись в 100 мс.
|
||
const allowed = Math.max(2, Math.ceil(elapsed / 100) + 1);
|
||
result.framesInGesture = frames;
|
||
result.viewBoxWritesDuringGesture = writesDuringGesture;
|
||
result.gestureDoesNotRepaintEveryFrame = writesDuringGesture <= allowed;
|
||
result.wellBelowFrameCount = writesDuringGesture < frames / 2;
|
||
// Сцена действительно едет: на большинстве кадров жеста на ней живой трансформ.
|
||
result.framesWithSceneTransform = framesWithSceneTransform;
|
||
result.sceneMovesByTransform = framesWithSceneTransform >= frames / 2;
|
||
// AC6: маркер и сцена смещаются на экране одинаково, в пределах пикселя.
|
||
result.worstParityPx = Math.round(worstParity * 100) / 100;
|
||
result.markerFollowsTheScene = worstParity <= 1;
|
||
// После жеста кадр осевший: новый viewBox записан, трансформов не осталось.
|
||
result.viewBoxSettledAfterGesture = viewBoxOf() !== boxBefore;
|
||
result.noTransformAfterGesture = ['none', ''].includes(getComputedStyle(scene).transform)
|
||
&& ['none', ''].includes(getComputedStyle(layer).transform);
|
||
return result;
|
||
});
|
||
|
||
console.log(JSON.stringify(out, null, 1));
|
||
checkAll(out, {
|
||
framesInGesture: out.framesInGesture,
|
||
viewBoxWritesDuringGesture: out.viewBoxWritesDuringGesture,
|
||
framesWithSceneTransform: out.framesWithSceneTransform,
|
||
worstParityPx: out.worstParityPx,
|
||
});
|
||
await browser.close();
|
||
finish();
|