mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:
- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
with `will-change: transform`; Chromium freezes the raster scale of such a
layer, so the 100 % raster was shown stretched. The explicit layer #582
needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
scene (#544) without bound, so the promoted layers grew with zoom squared
(CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
navigating at 800 %). The full card clips its outline to its box and marks
it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
every other exposure with an inline clip-path: inset(-25%) that leaves
with it, so idle DOM stays byte-identical (#531).
Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.
Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.
Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.
Issue: #689
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
export interface LiveViewBox {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
interface LiveViewportFrame {
|
||
view: LiveViewBox;
|
||
floor: LiveViewBox;
|
||
zoom: number;
|
||
}
|
||
|
||
interface LiveViewportState {
|
||
painted: LiveViewportFrame | null;
|
||
pending: LiveViewportFrame | null;
|
||
raf: number;
|
||
anchor: LiveViewportAnchor | null;
|
||
}
|
||
|
||
/**
|
||
* Кадр, чей `viewBox` сейчас записан в сцену, и момент записи (#531).
|
||
*
|
||
* Перезапись `viewBox` — это инвалидация растеризации всего плана: слой нельзя
|
||
* сдвинуть, его надо нарисовать заново. Профиль владельца на панораме: краска
|
||
* заканчивается, и до композиции проходит 94 мс медианы при незагруженном GPU,
|
||
* а драйвер пропускает 124–144 тика в секунду с пометкой «ждём краску».
|
||
* Поэтому кадр жеста двигает сцену трансформом, а `viewBox` переписывается по
|
||
* бюджету — иначе на набегающем крае осталась бы пустая полоса.
|
||
*/
|
||
export interface LiveViewportAnchor {
|
||
frame: LiveViewportFrame;
|
||
at: number;
|
||
}
|
||
|
||
interface LiveViewportHost {
|
||
_viewOr: (viewBox: number[]) => LiveViewBox;
|
||
_baseVb: () => number[];
|
||
_floorView: (view: LiveViewBox) => LiveViewBox;
|
||
_zoom: number;
|
||
renderRoot: ParentNode;
|
||
}
|
||
|
||
const states = new WeakMap<object, LiveViewportState>();
|
||
const stateOf = (host: object): LiveViewportState => {
|
||
let state = states.get(host);
|
||
if (!state) {
|
||
state = { painted: null, pending: null, raf: 0, anchor: null };
|
||
states.set(host, state);
|
||
}
|
||
return state;
|
||
};
|
||
|
||
export const liveViewBoxText = (view: LiveViewBox): string =>
|
||
`${view.x} ${view.y} ${view.w} ${view.h}`;
|
||
|
||
export interface LiveLayerProjection {
|
||
translateXPercent: number;
|
||
translateYPercent: number;
|
||
scaleX: number;
|
||
scaleY: number;
|
||
}
|
||
|
||
/** Map an HTML percentage layer painted for `before` onto `after`. */
|
||
export function liveLayerProjection(
|
||
before: LiveViewBox,
|
||
after: LiveViewBox,
|
||
): LiveLayerProjection {
|
||
return {
|
||
translateXPercent: ((before.x - after.x) / after.w) * 100,
|
||
translateYPercent: ((before.y - after.y) / after.h) * 100,
|
||
scaleX: before.w / after.w,
|
||
scaleY: before.h / after.h,
|
||
};
|
||
}
|
||
|
||
export const isIdentityLiveLayerProjection = (projection: LiveLayerProjection): boolean =>
|
||
projection.translateXPercent === 0
|
||
&& projection.translateYPercent === 0
|
||
&& projection.scaleX === 1
|
||
&& projection.scaleY === 1;
|
||
|
||
/** Как часто сцена догоняет жест содержимым, а не сдвигом пикселей. */
|
||
export const LIVE_VIEWBOX_REFRESH_MS = 100;
|
||
/** Доля видимой области, после которой ждать бюджет времени уже поздно. */
|
||
export const LIVE_VIEWBOX_REFRESH_SHIFT = 0.15;
|
||
|
||
/**
|
||
* Пора ли записать новый `viewBox`. Два условия, оба нужны: по времени —
|
||
* обычное перетаскивание, по сдвигу — рывок, за который план уезжает на
|
||
* полэкрана раньше, чем истечёт бюджет времени.
|
||
*/
|
||
export function needsViewBoxRefresh(
|
||
anchor: LiveViewportAnchor, current: LiveViewportFrame, now: number,
|
||
): boolean {
|
||
if (now - anchor.at >= LIVE_VIEWBOX_REFRESH_MS) return true;
|
||
const before = anchor.frame.view;
|
||
const after = current.view;
|
||
if (Math.abs(after.x - before.x) >= after.w * LIVE_VIEWBOX_REFRESH_SHIFT) return true;
|
||
if (Math.abs(after.y - before.y) >= after.h * LIVE_VIEWBOX_REFRESH_SHIFT) return true;
|
||
const scale = before.w / after.w;
|
||
return Math.abs(1 - scale) >= LIVE_VIEWBOX_REFRESH_SHIFT;
|
||
}
|
||
|
||
const finiteView = (view: LiveViewBox): boolean =>
|
||
[view.x, view.y, view.w, view.h].every(Number.isFinite) && view.w > 0 && view.h > 0;
|
||
|
||
const projectionText = (projection: LiveLayerProjection): string =>
|
||
`translate(${projection.translateXPercent}%,${projection.translateYPercent}%)`
|
||
+ ` scale(${projection.scaleX},${projection.scaleY})`;
|
||
|
||
/**
|
||
* #689: how far an exposed scene may paint beyond its own viewport box while
|
||
* it is projected. The budgeted `viewBox` refresh fires at a 15 % shift or
|
||
* scale change (`LIVE_VIEWBOX_REFRESH_SHIFT`), so 25 % covers every incoming
|
||
* edge #544 needs. Unbounded `overflow: visible` let a promoted scene layer
|
||
* grow with zoom² — 15.9× the stage at ~460 %, hundreds of MB of GPU memory at
|
||
* 800 % × DPR 2 — and the page flashed white.
|
||
*/
|
||
export const LIVE_SCENE_EXPOSURE_CLIP = 'inset(-25%)';
|
||
|
||
/** A scene marked so is clipped to its own box and never exposed (#689). */
|
||
const SCENE_OVERFLOW_ATTRIBUTE = 'data-hp-live-overflow';
|
||
|
||
const exposesOverflow = (
|
||
layer: ElementCSSInlineStyle,
|
||
options: { exposeSceneOverflow?: boolean },
|
||
): boolean => options.exposeSceneOverflow === true
|
||
&& (layer as unknown as Element).getAttribute?.(SCENE_OVERFLOW_ATTRIBUTE) !== 'clip';
|
||
|
||
const setLayerProjection = (
|
||
layer: ElementCSSInlineStyle,
|
||
projection: LiveLayerProjection | null,
|
||
options: { exposeSceneOverflow?: boolean } = {},
|
||
): void => {
|
||
const style = layer.style;
|
||
const expose = exposesOverflow(layer, options);
|
||
if (!projection) {
|
||
// #544: the SVG viewport may be opened only while it is being projected.
|
||
// `.stage` remains the outer clip, while removing these inline values
|
||
// keeps the settled DOM and filter/compositing path byte-equivalent to
|
||
// #531. The bound (#689) leaves together with the exposure it bounds.
|
||
if (expose && style.overflow === 'visible') {
|
||
style.removeProperty('overflow');
|
||
}
|
||
if (expose && style.clipPath === LIVE_SCENE_EXPOSURE_CLIP) {
|
||
style.removeProperty('clip-path');
|
||
}
|
||
// #531: снимать только то, что стоит. Лишняя запись в стиль — это
|
||
// инвалидация, а тихий кадр обязан оставлять DOM нетронутым.
|
||
if (style.transform) {
|
||
style.removeProperty('transform');
|
||
style.removeProperty('transform-origin');
|
||
style.removeProperty('will-change');
|
||
}
|
||
return;
|
||
}
|
||
// A transformed SVG keeps its old viewport box. Without exposing the scene
|
||
// beyond that internal box, the incoming edge shows `.stage` background
|
||
// until the next budgeted viewBox refresh (#544). The stage still clips the
|
||
// complete card, so no scene pixels escape the visible plan surface; the
|
||
// clip keeps the promoted layer stage-sized at any zoom (#689).
|
||
if (expose && style.overflow !== 'visible') {
|
||
style.overflow = 'visible';
|
||
}
|
||
if (expose && style.clipPath !== LIVE_SCENE_EXPOSURE_CLIP) {
|
||
style.clipPath = LIVE_SCENE_EXPOSURE_CLIP;
|
||
}
|
||
const text = projectionText(projection);
|
||
// Promotion is gesture-scoped, not a per-frame hint (#579). Avoid even
|
||
// equal writes while the projection changes: WebViews may treat them as
|
||
// compositor lifecycle changes rather than harmless string assignments.
|
||
if (style.transformOrigin !== '0 0' && style.transformOrigin !== '0px 0px') {
|
||
style.transformOrigin = '0 0';
|
||
}
|
||
if (style.willChange !== 'transform') style.willChange = 'transform';
|
||
if (style.transform !== text) style.transform = text;
|
||
};
|
||
|
||
/** Атрибут пишется только когда строка действительно другая (#531). */
|
||
const setViewBox = (svg: SVGElement, text: string): void => {
|
||
if (svg.getAttribute('viewBox') !== text) svg.setAttribute('viewBox', text);
|
||
};
|
||
|
||
/**
|
||
* Один атомарный кадр живого вьюпорта (#451, #531).
|
||
*
|
||
* Каждый кадр сцена едет трансформом от своего якоря, а слои устройств и
|
||
* подписей — от последнего осевшего кадра Lit: базы у них разные, потому что
|
||
* содержимое слоёв спозиционировано в процентах осевшего вида. Новый `viewBox`
|
||
* пишется только когда наступил бюджет (`needsViewBoxRefresh`) либо когда
|
||
* вызывающий требует этого явно (терминальное примирение). Возвращается якорь,
|
||
* который после этого кадра действительно записан в DOM.
|
||
*/
|
||
export function paintLiveViewport(
|
||
root: ParentNode,
|
||
painted: LiveViewportFrame,
|
||
current: LiveViewportFrame,
|
||
anchor?: LiveViewportAnchor | null,
|
||
options: { now?: number; force?: boolean; keepSceneLayer?: boolean } = {},
|
||
): LiveViewportAnchor {
|
||
const now = options.now ?? (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||
const base: LiveViewportAnchor = anchor ?? { frame: painted, at: -Infinity };
|
||
if (!finiteView(painted.view) || !finiteView(current.view) || !finiteView(current.floor)) {
|
||
return base;
|
||
}
|
||
const refresh = options.force === true || !finiteView(base.frame.view)
|
||
|| !finiteView(base.frame.floor) || needsViewBoxRefresh(base, current, now);
|
||
const next: LiveViewportAnchor = refresh ? { frame: current, at: now } : base;
|
||
if (refresh) {
|
||
const viewBox = liveViewBoxText(current.view);
|
||
const floorBox = liveViewBoxText(current.floor);
|
||
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="camera"]')) {
|
||
setViewBox(svg, viewBox);
|
||
}
|
||
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="floor"]')) {
|
||
setViewBox(svg, floorBox);
|
||
}
|
||
}
|
||
// Сцена: от записанного якоря к текущему кадру. Budget refresh делает
|
||
// проекцию тождественной, но активная live-сессия сохраняет compositor layer
|
||
// до terminal commit (#579), не демотирует и не промотирует SVG каждые 100 мс.
|
||
const sceneCamera = liveLayerProjection(next.frame.view, current.view);
|
||
const sceneFloor = liveLayerProjection(next.frame.floor, current.floor);
|
||
const keepSceneLayer = options.keepSceneLayer === true;
|
||
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="camera"]')) {
|
||
setLayerProjection(
|
||
svg,
|
||
isIdentityLiveLayerProjection(sceneCamera) && !keepSceneLayer ? null : sceneCamera,
|
||
{ exposeSceneOverflow: true },
|
||
);
|
||
}
|
||
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="floor"]')) {
|
||
setLayerProjection(
|
||
svg,
|
||
isIdentityLiveLayerProjection(sceneFloor) && !keepSceneLayer ? null : sceneFloor,
|
||
{ exposeSceneOverflow: true },
|
||
);
|
||
}
|
||
const projection = liveLayerProjection(painted.view, current.view);
|
||
for (const layer of root.querySelectorAll<HTMLElement>('[data-hp-live-layer="camera"]')) {
|
||
// Keeping an identity transform after terminal reconciliation changes the
|
||
// browser compositing path and therefore the settled raster by a few colour
|
||
// levels. The fast path must leave byte-equivalent DOM/CSS when it is idle.
|
||
setLayerProjection(layer, isIdentityLiveLayerProjection(projection) ? null : projection);
|
||
}
|
||
const badge = root.querySelector<HTMLElement>('[data-hp-live-zoom]');
|
||
if (badge) {
|
||
const hidden = current.zoom <= 1;
|
||
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||
const value = badge.querySelector<HTMLElement>('[data-hp-live-zoom-value]');
|
||
const text = `${Math.round(current.zoom * 100)}%`;
|
||
if (value && value.textContent !== text) value.textContent = text;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
const frameOf = (host: LiveViewportHost): LiveViewportFrame => {
|
||
const view = host._viewOr(host._baseVb());
|
||
return { view: { ...view }, floor: { ...host._floorView(view) }, zoom: host._zoom };
|
||
};
|
||
|
||
/** Coalesce camera input into one lightweight paint per animation frame. */
|
||
export function scheduleHouseplanViewport(value: object, now = false): void {
|
||
const host = value as LiveViewportHost;
|
||
const state = stateOf(value);
|
||
const next = frameOf(host);
|
||
if (now) {
|
||
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
|
||
state.raf = 0; state.pending = null;
|
||
if (!state.painted) state.painted = next;
|
||
state.anchor = paintLiveViewport(
|
||
host.renderRoot, state.painted, next, state.anchor, { keepSceneLayer: true },
|
||
);
|
||
return;
|
||
}
|
||
state.pending = next;
|
||
if (state.raf || typeof requestAnimationFrame !== 'function') return;
|
||
state.raf = requestAnimationFrame(() => {
|
||
state.raf = 0;
|
||
const next = state.pending;
|
||
state.pending = null;
|
||
const root = host.renderRoot as ParentNode | undefined;
|
||
if (!next || !root) return;
|
||
if (!state.painted) state.painted = next;
|
||
state.anchor = paintLiveViewport(
|
||
root, state.painted, next, state.anchor, { keepSceneLayer: true },
|
||
);
|
||
});
|
||
}
|
||
|
||
/** Record a complete Lit frame and reconcile gesture-scoped projections. */
|
||
export function commitHouseplanViewport(value: object, keepSceneLayer = false): void {
|
||
const host = value as LiveViewportHost;
|
||
const state = stateOf(value);
|
||
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
|
||
state.raf = 0;
|
||
state.pending = null;
|
||
state.painted = frameOf(host);
|
||
const root = host.renderRoot as ParentNode | undefined;
|
||
if (!root) return;
|
||
for (const layer of root.querySelectorAll<HTMLElement>('[data-hp-live-layer="camera"]')) {
|
||
setLayerProjection(layer, null);
|
||
}
|
||
// A full Lit frame may arrive while a pointer or programmatic camera is still
|
||
// active. It becomes the new exact anchor, but must not demote the scene in
|
||
// the middle of that session (#579). The terminal Lit frame passes false and
|
||
// restores the authored, transform-free idle DOM.
|
||
state.anchor = paintLiveViewport(root, state.painted, state.painted, state.anchor, {
|
||
force: true, keepSceneLayer,
|
||
});
|
||
}
|
||
|
||
export function disposeHouseplanViewport(host: object): void {
|
||
const state = states.get(host);
|
||
if (state?.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
|
||
states.delete(host);
|
||
}
|