"use client"; /** * The hand-rolled single-canvas graph renderer (decided 2026-09-13 after * the research pass — docs/research/graph-rendering.md, Option B). * * Replaces sigma.js. Why: sigma gave us WebGL node/edge rendering, * picking, and a camera, but our cluster regions had to live on foreign * canvases injected next to its private DOM stack, drawn from a separate * event loop — so hulls lagged behind nodes during pan, raced sigma's * canvas sizing on renderer rebuilds, and every camera event re-rasterized * the full hull canvas (GPU spikes + audible coil whine). At our scale * (338 → low thousands of nodes) one Canvas 2D is comfortably inside * 60fps territory, and a single loop makes the desync class of bugs * structurally impossible. * * Architecture (the canonical pattern — browser.engineering, MDN * "Optimizing canvas", web.dev canvas performance): * - The renderer is a DUMB DRAW FUNCTION over plain data: nodes with * world-space positions, edges, clusters, colors, settings. Layout * (forceAtlas2) and filtering happen OUTSIDE, in the React component; * this module never computes a position. * - ONE canvas, ONE requestAnimationFrame loop with a dirty flag. Input * and DOM events only mutate state and mark dirty; the loop draws at * most once per frame with every layer under the same camera — * hull fills → hull strokes → edges → nodes → region labels → node * labels → hover. Nothing can ever lag anything else. * - Camera: d3-zoom supplies the verified pan / zoom-to-cursor / pinch * math (screen = world·k + [x,y]). The camera lives in the renderer * instance, so data refetches and settings changes never move it. * - Picking: nearest-node distance check (µs at our scale), with a * PICKING_PAD so the whole hover halo is hit-testable. * * Zoom-gated labels (the Obsidian text-fade feel): sigma's camera "ratio" * convention is kept for the settings' persisted semantics — ratio 1 = * fit zoom, SMALLER = zoomed in (ratio = kFit / k). Node labels are * fully visible at ratio ≤ labelZoom and fade out across LABELS_FADE_SPAN * of ratio; cluster labels fade in over the exact same span (sums to 1) * so the two never coexist. All labels gate together — no per-node * popping. * * The hover glow is unchanged in look: a soft halo in the node's own * color at a constant radius, opacity cross-faded over ~180ms, with the * name brightening on the SAME factor. Only opacity animates; the only * per-frame work when idle is a dirty-flag check (no rAF is scheduled at * all until something changes). * * Unit tests cover the pure helpers (tests/graph-canvas.test.ts); the * class itself is DOM-bound and exercised live. Module scope stays * DOM-free so the file imports cleanly under Node. */ import { select } from "d3-selection"; import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom"; import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; // ─── Public data contract ────────────────────────────────────────────────── /** One node to draw: world-space position + the short glanceable label. */ export interface CanvasNode { id: string; x: number; y: number; label: string; } /** One directed edge (follows | supports); arrow points prerequisite → dependent. */ export interface CanvasEdge { from: string; to: string; } /** One cluster region: member node ids + the LLM-coined label (or null). */ export interface CanvasCluster { id: string; label: string | null; members: string[]; } /** Theme colors, resolved to #rrggbb by the caller (canvas wants hex, * and the renderer must not read CSS itself). */ export interface CanvasColors { node: string; edge: string; label: string; hoverText: string; } /** The rendering controls (persisted by the caller in localStorage). */ export interface GraphRenderSettings { nodeSize: number; edgeWidth: number; arrowLength: number; /** Camera-ratio threshold for node labels (1 = fit zoom, smaller = * zoomed in); cluster labels fade in over the same span, inversely. */ labelZoom: number; /** Toggle for the cluster region labels (hulls always draw). */ clusterLabels: boolean; } export interface CanvasData { nodes: CanvasNode[]; edges: CanvasEdge[]; clusters: CanvasCluster[]; colors: CanvasColors; /** Body font family (labels must match the app typography). */ fontFamily: string; } export interface RendererCallbacks { /** A node was clicked (screen position included for card anchoring). */ onSelect: (nodeId: string, screenPos: Pt) => void; /** The empty stage was clicked. */ onStageClick: () => void; /** Any camera move (pan/zoom) — the caller dismisses anchored UI. */ onCameraMove: () => void; } // ─── Constants (ported verbatim from the sigma view) ──────────────────────── const LABEL_SIZE = 12; // px (sigma's labelSize) const LABEL_WEIGHT = "500"; const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line const LABEL_LINE_HEIGHT = 1.3; /** Zoom span (camera-ratio units) over which labels fade together. */ export const LABELS_FADE_SPAN = 0.5; const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant) const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward const HOVER_FADE_MS = 180; // hover halo opacity fade length /** Extra pickable radius (px) beyond the disc — the whole halo is * hit-testable (the old flicker fix, minus the picking shader). */ const PICKING_PAD = 10; /** Click vs drag threshold: pointer travel under this many px is a click. */ const CLICK_SLOP = 4; /** Cluster hull styling: layered low-alpha fills approximate the soft * translucent tint (a canvas blur filter rasterizes on the GPU per call * — the original coil-whine driver; never bring ctx.filter back). */ const HULL_FILL_LAYERS = [ [24, 0.025], [19, 0.04], [14, 0.06], ] as const; const HULL_STROKE_PAD = 14; const HULL_CORNER_RADIUS = 9; const HULL_DASH = [7, 7] as const; const REGION_LABEL_SIZE = 18; const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1); // ─── Pure helpers (unit-tested) ───────────────────────────────────────────── /** * The sigma-compatible "camera ratio": 1 at fit zoom, SMALLER when zoomed * in (ratio = kFit / k). The persisted labelZoom setting is denominated in * these units, so the gate semantics survive the renderer swap unchanged. */ export function zoomRatioOf(kFit: number, k: number): number { return kFit / k; } /** * Node-label opacity at a given camera ratio: 1 at/below the labelZoom * threshold, fading to 0 across LABELS_FADE_SPAN of ratio. Cluster labels * use `1 - this` over the same span — the two cross-fade and never * coexist at full opacity. */ export function labelAlpha(ratio: number, labelZoom: number, span = LABELS_FADE_SPAN): number { return clamp01(1 - (ratio - labelZoom) / span); } /** The camera transform that frames `bbox` in a w×h viewport with padding. */ export function fitTransform( bbox: { minX: number; maxX: number; minY: number; maxY: number }, w: number, h: number, padding: number, ): { x: number; y: number; k: number } { const bw = Math.max(bbox.maxX - bbox.minX, 1e-6); const bh = Math.max(bbox.maxY - bbox.minY, 1e-6); const k = Math.min((w - 2 * padding) / bw, (h - 2 * padding) / bh); const cx = (bbox.minX + bbox.maxX) / 2; const cy = (bbox.minY + bbox.maxY) / 2; return { x: w / 2 - cx * k, y: h / 2 - cy * k, k }; } /** The arrowhead geometry for a directed edge a→b: shaft endpoints * (clear of both node discs) and the head triangle (tip on the target * disc edge, wideness = 0.8 × head length — the sigma arrow shape). */ export function arrowGeometry( a: Pt, b: Pt, fromRadius: number, toRadius: number, headLength: number, ): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null { const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy); if (len < 1e-6) return null; // degenerate (coincident) — skip const ux = dx / len; const uy = dy / len; const tip = { x: b.x - ux * toRadius, y: b.y - uy * toRadius }; const base = { x: tip.x - ux * headLength, y: tip.y - uy * headLength }; const halfW = 0.4 * headLength; return { shaftFrom: { x: a.x + ux * fromRadius, y: a.y + uy * fromRadius }, shaftTo: base, tip, left: { x: base.x - uy * halfW, y: base.y + ux * halfW }, right: { x: base.x + uy * halfW, y: base.y - ux * halfW }, }; } /** Nearest node within maxDist of a screen point (linear scan — µs at * our scale; a spatial grid only earns its keep past ~10k nodes). */ export function pickNearest( nodes: T[], px: number, py: number, maxDist: number, ): T | null { let best: T | null = null; let bestD = maxDist * maxDist; // compare squared distances for (const n of nodes) { const d = (n.x - px) ** 2 + (n.y - py) ** 2; if (d < bestD) { bestD = d; best = n; } } return best; } // ─── Canvas text helpers (ported verbatim) ───────────────────────────────── /** "#rrggbb" + alpha → "rgba(...)". */ function rgbaFromHex(hex: string, alpha: number): string { const r = Number.parseInt(hex.slice(1, 3), 16); const g = Number.parseInt(hex.slice(3, 5), 16); const b = Number.parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } /** Word-wrap for canvas text. Never truncates: overlong single words are * hard-broken by character so no line ever exceeds maxWidth. */ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { const lines: string[] = []; for (const word of text.split(/\s+/).filter(Boolean)) { const last = lines.length > 0 ? lines[lines.length - 1]! : null; if (last !== null) { const joined = `${last} ${word}`; if (context.measureText(joined).width <= maxWidth) { lines[lines.length - 1] = joined; continue; } } let chunk = ""; for (const char of word) { if (chunk && context.measureText(`${chunk}${char}`).width > maxWidth) { lines.push(chunk); chunk = char; } else { chunk += char; } } if (chunk) lines.push(chunk); } return lines; } /** The wrapped node name, centered below the node — identical geometry at * rest and on hover, so hover only changes the color, nothing jumps. */ function drawName( context: CanvasRenderingContext2D, x: number, y: number, size: number, label: string, font: string, color: string, ): void { context.save(); context.font = `${LABEL_WEIGHT} ${LABEL_SIZE}px ${font}`; const lines = wrapLabel(context, label, LABEL_MAX_WIDTH); context.fillStyle = color; context.textAlign = "center"; context.textBaseline = "top"; const firstTop = y + size + LABEL_GAP; for (let i = 0; i < lines.length; i++) { context.fillText(lines[i]!, x, firstTop + i * LABEL_SIZE * LABEL_LINE_HEIGHT); } context.restore(); } // ─── The renderer ─────────────────────────────────────────────────────────── export class GraphCanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private callbacks: RendererCallbacks; private zoomBehavior: ZoomBehavior; private transform: ZoomTransform = zoomIdentity; /** Set once the first fit happens; the camera then survives setData. */ private hasFit = false; /** The k that frames the whole graph — the label-gate's "1×". */ private kFit = 1; private data: CanvasData | null = null; /** id -> node for the current data (avoids O(n^2) scans). */ private nodeById = new Map(); private settings: GraphRenderSettings | null = null; private visible: ReadonlySet | null = null; private frameId: number | null = null; private dirty = true; // Hover transition: fade 0 = resting, 1 = fully hovered; the animating // flag keeps the loop scheduled until the fade settles (then rAF stops). private hoverId: string | null = null; private hoverFade = 0; private hoverFrom = 0; private hoverTarget = 0; private hoverStart = 0; private animating = false; private resizeObserver: ResizeObserver; private downPos: Pt | null = null; constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) { this.canvas = canvas; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("2d context unavailable"); this.ctx = ctx; this.callbacks = callbacks; // d3-zoom owns pan / wheel zoom-to-cursor / touch pinch; every // transform change just marks dirty (draws happen in OUR loop). this.zoomBehavior = zoom() .scaleExtent([0.02, 40]) .on("zoom", ({ transform }) => { this.transform = transform; this.markDirty(); this.callbacks.onCameraMove(); }); select(canvas) .call(this.zoomBehavior) // No surprise dblclick-zoom (sigma had none). .on("dblclick.zoom", null); canvas.addEventListener("pointermove", this.onPointerMove); canvas.addEventListener("pointerdown", this.onPointerDown); canvas.addEventListener("pointerup", this.onPointerUp); canvas.addEventListener("pointerleave", this.onPointerLeave); this.resizeObserver = new ResizeObserver(() => { this.syncSize(); if (!this.hasFit) this.fit(); // first real size → fit once this.markDirty(); }); this.resizeObserver.observe(canvas); this.syncSize(); } // ── Public API ── setData(data: CanvasData): void { this.data = data; this.nodeById = new Map(data.nodes.map((n) => [n.id, n])); if (!this.hasFit && data.nodes.length > 0) this.fit(); this.markDirty(); } setSettings(settings: GraphRenderSettings): void { this.settings = settings; this.markDirty(); } setVisible(visible: ReadonlySet | null): void { this.visible = visible; this.markDirty(); } /** Frame the whole graph (padding included). Keeps the camera on later * setData calls — call fit() only for an explicit re-frame. */ fit(): void { if (!this.data || this.data.nodes.length === 0) return; const w = this.canvas.clientWidth; const h = this.canvas.clientHeight; if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const n of this.data.nodes) { if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y; if (n.x > maxX) maxX = n.x; if (n.y > maxY) maxY = n.y; } const t = fitTransform({ minX, maxX, minY, maxY }, w, h, 40); this.kFit = t.k; this.hasFit = true; select(this.canvas).call( this.zoomBehavior.transform, zoomIdentity.translate(t.x, t.y).scale(t.k), ); } destroy(): void { if (this.frameId !== null) cancelAnimationFrame(this.frameId); this.resizeObserver.disconnect(); select(this.canvas).on(".zoom", null); this.canvas.removeEventListener("pointermove", this.onPointerMove); this.canvas.removeEventListener("pointerdown", this.onPointerDown); this.canvas.removeEventListener("pointerup", this.onPointerUp); this.canvas.removeEventListener("pointerleave", this.onPointerLeave); this.canvas.style.cursor = ""; } // ── Event handlers (state mutation only — never draw) ── private onPointerMove = (e: PointerEvent) => { const { x, y } = this.eventPos(e); const node = this.pickAt(x, y); if ((node?.id ?? null) !== this.hoverId) this.setHover(node?.id ?? null); this.canvas.style.cursor = node ? "pointer" : ""; }; private onPointerDown = (e: PointerEvent) => { this.downPos = this.eventPos(e); }; private onPointerUp = (e: PointerEvent) => { if (!this.downPos) return; const pos = this.eventPos(e); const moved = Math.hypot(pos.x - this.downPos.x, pos.y - this.downPos.y); this.downPos = null; if (moved > CLICK_SLOP) return; // that was a drag (pan), not a click const node = this.pickAt(pos.x, pos.y); if (node) this.callbacks.onSelect(node.id, pos); else this.callbacks.onStageClick(); }; private onPointerLeave = () => { this.downPos = null; this.setHover(null); }; // ── Internals ── private eventPos(e: PointerEvent): Pt { const rect = this.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } private pickAt(px: number, py: number): CanvasNode | null { if (!this.data || !this.settings) return null; // Screen positions, filtered to visible nodes. const { x: tx, y: ty, k } = this.transform; const maxDist = this.settings.nodeSize + PICKING_PAD; const projected: { id: string; x: number; y: number }[] = []; for (const n of this.data.nodes) { if (this.visible && !this.visible.has(n.id)) continue; projected.push({ id: n.id, x: n.x * k + tx, y: n.y * k + ty }); } const hit = pickNearest(projected, px, py, maxDist); if (!hit) return null; return this.nodeById.get(hit.id) ?? null; } private setHover(nodeId: string | null): void { if (nodeId === this.hoverId && this.hoverTarget === (nodeId ? 1 : 0)) return; this.hoverId = nodeId; // Rebase the animation from the CURRENT fade: rapid hover toggles // glide instead of snapping (ported from the sigma-era behavior). this.hoverFrom = this.hoverFade; this.hoverTarget = nodeId ? 1 : 0; this.hoverStart = performance.now(); this.animating = true; this.markDirty(); } private markDirty(): void { this.dirty = true; if (this.frameId === null) { this.frameId = requestAnimationFrame(() => { this.frameId = null; this.tick(); }); } } private tick(): void { this.advanceHover(); this.draw(); // Keep frames coming ONLY while the hover fade is in motion; an // idle graph schedules no rAF at all. if (this.animating) { this.frameId = requestAnimationFrame(() => { this.frameId = null; this.tick(); }); } } private advanceHover(): void { if (!this.animating) return; const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1); this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t; if (t >= 1) this.animating = false; } /** Resize the backing store to the CSS box × devicePixelRatio; all * drawing then happens in CSS pixels through the DPR transform. */ private syncSize(): void { const w = this.canvas.clientWidth; const h = this.canvas.clientHeight; const dpr = window.devicePixelRatio || 1; const bw = Math.max(1, Math.round(w * dpr)); const bh = Math.max(1, Math.round(h * dpr)); if (this.canvas.width !== bw || this.canvas.height !== bh) { this.canvas.width = bw; this.canvas.height = bh; } this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } /** THE draw function: every layer, one frame, one camera. */ private draw(): void { this.dirty = false; const data = this.data; const settings = this.settings; if (!data || !settings) return; const w = this.canvas.clientWidth; const h = this.canvas.clientHeight; this.ctx.clearRect(0, 0, w, h); const { x: tx, y: ty, k } = this.transform; const nodeById = new Map(); const ratio = zoomRatioOf(this.kFit, k); const nodeAlpha = labelAlpha(ratio, settings.labelZoom); const regionAlpha = 1 - nodeAlpha; // Screen positions for every VISIBLE node (one pass), plus the // byId lookup so the label/hover passes never go O(n^2). const pos = new Map(); for (const n of data.nodes) { if (this.visible && !this.visible.has(n.id)) continue; pos.set(n.id, { x: n.x * k + tx, y: n.y * k + ty }); nodeById.set(n.id, n); } // 1–2. Cluster hulls: soft layered fills, then the dashed boundary. for (const cluster of data.clusters) { const pts: Pt[] = []; for (const id of cluster.members) { const p = pos.get(id); if (p) pts.push(p); } const hull = convexHull(pts); if (hull.length < 3) continue; const hue = clusterHue(cluster.id); for (const [pad, alpha] of HULL_FILL_LAYERS) { this.ctx.beginPath(); traceRoundedPath(this.ctx, offsetPolygon(hull, pad), HULL_CORNER_RADIUS); this.ctx.fillStyle = `hsla(${hue}, 65%, 60%, ${alpha})`; this.ctx.fill(); } this.ctx.beginPath(); traceRoundedPath(this.ctx, offsetPolygon(hull, HULL_STROKE_PAD), HULL_CORNER_RADIUS); this.ctx.setLineDash([...HULL_DASH]); this.ctx.strokeStyle = `hsla(${hue}, 65%, 62%, 0.5)`; this.ctx.lineWidth = 1.5; this.ctx.stroke(); this.ctx.setLineDash([]); } // 3. Edges: shaft + arrowhead (tip lands on the target disc edge). if (settings.edgeWidth > 0) { this.ctx.strokeStyle = data.colors.edge; this.ctx.fillStyle = data.colors.edge; this.ctx.lineWidth = settings.edgeWidth; this.ctx.lineCap = "round"; for (const e of data.edges) { const from = pos.get(e.from); const to = pos.get(e.to); if (!from || !to) continue; // edge drops when either endpoint is hidden const g = arrowGeometry(from, to, settings.nodeSize, settings.nodeSize, settings.arrowLength); if (!g) continue; this.ctx.beginPath(); this.ctx.moveTo(g.shaftFrom.x, g.shaftFrom.y); this.ctx.lineTo(g.shaftTo.x, g.shaftTo.y); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(g.tip.x, g.tip.y); this.ctx.lineTo(g.left.x, g.left.y); this.ctx.lineTo(g.right.x, g.right.y); this.ctx.closePath(); this.ctx.fill(); } } // 4. Nodes: plain discs. this.ctx.fillStyle = data.colors.node; for (const [, p] of pos) { this.ctx.beginPath(); this.ctx.arc(p.x, p.y, settings.nodeSize, 0, Math.PI * 2); this.ctx.fill(); } // 5. Cluster region labels — inverse-gated on THE zoom control: // they fade in over the exact span node labels fade out. Drawn // above the nodes, below the node labels (the old layer order). if (settings.clusterLabels && regionAlpha > 0.01) { this.ctx.save(); this.ctx.globalAlpha = regionAlpha; this.ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE}px ${data.fontFamily}`; this.ctx.textAlign = "center"; this.ctx.textBaseline = "middle"; for (const cluster of data.clusters) { if (!cluster.label) continue; const pts: Pt[] = []; for (const id of cluster.members) { const p = pos.get(id); if (p) pts.push(p); } if (pts.length === 0) continue; const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; const hue = clusterHue(cluster.id); this.ctx.fillStyle = `hsla(${hue}, 55%, 78%, 0.85)`; this.ctx.fillText(cluster.label, cx, cy); } this.ctx.restore(); } // 6. Node labels — one global gate, faded together (no per-node pop). if (nodeAlpha > 0.01) { this.ctx.save(); this.ctx.globalAlpha = nodeAlpha; for (const [id, p] of pos) { const n = nodeById.get(id); if (n) drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.label); } this.ctx.restore(); } // 7. Hover glow (topmost): halo in the node's color + the brightened // name, both on the SAME fade factor. Shows even when labels are // zoomed out (hover is its own affordance, not a label). if (this.hoverId && this.hoverFade > 0.02) { const p = pos.get(this.hoverId); const n = p ? nodeById.get(this.hoverId) : null; if (p && n) { const inner = settings.nodeSize; const outer = settings.nodeSize + GLOW_PAD; const glow = this.ctx.createRadialGradient(p.x, p.y, inner, p.x, p.y, outer); glow.addColorStop(0, rgbaFromHex(data.colors.node, GLOW_ALPHA * this.hoverFade)); glow.addColorStop(1, rgbaFromHex(data.colors.node, 0)); this.ctx.fillStyle = glow; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, outer, 0, Math.PI * 2); this.ctx.arc(p.x, p.y, inner, 0, Math.PI * 2, true); this.ctx.fill("evenodd"); this.ctx.save(); this.ctx.globalAlpha = this.hoverFade; drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.hoverText); this.ctx.restore(); } } } }