import { describe, it, expect } from 'vitest'; import { nodeRadiusScale, project, orbit, zoom, depthFade, fitDistance, hitTest, DEFAULT_CAMERA, MAX_PITCH, } from './camera'; import type { GraphNodeState } from './graph.types'; const at = (x: number, y: number, z: number): GraphNodeState => ({ id: `${x},${y},${z}`, x, y, z, vx: 0, vy: 0, vz: 0, pinned: false, mass: 1, }); const flat = { ...DEFAULT_CAMERA, yaw: 0, pitch: 0 }; describe('project', () => { it('puts the origin at the centre of the canvas', () => { const p = project({ x: 0, y: 0, z: 0 }, flat, 800, 600); expect(p.x).toBeCloseTo(400, 5); expect(p.y).toBeCloseTo(300, 5); expect(p.visible).toBe(true); }); it('shrinks things that are further away', () => { const near = project({ x: 0, y: 0, z: -200 }, flat, 800, 600); const far = project({ x: 0, y: 0, z: 200 }, flat, 800, 600); expect(near.scale).toBeGreaterThan(far.scale); expect(near.depth).toBeLessThan(far.depth); }); it('marks a point behind the camera as not visible', () => { const behind = project({ x: 0, y: 0, z: -DEFAULT_CAMERA.distance - 10 }, flat, 800, 600); expect(behind.visible).toBe(false); }); it('rotates about the vertical axis with yaw', () => { const front = project({ x: 100, y: 0, z: 0 }, flat, 800, 600); const turned = project({ x: 100, y: 0, z: 0 }, { ...flat, yaw: Math.PI / 2 }, 800, 600); expect(front.x).toBeGreaterThan(400); // A quarter turn moves that point onto the depth axis. expect(turned.x).toBeCloseTo(400, 5); expect(turned.depth).toBeGreaterThan(front.depth); }); it('applies pan in canvas pixels', () => { const panned = project({ x: 0, y: 0, z: 0 }, { ...flat, panX: 30, panY: -20 }, 800, 600); expect(panned.x).toBeCloseTo(430, 5); expect(panned.y).toBeCloseTo(280, 5); }); }); describe('depthFade', () => { it('fades with distance but never to nothing', () => { const near = depthFade(DEFAULT_CAMERA.distance * 0.5, DEFAULT_CAMERA); const far = depthFade(DEFAULT_CAMERA.distance * 1.8, DEFAULT_CAMERA); expect(near).toBeGreaterThan(far); expect(far).toBeGreaterThan(0); expect(near).toBeLessThanOrEqual(1); }); }); describe('orbit', () => { it('turns a horizontal drag into yaw', () => { expect(orbit(flat, 100, 0).yaw).toBeGreaterThan(flat.yaw); }); it('clamps pitch short of the poles so the drag cannot flip', () => { expect(orbit(flat, 0, 100000).pitch).toBeCloseTo(MAX_PITCH, 5); expect(orbit(flat, 0, -100000).pitch).toBeCloseTo(-MAX_PITCH, 5); }); }); describe('zoom', () => { it('is multiplicative, so it feels the same at any distance', () => { const a = zoom({ ...flat, distance: 200 }, 100).distance / 200; const b = zoom({ ...flat, distance: 2000 }, 100).distance / 2000; expect(a).toBeCloseTo(b, 6); }); it('clamps to a usable range', () => { expect(zoom(flat, -100000).distance).toBe(60); expect(zoom(flat, 100000).distance).toBe(4000); }); }); describe('fitDistance', () => { it('backs off further for a larger graph', () => { const tight = fitDistance([at(10, 0, 0)], DEFAULT_CAMERA, 800, 600); const wide = fitDistance([at(500, 0, 0)], DEFAULT_CAMERA, 800, 600); expect(wide).toBeGreaterThan(tight); }); it('falls back for an empty or degenerate graph', () => { expect(fitDistance([], DEFAULT_CAMERA, 800, 600)).toBe(DEFAULT_CAMERA.distance); expect(fitDistance([at(0, 0, 0)], DEFAULT_CAMERA, 800, 600)).toBe(DEFAULT_CAMERA.distance); }); }); describe('hitTest', () => { const states = [at(0, 0, 0), at(200, 0, 0)]; it('finds the node under the pointer', () => { expect(hitTest(states, [10, 10], flat, 800, 600, 400, 300)).toBe(0); }); it('returns -1 on empty space', () => { expect(hitTest(states, [10, 10], flat, 800, 600, 10, 10)).toBe(-1); }); it('picks the nearer node when two overlap on screen', () => { // Same screen position, different depth: the front one must win. const stacked = [at(0, 0, 300), at(0, 0, -300)]; expect(hitTest(stacked, [40, 40], flat, 800, 600, 400, 300)).toBe(1); }); }); describe('nodeRadiusScale', () => { const close = { ...DEFAULT_CAMERA, distance: 200 }; it('leaves nodes alone when they already fit under the ceiling', () => { expect(nodeRadiusScale([1], close, 11)).toBe(1); }); it('caps the largest node at the ceiling', () => { const radii = [40]; const factor = nodeRadiusScale(radii, close, 11); const rendered = radii[0] * (close.focalLength / close.distance) * factor; expect(rendered).toBeCloseTo(11, 5); }); it('keeps relative weight intact while capping', () => { // Capping each node individually would flatten the weight differences that // make a graph readable. const radii = [40, 20, 10]; const factor = nodeRadiusScale(radii, close, 11); expect((radii[0] * factor) / (radii[1] * factor)).toBeCloseTo(2, 5); expect((radii[1] * factor) / (radii[2] * factor)).toBeCloseTo(2, 5); }); it('handles an empty graph', () => { expect(nodeRadiusScale([], close, 11)).toBe(1); }); });