export type CanvasSize = { width: number; height: number }; export type CanvasView = { zoom: number; x: number; y: number }; export type CanvasPosition = { x: number; y: number }; export const MIN_CANVAS_ZOOM = 0.25; export const MAX_CANVAS_ZOOM = 8; export const FIT_CANVAS_VIEW: CanvasView = { zoom: 1, x: 0, y: 0 }; export function fitCanvasImage(image: CanvasSize, viewport: CanvasSize): CanvasSize { if (image.width <= 0 || image.height <= 0 || viewport.width <= 0 || viewport.height <= 0) return { width: 0, height: 0 }; const scale = Math.min(Math.max(1, viewport.width - 20) / image.width, Math.max(1, viewport.height - 20) / image.height); return { width: image.width * scale, height: image.height * scale }; } export function constrainCanvasView(view: CanvasView, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView { const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, Number.isFinite(view.zoom) ? view.zoom : 1)); const maxX = Math.max(0, (fittedImage.width * zoom - viewport.width) / 2); const maxY = Math.max(0, (fittedImage.height * zoom - viewport.height) / 2); return { zoom, x: Math.max(-maxX, Math.min(maxX, Number.isFinite(view.x) ? view.x : 0)), y: Math.max(-maxY, Math.min(maxY, Number.isFinite(view.y) ? view.y : 0)) }; } // The anchor is relative to the viewport center. Preserve the image point // under the cursor when zooming, except where an image edge limits panning. export function zoomCanvasAt(view: CanvasView, requestedZoom: number, anchor: CanvasPosition, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView { const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, requestedZoom)); const factor = zoom / view.zoom; return constrainCanvasView({ zoom, x: anchor.x - (anchor.x - view.x) * factor, y: anchor.y - (anchor.y - view.y) * factor }, fittedImage, viewport); } // Use the *transformed image bounds*, never the clipped viewport bounds. // Persisted annotations and provider prompts remain in the 0–1000 space. export function canvasAnnotationPoint(point: CanvasPosition, bounds: CanvasSize & { left: number; top: number }): CanvasPosition | null { if (bounds.width <= 0 || bounds.height <= 0) return null; const clamp = (value: number) => Math.max(0, Math.min(1000, Math.round(value))); return { x: clamp((point.x - bounds.left) / bounds.width * 1000), y: clamp((point.y - bounds.top) / bounds.height * 1000) }; }