From 046568ea6b8fcb1edecee4d95452e36f3781037e Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:38:09 +1100 Subject: [PATCH] Harden camera zoom helper restore and key namespace --- web/src/utils/cameraZoom.ts | 162 ++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 web/src/utils/cameraZoom.ts diff --git a/web/src/utils/cameraZoom.ts b/web/src/utils/cameraZoom.ts new file mode 100644 index 000000000..c69335a01 --- /dev/null +++ b/web/src/utils/cameraZoom.ts @@ -0,0 +1,162 @@ +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}`; +}