import type { GraphNodeState } from './graph.types'; export interface GraphCamera { /** Rotation about the vertical axis, in radians. */ yaw: number; /** Rotation above and below the horizon, in radians. Clamped near the poles. */ pitch: number; /** Distance from the origin. Smaller is closer. */ distance: number; /** Perspective strength. Larger flattens toward an orthographic view. */ focalLength: number; panX: number; panY: number; } export const DEFAULT_CAMERA: GraphCamera = { yaw: 0.6, pitch: 0.35, distance: 520, focalLength: 620, panX: 0, panY: 0, }; /** Just short of the poles, where yaw would gimbal-lock and the drag flip. */ export const MAX_PITCH = Math.PI / 2 - 0.05; export interface ProjectedPoint { x: number; y: number; /** Camera-space depth. Larger is further away. */ depth: number; /** Perspective scale factor: 1 at the focal plane, smaller further off. */ scale: number; /** False when the point sits behind the camera and must not be drawn. */ visible: boolean; } /** * Projects a layout coordinate to canvas pixels. * * @description * A graph needs no mesh, material or shader — only points and lines — so the * whole 3D pipeline is this function plus a depth sort. That is the reason a * WebGL dependency buys so little here: it would replace the easy half and leave * the force simulation, which has to be written either way. */ export function project( point: { x: number; y: number; z: number }, camera: GraphCamera, width: number, height: number ): ProjectedPoint { const cosYaw = Math.cos(camera.yaw); const sinYaw = Math.sin(camera.yaw); const cosPitch = Math.cos(camera.pitch); const sinPitch = Math.sin(camera.pitch); // Yaw about Y, then pitch about X. const x1 = point.x * cosYaw - point.z * sinYaw; const z1 = point.x * sinYaw + point.z * cosYaw; const y2 = point.y * cosPitch - z1 * sinPitch; const z2 = point.y * sinPitch + z1 * cosPitch; const depth = z2 + camera.distance; // Behind the camera, or on the plane where perspective diverges. if (depth <= 1) { return { x: 0, y: 0, depth, scale: 0, visible: false }; } const scale = camera.focalLength / depth; return { x: width / 2 + (x1 * scale + camera.panX), y: height / 2 + (y2 * scale + camera.panY), depth, scale, visible: true, }; } /** Fades and shrinks distant nodes so depth reads without stereo cues. */ export function depthFade(depth: number, camera: GraphCamera): number { const near = camera.distance * 0.45; const far = camera.distance * 1.9; const t = (depth - near) / (far - near); return Math.max(0.18, Math.min(1, 1 - t * 0.82)); } /** Camera distance that frames every node with a little margin. */ export function fitDistance( states: GraphNodeState[], camera: GraphCamera, width: number, height: number ): number { if (states.length === 0) return DEFAULT_CAMERA.distance; let maxRadius = 0; for (const state of states) { const radius = Math.sqrt(state.x * state.x + state.y * state.y + state.z * state.z); if (radius > maxRadius) maxRadius = radius; } if (maxRadius === 0) return DEFAULT_CAMERA.distance; // Solve scale = focal / distance for the scale that puts maxRadius at 40% of // the smaller viewport axis. const target = Math.min(width, height) * 0.4; return Math.max(120, (camera.focalLength * maxRadius) / target); } /** Applies a drag as a rotation, with pitch clamped away from the poles. */ export function orbit(camera: GraphCamera, deltaX: number, deltaY: number): GraphCamera { return { ...camera, yaw: camera.yaw + deltaX * 0.006, pitch: Math.max(-MAX_PITCH, Math.min(MAX_PITCH, camera.pitch + deltaY * 0.006)), }; } /** Applies a wheel notch as a zoom, multiplicative so it feels even at any distance. */ export function zoom(camera: GraphCamera, delta: number): GraphCamera { return { ...camera, distance: Math.max(60, Math.min(4000, camera.distance * Math.exp(delta * 0.0012))), }; } /** Finds the topmost node under a canvas point. */ export function hitTest( states: GraphNodeState[], radii: number[], camera: GraphCamera, width: number, height: number, pointerX: number, pointerY: number ): number { let best = -1; let bestDepth = Infinity; for (let i = 0; i < states.length; i += 1) { const projected = project(states[i], camera, width, height); if (!projected.visible) continue; const radius = Math.max(4, radii[i] * projected.scale); const dx = pointerX - projected.x; const dy = pointerY - projected.y; // Nearest wins, so a node in front is always the one picked. if (dx * dx + dy * dy <= radius * radius && projected.depth < bestDepth) { best = i; bestDepth = projected.depth; } } return best; } /** * Uniform factor that keeps the largest node under a pixel ceiling. * * @description * Node radius is in layout units, so a sparse graph framed to fill the canvas * renders enormous discs while a dense one renders specks. Capping each node * individually would flatten the weight differences that make a graph readable, * so a single factor is applied to all of them: the biggest node lands on the * ceiling and every other keeps its proportion. * * Measured at the origin, so per-node depth still varies size and the depth cue * survives. */ export function nodeRadiusScale(radii: number[], camera: GraphCamera, maxRadiusPx: number): number { if (radii.length === 0) return 1; const originScale = camera.focalLength / camera.distance; const largest = Math.max(...radii) * originScale; return largest > maxRadiusPx ? maxRadiusPx / largest : 1; }