web based infinite canvas
at main 164 lines 5.5 kB view raw
1import { Vec2 } from "./math"; 2 3/** 4 * Camera represents the viewport into the infinite canvas 5 * - x, y: world coordinates at the center of the screen 6 * - zoom: scale factor (1 = 100%, 2 = 200% zoomed in, 0.5 = 50% zoomed out) 7 */ 8export type Camera = { x: number; y: number; zoom: number }; 9 10/** 11 * Viewport dimensions in screen pixels 12 */ 13export type Viewport = { width: number; height: number }; 14 15export const Camera = { 16 /** 17 * Create a new camera with default values 18 */ 19 create(x = 0, y = 0, zoom = 1): Camera { 20 return { x, y, zoom }; 21 }, 22 23 /** 24 * Transform a point from world coordinates to screen coordinates 25 * 26 * Algorithm: 27 * 1. Translate point relative to camera position 28 * 2. Scale by zoom factor 29 * 3. Translate to screen center 30 * 31 * @param camera - The camera 32 * @param worldPoint - Point in world coordinates 33 * @param viewport - Screen viewport dimensions 34 * @returns Point in screen coordinates (pixels) 35 */ 36 worldToScreen(camera: Camera, worldPoint: Vec2, viewport: Viewport): Vec2 { 37 const offsetX = worldPoint.x - camera.x; 38 const offsetY = worldPoint.y - camera.y; 39 return { x: offsetX * camera.zoom + viewport.width / 2, y: offsetY * camera.zoom + viewport.height / 2 }; 40 }, 41 42 /** 43 * Transform a point from screen coordinates to world coordinates 44 * 45 * This is the inverse of worldToScreen 46 * 47 * @param camera - The camera 48 * @param screenPoint - Point in screen coordinates (pixels) 49 * @param viewport - Screen viewport dimensions 50 * @returns Point in world coordinates 51 */ 52 screenToWorld(camera: Camera, screenPoint: Vec2, viewport: Viewport): Vec2 { 53 const offsetX = screenPoint.x - viewport.width / 2; 54 const offsetY = screenPoint.y - viewport.height / 2; 55 return { x: offsetX / camera.zoom + camera.x, y: offsetY / camera.zoom + camera.y }; 56 }, 57 58 /** 59 * Pan the camera by a delta in screen space 60 * 61 * When the user drags the canvas, they move it in screen pixels. 62 * We need to convert that to world space movement. 63 * 64 * @param camera - The current camera 65 * @param deltaScreen - Movement delta in screen pixels 66 * @returns New camera with updated position 67 */ 68 pan(camera: Camera, deltaScreen: Vec2): Camera { 69 const worldDeltaX = -deltaScreen.x / camera.zoom; 70 const worldDeltaY = -deltaScreen.y / camera.zoom; 71 return { x: camera.x + worldDeltaX, y: camera.y + worldDeltaY, zoom: camera.zoom }; 72 }, 73 74 /** 75 * Zoom the camera at a specific screen anchor point 76 * 77 * The anchor point should remain at the same screen position after zoom. 78 * This creates the "zoom to cursor" behavior. 79 * 80 * Algorithm: 81 * 1. Convert anchor from screen to world coordinates (at current zoom) 82 * 2. Apply zoom factor 83 * 3. Convert anchor back to screen coordinates (at new zoom) 84 * 4. Adjust camera position so anchor stays at same screen position 85 * 86 * @param camera - The current camera 87 * @param factor - Zoom multiplier (e.g., 1.1 = zoom in 10%, 0.9 = zoom out 10%) 88 * @param anchorScreen - The screen point to zoom towards 89 * @param viewport - Screen viewport dimensions 90 * @returns New camera with updated zoom and position 91 */ 92 zoomAt(camera: Camera, factor: number, anchorScreen: Vec2, viewport: Viewport): Camera { 93 const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); 94 const newZoom = camera.zoom * factor; 95 96 const offsetX = anchorScreen.x - viewport.width / 2; 97 const offsetY = anchorScreen.y - viewport.height / 2; 98 99 return { x: anchorWorld.x - offsetX / newZoom, y: anchorWorld.y - offsetY / newZoom, zoom: newZoom }; 100 }, 101 102 /** 103 * Clamp camera zoom to reasonable bounds 104 * 105 * @param camera - The camera to clamp 106 * @param minZoom - Minimum zoom level (default: 0.1 = 10%) 107 * @param maxZoom - Maximum zoom level (default: 10 = 1000%) 108 * @returns Camera with clamped zoom 109 */ 110 clampZoom(camera: Camera, minZoom = 0.1, maxZoom = 10): Camera { 111 const clampedZoom = Math.max(minZoom, Math.min(maxZoom, camera.zoom)); 112 113 if (clampedZoom === camera.zoom) { 114 return camera; 115 } 116 117 return { x: camera.x, y: camera.y, zoom: clampedZoom }; 118 }, 119 120 /** 121 * Reset camera to default position and zoom 122 * 123 * @returns Camera at origin with 100% zoom 124 */ 125 reset(): Camera { 126 return Camera.create(0, 0, 1); 127 }, 128 129 /** 130 * Clone a camera 131 * 132 * @param camera - Camera to clone 133 * @returns New camera with same values 134 */ 135 clone(camera: Camera): Camera { 136 return { x: camera.x, y: camera.y, zoom: camera.zoom }; 137 }, 138 139 /** 140 * Check if two cameras are approximately equal 141 * 142 * @param a - First camera 143 * @param b - Second camera 144 * @param epsilon - Tolerance for comparison 145 * @returns True if cameras are equal within epsilon 146 */ 147 equals(a: Camera, b: Camera, epsilon = 1e-10): boolean { 148 return (Math.abs(a.x - b.x) <= epsilon && Math.abs(a.y - b.y) <= epsilon && Math.abs(a.zoom - b.zoom) <= epsilon); 149 }, 150 151 /** 152 * Get the world-space bounds visible in the viewport 153 * 154 * @param camera - The camera 155 * @param viewport - Screen viewport dimensions 156 * @returns Bounding box in world coordinates 157 */ 158 getViewportBounds(camera: Camera, viewport: Viewport) { 159 const topLeft = Camera.screenToWorld(camera, { x: 0, y: 0 }, viewport); 160 const bottomRight = Camera.screenToWorld(camera, { x: viewport.width, y: viewport.height }, viewport); 161 162 return { min: topLeft, max: bottomRight, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y }; 163 }, 164};