// Camera helpers for bounded-plane (no-wrap) mode. // // In no-wrap mode the world is a finite rectangle [0, W) × [0, H). // The camera must keep the visible viewport inside the world, except // when the visible viewport is larger than the world along some axis // — in that case the camera is centred on that axis. This is the // semantics asserted by the tests in tests/map-no-wrap.test.ts. import { clamp } from "./math"; import type { Camera, Viewport, World } from "./world"; // minScaleNoWrap returns the smallest camera.scale value at which the // visible viewport fits inside the world along both axes. Below this // scale the user would see "void" outside world bounds. export function minScaleNoWrap(viewport: Viewport, world: World): number { return Math.max(viewport.widthPx / world.width, viewport.heightPx / world.height); } // clampCameraNoWrap returns a camera whose centre is constrained so // that the visible viewport stays within world bounds. When the // visible viewport span exceeds world span on an axis, the camera is // centred on that axis (independent of input centerX/centerY). // // The function does not modify camera.scale. Callers that want to // also enforce minScaleNoWrap should call that separately. export function clampCameraNoWrap(camera: Camera, viewport: Viewport, world: World): Camera { const halfSpanX = viewport.widthPx / (2 * camera.scale); const halfSpanY = viewport.heightPx / (2 * camera.scale); let centerX = camera.centerX; if (halfSpanX * 2 >= world.width) { centerX = world.width / 2; } else { centerX = clamp(centerX, halfSpanX, world.width - halfSpanX); } let centerY = camera.centerY; if (halfSpanY * 2 >= world.height) { centerY = world.height / 2; } else { centerY = clamp(centerY, halfSpanY, world.height - halfSpanY); } return { centerX, centerY, scale: camera.scale }; } // pivotZoom keeps the world point under cursor stable while changing // camera.scale from oldScale to newScale. It returns a new camera // with the same scale=newScale and a recomputed centre. // // Invariant: screenToWorld(cursorPx, returned, viewport) === // screenToWorld(cursorPx, { ...camera, scale: oldScale }, viewport) // (within float64 precision, see tests/map-no-wrap.test.ts). export function pivotZoom( camera: Camera, viewport: Viewport, cursorPx: { x: number; y: number }, newScale: number, ): Camera { const oldScale = camera.scale; if (!(newScale > 0)) { throw new Error(`pivotZoom: newScale must be positive, got ${newScale}`); } const offX = cursorPx.x - viewport.widthPx / 2; const offY = cursorPx.y - viewport.heightPx / 2; const worldX = camera.centerX + offX / oldScale; const worldY = camera.centerY + offY / oldScale; return { centerX: worldX - offX / newScale, centerY: worldY - offY / newScale, scale: newScale, }; }