export const CAMERA_ZOOM_MIN_SCALE = 1; export const CAMERA_ZOOM_MAX_SCALE = 8; // Tuning constant for discrete shift+wheel zoom on grid cards. export const CAMERA_ZOOM_SHIFT_WHEEL_STEP = 0.1; export type CameraZoomRuntimeTransform = { scale: number; positionX: number; positionY: number; }; export type CameraZoomDimensions = { viewportWidth: number; viewportHeight: number; contentWidth: number; contentHeight: number; }; export type CameraZoomPersistedState = { /** * Scale normalized to a [0, 1] range between min and max zoom. */ normalizedScale: number; /** * Relative content x-coordinate (0..1) that should map to the viewport center. */ focusX: number; /** * Relative content y-coordinate (0..1) that should map to the viewport center. */ focusY: number; }; export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } export function clampScale( scale: number, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): number { return clamp(scale, minScale, maxScale); } export function normalizeScale( scale: number, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): number { if (maxScale <= minScale) { return 0; } return clamp( (clampScale(scale, minScale, maxScale) - minScale) / (maxScale - minScale), 0, 1, ); } export function denormalizeScale( normalizedScale: number, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): number { if (maxScale <= minScale) { return minScale; } const normalized = clamp(normalizedScale, 0, 1); return minScale + normalized * (maxScale - minScale); } /** * Calculates a new pan position to keep the content under the cursor fixed * while changing scale. */ export function getCursorRelativeZoomTransform( current: CameraZoomRuntimeTransform, targetScale: number, cursorX: number, cursorY: number, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): CameraZoomRuntimeTransform { const nextScale = clampScale(targetScale, minScale, maxScale); const safeCurrentScale = Number.isFinite(current.scale) && current.scale > 0 ? current.scale : CAMERA_ZOOM_MIN_SCALE; const contentX = (cursorX - current.positionX) / safeCurrentScale; const contentY = (cursorY - current.positionY) / safeCurrentScale; return { scale: nextScale, positionX: cursorX - contentX * nextScale, positionY: cursorY - contentY * nextScale, }; } export function toPersistedCameraZoomState( runtime: CameraZoomRuntimeTransform, dimensions: CameraZoomDimensions, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): CameraZoomPersistedState { const safeContentWidth = dimensions.contentWidth || 1; const safeContentHeight = dimensions.contentHeight || 1; const safeScale = Number.isFinite(runtime.scale) && runtime.scale > 0 ? runtime.scale : CAMERA_ZOOM_MIN_SCALE; const centerContentX = (dimensions.viewportWidth / 2 - runtime.positionX) / safeScale; const centerContentY = (dimensions.viewportHeight / 2 - runtime.positionY) / safeScale; return { normalizedScale: normalizeScale(runtime.scale, minScale, maxScale), focusX: clamp(centerContentX / safeContentWidth, 0, 1), focusY: clamp(centerContentY / safeContentHeight, 0, 1), }; } export function fromPersistedCameraZoomState( persisted: CameraZoomPersistedState, dimensions: CameraZoomDimensions, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): CameraZoomRuntimeTransform { const scale = denormalizeScale(persisted.normalizedScale, minScale, maxScale); const safeContentWidth = dimensions.contentWidth || 1; const safeContentHeight = dimensions.contentHeight || 1; const contentX = clamp(persisted.focusX, 0, 1) * safeContentWidth; const contentY = clamp(persisted.focusY, 0, 1) * safeContentHeight; return { scale, positionX: dimensions.viewportWidth / 2 - contentX * scale, positionY: dimensions.viewportHeight / 2 - contentY * scale, }; } export function getNextScaleFromWheelDelta( currentScale: number, wheelDeltaY: number, step: number = CAMERA_ZOOM_SHIFT_WHEEL_STEP, minScale: number = CAMERA_ZOOM_MIN_SCALE, maxScale: number = CAMERA_ZOOM_MAX_SCALE, ): number { if (wheelDeltaY === 0) { return clampScale(currentScale, minScale, maxScale); } const direction = wheelDeltaY > 0 ? -1 : 1; return clampScale(currentScale + direction * step, minScale, maxScale); } export function getCameraZoomStorageKey(cameraName: string): string { return `live:grid-card:zoom:${cameraName}`; }