web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: camera operations

+809 -22
+12 -18
TODO.txt
··· 40 40 - `pnpm dev:desktop` launches a Tauri window that shows the same canvas. 41 41 - `pnpm test` runs at least 1 passing core test. 42 42 43 - 44 43 ============================================================================== 45 44 2. Milestone B: Math + coordinate systems *wb-B* 46 45 ============================================================================== ··· 60 59 - multiply(a, b) 61 60 - transformPoint(m, p) 62 61 63 - Camera (/packages/core/src/camera): 64 - [ ] Define Camera { x, y, zoom } (world origin + scale) 65 - [ ] Implement worldToScreen(camera, p) 66 - [ ] Implement screenToWorld(camera, p) 67 - [ ] Implement cameraPan(camera, deltaScreen) -> camera' 68 - [ ] Implement cameraZoomAt(camera, factor, anchorScreenPoint) -> camera' 62 + Camera (/packages/core/src/camera.ts): 63 + [x] Define Camera { x, y, zoom } (world origin + scale) 64 + [x] Implement worldToScreen(camera, p) 65 + [x] Implement screenToWorld(camera, p) 66 + [x] Implement cameraPan(camera, deltaScreen) -> camera' 67 + [x] Implement cameraZoomAt(camera, factor, anchorScreenPoint) -> camera' 69 68 70 - Tests (/packages/core/test): 71 - [ ] worldToScreen(screenToWorld(p)) round-trip within epsilon 72 - [ ] zoomAt keeps anchor point stable (screen position unchanged) 73 - [ ] pan moves world under cursor as expected 69 + Tests (/packages/core/tests/camera.test.ts): 70 + [x] worldToScreen(screenToWorld(p)) round-trip within epsilon 71 + [x] zoomAt keeps anchor point stable (screen position unchanged) 72 + [x] pan moves world under cursor as expected 74 73 75 74 (DoD): 76 75 - All math/camera functions are unit-tested and pass. 77 - 78 76 79 77 ============================================================================== 80 78 3. Milestone C: Document model (records) *wb-C* ··· 82 80 83 81 Goal: define the minimal data model that can represent a drawing. 84 82 85 - IDs (/packages/core/src/id): 86 - [ ] Implement createId(prefix) -> string (stable, unique enough for offline) 87 - [ ] Add test: ids are unique across 10k calls 88 - 89 - Records (/packages/core/src/model): 83 + Records & ID (/packages/core/src/model): 84 + [ ] Implement createId(prefix) -> uuid (v4) 90 85 [ ] Define PageRecord { id, name, shapeIds: string[] } 91 86 [ ] Define ShapeRecord base: 92 87 - id, type, pageId ··· 113 108 114 109 (DoD): 115 110 - You can serialize a doc with a page + 1 shape to JSON and validate it. 116 - 117 111 118 112 ============================================================================== 119 113 4. Milestone D: Store + selectors (reactive core) *wb-D*
+17 -4
packages/core/package.json
··· 6 6 "author": "Author Name <author.name@mail.com>", 7 7 "license": "MIT", 8 8 "homepage": "https://github.com/author/library#readme", 9 - "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, 10 - "bugs": { "url": "https://github.com/author/library/issues" }, 11 - "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, 9 + "repository": { 10 + "type": "git", 11 + "url": "git+https://github.com/author/library.git" 12 + }, 13 + "bugs": { 14 + "url": "https://github.com/author/library/issues" 15 + }, 16 + "exports": { 17 + ".": "./dist/index.mjs", 18 + "./package.json": "./package.json" 19 + }, 12 20 "main": "./dist/index.mjs", 13 21 "module": "./dist/index.mjs", 14 22 "types": "./dist/index.d.mts", 15 - "files": ["dist"], 23 + "files": [ 24 + "dist" 25 + ], 16 26 "scripts": { 17 27 "build": "tsdown", 18 28 "dev": "tsdown --watch", ··· 26 36 "tsdown": "^0.18.1", 27 37 "typescript": "^5.9.3", 28 38 "vitest": "^4.0.16" 39 + }, 40 + "dependencies": { 41 + "uuid": "^13.0.0" 29 42 } 30 43 }
+164
packages/core/src/camera.ts
··· 1 + import { 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 + */ 8 + export type Camera = { x: number; y: number; zoom: number }; 9 + 10 + /** 11 + * Viewport dimensions in screen pixels 12 + */ 13 + export type Viewport = { width: number; height: number }; 14 + 15 + export 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 + };
+606
packages/core/tests/camera.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Camera, Viewport } from "../src/camera"; 3 + 4 + const viewport: Viewport = { width: 800, height: 600 }; 5 + 6 + describe("Camera", () => { 7 + describe("create", () => { 8 + it("should create camera with default values", () => { 9 + const camera = Camera.create(); 10 + expect(camera).toEqual({ x: 0, y: 0, zoom: 1 }); 11 + }); 12 + 13 + it("should create camera with custom values", () => { 14 + const camera = Camera.create(100, 200, 2); 15 + expect(camera).toEqual({ x: 100, y: 200, zoom: 2 }); 16 + }); 17 + 18 + it.each([ 19 + { description: "negative position", x: -100, y: -200, zoom: 1 }, 20 + { description: "fractional zoom", x: 0, y: 0, zoom: 0.5 }, 21 + { description: "large zoom", x: 0, y: 0, zoom: 10 }, 22 + { description: "very small zoom", x: 0, y: 0, zoom: 0.01 }, 23 + ])("should handle $description", ({ x, y, zoom }) => { 24 + const camera = Camera.create(x, y, zoom); 25 + expect(camera).toEqual({ x, y, zoom }); 26 + }); 27 + }); 28 + 29 + describe("clone", () => { 30 + it("should create a copy of the camera", () => { 31 + const camera = Camera.create(10, 20, 1.5); 32 + const cloned = Camera.clone(camera); 33 + expect(cloned).toEqual(camera); 34 + expect(cloned).not.toBe(camera); 35 + }); 36 + }); 37 + 38 + describe("equals", () => { 39 + it.each([ 40 + { 41 + description: "identical cameras", 42 + a: Camera.create(10, 20, 1.5), 43 + b: Camera.create(10, 20, 1.5), 44 + expected: true, 45 + }, 46 + { description: "different position", a: Camera.create(10, 20, 1), b: Camera.create(11, 20, 1), expected: false }, 47 + { description: "different zoom", a: Camera.create(10, 20, 1), b: Camera.create(10, 20, 1.1), expected: false }, 48 + ])("should compare $description", ({ a, b, expected }) => { 49 + expect(Camera.equals(a, b)).toBe(expected); 50 + }); 51 + 52 + it("should use epsilon for floating point comparison", () => { 53 + const a = Camera.create(10, 20, 1); 54 + const b = Camera.create(10 + 5e-11, 20 + 5e-11, 1 + 5e-11); 55 + expect(Camera.equals(a, b)).toBe(true); 56 + }); 57 + 58 + it("should allow custom epsilon", () => { 59 + const a = Camera.create(10, 20, 1); 60 + const b = Camera.create(10.005, 20.005, 1.005); 61 + expect(Camera.equals(a, b, 0.01)).toBe(true); 62 + expect(Camera.equals(a, b, 0.001)).toBe(false); 63 + }); 64 + }); 65 + 66 + describe("reset", () => { 67 + it("should reset camera to origin with 100% zoom", () => { 68 + expect(Camera.reset()).toEqual({ x: 0, y: 0, zoom: 1 }); 69 + }); 70 + }); 71 + 72 + describe("worldToScreen", () => { 73 + it.each([{ 74 + description: "camera at origin, point at origin", 75 + camera: Camera.create(0, 0, 1), 76 + worldPoint: { x: 0, y: 0 }, 77 + expected: { x: 400, y: 300 }, 78 + }, { 79 + description: "camera at origin, point to the right", 80 + camera: Camera.create(0, 0, 1), 81 + worldPoint: { x: 100, y: 0 }, 82 + expected: { x: 500, y: 300 }, 83 + }, { 84 + description: "camera at origin, point below", 85 + camera: Camera.create(0, 0, 1), 86 + worldPoint: { x: 0, y: 100 }, 87 + expected: { x: 400, y: 400 }, 88 + }, { 89 + description: "camera offset, point at camera position", 90 + camera: Camera.create(100, 200, 1), 91 + worldPoint: { x: 100, y: 200 }, 92 + expected: { x: 400, y: 300 }, 93 + }, { 94 + description: "zoomed in 2x, point at origin", 95 + camera: Camera.create(0, 0, 2), 96 + worldPoint: { x: 100, y: 0 }, 97 + expected: { x: 600, y: 300 }, 98 + }, { 99 + description: "zoomed out 0.5x, point at origin", 100 + camera: Camera.create(0, 0, 0.5), 101 + worldPoint: { x: 100, y: 0 }, 102 + expected: { x: 450, y: 300 }, 103 + }, { 104 + description: "negative world coordinates", 105 + camera: Camera.create(0, 0, 1), 106 + worldPoint: { x: -100, y: -50 }, 107 + expected: { x: 300, y: 250 }, 108 + }, { 109 + description: "camera and point both negative", 110 + camera: Camera.create(-100, -100, 1), 111 + worldPoint: { x: -50, y: -50 }, 112 + expected: { x: 450, y: 350 }, 113 + }])("should handle $description", ({ camera, worldPoint, expected }) => { 114 + const result = Camera.worldToScreen(camera, worldPoint, viewport); 115 + expect(result.x).toBeCloseTo(expected.x); 116 + expect(result.y).toBeCloseTo(expected.y); 117 + }); 118 + }); 119 + 120 + describe("screenToWorld", () => { 121 + it.each([{ 122 + description: "center of screen", 123 + camera: Camera.create(0, 0, 1), 124 + screenPoint: { x: 400, y: 300 }, 125 + expected: { x: 0, y: 0 }, 126 + }, { 127 + description: "top-left corner", 128 + camera: Camera.create(0, 0, 1), 129 + screenPoint: { x: 0, y: 0 }, 130 + expected: { x: -400, y: -300 }, 131 + }, { 132 + description: "bottom-right corner", 133 + camera: Camera.create(0, 0, 1), 134 + screenPoint: { x: 800, y: 600 }, 135 + expected: { x: 400, y: 300 }, 136 + }, { 137 + description: "with camera offset", 138 + camera: Camera.create(100, 200, 1), 139 + screenPoint: { x: 400, y: 300 }, 140 + expected: { x: 100, y: 200 }, 141 + }, { 142 + description: "with 2x zoom", 143 + camera: Camera.create(0, 0, 2), 144 + screenPoint: { x: 600, y: 300 }, 145 + expected: { x: 100, y: 0 }, 146 + }, { 147 + description: "with 0.5x zoom", 148 + camera: Camera.create(0, 0, 0.5), 149 + screenPoint: { x: 450, y: 300 }, 150 + expected: { x: 100, y: 0 }, 151 + }])("should handle $description", ({ camera, screenPoint, expected }) => { 152 + const result = Camera.screenToWorld(camera, screenPoint, viewport); 153 + expect(result.x).toBeCloseTo(expected.x); 154 + expect(result.y).toBeCloseTo(expected.y); 155 + }); 156 + }); 157 + 158 + describe("worldToScreen <-> screenToWorld round-trip", () => { 159 + const testCases = [ 160 + { description: "origin camera, origin point", camera: Camera.create(0, 0, 1), worldPoint: { x: 0, y: 0 } }, 161 + { 162 + description: "origin camera, arbitrary point", 163 + camera: Camera.create(0, 0, 1), 164 + worldPoint: { x: 123.456, y: -789.012 }, 165 + }, 166 + { 167 + description: "offset camera, arbitrary point", 168 + camera: Camera.create(100, 200, 1), 169 + worldPoint: { x: 50, y: -30 }, 170 + }, 171 + { description: "zoomed in camera", camera: Camera.create(0, 0, 2.5), worldPoint: { x: 100, y: 200 } }, 172 + { description: "zoomed out camera", camera: Camera.create(0, 0, 0.3), worldPoint: { x: 1000, y: 2000 } }, 173 + { description: "complex camera state", camera: Camera.create(-500, 300, 1.7), worldPoint: { x: -123, y: 456 } }, 174 + { 175 + description: "very large coordinates", 176 + camera: Camera.create(10_000, -10_000, 1), 177 + worldPoint: { x: 9999, y: -9999 }, 178 + }, 179 + { description: "very small zoom", camera: Camera.create(0, 0, 0.01), worldPoint: { x: 50_000, y: 30_000 } }, 180 + ]; 181 + 182 + it.each(testCases)("should round-trip for $description", ({ camera, worldPoint }) => { 183 + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); 184 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 185 + 186 + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 10); 187 + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 10); 188 + }); 189 + 190 + it("should round-trip in reverse direction", () => { 191 + const camera = Camera.create(100, 200, 1.5); 192 + const screenPoint = { x: 250, y: 450 }; 193 + 194 + const worldPoint = Camera.screenToWorld(camera, screenPoint, viewport); 195 + const backToScreen = Camera.worldToScreen(camera, worldPoint, viewport); 196 + 197 + expect(backToScreen.x).toBeCloseTo(screenPoint.x, 10); 198 + expect(backToScreen.y).toBeCloseTo(screenPoint.y, 10); 199 + }); 200 + }); 201 + 202 + describe("pan", () => { 203 + it.each([{ 204 + description: "pan right (positive screen delta)", 205 + camera: Camera.create(0, 0, 1), 206 + deltaScreen: { x: 100, y: 0 }, 207 + expectedPosition: { x: -100, y: 0 }, 208 + }, { 209 + description: "pan left (negative screen delta)", 210 + camera: Camera.create(0, 0, 1), 211 + deltaScreen: { x: -100, y: 0 }, 212 + expectedPosition: { x: 100, y: 0 }, 213 + }, { 214 + description: "pan down", 215 + camera: Camera.create(0, 0, 1), 216 + deltaScreen: { x: 0, y: 50 }, 217 + expectedPosition: { x: 0, y: -50 }, 218 + }, { 219 + description: "pan up", 220 + camera: Camera.create(0, 0, 1), 221 + deltaScreen: { x: 0, y: -50 }, 222 + expectedPosition: { x: 0, y: 50 }, 223 + }, { 224 + description: "diagonal pan", 225 + camera: Camera.create(0, 0, 1), 226 + deltaScreen: { x: 100, y: 50 }, 227 + expectedPosition: { x: -100, y: -50 }, 228 + }, { 229 + description: "pan from non-origin", 230 + camera: Camera.create(200, 300, 1), 231 + deltaScreen: { x: 50, y: 25 }, 232 + expectedPosition: { x: 150, y: 275 }, 233 + }])("should handle $description", ({ camera, deltaScreen, expectedPosition }) => { 234 + const newCamera = Camera.pan(camera, deltaScreen); 235 + expect(newCamera.x).toBeCloseTo(expectedPosition.x); 236 + expect(newCamera.y).toBeCloseTo(expectedPosition.y); 237 + expect(newCamera.zoom).toBe(camera.zoom); 238 + }); 239 + 240 + it("should scale pan by zoom level", () => { 241 + const testCases = [{ 242 + description: "zoomed in 2x (smaller world movement)", 243 + camera: Camera.create(0, 0, 2), 244 + deltaScreen: { x: 100, y: 0 }, 245 + expectedPosition: { x: -50, y: 0 }, 246 + }, { 247 + description: "zoomed out 0.5x (larger world movement)", 248 + camera: Camera.create(0, 0, 0.5), 249 + deltaScreen: { x: 100, y: 0 }, 250 + expectedPosition: { x: -200, y: 0 }, 251 + }, { 252 + description: "zoomed in 4x", 253 + camera: Camera.create(0, 0, 4), 254 + deltaScreen: { x: 80, y: 40 }, 255 + expectedPosition: { x: -20, y: -10 }, 256 + }]; 257 + 258 + for (const { camera, deltaScreen, expectedPosition } of testCases) { 259 + const newCamera = Camera.pan(camera, deltaScreen); 260 + expect(newCamera.x).toBeCloseTo(expectedPosition.x); 261 + expect(newCamera.y).toBeCloseTo(expectedPosition.y); 262 + } 263 + }); 264 + 265 + it("should verify pan moves world under cursor correctly", () => { 266 + const camera = Camera.create(0, 0, 1); 267 + const cursorScreen = { x: 300, y: 200 }; 268 + const worldBefore = Camera.screenToWorld(camera, cursorScreen, viewport); 269 + 270 + const deltaScreen = { x: 50, y: 30 }; 271 + const newCamera = Camera.pan(camera, deltaScreen); 272 + 273 + const worldAfter = Camera.screenToWorld(newCamera, cursorScreen, viewport); 274 + 275 + expect(worldAfter.x - worldBefore.x).toBeCloseTo(-50); 276 + expect(worldAfter.y - worldBefore.y).toBeCloseTo(-30); 277 + }); 278 + }); 279 + 280 + describe("zoomAt", () => { 281 + it.each([{ 282 + description: "zoom in 2x at center", 283 + camera: Camera.create(0, 0, 1), 284 + factor: 2, 285 + anchorScreen: { x: 400, y: 300 }, 286 + expectedZoom: 2, 287 + }, { 288 + description: "zoom out 0.5x at center", 289 + camera: Camera.create(0, 0, 1), 290 + factor: 0.5, 291 + anchorScreen: { x: 400, y: 300 }, 292 + expectedZoom: 0.5, 293 + }, { 294 + description: "zoom in 1.5x at center", 295 + camera: Camera.create(0, 0, 1), 296 + factor: 1.5, 297 + anchorScreen: { x: 400, y: 300 }, 298 + expectedZoom: 1.5, 299 + }, { 300 + description: "compound zoom", 301 + camera: Camera.create(0, 0, 2), 302 + factor: 1.5, 303 + anchorScreen: { x: 400, y: 300 }, 304 + expectedZoom: 3, 305 + }])("should update zoom for $description", ({ camera, factor, anchorScreen, expectedZoom }) => { 306 + const newCamera = Camera.zoomAt(camera, factor, anchorScreen, viewport); 307 + expect(newCamera.zoom).toBeCloseTo(expectedZoom); 308 + }); 309 + 310 + it("should keep anchor point stable when zooming at center", () => { 311 + const camera = Camera.create(0, 0, 1); 312 + const anchorScreen = { x: 400, y: 300 }; 313 + 314 + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); 315 + 316 + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); 317 + const anchorWorldAfter = Camera.screenToWorld(newCamera, anchorScreen, viewport); 318 + 319 + expect(anchorWorldBefore.x).toBeCloseTo(anchorWorldAfter.x); 320 + expect(anchorWorldBefore.y).toBeCloseTo(anchorWorldAfter.y); 321 + 322 + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); 323 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x); 324 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y); 325 + }); 326 + 327 + it("should keep anchor point stable when zooming at corner", () => { 328 + const camera = Camera.create(0, 0, 1); 329 + const anchorScreen = { x: 100, y: 150 }; 330 + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); 331 + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); 332 + 333 + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); 334 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x); 335 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y); 336 + }); 337 + 338 + it("should keep anchor point stable when zooming at bottom-right", () => { 339 + const camera = Camera.create(0, 0, 1); 340 + const anchorScreen = { x: 700, y: 550 }; 341 + 342 + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); 343 + const newCamera = Camera.zoomAt(camera, 0.5, anchorScreen, viewport); 344 + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); 345 + 346 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 347 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 348 + }); 349 + 350 + it("should keep anchor stable with offset camera", () => { 351 + const camera = Camera.create(100, 200, 1.5); 352 + const anchorScreen = { x: 250, y: 400 }; 353 + 354 + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); 355 + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); 356 + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); 357 + 358 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 359 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 360 + }); 361 + 362 + it("should handle multiple zoom operations correctly", () => { 363 + let camera = Camera.create(0, 0, 1); 364 + const anchorScreen = { x: 300, y: 250 }; 365 + 366 + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); 367 + 368 + camera = Camera.zoomAt(camera, 2, anchorScreen, viewport); 369 + let anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); 370 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 371 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 372 + 373 + camera = Camera.zoomAt(camera, 1.5, anchorScreen, viewport); 374 + anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); 375 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 376 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 377 + 378 + expect(camera.zoom).toBeCloseTo(3, 5); 379 + }); 380 + 381 + it("should handle zoom out correctly", () => { 382 + const camera = Camera.create(100, 100, 2); 383 + const anchorScreen = { x: 500, y: 400 }; 384 + 385 + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); 386 + const newCamera = Camera.zoomAt(camera, 0.5, anchorScreen, viewport); 387 + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorld, viewport); 388 + 389 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 390 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 391 + expect(newCamera.zoom).toBeCloseTo(1, 5); 392 + }); 393 + }); 394 + 395 + describe("clampZoom", () => { 396 + it.each([ 397 + { description: "within bounds", camera: Camera.create(0, 0, 1), minZoom: 0.1, maxZoom: 10, expectedZoom: 1 }, 398 + { description: "below minimum", camera: Camera.create(0, 0, 0.05), minZoom: 0.1, maxZoom: 10, expectedZoom: 0.1 }, 399 + { description: "above maximum", camera: Camera.create(0, 0, 15), minZoom: 0.1, maxZoom: 10, expectedZoom: 10 }, 400 + { description: "at minimum", camera: Camera.create(0, 0, 0.1), minZoom: 0.1, maxZoom: 10, expectedZoom: 0.1 }, 401 + { description: "at maximum", camera: Camera.create(0, 0, 10), minZoom: 0.1, maxZoom: 10, expectedZoom: 10 }, 402 + ])("should clamp $description", ({ camera, minZoom, maxZoom, expectedZoom }) => { 403 + const clamped = Camera.clampZoom(camera, minZoom, maxZoom); 404 + expect(clamped.zoom).toBe(expectedZoom); 405 + expect(clamped.x).toBe(camera.x); 406 + expect(clamped.y).toBe(camera.y); 407 + }); 408 + 409 + it("should return same camera if no clamping needed", () => { 410 + const camera = Camera.create(10, 20, 1); 411 + const clamped = Camera.clampZoom(camera, 0.1, 10); 412 + expect(clamped).toBe(camera); 413 + }); 414 + 415 + it("should use default min/max when not specified", () => { 416 + expect(Camera.clampZoom(Camera.create(0, 0, 0.05)).zoom).toBe(0.1); 417 + expect(Camera.clampZoom(Camera.create(0, 0, 15)).zoom).toBe(10); 418 + }); 419 + }); 420 + 421 + describe("getViewportBounds", () => { 422 + it("should return correct bounds for camera at origin", () => { 423 + const camera = Camera.create(0, 0, 1); 424 + const bounds = Camera.getViewportBounds(camera, viewport); 425 + 426 + expect(bounds.min.x).toBeCloseTo(-400); 427 + expect(bounds.min.y).toBeCloseTo(-300); 428 + expect(bounds.max.x).toBeCloseTo(400); 429 + expect(bounds.max.y).toBeCloseTo(300); 430 + expect(bounds.width).toBeCloseTo(800); 431 + expect(bounds.height).toBeCloseTo(600); 432 + }); 433 + 434 + it("should return correct bounds for offset camera", () => { 435 + const camera = Camera.create(100, 200, 1); 436 + const bounds = Camera.getViewportBounds(camera, viewport); 437 + 438 + expect(bounds.min.x).toBeCloseTo(-300); 439 + expect(bounds.min.y).toBeCloseTo(-100); 440 + expect(bounds.max.x).toBeCloseTo(500); 441 + expect(bounds.max.y).toBeCloseTo(500); 442 + }); 443 + 444 + it("should return correct bounds for zoomed camera", () => { 445 + const camera = Camera.create(0, 0, 2); 446 + const bounds = Camera.getViewportBounds(camera, viewport); 447 + 448 + expect(bounds.width).toBeCloseTo(400); 449 + expect(bounds.height).toBeCloseTo(300); 450 + expect(bounds.min.x).toBeCloseTo(-200); 451 + expect(bounds.max.x).toBeCloseTo(200); 452 + }); 453 + 454 + it("should return correct bounds for zoomed out camera", () => { 455 + const camera = Camera.create(0, 0, 0.5); 456 + const bounds = Camera.getViewportBounds(camera, viewport); 457 + expect(bounds.width).toBeCloseTo(1600); 458 + expect(bounds.height).toBeCloseTo(1200); 459 + }); 460 + }); 461 + 462 + describe("edge cases", () => { 463 + it("should handle very large world coordinates", () => { 464 + const camera = Camera.create(1_000_000, 2_000_000, 1); 465 + const worldPoint = { x: 999_999, y: 1_999_999 }; 466 + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); 467 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 468 + 469 + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 5); 470 + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 5); 471 + }); 472 + 473 + it("should handle very small zoom levels", () => { 474 + const camera = Camera.create(0, 0, 0.001); 475 + const worldPoint = { x: 100_000, y: 50_000 }; 476 + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); 477 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 478 + 479 + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 0); 480 + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 0); 481 + }); 482 + 483 + it("should handle very large zoom levels", () => { 484 + const camera = Camera.create(0, 0, 100); 485 + const worldPoint = { x: 1, y: 0.5 }; 486 + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); 487 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 488 + 489 + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 5); 490 + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 5); 491 + }); 492 + 493 + it("should handle fractional pixel coordinates", () => { 494 + const camera = Camera.create(0, 0, 1); 495 + const screenPoint = { x: 123.456, y: 789.012 }; 496 + const worldPoint = Camera.screenToWorld(camera, screenPoint, viewport); 497 + const backToScreen = Camera.worldToScreen(camera, worldPoint, viewport); 498 + 499 + expect(backToScreen.x).toBeCloseTo(screenPoint.x); 500 + expect(backToScreen.y).toBeCloseTo(screenPoint.y); 501 + }); 502 + 503 + it("should handle negative zoom gracefully in transforms", () => { 504 + const camera = Camera.create(0, 0, -1); 505 + const worldPoint = { x: 100, y: 50 }; 506 + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); 507 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 508 + 509 + expect(backToWorld.x).toBeCloseTo(worldPoint.x); 510 + expect(backToWorld.y).toBeCloseTo(worldPoint.y); 511 + }); 512 + 513 + it("should handle different viewport sizes", () => { 514 + const smallViewport: Viewport = { width: 320, height: 240 }; 515 + const largeViewport: Viewport = { width: 1920, height: 1080 }; 516 + const camera = Camera.create(0, 0, 1); 517 + const worldPoint = { x: 100, y: 50 }; 518 + 519 + const screenSmall = Camera.worldToScreen(camera, worldPoint, smallViewport); 520 + const backSmall = Camera.screenToWorld(camera, screenSmall, smallViewport); 521 + expect(backSmall.x).toBeCloseTo(worldPoint.x); 522 + expect(backSmall.y).toBeCloseTo(worldPoint.y); 523 + 524 + const screenLarge = Camera.worldToScreen(camera, worldPoint, largeViewport); 525 + const backLarge = Camera.screenToWorld(camera, screenLarge, largeViewport); 526 + expect(backLarge.x).toBeCloseTo(worldPoint.x); 527 + expect(backLarge.y).toBeCloseTo(worldPoint.y); 528 + }); 529 + 530 + it("should handle zero-sized viewport dimensions gracefully", () => { 531 + const zeroViewport: Viewport = { width: 0, height: 0 }; 532 + const camera = Camera.create(0, 0, 1); 533 + const worldPoint = { x: 100, y: 50 }; 534 + const screenPoint = Camera.worldToScreen(camera, worldPoint, zeroViewport); 535 + expect(screenPoint).toBeDefined(); 536 + }); 537 + }); 538 + 539 + describe("integration scenarios", () => { 540 + it("should handle pan then zoom correctly", () => { 541 + let camera = Camera.create(0, 0, 1); 542 + const anchorScreen = { x: 300, y: 250 }; 543 + 544 + camera = Camera.pan(camera, { x: 100, y: 50 }); 545 + 546 + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); 547 + 548 + camera = Camera.zoomAt(camera, 2, anchorScreen, viewport); 549 + 550 + const anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); 551 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); 552 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); 553 + }); 554 + 555 + it("should handle zoom then pan correctly", () => { 556 + let camera = Camera.create(0, 0, 1); 557 + const testWorldPoint = { x: 100, y: 200 }; 558 + 559 + camera = Camera.zoomAt(camera, 2, { x: 400, y: 300 }, viewport); 560 + 561 + const screenBefore = Camera.worldToScreen(camera, testWorldPoint, viewport); 562 + 563 + camera = Camera.pan(camera, { x: 50, y: 30 }); 564 + 565 + const screenAfter = Camera.worldToScreen(camera, testWorldPoint, viewport); 566 + expect(screenAfter.x - screenBefore.x).toBeCloseTo(50, 5); 567 + expect(screenAfter.y - screenBefore.y).toBeCloseTo(30, 5); 568 + }); 569 + 570 + it("should handle complex manipulation sequence", () => { 571 + let camera = Camera.create(0, 0, 1); 572 + const trackPoint = { x: 500, y: 300 }; 573 + 574 + camera = Camera.pan(camera, { x: 50, y: 25 }); 575 + camera = Camera.zoomAt(camera, 1.5, { x: 400, y: 300 }, viewport); 576 + camera = Camera.pan(camera, { x: -30, y: 40 }); 577 + camera = Camera.zoomAt(camera, 0.8, { x: 600, y: 200 }, viewport); 578 + 579 + const screenPoint = Camera.worldToScreen(camera, trackPoint, viewport); 580 + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); 581 + 582 + expect(backToWorld.x).toBeCloseTo(trackPoint.x, 5); 583 + expect(backToWorld.y).toBeCloseTo(trackPoint.y, 5); 584 + }); 585 + 586 + it("should maintain consistency after many zoom operations", () => { 587 + let camera = Camera.create(0, 0, 1); 588 + const anchorScreen = { x: 400, y: 300 }; 589 + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); 590 + 591 + for (let i = 0; i < 10; i++) { 592 + camera = Camera.zoomAt(camera, 1.1, anchorScreen, viewport); 593 + } 594 + 595 + for (let i = 0; i < 10; i++) { 596 + camera = Camera.zoomAt(camera, 1 / 1.1, anchorScreen, viewport); 597 + } 598 + 599 + expect(camera.zoom).toBeCloseTo(1, 5); 600 + 601 + const anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); 602 + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 3); 603 + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 3); 604 + }); 605 + }); 606 + });
+10
pnpm-lock.yaml
··· 31 31 version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 32 32 33 33 packages/core: 34 + dependencies: 35 + uuid: 36 + specifier: ^13.0.0 37 + version: 13.0.0 34 38 devDependencies: 35 39 '@types/node': 36 40 specifier: ^25.0.3 ··· 1357 1361 uri-js@4.4.1: 1358 1362 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 1359 1363 1364 + uuid@13.0.0: 1365 + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} 1366 + hasBin: true 1367 + 1360 1368 vite@7.3.0: 1361 1369 resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} 1362 1370 engines: {node: ^20.19.0 || >=22.12.0} ··· 2636 2644 uri-js@4.4.1: 2637 2645 dependencies: 2638 2646 punycode: 2.3.1 2647 + 2648 + uuid@13.0.0: {} 2639 2649 2640 2650 vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2): 2641 2651 dependencies: