Merge pull request #31 from ibs0d/codex/add-shared-zoom-helper-module

Add camera zoom utility module for grid cards
This commit is contained in:
ibs0d 2026-03-09 10:38:52 +11:00 committed by GitHub
commit 8d6be93d25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

162
web/src/utils/cameraZoom.ts Normal file
View File

@ -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}`;
}