web based infinite canvas
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};