mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
feat: add smooth viewport zoom transitions
Issue: #82 User-Visible: yes
This commit is contained in:
+150
-24
@@ -293,6 +293,10 @@ import {
|
||||
ModeTransitionController, viewportFromViewBox,
|
||||
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
|
||||
} from './mode-transition';
|
||||
import {
|
||||
CameraTransitionController, cameraTargetAtAnchor, sameCameraState,
|
||||
type CameraState, type CameraTransitionReason, type CameraTransitionState,
|
||||
} from './viewport-transition';
|
||||
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
|
||||
import type { BackdropGuardState } from './backdrop-pick';
|
||||
|
||||
@@ -406,6 +410,9 @@ const BOOT_MAX_MS = 1200;
|
||||
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
|
||||
* (CSS height transition on the stage) instead of snapping. */
|
||||
const BOOT_SOFT_MS = 1500;
|
||||
const CAMERA_BUTTON_MS = 180;
|
||||
const CAMERA_WHEEL_MS = 160;
|
||||
const CAMERA_FIT_MS = 220;
|
||||
/** Numeric editor fields must consume the whole value: `50abc` is invalid,
|
||||
* not a surprisingly accepted 50. Decimal comma remains supported. */
|
||||
const strictNumber = (value: string): number | null => {
|
||||
@@ -1149,6 +1156,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _onMotionChange = (event: MediaQueryListEvent): void => {
|
||||
this._reducedMotion = event.matches;
|
||||
this._cancelDevicePressFeedback();
|
||||
if (event.matches) this._cancelCameraTransition(true);
|
||||
// A preference change to reduced motion is authoritative immediately: do
|
||||
// not leave a 220 ms camera/chrome tween running until its old deadline.
|
||||
if (event.matches && this._modeTransitionPreparing) {
|
||||
@@ -1179,6 +1187,13 @@ export class HouseplanCard extends LitElement {
|
||||
frame: (state) => this._applyModeTransitionFrame(state),
|
||||
settled: (state) => this._settleModeTransition(state),
|
||||
});
|
||||
/** #82: one camera-only transition inside a settled mode. It never owns
|
||||
* chrome/background coordinates — those remain exclusive to #101. */
|
||||
private _cameraTransitionFit: ModeViewBox | null = null;
|
||||
private readonly _cameraTransition = new CameraTransitionController({
|
||||
frame: (state) => this._applyCameraTransitionFrame(state),
|
||||
settled: (state) => this._settleCameraTransition(state),
|
||||
});
|
||||
/** Explicit second-level toolbar group. No current tools are grouped yet;
|
||||
* the shared host/API prevents future editors from inventing dropdowns. */
|
||||
private declare _editorSecondary: import('./editor-secondary').EditorSecondaryController;
|
||||
@@ -1192,6 +1207,72 @@ export class HouseplanCard extends LitElement {
|
||||
return this._modeTransitionPreparing || this._modeTransition.active;
|
||||
}
|
||||
|
||||
private _cameraState(): CameraState {
|
||||
const view = this._viewOr(this._baseVb());
|
||||
return { zoom: this._zoom, viewBox: { ...view } };
|
||||
}
|
||||
|
||||
private _normalizeCameraState(state: CameraState): CameraState {
|
||||
const fit = this._cameraTransitionFit || fitView(this._baseVb(), this._stageAspect());
|
||||
const zoom = Math.min(HouseplanCard.ZOOM_MAX,
|
||||
Math.max(HouseplanCard.ZOOM_MIN, state.zoom));
|
||||
return { zoom, viewBox: this._clampView({ ...state.viewBox }, fit) };
|
||||
}
|
||||
|
||||
private _applyCameraTransitionFrame(state: CameraTransitionState): void {
|
||||
const presented = this._normalizeCameraState(state.presented);
|
||||
// Retargeting must start from the camera that was actually painted, not
|
||||
// from an unclamped interpolation hidden inside the controller.
|
||||
state.presented = presented;
|
||||
this._zoom = presented.zoom;
|
||||
this._view = { ...presented.viewBox };
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _settleCameraTransition(state: CameraTransitionState): void {
|
||||
const target = this._normalizeCameraState(state.to);
|
||||
this._zoom = target.zoom;
|
||||
this._view = { ...target.viewBox };
|
||||
this._cameraTransitionFit = null;
|
||||
this._saveZoom();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _cancelCameraTransition(commitTarget = false): void {
|
||||
this._cameraTransition.cancel(commitTarget);
|
||||
this._cameraTransitionFit = null;
|
||||
}
|
||||
|
||||
/** A discrete camera command may follow a rapid mode click. Finish that
|
||||
* short structural transition first so two controllers never write `_view`
|
||||
* in the same frame. */
|
||||
private _prepareCameraCommand(): void {
|
||||
if (this._modeTransitionBusy) this._cancelModeTransition(true);
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
}
|
||||
|
||||
private _startCameraTransition(
|
||||
target: CameraState,
|
||||
fit: ModeViewBox,
|
||||
reason: CameraTransitionReason,
|
||||
duration: number,
|
||||
): boolean {
|
||||
const current = this._cameraState();
|
||||
const runningTarget = this._cameraTransition.target;
|
||||
if (runningTarget && sameCameraState(runningTarget, target)) return false;
|
||||
if (sameCameraState(current, target)) {
|
||||
this._cancelCameraTransition(false);
|
||||
return false;
|
||||
}
|
||||
this._cameraTransitionFit = { ...fit };
|
||||
this._cameraTransition.start(current, target, reason,
|
||||
this._reducedMotion ? 0 : duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _cssColor(value: string | null | undefined, fallback: string): string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return fallback;
|
||||
@@ -1317,6 +1398,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _cancelModeTransition(commitTarget = true): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const hadControllerState = !!this._modeTransition.state;
|
||||
this._modeTransitionRequest++;
|
||||
this._modeTransitionPreparing = false;
|
||||
@@ -1466,6 +1548,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _commitSpace(id: string, authority = false): boolean {
|
||||
if (!this._canCommitSpace(id, authority)) return false;
|
||||
if (id !== this._space) {
|
||||
this._cancelCameraTransition(false);
|
||||
this._clearTransientHover(true);
|
||||
this._cancelDevicePressFeedback();
|
||||
this._editorRuntime?._clearFurniturePreview();
|
||||
@@ -2277,6 +2360,7 @@ export class HouseplanCard extends LitElement {
|
||||
if (signal.kind === 'hidden') {
|
||||
this._clearTransientHover(true);
|
||||
this._cancelDevicePressFeedback();
|
||||
this._cancelCameraTransition(true);
|
||||
if (this._modeTransitionPreparing) {
|
||||
// There is no measured controller endpoint to commit yet. Keep the
|
||||
// already queued measurement but force its synchronous atomic path;
|
||||
@@ -2658,6 +2742,8 @@ export class HouseplanCard extends LitElement {
|
||||
this._editorRuntimeLoadingTimer = undefined;
|
||||
this._editorRuntimeLoadingVisible = false;
|
||||
this._modeTransition.dispose();
|
||||
this._cameraTransition.dispose();
|
||||
this._cameraTransitionFit = null;
|
||||
this._modeTransitionVisual = null;
|
||||
this._modeTransitionPreparing = false;
|
||||
this._modeTransitionForceAtomic = false;
|
||||
@@ -4090,6 +4176,10 @@ export class HouseplanCard extends LitElement {
|
||||
const configChanged = nextCfgFingerprint !== (this._cfgContentFingerprint
|
||||
|| contentFingerprint(this._serverCfg));
|
||||
if (configChanged) {
|
||||
// #82: a new structural baseline owns the viewport. Freeze the last
|
||||
// painted camera frame before replacing geometry so an obsolete target
|
||||
// cannot settle against the new content frame.
|
||||
this._cancelCameraTransition(false);
|
||||
// A genuinely different baseline invalidates local geometry undo. A
|
||||
// reconnect echo with identical content deliberately does not.
|
||||
this._geometryHistory.clear();
|
||||
@@ -4123,6 +4213,7 @@ export class HouseplanCard extends LitElement {
|
||||
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|
||||
|| contentFingerprint(this._layout));
|
||||
if (layoutChanged) {
|
||||
this._cancelCameraTransition(false);
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._layout = nextLayout;
|
||||
@@ -5885,10 +5976,10 @@ export class HouseplanCard extends LitElement {
|
||||
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar button and the "home is
|
||||
* that way" arrow share it. It fits whatever the frame currently means:
|
||||
* the main mass, or everything once the far-objects hint has been used. */
|
||||
private _fitAll(): void {
|
||||
private _fitAll(reason: 'fit' | 'home' = 'fit'): void {
|
||||
this._showFar = true;
|
||||
this._frame = null;
|
||||
this._resetZoom();
|
||||
this._resetZoom(reason);
|
||||
}
|
||||
|
||||
/** Unobtrusive inline hint: some objects stand an order of magnitude away
|
||||
@@ -5919,7 +6010,7 @@ export class HouseplanCard extends LitElement {
|
||||
const top = 50 + Math.sin(ang) * 38;
|
||||
return html`<button class="homearrow" title=${this._t('canvas.home_tip')}
|
||||
style="left:${left.toFixed(1)}%;top:${top.toFixed(1)}%"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._fitAll(); }}>
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._fitAll('home'); }}>
|
||||
<ha-icon icon="mdi:arrow-right-thick" style="transform:rotate(${((ang * 180) / Math.PI).toFixed(1)}deg)"></ha-icon>
|
||||
</button>`;
|
||||
}
|
||||
@@ -5991,6 +6082,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
|
||||
private _applyView(zoom: number, cx?: number, cy?: number): boolean {
|
||||
this._cancelCameraTransition(false);
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom));
|
||||
@@ -6105,6 +6197,9 @@ export class HouseplanCard extends LitElement {
|
||||
/** Recompute the view for a new scene size, preserving zoom and center. */
|
||||
private _refitView(): void {
|
||||
if (this._modeTransitionBusy || this._warmModeRequest) return;
|
||||
// Resize is structural: keep the currently painted camera, cancel its old
|
||||
// target, then let the existing refit path own the new stage geometry.
|
||||
this._cancelCameraTransition(false);
|
||||
const stage = this._stageEl;
|
||||
// ResizeObserver may deliver a zero/transitional box while a browser tab
|
||||
// is frozen or while Lovelace replaces the card. Mutating `_view` from
|
||||
@@ -6160,53 +6255,82 @@ export class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** Change the zoom while keeping the point (sx,sy relative to the scene) in place. */
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
private _cameraTargetAt(
|
||||
sx: number,
|
||||
sy: number,
|
||||
newZoom: number,
|
||||
): { target: CameraState; fit: ModeViewBox } | null {
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
|
||||
const target = cameraTargetAtAnchor(
|
||||
this._cameraState(), z, fit,
|
||||
stage.clientWidth, stage.clientHeight, sx, sy,
|
||||
);
|
||||
if (!target) return null;
|
||||
target.viewBox = this._clampView(target.viewBox, fit);
|
||||
return { target, fit };
|
||||
}
|
||||
|
||||
/** Immediate path for direct pinch: keep the point under the fingers. */
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const result = this._cameraTargetAt(sx, sy, newZoom);
|
||||
if (!result) return;
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
const pt = this._screenToVb(sx, sy);
|
||||
const nw = fit.w / z, nh = fit.h / z;
|
||||
this._zoom = z;
|
||||
this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit);
|
||||
this._zoom = result.target.zoom;
|
||||
this._view = { ...result.target.viewBox };
|
||||
}
|
||||
|
||||
private _onWheel(ev: WheelEvent): void {
|
||||
this._prepareCameraCommand();
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
ev.preventDefault();
|
||||
const r = stage.getBoundingClientRect();
|
||||
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor);
|
||||
this._saveZoom();
|
||||
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
|
||||
const result = this._cameraTargetAt(
|
||||
ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor,
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'wheel', CAMERA_WHEEL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private _stepZoom(delta: number): void {
|
||||
this._prepareCameraCommand();
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4));
|
||||
this._saveZoom();
|
||||
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
|
||||
const result = this._cameraTargetAt(
|
||||
stage.clientWidth / 2,
|
||||
stage.clientHeight / 2,
|
||||
baseZoom * (delta > 0 ? 1.4 : 1 / 1.4),
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'button', CAMERA_BUTTON_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar's middle button and the
|
||||
* old "reset zoom" in one: frame the content at zoom 1, centred. With
|
||||
* `all` it also takes in the far strays the opening view leaves out. */
|
||||
private _resetZoom(): void {
|
||||
private _resetZoom(reason: 'fit' | 'home' | 'double-tap' = 'fit'): void {
|
||||
this._prepareCameraCommand();
|
||||
const vb = this._baseVb();
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
this._zoom = 1;
|
||||
this._view = fitView(vb, this._stageAspect());
|
||||
this._saveZoom();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
this._startCameraTransition(
|
||||
{ zoom: 1, viewBox: { ...fit } },
|
||||
fit,
|
||||
reason,
|
||||
CAMERA_FIT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Save the current space zoom to localStorage (view mode only). */
|
||||
@@ -6229,6 +6353,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Restore the saved space zoom and center the plan. */
|
||||
private _restoreZoom(): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const z = this._zoomBySpace[this._space] || 1;
|
||||
this._zoom = z;
|
||||
const stage = this._stageEl;
|
||||
@@ -6255,6 +6380,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerDown(ev: PointerEvent): void {
|
||||
this._cancelCameraTransition(false);
|
||||
if (this._vacFit) return; // no pan/swipe while fitting the robot map
|
||||
// The shared secondary controller owns palette dismissal in window
|
||||
// capture, including the matching synthetic click. Do not duplicate that
|
||||
@@ -6450,7 +6576,7 @@ export class HouseplanCard extends LitElement {
|
||||
// double tap (no movement) resets the zoom to 1:1
|
||||
if (Math.abs(dx) + Math.abs(dy) < 8) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastTap < 350) this._resetZoom();
|
||||
if (now - this._lastTap < 350) this._resetZoom('double-tap');
|
||||
this._lastTap = now;
|
||||
}
|
||||
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
|
||||
|
||||
@@ -64,7 +64,7 @@ const cubicCoordinate = (t: number, p1: number, p2: number): number => {
|
||||
return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
|
||||
};
|
||||
|
||||
const ease = (p: number): number => {
|
||||
export const easeTransitionProgress = (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.
|
||||
@@ -126,7 +126,7 @@ export function interpolateModeVisualState(
|
||||
to: ModeVisualState,
|
||||
progress: number,
|
||||
): ModeVisualState {
|
||||
const p = ease(progress);
|
||||
const p = easeTransitionProgress(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
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { easeTransitionProgress } from './mode-transition';
|
||||
|
||||
export interface CameraViewBox { x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface CameraState {
|
||||
zoom: number;
|
||||
viewBox: CameraViewBox;
|
||||
}
|
||||
|
||||
export type CameraTransitionReason = 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
|
||||
export type CameraTransitionPhase = 'running' | 'settling';
|
||||
|
||||
export interface CameraTransitionState {
|
||||
token: number;
|
||||
phase: CameraTransitionPhase;
|
||||
from: CameraState;
|
||||
to: CameraState;
|
||||
presented: CameraState;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
reason: CameraTransitionReason;
|
||||
}
|
||||
|
||||
export interface CameraTransitionHooks {
|
||||
frame: (state: CameraTransitionState) => void;
|
||||
settled: (state: CameraTransitionState) => void;
|
||||
}
|
||||
|
||||
export interface CameraTransitionClock {
|
||||
now: () => number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number | null;
|
||||
cancelFrame: (handle: number) => void;
|
||||
}
|
||||
|
||||
const browserClock: CameraTransitionClock = {
|
||||
now: () => typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
requestFrame: (callback) => typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(callback) : null,
|
||||
cancelFrame: (handle) => {
|
||||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);
|
||||
},
|
||||
};
|
||||
|
||||
const finite = (value: number): boolean => Number.isFinite(value);
|
||||
|
||||
export function validCameraState(state: CameraState | null | undefined): state is CameraState {
|
||||
return !!state && finite(state.zoom) && state.zoom > 0
|
||||
&& finite(state.viewBox.x) && finite(state.viewBox.y)
|
||||
&& finite(state.viewBox.w) && state.viewBox.w > 0
|
||||
&& finite(state.viewBox.h) && state.viewBox.h > 0;
|
||||
}
|
||||
|
||||
export function cloneCameraState(state: CameraState): CameraState {
|
||||
return { zoom: state.zoom, viewBox: { ...state.viewBox } };
|
||||
}
|
||||
|
||||
export function sameCameraState(a: CameraState, b: CameraState): boolean {
|
||||
return Math.abs(a.zoom - b.zoom) < 1e-9
|
||||
&& Math.abs(a.viewBox.x - b.viewBox.x) < 1e-6
|
||||
&& Math.abs(a.viewBox.y - b.viewBox.y) < 1e-6
|
||||
&& Math.abs(a.viewBox.w - b.viewBox.w) < 1e-6
|
||||
&& Math.abs(a.viewBox.h - b.viewBox.h) < 1e-6;
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, progress: number): number => a + (b - a) * progress;
|
||||
const logLerp = (a: number, b: number, progress: number): number =>
|
||||
Math.exp(lerp(Math.log(a), Math.log(b), progress));
|
||||
|
||||
export function interpolateCameraState(
|
||||
from: CameraState,
|
||||
to: CameraState,
|
||||
progress: number,
|
||||
): CameraState {
|
||||
if (!validCameraState(from)) return cloneCameraState(to);
|
||||
if (!validCameraState(to)) return cloneCameraState(from);
|
||||
const p = easeTransitionProgress(progress);
|
||||
if (p <= 0) return cloneCameraState(from);
|
||||
if (p >= 1) return cloneCameraState(to);
|
||||
const fromCenterX = from.viewBox.x + from.viewBox.w / 2;
|
||||
const fromCenterY = from.viewBox.y + from.viewBox.h / 2;
|
||||
const toCenterX = to.viewBox.x + to.viewBox.w / 2;
|
||||
const toCenterY = to.viewBox.y + to.viewBox.h / 2;
|
||||
const w = logLerp(from.viewBox.w, to.viewBox.w, p);
|
||||
const h = logLerp(from.viewBox.h, to.viewBox.h, p);
|
||||
const centerX = lerp(fromCenterX, toCenterX, p);
|
||||
const centerY = lerp(fromCenterY, toCenterY, p);
|
||||
return {
|
||||
zoom: logLerp(from.zoom, to.zoom, p),
|
||||
viewBox: { x: centerX - w / 2, y: centerY - h / 2, w, h },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unclamped target which leaves the presented world point under the anchor. */
|
||||
export function cameraTargetAtAnchor(
|
||||
from: CameraState,
|
||||
targetZoom: number,
|
||||
fit: CameraViewBox,
|
||||
stageWidth: number,
|
||||
stageHeight: number,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
): CameraState | null {
|
||||
if (!validCameraState(from) || !validCameraState({ zoom: targetZoom, viewBox: fit })
|
||||
|| !finite(stageWidth) || stageWidth <= 0 || !finite(stageHeight) || stageHeight <= 0
|
||||
|| !finite(anchorX) || !finite(anchorY)) return null;
|
||||
const xRatio = anchorX / stageWidth;
|
||||
const yRatio = anchorY / stageHeight;
|
||||
const worldX = from.viewBox.x + xRatio * from.viewBox.w;
|
||||
const worldY = from.viewBox.y + yRatio * from.viewBox.h;
|
||||
const w = fit.w / targetZoom;
|
||||
const h = fit.h / targetZoom;
|
||||
return {
|
||||
zoom: targetZoom,
|
||||
viewBox: { x: worldX - xRatio * w, y: worldY - yRatio * h, w, h },
|
||||
};
|
||||
}
|
||||
|
||||
/** One token/RAF owner for camera-only transitions inside a settled UI mode. */
|
||||
export class CameraTransitionController {
|
||||
private _token = 0;
|
||||
private _raf: number | null = null;
|
||||
private _state: CameraTransitionState | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly _hooks: CameraTransitionHooks,
|
||||
private readonly _clock: CameraTransitionClock = browserClock,
|
||||
) {}
|
||||
|
||||
public get state(): CameraTransitionState | null { return this._state; }
|
||||
public get active(): boolean { return this._state?.phase === 'running'; }
|
||||
public get presented(): CameraState | null { return this._state?.presented || null; }
|
||||
public get target(): CameraState | null { return this._state?.to || null; }
|
||||
|
||||
public start(
|
||||
fromInput: CameraState,
|
||||
toInput: CameraState,
|
||||
reason: CameraTransitionReason,
|
||||
duration: number,
|
||||
): number {
|
||||
this.cancel(false);
|
||||
const token = ++this._token;
|
||||
const from = cloneCameraState(fromInput);
|
||||
const to = cloneCameraState(toInput);
|
||||
const canAnimate = duration > 0 && validCameraState(from) && validCameraState(to);
|
||||
const now = this._clock.now();
|
||||
const state: CameraTransitionState = {
|
||||
token,
|
||||
phase: canAnimate ? 'running' : 'settling',
|
||||
from,
|
||||
to,
|
||||
presented: canAnimate ? cloneCameraState(from) : cloneCameraState(to),
|
||||
startedAt: now,
|
||||
duration: canAnimate ? duration : 0,
|
||||
reason,
|
||||
};
|
||||
this._state = state;
|
||||
this._hooks.frame(state);
|
||||
if (!canAnimate) {
|
||||
this._settle(token);
|
||||
return token;
|
||||
}
|
||||
const tick = (time: number): void => {
|
||||
const current = this._state;
|
||||
if (!current || current.token !== token) return;
|
||||
const raw = Math.max(0, Math.min(1, (time - current.startedAt) / current.duration));
|
||||
current.presented = raw >= 1
|
||||
? cloneCameraState(current.to)
|
||||
: interpolateCameraState(current.from, current.to, raw);
|
||||
current.phase = raw >= 1 ? 'settling' : 'running';
|
||||
this._hooks.frame(current);
|
||||
if (raw >= 1) {
|
||||
this._raf = null;
|
||||
this._settle(token);
|
||||
return;
|
||||
}
|
||||
this._raf = this._clock.requestFrame(tick);
|
||||
if (this._raf === null) this._settle(token);
|
||||
};
|
||||
this._raf = this._clock.requestFrame(tick);
|
||||
if (this._raf === null) {
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(to);
|
||||
this._hooks.frame(state);
|
||||
this._settle(token);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
public cancel(commitTarget = true): void {
|
||||
if (this._raf !== null) this._clock.cancelFrame(this._raf);
|
||||
this._raf = null;
|
||||
const state = this._state;
|
||||
this._state = null;
|
||||
this._token++;
|
||||
if (!state || !commitTarget) return;
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(state.to);
|
||||
this._hooks.frame(state);
|
||||
this._hooks.settled(state);
|
||||
}
|
||||
|
||||
public dispose(): void { this.cancel(false); }
|
||||
|
||||
private _settle(token: number): void {
|
||||
const state = this._state;
|
||||
if (!state || state.token !== token) return;
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(state.to);
|
||||
this._hooks.settled(state);
|
||||
if (this._state?.token === token) this._state = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user