mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
Панорама двигает сцену трансформом, viewBox пишется по бюджету
Перезапись `viewBox` — это не сдвиг, а инвалидация растеризации всей сцены. Кадр жеста делал её каждый раз: в профиле владельца (Firefox 155, 144 Гц) кадр доезжал до экрана 200 мс, а драйвер пропускал 124–144 тика в секунду с пометкой «ждём краску». Теперь `paintLiveViewport` держит якорь — кадр, чей `viewBox` записан в DOM, и момент записи. Кадр жеста двигает узлы сцены тем же проективным преобразованием, которым уже двигались HTML-слои, а `viewBox` переписывается по бюджету: 100 мс либо 15 % сдвига/масштаба. Ни атрибут, ни стиль не пишутся, если строка не изменилась. Issue: #531 User-Visible: yes
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
isIdentityLiveLayerProjection,
|
||||
liveLayerProjection,
|
||||
liveViewBoxText,
|
||||
paintLiveViewport,
|
||||
} from '../test-build/live-viewport.js';
|
||||
|
||||
test('live viewport projects pan and zoom from the last complete frame', () => {
|
||||
@@ -29,3 +30,117 @@ test('a settled live viewport removes its compositor transform', () => {
|
||||
{ ...settled, x: settled.x + 0.001 },
|
||||
)), false);
|
||||
});
|
||||
|
||||
// #531. Перезапись `viewBox` — это инвалидация растеризации всего плана: слой
|
||||
// нельзя сдвинуть, его надо нарисовать заново. Профиль владельца на панораме
|
||||
// (Firefox 155, 144 Гц): краска закончилась, а до композиции 94 мс медианы при
|
||||
// незагруженном GPU, и драйвер пропускает 124–144 тика в секунду с пометкой
|
||||
// «ждём краску». Поэтому кадр жеста двигает сцену трансформом, а содержимое
|
||||
// подтягивается по бюджету — иначе на набегающем крае осталась бы пустая полоса.
|
||||
|
||||
/** Крошечный DOM: только то, что читает `paintLiveViewport`. */
|
||||
const fakeRoot = () => {
|
||||
const node = (attrs) => {
|
||||
const el = {
|
||||
attrs: { ...attrs }, writes: 0, styleWrites: 0,
|
||||
getAttribute: (name) => (name in el.attrs ? el.attrs[name] : null),
|
||||
setAttribute: (name, value) => { el.attrs[name] = value; el.writes += 1; },
|
||||
style: new Proxy({ _v: {} }, {
|
||||
get: (target, key) => {
|
||||
if (key === 'removeProperty') return (name) => { delete target._v[name]; el.styleWrites += 1; };
|
||||
if (key === '_v') return target._v;
|
||||
return target._v[key];
|
||||
},
|
||||
set: (target, key, value) => { target._v[key] = value; el.styleWrites += 1; return true; },
|
||||
}),
|
||||
};
|
||||
return el;
|
||||
};
|
||||
const camera = node({ viewBox: '0 0 1000 500' });
|
||||
const floor = node({ viewBox: '0 0 1000 500' });
|
||||
const layer = node({});
|
||||
return {
|
||||
camera, floor, layer,
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector.includes('viewbox="camera"')) return [camera];
|
||||
if (selector.includes('viewbox="floor"')) return [floor];
|
||||
if (selector.includes('live-layer')) return [layer];
|
||||
return [];
|
||||
},
|
||||
querySelector: () => null,
|
||||
};
|
||||
};
|
||||
|
||||
const frame = (x, y, w = 1000, h = 500) => ({
|
||||
view: { x, y, w, h }, floor: { x, y, w, h }, zoom: 1,
|
||||
});
|
||||
|
||||
test('#531 AC1: кадр жеста двигает сцену трансформом и не трогает viewBox', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 1000 });
|
||||
const writesAfterAnchor = root.camera.writes;
|
||||
for (let step = 1; step <= 5; step += 1) {
|
||||
anchor = paintLiveViewport(root, painted, frame(step * 10, 0), anchor, { now: 1000 + step * 10 });
|
||||
}
|
||||
assert.equal(root.camera.writes, writesAfterAnchor, 'viewBox переписывать не за что');
|
||||
assert.equal(root.camera.attrs.viewBox, '0 0 1000 500');
|
||||
assert.match(root.camera.style.transform, /^translate\(-5%,0%\) scale\(1,1\)$/);
|
||||
assert.equal(root.layer.style.transform, root.camera.style.transform, 'слой и сцена едут одинаково');
|
||||
});
|
||||
|
||||
test('#531 AC2: по истечении бюджета viewBox пишется один раз, трансформ снимается', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 50 });
|
||||
const before = root.camera.writes;
|
||||
anchor = paintLiveViewport(root, painted, frame(20, 0), anchor, { now: 100 });
|
||||
assert.equal(root.camera.writes, before + 1, 'ровно одна перезапись');
|
||||
assert.equal(root.camera.attrs.viewBox, '20 0 1000 500');
|
||||
assert.equal(root.camera.style.transform, undefined, 'сцена вернулась в тождество');
|
||||
assert.ok(root.layer.style.transform, 'слой по-прежнему спроецирован от осевшего кадра');
|
||||
});
|
||||
|
||||
test('#531 AC2а: рывок переписывает viewBox сразу, не дожидаясь бюджета времени', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
|
||||
const before = root.camera.writes;
|
||||
// 14 % видимой ширины — меньше порога: едем трансформом.
|
||||
anchor = paintLiveViewport(root, painted, frame(140, 0), anchor, { now: 5 });
|
||||
assert.equal(root.camera.writes, before, 'ниже порога сдвига — без перезаписи');
|
||||
// 15 % — порог достигнут, время не истекло.
|
||||
anchor = paintLiveViewport(root, painted, frame(150, 0), anchor, { now: 10 });
|
||||
assert.equal(root.camera.writes, before + 1);
|
||||
assert.equal(root.camera.attrs.viewBox, '150 0 1000 500');
|
||||
});
|
||||
|
||||
test('#531 AC3: повторная покраска тем же кадром не пишет в DOM ничего', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, frame(10, 0), null, { now: 0 });
|
||||
const writes = root.camera.writes + root.floor.writes;
|
||||
const styles = root.camera.styleWrites + root.floor.styleWrites + root.layer.styleWrites;
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 1 });
|
||||
assert.equal(root.camera.writes + root.floor.writes, writes, 'атрибуты не переписаны');
|
||||
assert.equal(
|
||||
root.camera.styleWrites + root.floor.styleWrites + root.layer.styleWrites, styles,
|
||||
'стили не переписаны',
|
||||
);
|
||||
});
|
||||
|
||||
test('#531 AC4: терминальное примирение пишет окончательный viewBox и снимает трансформы', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, frame(30, 0), null, { now: 0 });
|
||||
anchor = paintLiveViewport(root, painted, frame(60, 0), anchor, { now: 10 });
|
||||
const settled = frame(60, 0);
|
||||
anchor = paintLiveViewport(root, settled, settled, anchor, { now: 20, force: true });
|
||||
assert.equal(root.camera.attrs.viewBox, '60 0 1000 500');
|
||||
assert.equal(root.camera.style.transform, undefined);
|
||||
assert.equal(root.layer.style.transform, undefined);
|
||||
const writes = root.camera.writes;
|
||||
paintLiveViewport(root, settled, settled, anchor, { now: 21, force: true });
|
||||
assert.equal(root.camera.writes, writes, 'повторное примирение ничего не пишет');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user