feat(zoom): add cursor follow camera with edge snap focus

Cursor follow camera tracks cursor position for auto zoom regions
with edge snapping and zoom-out freeze. Edge snap focus remaps cursor
to clamp near screen boundaries.
This commit is contained in:
webadderall
2026-04-08 21:43:03 +10:00
parent 76143731e3
commit 34bc473479
2 changed files with 176 additions and 0 deletions
@@ -0,0 +1,134 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
import { interpolateCursorPosition } from "./cursorRenderer";
import { edgeSnapFocus } from "./focusUtils";
/**
* Cursor-follow camera.
*
* Computes a TARGET focus point each frame (cursor → edge snap → focus),
* then the zoom transition animation layer smoothly interpolates toward it.
*
* Edge snap: With snapToEdgesRatio = 0.25, cursor positions within 25% of
* each edge pin the camera to that edge. The middle 50% maps linearly.
* This prevents the camera from panning beyond the viewport bounds.
*/
/** Default snap ratio for manual zoom regions */
export const SNAP_TO_EDGES_RATIO_MANUAL = 0.25;
/** Snap ratio for system/auto zoom regions */
export const SNAP_TO_EDGES_RATIO_AUTO = 0.25;
export interface CursorFollowCameraState {
/** Whether the state has been initialized with a starting position */
initialized: boolean;
/** Time of last update in ms (video time, not wall clock) */
lastTimeMs: number;
/** Whether the camera was active (zoomed) on the previous frame */
wasZoomed: boolean;
/** Whether the zoom reached full strength (≈1) — used to detect zoom-out */
reachedFullZoom: boolean;
/** Frozen focus when zooming out (camera holds position) */
frozenFocusX: number;
frozenFocusY: number;
}
export interface CursorFollowConfig {
/**
* snapToEdgesRatio — how much of the screen edge pins the camera.
* 0.25 for manual zooms, 0.5 for auto/system zooms.
*/
snapToEdgesRatio: number;
}
export const DEFAULT_CURSOR_FOLLOW_CONFIG: CursorFollowConfig = {
snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO,
};
export function createCursorFollowCameraState(): CursorFollowCameraState {
return {
initialized: false,
lastTimeMs: 0,
wasZoomed: false,
reachedFullZoom: false,
frozenFocusX: 0.5,
frozenFocusY: 0.5,
};
}
export function resetCursorFollowCamera(state: CursorFollowCameraState): void {
state.initialized = false;
state.lastTimeMs = 0;
state.wasZoomed = false;
state.reachedFullZoom = false;
state.frozenFocusX = 0.5;
state.frozenFocusY = 0.5;
}
/**
* Cursor follow: target focus computation.
*
* Computes the desired camera focus point based on cursor position and
* edge snap. The zoom transition layer handles smooth interpolation.
*
* Pipeline: cursor → edgeSnap(snapToEdgesRatio) → focus
*
* @returns The target focus point for this frame (normalized 0-1).
*/
export function computeCursorFollowFocus(
state: CursorFollowCameraState,
cursorSamples: CursorTelemetryPoint[],
timeMs: number,
zoomScale: number,
zoomStrength: number,
regionFocus: ZoomFocus,
config: CursorFollowConfig = DEFAULT_CURSOR_FOLLOW_CONFIG,
): ZoomFocus {
// If no cursor data available, fall back to static region focus
const cursorPos = interpolateCursorPosition(cursorSamples, timeMs);
if (!cursorPos) {
return regionFocus;
}
// If not zoomed (strength ≈ 0), reset state and return region focus
if (zoomStrength < 0.01) {
if (state.wasZoomed) {
state.wasZoomed = false;
state.initialized = false;
state.reachedFullZoom = false;
}
return regionFocus;
}
// Track when zoom reaches full strength
if (zoomStrength >= 0.99) {
state.reachedFullZoom = true;
}
// Zooming out: was fully zoomed but strength is now dropping — freeze camera
if (state.reachedFullZoom && zoomStrength < 0.99) {
return { cx: state.frozenFocusX, cy: state.frozenFocusY };
}
// First frame of a zoom: mark initialized
if (!state.initialized || !state.wasZoomed) {
state.lastTimeMs = timeMs;
state.initialized = true;
state.wasZoomed = true;
}
state.lastTimeMs = timeMs;
// Edge snap: maps cursor through clamped linear remap.
// Camera pins to edge when cursor is within snapToEdgesRatio of boundary.
const targetFocus = edgeSnapFocus(
{ cx: cursorPos.cx, cy: cursorPos.cy },
zoomScale,
config.snapToEdgesRatio,
);
// Save for zoom-out freeze
state.frozenFocusX = targetFocus.cx;
state.frozenFocusY = targetFocus.cy;
return targetFocus;
}
@@ -101,6 +101,48 @@ export function softenFocusToScale(
};
}
/**
* Edge snap algorithm for cursor-follow camera.
*
* Maps cursor position through a clamped linear remap so the camera
* pins to the edge when the cursor is within `snapToEdgesRatio` of
* the viewport boundary.
*
* With snapToEdgesRatio = 0.25 (default for manual zooms):
* cursor ∈ [0, 0.25] → output 0 (camera pinned to left/top)
* cursor ∈ [0.25, 0.75] → linearly maps 0→1 (camera follows)
* cursor ∈ [0.75, 1.0] → output 1 (camera pinned to right/bottom)
*
* The result is then mapped back to focus-space bounds for the given
* zoom scale, so the camera stays within the valid viewport.
*
* @param snapToEdgesRatio 0.25 for manual zooms, 0.5 for system/auto zooms
*/
export function edgeSnapFocus(
cursorFocus: ZoomFocus,
zoomScale: number,
snapToEdgesRatio: number,
): ZoomFocus {
const bounds = getFocusBoundsForScale(zoomScale);
const snappedX = clampedInterpolate(cursorFocus.cx, snapToEdgesRatio, 1 - snapToEdgesRatio + 0.0001);
const snappedY = clampedInterpolate(cursorFocus.cy, snapToEdgesRatio, 1 - snapToEdgesRatio + 0.0001);
return {
cx: bounds.minX + snappedX * (bounds.maxX - bounds.minX),
cy: bounds.minY + snappedY * (bounds.maxY - bounds.minY),
};
}
/**
* Clamped linear interpolation: maps `value` from [inMin, inMax] → [0, 1].
*/
function clampedInterpolate(value: number, inMin: number, inMax: number): number {
if (inMax <= inMin) return 0;
const t = (value - inMin) / (inMax - inMin);
return clamp(t, 0, 1);
}
export function stageFocusToVideoSpace(
focus: ZoomFocus,
stageSize: StageSize,