Files
houseplan-card/src/live-viewport.ts
T
2026-09-04 22:04:17 +03:00

166 lines
5.6 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;
}
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 };
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;
const finiteView = (view: LiveViewBox): boolean =>
[view.x, view.y, view.w, view.h].every(Number.isFinite) && view.w > 0 && view.h > 0;
const setLayerProjection = (
layer: HTMLElement,
projection: LiveLayerProjection | null,
): void => {
if (!projection) {
layer.style.removeProperty('transform');
layer.style.removeProperty('transform-origin');
layer.style.removeProperty('will-change');
return;
}
layer.style.transformOrigin = '0 0';
layer.style.willChange = 'transform';
layer.style.transform = `translate(${projection.translateXPercent}%,${projection.translateYPercent}%) scale(${projection.scaleX},${projection.scaleY})`;
};
/** One atomic DOM-only viewport paint; no Lit host update is involved. */
export function paintLiveViewport(
root: ParentNode,
painted: LiveViewportFrame,
current: LiveViewportFrame,
): void {
if (!finiteView(painted.view) || !finiteView(current.view) || !finiteView(current.floor)) return;
const viewBox = liveViewBoxText(current.view);
const floorBox = liveViewBoxText(current.floor);
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="camera"]')) {
svg.setAttribute('viewBox', viewBox);
}
for (const svg of root.querySelectorAll<SVGElement>('[data-hp-live-viewbox="floor"]')) {
svg.setAttribute('viewBox', floorBox);
}
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) {
badge.hidden = current.zoom <= 1;
const value = badge.querySelector<HTMLElement>('[data-hp-live-zoom-value]');
if (value) value.textContent = `${Math.round(current.zoom * 100)}%`;
}
}
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;
paintLiveViewport(host.renderRoot, state.painted, next);
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;
paintLiveViewport(root, state.painted, next);
});
}
/** Record a complete Lit frame and remove any temporary HTML projection. */
export function commitHouseplanViewport(value: object): 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);
}
paintLiveViewport(root, state.painted, state.painted);
}
export function disposeHouseplanViewport(host: object): void {
const state = states.get(host);
if (state?.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
states.delete(host);
}