/** Pure pan/zoom viewport math for the map canvas (no DOM, unit-tested). */ import type { Point } from './types'; export interface Viewport { /** multiplier; 1 = natural image resolution */ zoom: number; /** translation in screen pixels, applied before scale */ panX: number; panY: number; } export const MIN_ZOOM = 0.1; export const MAX_ZOOM = 6; export function clampZoom(z: number): number { return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z)); } /** * Zoom by `factor` keeping the world point under (cx, cy) fixed on screen. * cx/cy are cursor coordinates relative to the viewport's top-left. */ export function zoomToPoint(vp: Viewport, factor: number, cx: number, cy: number): Viewport { const zoom = clampZoom(vp.zoom * factor); const wx = (cx - vp.panX) / vp.zoom; const wy = (cy - vp.panY) / vp.zoom; return { zoom, panX: cx - wx * zoom, panY: cy - wy * zoom }; } /** Center a natural-size image inside a viewport, never upscaling past 1:1. */ export function fitViewport(vw: number, vh: number, naturalW: number, naturalH: number): Viewport { if (naturalW <= 0 || naturalH <= 0 || vw <= 0 || vh <= 0) return { zoom: 1, panX: 0, panY: 0 }; const zoom = Math.min(vw / naturalW, vh / naturalH, 1); return { zoom, panX: (vw - naturalW * zoom) / 2, panY: (vh - naturalH * zoom) / 2 }; } /** * Convert a screen point to world (natural image) pixels, given the bounding rect * of the already-transformed world layer. Because the layer's rect reflects the * CSS transform, dividing by zoom is all that's needed. */ export function worldFromScreen(zoom: number, clientX: number, clientY: number, rectLeft: number, rectTop: number): Point { return { x: (clientX - rectLeft) / zoom, y: (clientY - rectTop) / zoom }; }