import { depthFade, nodeRadiusScale, project, type GraphCamera, type ProjectedPoint, } from './camera'; import type { GraphEdge, GraphNodeState } from './graph.types'; export interface RenderPalette { background: string; foreground: string; mutedForeground: string; border: string; ring: string; /** Literal color per node index. */ nodeColors: string[]; /** Literal color per edge index. */ edgeColors: string[]; } export interface RenderOptions { states: GraphNodeState[]; edges: GraphEdge[]; indexById: Map; radii: number[]; labels: Array; camera: GraphCamera; width: number; height: number; palette: RenderPalette; selectedIndex: number | null; hoveredIndex: number | null; /** Indices adjacent to the selection, dimmed differently from the rest. */ neighborIndices: Set | null; showLabels: boolean; /** Nodes smaller than this on screen get no label, however labels are set. */ labelMinRadiusPx: number; /** Ceiling on the largest node's on-screen radius. */ maxNodeRadiusPx: number; directed: boolean; devicePixelRatio: number; } /** Opacity applied to everything outside the focused neighbourhood. */ const DIMMED = 0.12; function withGlobalAlpha(ctx: CanvasRenderingContext2D, alpha: number, draw: () => void) { const previous = ctx.globalAlpha; ctx.globalAlpha = alpha; draw(); ctx.globalAlpha = previous; } /** * Draws one frame of the graph. * * @description * Painter's algorithm: edges first, then nodes back-to-front, so nearer things * cover further ones. There is no depth buffer and none is needed — a graph is * discs and lines, which is exactly why this does not require WebGL. * * Depth is communicated three ways at once, because a single cue reads as * ambiguous on a flat screen: perspective scale, opacity fade, and draw order. */ export function renderGraph(ctx: CanvasRenderingContext2D, options: RenderOptions): void { const { states, edges, indexById, radii, labels, camera, width, height, palette, selectedIndex, hoveredIndex, neighborIndices, showLabels, labelMinRadiusPx, maxNodeRadiusPx, directed, devicePixelRatio, } = options; const radiusScale = nodeRadiusScale(radii, camera, maxNodeRadiusPx); ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); ctx.clearRect(0, 0, width, height); // Project once; every later pass reads from this. const projected: ProjectedPoint[] = states.map(state => project(state, camera, width, height)); const isFocused = (index: number) => !neighborIndices || index === selectedIndex || neighborIndices.has(index); /* --- Edges -------------------------------------------------------------- */ ctx.lineCap = 'round'; edges.forEach((edge, edgeIndex) => { const a = indexById.get(edge.source); const b = indexById.get(edge.target); if (a === undefined || b === undefined) return; const from = projected[a]; const to = projected[b]; if (!from.visible || !to.visible) return; const focused = isFocused(a) && isFocused(b); const fade = depthFade((from.depth + to.depth) / 2, camera); withGlobalAlpha(ctx, focused ? fade * 0.55 : DIMMED * 0.6, () => { ctx.strokeStyle = palette.edgeColors[edgeIndex] ?? palette.border; ctx.lineWidth = Math.max(0.4, 1.1 * ((from.scale + to.scale) / 2)); if (edge.dashed) ctx.setLineDash([5, 4]); else ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.setLineDash([]); if (edge.directed ?? directed) { drawArrowhead(ctx, from, to, radii[b] * to.scale * radiusScale); } }); }); /* --- Nodes, back to front ----------------------------------------------- */ const order = states .map((_, index) => index) .filter(index => projected[index].visible) .sort((a, b) => projected[b].depth - projected[a].depth); for (const index of order) { const point = projected[index]; const focused = isFocused(index); const fade = depthFade(point.depth, camera); const radius = Math.max(1.5, radii[index] * point.scale * radiusScale); const isSelected = index === selectedIndex; const isHovered = index === hoveredIndex; withGlobalAlpha(ctx, focused ? fade : DIMMED, () => { ctx.beginPath(); ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); ctx.fillStyle = palette.nodeColors[index] ?? palette.foreground; ctx.fill(); // A hairline in the background color separates overlapping discs, which // is what keeps a dense cluster from reading as one blob. ctx.lineWidth = Math.min(2, radius * 0.28); ctx.strokeStyle = palette.background; ctx.stroke(); if (isSelected || isHovered) { ctx.beginPath(); ctx.arc(point.x, point.y, radius + (isSelected ? 4 : 2.5), 0, Math.PI * 2); ctx.lineWidth = isSelected ? 2.5 : 1.5; ctx.strokeStyle = palette.ring; ctx.stroke(); } }); const label = labels[index]; if (showLabels && label && focused && radius >= labelMinRadiusPx) { withGlobalAlpha(ctx, Math.min(1, fade + 0.25), () => { ctx.font = `${Math.max(9, Math.min(13, radius * 0.95))}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // Drawn twice: a background-colored halo underneath makes the label // legible over edges and other nodes without a solid plate. ctx.lineWidth = 3; ctx.strokeStyle = palette.background; ctx.strokeText(label, point.x, point.y + radius + 3); ctx.fillStyle = palette.foreground; ctx.fillText(label, point.x, point.y + radius + 3); }); } } } function drawArrowhead( ctx: CanvasRenderingContext2D, from: ProjectedPoint, to: ProjectedPoint, targetRadius: number ) { const dx = to.x - from.x; const dy = to.y - from.y; const length = Math.hypot(dx, dy); if (length < 1) return; const ux = dx / length; const uy = dy / length; // Sit the head on the target's rim, not at its centre. const tipX = to.x - ux * (targetRadius + 1); const tipY = to.y - uy * (targetRadius + 1); const size = Math.max(3, 5 * to.scale); ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(tipX - ux * size + -uy * size * 0.5, tipY - uy * size + ux * size * 0.5); ctx.lineTo(tipX - ux * size - -uy * size * 0.5, tipY - uy * size - ux * size * 0.5); ctx.closePath(); ctx.fill(); }