Files
houseplan-card/src/live-viewport.ts
T
Claude 600330e187 fix(daycycle): stage-sized plan layers without a frozen raster; revert #685 hatch
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
2026-09-28 17:51:06 +03:00

319 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}