mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
export type HouseplanMode = 'view' | 'plan' | 'devices' | 'decor';
|
|
export type ModeTransitionPhase = 'idle' | 'preparing' | 'running' | 'settling';
|
|
|
|
export interface ModeViewBox { x: number; y: number; w: number; h: number }
|
|
|
|
export interface ViewportPresentation {
|
|
centerX: number;
|
|
centerY: number;
|
|
pixelsPerUnit: number;
|
|
viewBox: ModeViewBox;
|
|
}
|
|
|
|
export interface ModeVisualState {
|
|
presentedMode: HouseplanMode;
|
|
editorChromeHeight: number;
|
|
stageWidth: number;
|
|
stageHeight: number;
|
|
viewport: ViewportPresentation;
|
|
stageColor: string;
|
|
paperColor: string;
|
|
sceneBrightness: number;
|
|
/** Presentation-only fade used by the backdrop editor; never source alpha. */
|
|
architectureOpacity: number;
|
|
/** The imported backdrop has a separate editor de-emphasis contract. */
|
|
backdropOpacity: number;
|
|
viewWeight: number;
|
|
editorWeight: number;
|
|
toolbarContentOpacity: number;
|
|
}
|
|
|
|
export interface ModeTransitionState {
|
|
token: number;
|
|
phase: ModeTransitionPhase;
|
|
from: ModeVisualState;
|
|
to: ModeVisualState;
|
|
presented: ModeVisualState;
|
|
startedAt: number;
|
|
duration: number;
|
|
targetMode: HouseplanMode;
|
|
}
|
|
|
|
export interface ModeTransitionHooks {
|
|
frame: (state: ModeTransitionState) => void;
|
|
settled: (state: ModeTransitionState) => void;
|
|
}
|
|
|
|
export interface ModeTransitionClock {
|
|
now: () => number;
|
|
requestFrame: (callback: FrameRequestCallback) => number;
|
|
cancelFrame: (handle: number) => void;
|
|
}
|
|
|
|
const browserClock: ModeTransitionClock = {
|
|
now: () => performance.now(),
|
|
requestFrame: (callback) => requestAnimationFrame(callback),
|
|
cancelFrame: (handle) => cancelAnimationFrame(handle),
|
|
};
|
|
|
|
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
|
const lerp = (a: number, b: number, p: number): number => a + (b - a) * p;
|
|
|
|
const cubicCoordinate = (t: number, p1: number, p2: number): number => {
|
|
const u = 1 - t;
|
|
return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
|
|
};
|
|
|
|
const ease = (p: number): number => {
|
|
// Exact CSS cubic-bezier(0.2, 0.7, 0.2, 1): animation progress is the
|
|
// curve's x coordinate, so solve x(t) and then evaluate y(t). A bounded
|
|
// bisection is deterministic, monotonic and negligible beside one render.
|
|
const x = clamp01(p);
|
|
if (x === 0 || x === 1) return x;
|
|
let low = 0, high = 1, t = x;
|
|
for (let i = 0; i < 12; i++) {
|
|
t = (low + high) / 2;
|
|
if (cubicCoordinate(t, 0.2, 0.2) < x) low = t;
|
|
else high = t;
|
|
}
|
|
return cubicCoordinate(t, 0.7, 1);
|
|
};
|
|
|
|
export function viewportFromViewBox(
|
|
viewBox: ModeViewBox,
|
|
stageWidth: number,
|
|
): ViewportPresentation {
|
|
return {
|
|
centerX: viewBox.x + viewBox.w / 2,
|
|
centerY: viewBox.y + viewBox.h / 2,
|
|
pixelsPerUnit: stageWidth > 0 && viewBox.w > 0 ? stageWidth / viewBox.w : 1,
|
|
viewBox: { ...viewBox },
|
|
};
|
|
}
|
|
|
|
export function viewBoxFromViewport(
|
|
viewport: Pick<ViewportPresentation, 'centerX' | 'centerY' | 'pixelsPerUnit'>,
|
|
stageWidth: number,
|
|
stageHeight: number,
|
|
): ModeViewBox {
|
|
const pixelsPerUnit = Math.max(1e-9, viewport.pixelsPerUnit);
|
|
const w = Math.max(1e-9, stageWidth / pixelsPerUnit);
|
|
const h = Math.max(1e-9, stageHeight / pixelsPerUnit);
|
|
return { x: viewport.centerX - w / 2, y: viewport.centerY - h / 2, w, h };
|
|
}
|
|
|
|
function parseColor(value: string): [number, number, number, number] | null {
|
|
const text = value.trim();
|
|
const rgb = /^rgba?\(\s*([\d.]+)[, ]+([\d.]+)[, ]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text);
|
|
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3]), rgb[4] == null ? 1 : Number(rgb[4])];
|
|
const hex = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.exec(text)?.[1];
|
|
if (!hex) return null;
|
|
return [
|
|
parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16),
|
|
parseInt(hex.slice(4, 6), 16), hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
|
|
];
|
|
}
|
|
|
|
export function interpolateColor(from: string, to: string, progress: number): string {
|
|
const a = parseColor(from), b = parseColor(to);
|
|
if (!a || !b) return progress < 0.5 ? from : to;
|
|
const p = clamp01(progress);
|
|
return `rgba(${Math.round(lerp(a[0], b[0], p))}, ${Math.round(lerp(a[1], b[1], p))}, ${Math.round(lerp(a[2], b[2], p))}, ${lerp(a[3], b[3], p).toFixed(3)})`;
|
|
}
|
|
|
|
export function interpolateModeVisualState(
|
|
from: ModeVisualState,
|
|
to: ModeVisualState,
|
|
progress: number,
|
|
): ModeVisualState {
|
|
const p = ease(progress);
|
|
const stageWidth = lerp(from.stageWidth, to.stageWidth, p);
|
|
const stageHeight = lerp(from.stageHeight, to.stageHeight, p);
|
|
// Screen scale interpolates logarithmically, so zoom speed is perceptually
|
|
// even and independent of either endpoint's content-frame definition.
|
|
const fromPpu = Math.max(1e-9, from.viewport.pixelsPerUnit);
|
|
const toPpu = Math.max(1e-9, to.viewport.pixelsPerUnit);
|
|
const camera = {
|
|
centerX: lerp(from.viewport.centerX, to.viewport.centerX, p),
|
|
centerY: lerp(from.viewport.centerY, to.viewport.centerY, p),
|
|
pixelsPerUnit: Math.exp(lerp(Math.log(fromPpu), Math.log(toPpu), p)),
|
|
};
|
|
return {
|
|
presentedMode: p < 0.5 ? from.presentedMode : to.presentedMode,
|
|
editorChromeHeight: lerp(from.editorChromeHeight, to.editorChromeHeight, p),
|
|
stageWidth,
|
|
stageHeight,
|
|
viewport: { ...camera, viewBox: viewBoxFromViewport(camera, stageWidth, stageHeight) },
|
|
stageColor: interpolateColor(from.stageColor, to.stageColor, p),
|
|
paperColor: interpolateColor(from.paperColor, to.paperColor, p),
|
|
sceneBrightness: lerp(from.sceneBrightness, to.sceneBrightness, p),
|
|
architectureOpacity: lerp(from.architectureOpacity, to.architectureOpacity, p),
|
|
backdropOpacity: lerp(from.backdropOpacity, to.backdropOpacity, p),
|
|
viewWeight: lerp(from.viewWeight, to.viewWeight, p),
|
|
editorWeight: lerp(from.editorWeight, to.editorWeight, p),
|
|
toolbarContentOpacity: lerp(from.toolbarContentOpacity, to.toolbarContentOpacity, p),
|
|
};
|
|
}
|
|
|
|
/** Single RAF/token owner for all View/editor transition coordinates. */
|
|
export class ModeTransitionController {
|
|
private _token = 0;
|
|
private _raf = 0;
|
|
private _state: ModeTransitionState | null = null;
|
|
|
|
public constructor(
|
|
private readonly _hooks: ModeTransitionHooks,
|
|
private readonly _clock: ModeTransitionClock = browserClock,
|
|
) {}
|
|
|
|
public get state(): ModeTransitionState | null { return this._state; }
|
|
public get active(): boolean { return this._state?.phase === 'running' || this._state?.phase === 'preparing'; }
|
|
public get presented(): ModeVisualState | null { return this._state?.presented || null; }
|
|
|
|
public start(
|
|
from: ModeVisualState,
|
|
to: ModeVisualState,
|
|
targetMode: HouseplanMode,
|
|
duration = 220,
|
|
): number {
|
|
this.cancel(false);
|
|
const token = ++this._token;
|
|
const now = this._clock.now();
|
|
this._state = {
|
|
token, phase: duration <= 0 ? 'settling' : 'running', from, to,
|
|
presented: duration <= 0 ? to : from,
|
|
startedAt: now, duration: Math.max(0, duration), targetMode,
|
|
};
|
|
if (duration <= 0) {
|
|
this._hooks.frame(this._state);
|
|
this._hooks.settled(this._state);
|
|
this._state = null;
|
|
return token;
|
|
}
|
|
this._hooks.frame(this._state);
|
|
const tick = (time: number): void => {
|
|
const current = this._state;
|
|
if (!current || current.token !== token) return;
|
|
const raw = clamp01((time - current.startedAt) / current.duration);
|
|
current.presented = raw >= 1 ? current.to
|
|
: interpolateModeVisualState(current.from, current.to, raw);
|
|
current.phase = raw >= 1 ? 'settling' : 'running';
|
|
this._hooks.frame(current);
|
|
if (raw < 1) {
|
|
this._raf = this._clock.requestFrame(tick);
|
|
return;
|
|
}
|
|
this._raf = 0;
|
|
this._hooks.settled(current);
|
|
if (this._state?.token === token) this._state = null;
|
|
};
|
|
this._raf = this._clock.requestFrame(tick);
|
|
return token;
|
|
}
|
|
|
|
public cancel(commitTarget = true): void {
|
|
if (this._raf) this._clock.cancelFrame(this._raf);
|
|
this._raf = 0;
|
|
const state = this._state;
|
|
this._state = null;
|
|
this._token++;
|
|
if (state && commitTarget) {
|
|
state.phase = 'settling';
|
|
state.presented = state.to;
|
|
this._hooks.frame(state);
|
|
this._hooks.settled(state);
|
|
}
|
|
}
|
|
|
|
public dispose(): void { this.cancel(false); }
|
|
}
|