diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts new file mode 100644 index 0000000..741587a --- /dev/null +++ b/components/graph-canvas.ts @@ -0,0 +1,677 @@ +"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(); + } + } + } +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ba185cd..d939bc8 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -3,73 +3,57 @@ /** * The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)"). * - * sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous - * forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect - * writes x/y. No layout animation ever starts; the ONLY animation is the - * short hover fade (enter/leave transitions), strictly bounded in time - * and cancelled on unmount — idle CPU stays near zero. Physics is - * placement only: pan/zoom, no dragging, no editing. + * ARCHITECTURE (2026-09-13, decided after the research pass — + * docs/research/graph-rendering.md, Option B): sigma.js is GONE. The + * renderer is now a hand-rolled single Canvas 2D (./graph-canvas.ts) — + * a DUMB DRAW FUNCTION over plain data with ONE requestAnimationFrame + * loop and a dirty flag. Everything that made the sigma era fragile is + * structurally impossible now: hulls cannot lag nodes (one loop, one + * camera, one frame), settings changes cannot shift anything (the + * camera lives in the renderer and no rebuild ever happens), and the + * per-camera-event full-canvas repaints (GPU spikes + coil whine) are + * gone (at most one draw per frame, and NO frame at all while idle). * - * Labels render the node `name` — the very short, glanceable label the - * extractor coins (a glossary-headword-style handle); the full-precision - * `statement` lives on the detail card. Custom canvas drawers replace - * sigma's defaults: - * - defaultDrawNodeLabel: plain wrapped text, centered below the node, - * word-wrapped at a max line width — no truncation, no one-line sprawl. - * Cluster hulls (vision §1) live on TWO custom canvas layers around - * sigma's own stack: a hull layer behind everything (blurred low-opacity - * fill + dashed boundary per cluster, stable hue from the cluster id) - * and a region-label layer above nodes but below node labels (the label - * at the centroid, inverse-gated on THE zoom control — node labels fade - * out over the span as cluster labels fade in, so they never coexist). - * Geometry is a pure function of member positions at draw time: every - * afterRender re-derives hulls through the current camera, so pan/zoom - * can never invalidate it. - * - defaultDrawNodeHover: NO background box (sigma's default is a - * hardcoded white one). Instead the hovered node gets a soft glow - * halo in the node's color at a constant radius, flush with the - * disc edge, and its name brightens — the "this is clickable, - * click for the full fact" affordance. Entering/leaving hover fades - * ONE transition factor shared by halo and text alike, so the - * halo's opacity and the text's muted→bright cross-fade stay in - * sync (no growth, no pulse; interpolating from the current value - * means rapid hover toggles glide instead of snapping). Hover - * always shows the name, even when labels are zoomed out. - * Node hit-testing is the disc PLUS a pad: sigma picks nodes from a - * half-resolution framebuffer (no v3 setting enlarges hitboxes), and the - * glow halo is bigger than the stock hitbox — touching the halo - * de-hovered the node, which oscillated (the hover flicker). A - * NodeCircleProgram subclass (PICKING_VERTEX_SHADER below) adds - * PICKING_PAD_PX of pickable radius under #ifdef PICKING_MODE only, per - * the maintainer-endorsed v3 pattern (#1493); the visible disc stays - * pixel-identical, and the whole halo becomes hoverable/clickable. - * Labels are zoom-gated GLOBALLY with a fade: all names appear at once - * when the camera is at fit zoom or closer (ratio <= 1), then fade out - * together across a short zoom span before label rendering switches off — - * no per-node size gating, so labels never pop in one by one. (The Obsidian - * text-fade feel, applied to the whole graph.) + * Layout (graphology + forceAtlas2) runs-then-freezes exactly as + * before: ONE synchronous 100-iteration pass inside a useEffect writes + * world-space x/y, then the positions are handed to the renderer as + * data. No layout animation ever starts; the only animation is the + * short hover fade, strictly bounded in time — idle CPU is zero (the + * loop schedules no rAF until something actually changes). * - * The search panel (top-right overlay): a search box matching name or - * statement text — the transitional node-state classes are gone (2026-09-08; - * nodes render uniformly until the state model ideation lands), so there - * are no state legend rows or toggles anymore. Filtering flows through - * sigma's official - * nodeReducer/edgeReducer settings (`hidden`), so rendering, hover, - * picking, and labels all respect it; edges drop when either endpoint is - * hidden, and an open detail card closes if its node is filtered out. - * Clicking a node opens the detail card anchored just below the node's - * viewport position (flipping above near the bottom edge); any camera move - * (pan/zoom) dismisses it. The `requires` edges render as arrows pointing - * prerequisite → dependent. + * The component's job is everything AROUND drawing: fetch + refetch the + * snapshot, run the layout, resolve theme colors to hex, filter via the + * search box, own the settings panel / detail card / import button / + * pipeline chips, and FEED the renderer (data, settings, visibility). + * The renderer's contract stays presentation-only — which is exactly + * what the upcoming playground and cluster-first layout work needs: a + * renderer that draws whatever data it is given, never questioning it. * - * Rendering settings (2026-09-13): a gear in the search panel reveals four - * sliders — node size, edge width, arrowhead length, and THE zoom-level - * control (label visibility threshold; 1× = fit zoom) — persisted per browser - * (localStorage) and applied by REBUILDING ONLY THE RENDERER: the layout - * effect (graph + forceAtlas2) is untouched by settings, and the camera - * position carries across rebuilds, so tweaking a slider never re-fits or - * re-lays-out the view. The arrowhead is a program option (head px = - * thickness × lengthToThicknessRatio × 2), solved from the two settings. + * Visual semantics, all preserved verbatim from the sigma era: + * - Node labels (the short extractor-coined names) wrap under the + * node; one GLOBAL zoom gate fades them together (no per-node pop). + * - Hover: a soft glow halo in the node's own color at a constant + * radius, opacity cross-faded ~180ms with the name brightening on + * the same factor; the whole halo is hit-testable (picking pad). + * - Cluster hulls (vision §1): soft layered translucent fills + a + * dashed boundary per cluster, stable hue from the cluster id; the + * region label at the centroid fades in over the exact zoom span + * node labels fade out (THE single zoom control) — and is + * toggleable in the settings panel (hulls always draw). + * - The persisted labelZoom setting keeps sigma's camera-ratio + * convention (1 = fit zoom, SMALLER = zoomed in): labels until + * zoom 0.5x means labels appear once zoomed in past 2x fit. + * - Click a node -> the detail card anchored just below it (flipping + * above near the bottom edge); any camera move dismisses it. + * Clicking the empty stage closes it. Search filtering hides nodes + * (and their edges) from rendering, picking, hover, AND labels; + * an open card closes if its node is filtered out. + * + * Rendering settings (2026-09-13): the gear in the search panel reveals + * four sliders (node size, edge width, arrowhead length, the zoom + * threshold) plus the cluster-labels checkbox, persisted per browser + * (localStorage). Applied by just redrawing — no rebuild, no re-layout, + * and the camera never moves. */ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -77,41 +61,22 @@ import Link from "next/link"; import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; -import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering"; import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; -import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; +import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; -/** The uniform node look (no state classes — WebGL wants hex, not oklch): +/** The uniform node look (no state classes — canvas wants hex, not oklch): * the accent color, until the state model lands. */ const NODE_COLOR_TOKEN = "--primary"; // ---- Render settings (the settings-panel controls) ------------------------ // // Three basic controls (user-decided 2026-09-13): node disc size, edge line -// width, and arrowhead length (px). The arrowhead program couples head size -// to thickness via lengthToThicknessRatio — the renderer derives the ratio -// from the two settings, so the controls stay independent. Defaults reflect -// the pre-panel look (edge 1px + its stock 5px head) except nodeSize, which -// dropped 8 → 6 for dense graphs (user: "nodes are quite large"). Persisted -// per browser in localStorage. +// width, and arrowhead length (px). Defaults reflect the pre-panel look +// (edge 1px + its stock 5px head) except nodeSize, which dropped 8 → 6 for +// dense graphs (user: "nodes are quite large"). Persisted per browser in +// localStorage. -interface RenderSettings { - nodeSize: number; - edgeWidth: number; - arrowLength: number; - /** - * The single zoom-level control (camera ratio at/below which node labels - * appear; 1 = fit zoom, higher = labels visible further zoomed out). It - * will ALSO drive cluster labels (vision §1, DECIDED 2026-09-13) — - * inversely: cluster/domain labels fade in over the same span as node - * labels fade out — so the two never coexist. Kept in the persisted - * settings for exactly that reuse. - */ - labelZoom: number; - /** Toggle for the cluster region labels (the hulls stay) — they can - * crowd the screen at fit zoom with many regions (user, 2026-09-13). */ - clusterLabels: boolean; -} +type RenderSettings = GraphRenderSettings; const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }; const RENDER_SETTINGS_KEY = "graph-render-settings"; const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); @@ -130,7 +95,7 @@ function sanitizeRenderSettings(raw: unknown): RenderSettings { /** * Pure filter predicate for the search panel: a node is visible when the * query is empty or matches its name or statement (case-insensitive). - * Exported for tests; the sigma reducers just consume its verdict. + * Exported for tests; the visibility feed just consumes its verdict. */ export function isNodeVisible(node: Pick, query: string): boolean { const q = query.trim().toLowerCase(); @@ -138,223 +103,7 @@ export function isNodeVisible(node: Pick, query return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } -// ---- Label drawing (canvas) --------------------------------------------- -// -// sigma's defaults draw the label on ONE line to the right of the node with -// a white box on hover. We replace both: names wrap under the node at a max -// line width, muted at rest; on hover the node pulses a soft glow in its -// own state color and the name brightens (no background box). - -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 out together. */ -const LABELS_FADE_SPAN = 0.5; - -// Hover glow: a soft halo ring in the node's own color at a CONSTANT -// radius, starting flush at the disc edge (no gap — a gap reads as a -// false outline/border between node and glow). Only the halo's OPACITY -// animates (hover enter/leave); it never grows and never pulses. -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 - -// ---- Padded picking (WebGL) ---------------------------------------------- -// -// sigma picks nodes by reading a single pixel from a picking framebuffer -// rendered at HALF the CSS resolution: small nodes get tiny, ragged hit -// edges, and our glow halo (GLOW_PAD beyond the disc) is NOT pickable, so -// moving the cursor from the disc onto its own halo de-hovered it — an -// oscillation loop (the hover flicker). sigma v3 has no setting for hit -// padding; the maintainer-endorsed pattern (#1493, fixed officially in v4 -// as `nodePickingPadding`) is a node program whose vertex shader enlarges -// the disc ONLY in the picking shader pass. - -/** Extra radius (px) added to each node's PICKABLE disc (picking layer - * only — the visible disc is untouched). >= GLOW_PAD so the entire hover - * halo is hit-testable. */ -const PICKING_PAD_PX = 10; - -/** - * The node-circle vertex shader, verbatim from sigma 3.0.3 - * (dist/index-fad77a13.esm.js, SHADER_SOURCE$5) with ONE change: under - * `#ifdef PICKING_MODE` (sigma compiles this source twice — visible render - * vs picking readback — so the visible pass never takes this branch) the - * size gains PICKING_PAD_PX of radius, which is the same on-screen unit as - * `data.size` (the px radius the canvas drawers use for the halo). - */ -const PICKING_VERTEX_SHADER = /* glsl */ ` -attribute vec4 a_id; -attribute vec4 a_color; -attribute vec2 a_position; -attribute float a_size; -attribute float a_angle; - -uniform mat3 u_matrix; -uniform float u_sizeRatio; -uniform float u_correctionRatio; - -varying vec4 v_color; -varying vec2 v_diffVector; -varying float v_radius; -varying float v_border; - -const float bias = 255.0 / 254.0; - -void main() { - float pickedSize = a_size; -#ifdef PICKING_MODE - // Picking pass only: enlarge the hitbox by PICKING_PAD_PX of radius. - pickedSize += ${PICKING_PAD_PX.toFixed(1)}; -#endif - float size = pickedSize * u_correctionRatio / u_sizeRatio * 4.0; - vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle)); - vec2 position = a_position + diffVector; - gl_Position = vec4( - (u_matrix * vec3(position, 1)).xy, - 0, - 1 - ); - - v_diffVector = diffVector; - v_radius = size / 2.0; - -#ifdef PICKING_MODE - // For picking mode, we use the ID as the color: - v_color = a_id; -#else - // For normal mode, we use the color: - v_color = a_color; -#endif - - v_color.a *= bias; -} -`; - -/** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ -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 (the short label), centered below the node — used by both drawers - * with identical geometry so hover only changes the color, nothing jumps. */ -function drawName( - context: CanvasRenderingContext2D, - data: { label?: string | null; x: number; y: number; size: number }, - settings: { labelSize: number; labelWeight: string; labelFont: string }, - color: string, -): void { - if (!data.label) return; - const size = settings.labelSize; - context.save(); - context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`; - const lines = wrapLabel(context, data.label, LABEL_MAX_WIDTH); - context.fillStyle = color; - context.textAlign = "center"; - context.textBaseline = "top"; - const firstTop = data.y + data.size + LABEL_GAP; - for (let i = 0; i < lines.length; i++) { - context.fillText(lines[i]!, data.x, firstTop + i * size * LABEL_LINE_HEIGHT); - } - context.restore(); -} - -/** The hovered-state paint at a given transition factor (0 = resting, - * 1 = fully hovered): a glow halo in the node's own state color at a - * CONSTANT radius (opacity fades in/out; the halo never grows) starting - * flush at the disc edge (the evenodd punch-out keeps the disc itself - * unpainted). The name is drawn at partial alpha in the bright hover - * color — the muted resting label on the layer beneath shows through, - * so text and halo cross-fade on the SAME factor and stay in sync. */ -function paintHover( - context: CanvasRenderingContext2D, - data: { label?: string | null; x: number; y: number; size: number; color: string }, - settings: { labelSize: number; labelWeight: string; labelFont: string }, - fade: number, - hoverTextColor: string, -): void { - context.save(); - if (fade > 0.02) { - const inner = data.size; - const outer = data.size + GLOW_PAD; - const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer); - glow.addColorStop(0, rgbaFromHex(data.color, GLOW_ALPHA * fade)); - glow.addColorStop(1, rgbaFromHex(data.color, 0)); - context.fillStyle = glow; - context.beginPath(); - context.arc(data.x, data.y, outer, 0, Math.PI * 2); - context.arc(data.x, data.y, inner, 0, Math.PI * 2, true); - context.fill("evenodd"); - } - // Same fade factor as the halo: text and glow rise together. The - // bright name at partial alpha over the muted resting label beneath is - // a smooth muted→bright cross-fade rather than an instant color swap. - context.globalAlpha = fade; - drawName(context, data, settings, hoverTextColor); - context.restore(); -} - -/** Mutable hover-transition state shared between the drawers and the - * effect's enter/leave animation. */ -interface HoverState { - fade: number; - /** Set on every sigma-driven hover paint; the leave animation draws - * through it directly, since sigma stops calling drawHover once the - * mouse has left the node. */ - repaint: ((fade: number) => void) | null; -} - -/** Make the pair of canvas label drawers bound to the theme's colors. */ -function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: HoverState) { - const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => { - drawName(context, data, settings, labelColor); - }; - - const drawHover: NodeHoverDrawingFunction = (context, data, settings) => { - hover.repaint = (fade) => { - context.save(); - context.setTransform(1, 0, 0, 1, 0, 0); - context.clearRect(0, 0, context.canvas.width, context.canvas.height); - context.restore(); - if (fade > 0) paintHover(context, data, settings, fade, hoverTextColor); - }; - paintHover(context, data, settings, hover.fade, hoverTextColor); - }; - - return { drawLabel, drawHover }; -} - -// ---- Color helpers (WebGL/canvas want hex, not oklch) -------------------- +// ---- Color helpers (canvas wants hex, not oklch) --------------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ function cssColorToHex(css: string): string { @@ -383,7 +132,8 @@ interface Selection { /** The sources that touched this node: chat ids (deep-linkable sessions) * and notes-source ids ("src_…" — imported files, static entries). */ sourceIds: string[]; - /** Node position in container pixels (from graphToViewport at click time). */ + /** Node position in container pixels (at click time; camera moves + * dismiss the card, so it never needs re-anchoring). */ anchor: { x: number; y: number }; } @@ -425,18 +175,17 @@ function SettingSlider({ ); } -// ─── cluster hulls (vision §1, DECIDED 2026-09-13) ──────────────────────────── -// Boundary geometry is a PURE FUNCTION OF POSITIONS AT DRAW TIME: every -// redraw re-derives the hull from current member positions mapped through -// the camera, so pan/zoom/relayout can never invalidate it. The math -// lives in ./cluster-geometry.ts (unit-tested — see that file's header -// for the inward-normal bug this extraction is named after). - export function GraphView() { const containerRef = useRef(null); + const canvasRef = useRef(null); const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); + // The latest snapshot for the renderer's click callbacks (they are + // constructed once, so they read through this ref, never a closure). + const snapshotRef = useRef(null); + snapshotRef.current = snapshot; + // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); const [settingsOpen, setSettingsOpen] = useState(false); @@ -460,20 +209,14 @@ export function GraphView() { } }, [settings]); - // The sigma instance and its graphology graph live inside the mount - // effects (data: graph+layout; render: renderer); refs expose them to - // the filters effect, and `rendererEpoch` re-triggers it when a new - // renderer is constructed. `cameraRef` carries the camera position - // across renderer rebuilds (settings changes must never re-fit the - // view), and `hoverStateRef` lets the layout effect's hover repainter - // reach the CURRENT renderer's drawers. - const rendererRef = useRef(null); - const graphRef = useRef(null); - const [rendererEpoch, setRendererEpoch] = useState(0); - const cameraRef = useRef<{ x: number; y: number; ratio: number; angle: number } | null>(null); + // The renderer instance lives across snapshot refetches and settings + // changes (its camera is never reset by either); `posRef` carries the + // frozen layout out of the layout effect. + const rendererRef = useRef(null); + const posRef = useRef | null>(null); - // The id set that survives the current search — consumed by the sigma - // reducers and the "no matches" hint. + // The id set that survives the current search — consumed by the + // visibility feed and the "no matches" hint. const visibleIds = useMemo(() => { const set = new Set(); if (snapshot) @@ -545,9 +288,8 @@ export function GraphView() { // Build the graphology graph and lay it out ONCE per snapshot: seeded // start positions, one synchronous forceAtlas2 pass, then freeze. The - // renderer is a SEPARATE effect — render settings change only the - // renderer; the layout (positions live in this graph instance) is - // never recomputed for a settings tweak. + // renderer only ever receives the frozen positions as data — layout is + // never recomputed for a settings tweak or a refetch of identical data. useEffect(() => { if (!snapshot || snapshot.nodes.length === 0) return; const graph = new Graph(); @@ -556,9 +298,6 @@ export function GraphView() { // frozen layout stable for a given node set across remounts. const rand = seededRandom(node.id); graph.addNode(node.id, { - label: node.name, - size: DEFAULT_RENDER_SETTINGS.nodeSize, - color: tokenHex(NODE_COLOR_TOKEN), x: (rand() - 0.5) * 100, y: (rand() - 0.5) * 100, }); @@ -567,11 +306,7 @@ export function GraphView() { // Snapshot edges are already merged by (from,to,type); the guard // is just belt-and-braces for the simple graphology graph. if (!graph.hasEdge(edge.from, edge.to)) { - graph.addEdge(edge.from, edge.to, { - type: "arrow", - color: tokenHex("--ring"), - size: DEFAULT_RENDER_SETTINGS.edgeWidth, - }); + graph.addEdge(edge.from, edge.to); } } @@ -584,300 +319,85 @@ export function GraphView() { }; forceAtlas2.assign(graph, { iterations: 100, settings: layoutSettings }); - // The render effect (declared next) builds its Sigma over THIS - // instance in the same commit; on snapshot change it re-runs too - // (snapshot is in its deps) and picks the new graph up. - graphRef.current = graph; + const positions = new Map(); + graph.forEachNode((id, attrs) => positions.set(id, { x: attrs.x, y: attrs.y })); + posRef.current = positions; return () => { - graphRef.current = null; + posRef.current = null; }; }, [snapshot]); - // Mount sigma over the laid-out graph. Re-runs when the snapshot changes - // (new graph) or the (deferred) render settings change — nothing else. - // Sizes are stamped onto the graph here, and the arrowhead geometry is - // a PROGRAM option (head px = thickness × lengthToThicknessRatio × 2), - // so both flow through a renderer rebuild while the layout and the - // camera position (carried via cameraRef) stay put. - const deferredSettings = useDeferredValue(settings); + // Construct the renderer once the canvas exists; destroy when it goes + // away (the empty-graph placeholder replaces it). The callbacks are + // created once and read through refs, so they never go stale. + const isEmpty = snapshot === null || snapshot.nodes.length === 0; useEffect(() => { - if (!snapshot || snapshot.nodes.length === 0) return; - const container = containerRef.current; - const graph = graphRef.current; - if (!container || !graph) return; - - const s = deferredSettings; - // Stamp the size-controlled attributes (idempotent; the old renderer - // died in cleanup — this graph is renderer-less right now). - graph.updateEachNodeAttributes((_node, attrs) => ({ ...attrs, size: s.nodeSize })); - graph.updateEachEdgeAttributes((_edge, attrs) => ({ ...attrs, size: s.edgeWidth })); - - const labelColor = tokenHex("--muted-foreground"); - const hoverTextColor = tokenHex("--foreground"); - // Mutable hover-transition state: 0 = resting, 1 = fully hovered. - const hover: HoverState = { fade: 0, repaint: null }; - const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover); - - let killed = false; - let renderer: import("sigma").default | null = null; - // The custom cluster layers (hull + region label canvases) — created - // inside the dynamic-import callback, removed in this effect's cleanup. - let clusterCanvas: HTMLCanvasElement | null = null; - let clusterLabelCanvas: HTMLCanvasElement | null = null; - // rAF id of the hover fade transition — the ONLY animation (see the - // file header). Lives out here so the effect cleanup can cancel it. - let hoverAnimId: number | null = null; - // Both sigma and its rendering package touch WebGL globals at module - // scope, so both load dynamically (SSR-safe; the effect is browser-only). - void Promise.all([import("sigma"), import("sigma/rendering")]).then( - ([{ default: Sigma }, { NodeCircleProgram, createEdgeArrowProgram }]) => { - if (killed) return; - // The arrowhead program with our ratios: solve - // lengthToThicknessRatio for the desired head length - // (head px = thickness × ratio × 2), keeping the stock head - // SHAPE (wideness = 0.8 × length, as the 2.5/2 defaults). - const thicknessRatio = s.arrowLength / (2 * s.edgeWidth); - const arrowProgram = createEdgeArrowProgram({ - lengthToThicknessRatio: thicknessRatio, - widenessToThicknessRatio: thicknessRatio * 0.8, + if (isEmpty) return; + const canvas = canvasRef.current; + if (!canvas) return; + const renderer = new GraphCanvasRenderer(canvas, { + onSelect: (nodeId, pos) => { + const snap = snapshotRef.current; + const node = snap?.nodes.find((n) => n.id === nodeId); + if (!snap || !node) return; + setSelected({ + node, + sourceIds: snap.nodeSessions[node.id] ?? [], + anchor: pos, }); - // Padded picking (see PICKING_VERTEX_SHADER): the visible disc is - // pixel-identical to the stock circle program; the PICKABLE disc - // gains PICKING_PAD_PX of radius, so the whole hover halo is - // hit-testable and small nodes are comfortable to hover/click. - // (Maintainer-endorsed pattern for sigma v3; v4 ships this as - // the `nodePickingPadding` setting.) - const PaddedNodeCircleProgram = class extends NodeCircleProgram { - getDefinition() { - return { - ...super.getDefinition(), - VERTEX_SHADER_SOURCE: PICKING_VERTEX_SHADER, - }; - } - }; - renderer = new Sigma(graph, container, { - nodeProgramClasses: { circle: PaddedNodeCircleProgram }, - edgeProgramClasses: { arrow: arrowProgram }, - defaultEdgeType: "arrow", - // All labels appear/disappear together: no per-node size gating - // (threshold 0) and NO sigma label-grid declutter (density → - // ∞) — clustered nodes otherwise lose their cells' label - // quota and pop in late, un-faded, mid-zoom. The camera-ratio - // gate below is the ONLY label culling: every label appears at - // the same zoom with the same fade. - labelRenderedSizeThreshold: 0, - labelDensity: Number.MAX_SAFE_INTEGER, - // Match the app typography (Geist via the body's stack). - labelFont: getComputedStyle(document.body).fontFamily, - labelWeight: "500", - labelSize: 12, - labelColor: { color: labelColor }, - renderEdgeLabels: false, - defaultDrawNodeLabel: drawLabel, - defaultDrawNodeHover: drawHover, - }); - graphRef.current = graph; - rendererRef.current = renderer; - setRendererEpoch((e) => e + 1); - // A renderer rebuild (settings tweak) must never re-fit the - // view: restore the carried camera position, if any. - if (cameraRef.current) renderer.getCamera().setState(cameraRef.current); - - // ── Cluster hulls (vision §1): two custom canvas layers around - // sigma's own stack (DOM order: edges, nodes, [labels], edgeLabels, - // labels, ...). The HULL layer sits behind everything; the region - // LABEL layer sits above nodes but below node labels — the two - // label systems are inverse-gated on the same zoom control, so - // they never coexist (see syncRenderLabels below). - const sigmaCanvases = renderer.getCanvases(); - // Local consts so the draw closure below stays narrowed. - const hullCanvas = (clusterCanvas = document.createElement("canvas")); - hullCanvas.style.position = "absolute"; - container.insertBefore(hullCanvas, container.firstChild ?? null); - const regionCanvas = (clusterLabelCanvas = document.createElement("canvas")); - regionCanvas.style.position = "absolute"; - // Actual DOM order (sigma v3): edges, edgeLabels, nodes, labels, … - // so "before labels" = above nodes, below node labels — the two - // label systems are inverse-gated and never coexist anyway. - sigmaCanvases.labels?.parentNode?.insertBefore(regionCanvas, sigmaCanvases.labels); - // All hull geometry is derived per draw — positions through the - // CURRENT camera — so any pan/zoom/render stays correct. - const drawClusters = () => { - const r = renderer; - if (!r || snapshot.clusters.length === 0) return; - const ref = sigmaCanvases.nodes; - if (!ref) return; - const cssW = ref.clientWidth; - const cssH = ref.clientHeight; - if (cssW === 0 || cssH === 0) return; - const dpr = ref.width / cssW; - const hullCtx = hullCanvas.getContext("2d"); - const labelCtx = regionCanvas.getContext("2d"); - if (!hullCtx || !labelCtx) return; - for (const canvas of [hullCanvas, regionCanvas]) { - if (canvas.width !== ref.width || canvas.height !== ref.height) { - canvas.width = ref.width; - canvas.height = ref.height; - } - canvas.style.width = ref.style.width || `${cssW}px`; - canvas.style.height = ref.style.height || `${cssH}px`; - } - hullCtx.setTransform(dpr, 0, 0, dpr, 0, 0); - labelCtx.setTransform(dpr, 0, 0, dpr, 0, 0); - hullCtx.clearRect(0, 0, cssW, cssH); - labelCtx.clearRect(0, 0, cssW, cssH); - labelCtx.textAlign = "center"; - labelCtx.textBaseline = "middle"; - labelCtx.font = `500 18px ${getComputedStyle(document.body).fontFamily}`; - for (const cluster of snapshot.clusters) { - const pts: Pt[] = []; - for (const id of cluster.members) { - if (!graph.hasNode(id)) continue; - const a = graph.getNodeAttributes(id); - const vp = r.graphToViewport({ x: a.x ?? 0, y: a.y ?? 0 }); - if (vp) pts.push(vp); - } - const hull = convexHull(pts); - if (hull.length < 3) continue; - const hue = clusterHue(cluster.id); - // Soft translucent tint WITHOUT ctx.filter: a canvas blur - // filter rasterizes on the GPU per call — hooked to every - // render that was ~32 blurred fills per frame (sustained - // ~100% GPU rasterization + audible coil whine; reported - // 2026-09-13). Layered low-alpha fills approximate the - // same soft edge for a fraction of the cost. - for (const [pad, alpha] of [[24, 0.025], [19, 0.04], [14, 0.06]] as const) { - hullCtx.beginPath(); - traceRoundedPath(hullCtx, offsetPolygon(hull, pad), 9); - hullCtx.fillStyle = `hsla(${hue}, 65%, 60%, ${alpha})`; - hullCtx.fill(); - } - // Dashed boundary on the innermost offset. - hullCtx.beginPath(); - traceRoundedPath(hullCtx, offsetPolygon(hull, 14), 9); - hullCtx.setLineDash([7, 7]); - hullCtx.strokeStyle = `hsla(${hue}, 65%, 62%, 0.5)`; - hullCtx.lineWidth = 1.5; - hullCtx.stroke(); - hullCtx.setLineDash([]); - // The region label at the centroid (inverse-gated layer) — - // skippable entirely via the settings toggle. - if (!settings.clusterLabels || !cluster.label) continue; - const centroid = hull.reduce((acc, p) => ({ x: acc.x + p.x / hull.length, y: acc.y + p.y / hull.length }), { x: 0, y: 0 }); - labelCtx.fillStyle = `hsla(${hue}, 55%, 78%, 0.85)`; - labelCtx.fillText(cluster.label, centroid.x, centroid.y); - } - }; - // Redraw ONLY when the view actually moves: camera updates and - // resizes. NOT on sigma's afterRender — hover animations re-render - // sigma many frames per interaction, and hulls don't care. - renderer.on("resize", drawClusters); - - renderer.on("clickNode", ({ node }) => { - const graphNode = snapshot.nodes.find((n) => n.id === node); - if (!graphNode) return; - const pos = graph.getNodeAttributes(node); - const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 }); - setSelected({ - node: graphNode, - sourceIds: snapshot.nodeSessions[node] ?? [], - anchor: anchor ?? { x: 0, y: 0 }, - }); - }); - // Clicking the empty stage closes the node card. - renderer.on("clickStage", () => setSelected(null)); - // One global label gate with a fade: names are fully visible at fit - // zoom or closer, then fade out together across LABELS_FADE_SPAN of - // zoom (the labels canvas layer's opacity), and only once fully - // faded does label rendering switch off. Any pan/zoom also detaches - // the card from its node — close it. - const labelsCanvas = renderer.getCanvases().labels; - let labelsRendering: boolean | null = null; - const syncRenderLabels = (ratio: number) => { - // 0 at the labelZoom threshold → 1 at the end of the fade span - const fade = (ratio - s.labelZoom) / LABELS_FADE_SPAN; - const alpha = Math.min(Math.max(1 - fade, 0), 1); - const shouldRender = alpha > 0; - if (shouldRender !== labelsRendering) { - labelsRendering = shouldRender; - renderer?.setSetting("renderLabels", shouldRender); - } - if (labelsCanvas) labelsCanvas.style.opacity = alpha.toFixed(3); - // The inverse gate (vision §1): cluster labels fade in over - // the exact span node labels fade out — THE one zoom control. - regionCanvas.style.opacity = (1 - alpha).toFixed(3); - }; - drawClusters(); - syncRenderLabels(renderer.getCamera().getState().ratio); - renderer.getCamera().on("updated", (state) => { - syncRenderLabels(state.ratio); - drawClusters(); - setSelected(null); - }); - // The hover fade: entering schedules renders (sigma re-invokes - // drawHover with the new fade); leaving draws through the captured - // repainter, since sigma stops calling drawHover once unhovered. - // Both directions are bounded (~HOVER_FADE_MS) and cancel each - // other, so idle CPU returns to zero after every transition. - const animateHoverTo = (target: number) => { - if (hoverAnimId !== null) { - cancelAnimationFrame(hoverAnimId); - hoverAnimId = null; - } - const from = hover.fade; - if (from === target) { - if (target === 0) hover.repaint?.(0); - return; - } - const start = performance.now(); - const tick = (now: number) => { - const t = Math.min((now - start) / HOVER_FADE_MS, 1); - hover.fade = from + (target - from) * t; - if (target === 0) hover.repaint?.(hover.fade); - else renderer?.scheduleRefresh(); - hoverAnimId = t < 1 ? requestAnimationFrame(tick) : null; - }; - hoverAnimId = requestAnimationFrame(tick); - }; - renderer.on("enterNode", () => animateHoverTo(1)); - renderer.on("leaveNode", () => animateHoverTo(0)); - renderer.on("leaveStage", () => animateHoverTo(0)); - }); - + }, + onStageClick: () => setSelected(null), + // Any camera move detaches the card from its node — close it. + onCameraMove: () => setSelected(null), + }); + rendererRef.current = renderer; return () => { - killed = true; - if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); - // Carry the camera across rebuilds — a settings tweak keeps the - // user's pan/zoom (and the detail-card anchors stay valid). - if (renderer) cameraRef.current = renderer.getCamera().getState(); rendererRef.current = null; - renderer?.kill(); - // The custom cluster layers are ours — sigma's kill() removes only - // its own canvases. - clusterCanvas?.remove(); - clusterLabelCanvas?.remove(); + renderer.destroy(); }; - }, [snapshot, deferredSettings]); + }, [isEmpty]); - // Filtering through sigma's official reducers: hidden nodes drop out of - // rendering, picking, hover, AND labels; edges drop when either endpoint - // is hidden. Re-runs whenever filters change or a renderer is (re)built - // (epoch), so the reducers always close over current values. + // Feed #1 — data: positions + edges + clusters + resolved colors. Runs + // after the layout effect (same commit, declaration order) and after + // the construct effect, so the renderer exists and positions are fresh. useEffect(() => { const renderer = rendererRef.current; - const graph = graphRef.current; - if (!renderer || !graph) return; - renderer.setSetting("nodeReducer", (key, attrs) => - visibleIds.has(key) ? attrs : { ...attrs, hidden: true }, - ); - renderer.setSetting("edgeReducer", (key, attrs) => { - const source = graph.source(key); - const target = graph.target(key); - return visibleIds.has(source) && visibleIds.has(target) ? attrs : { ...attrs, hidden: true }; + const positions = posRef.current; + if (!renderer || !snapshot || snapshot.nodes.length === 0 || !positions) return; + renderer.setData({ + nodes: snapshot.nodes.map((n) => ({ + id: n.id, + x: positions.get(n.id)?.x ?? 0, + y: positions.get(n.id)?.y ?? 0, + label: n.name, + })), + edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })), + clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })), + colors: { + node: tokenHex(NODE_COLOR_TOKEN), + edge: tokenHex("--ring"), + label: tokenHex("--muted-foreground"), + hoverText: tokenHex("--foreground"), + }, + // Match the app typography (Geist via the body's stack). + fontFamily: getComputedStyle(document.body).fontFamily, }); + }, [snapshot, isEmpty]); + + // Feed #2 — settings: just marks the frame dirty. No rebuild, no + // re-layout, no camera touch (the whole point of the new renderer). + const deferredSettings = useDeferredValue(settings); + useEffect(() => { + rendererRef.current?.setSettings(deferredSettings); + }, [deferredSettings, isEmpty]); + + // Feed #3 — visibility: hidden nodes drop out of rendering, picking, + // hover, AND labels; edges drop when either endpoint is hidden. + useEffect(() => { + rendererRef.current?.setVisible(query.trim() ? visibleIds : null); // The open detail card cannot outlive its node's visibility. setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null)); - }, [rendererEpoch, visibleIds]); + }, [visibleIds, query]); // Position the detail card: below the clicked node, flipped above near // the bottom edge, clamped horizontally. Imperative (ref + layout effect, @@ -903,8 +423,6 @@ export function GraphView() { card.style.visibility = "visible"; }, [selected]); - const isEmpty = snapshot === null || snapshot.nodes.length === 0; - return (
{isEmpty ? ( @@ -920,7 +438,11 @@ export function GraphView() {
) : ( -
+
+ {/* THE single canvas: every layer (hulls, edges, nodes, both + label systems, hover) draws into it, once per frame. */} + +
)} {/* Legend + filters: search box and per-state toggle rows. */} @@ -1076,7 +598,7 @@ export function GraphView() {
{snapshot?.sessions[sourceId] ?? sourceId} diff --git a/package-lock.json b/package-lock.json index e095625..3f1e3c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", "graphology": "^0.26.0", "graphology-communities-louvain": "^2.0.2", @@ -43,7 +45,6 @@ "react-dom": "^19.2.8", "react-jsx-parser": "^2.4.1", "remark-breaks": "^4.0.0", - "sigma": "^3.0.3", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", @@ -13342,16 +13343,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sigma": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", - "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "graphology-utils": "^2.5.2" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 32a7514..3f784f2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", "graphology": "^0.26.0", "graphology-communities-louvain": "^2.0.2", @@ -48,7 +50,6 @@ "react-dom": "^19.2.8", "react-jsx-parser": "^2.4.1", "remark-breaks": "^4.0.0", - "sigma": "^3.0.3", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts new file mode 100644 index 0000000..cc6b4be --- /dev/null +++ b/tests/graph-canvas.test.ts @@ -0,0 +1,139 @@ +/** + * Pure-helper tests for the hand-rolled canvas renderer + * (components/graph-canvas.ts). The renderer class itself is DOM-bound + * (canvas, rAF, ResizeObserver) and is verified live in the browser; the + * math it runs on every frame must be exact, so it is pinned here. + * + * The camera-ratio convention is the load-bearing contract with the + * persisted settings: sigma's ratio (1 = fit, SMALLER = zoomed in) is kept + * verbatim so stored labelZoom values survive the renderer swap. + */ + +import assert from "node:assert/strict"; +import { describe, it as test } from "node:test"; +import { + arrowGeometry, + fitTransform, + labelAlpha, + pickNearest, + zoomRatioOf, + LABELS_FADE_SPAN, +} from "../components/graph-canvas.ts"; + +describe("zoomRatioOf (the sigma-compatible camera ratio)", () => { + test("1 at fit zoom, smaller when zoomed in, larger when zoomed out", () => { + assert.equal(zoomRatioOf(2, 2), 1); // at fit + assert.equal(zoomRatioOf(2, 4), 0.5); // zoomed in 2x -> ratio halves + assert.equal(zoomRatioOf(2, 1), 2); // zoomed out 2x -> ratio doubles + }); +}); + +describe("labelAlpha (the single zoom gate)", () => { + test("fully visible at/below the threshold, faded out after the span", () => { + assert.equal(labelAlpha(0.5, 0.5), 1); // at the threshold + assert.equal(labelAlpha(0.25, 0.5), 1); // zoomed further in: stays 1 + assert.equal(labelAlpha(0.75, 0.5), 0.5); // mid-fade + assert.equal(labelAlpha(1.0, 0.5), 0); // one span out: gone + assert.equal(labelAlpha(2.0, 0.5), 0); // far out: clamped + }); + + test("node labels and cluster labels cross-fade over the exact same span", () => { + // The renderer draws cluster labels at 1 - labelAlpha; the two must + // always sum to 1 within the fade and never coexist at full opacity. + for (const ratio of [0, 0.25, 0.5, 0.6, 0.75, 0.9, 1, 2, 4]) { + const node = labelAlpha(ratio, 0.5); + const cluster = 1 - node; + assert.ok(node + cluster > 0.999 && node + cluster < 1.001, `sum at ratio ${ratio}`); + assert.ok(node <= 1 && cluster <= 1, `clamped at ratio ${ratio}`); + } + }); + + test("the span constant is unchanged from the sigma era (stored settings depend on it)", () => { + assert.equal(LABELS_FADE_SPAN, 0.5); + }); +}); + +describe("fitTransform", () => { + test("centers the bbox and respects padding", () => { + // A 100x50 graph in a 500x300 viewport, 40px padding: + // available 420x220 -> k = min(4.2, 4.4) = 4.2 + const t = fitTransform({ minX: -50, maxX: 50, minY: -25, maxY: 25 }, 500, 300, 40); + assert.ok(Math.abs(t.k - 4.2) < 1e-9, `k=${t.k}`); + assert.ok(Math.abs(t.x - 250) < 1e-9, `x=${t.x}`); // centered + assert.ok(Math.abs(t.y - 150) < 1e-9, `y=${t.y}`); + // World center maps to viewport center: + assert.ok(Math.abs(0 * t.k + t.x - 250) < 1e-9); + }); + + test("never returns a non-positive scale for degenerate bboxes", () => { + const t = fitTransform({ minX: 5, maxX: 5, minY: 5, maxY: 5 }, 100, 100, 10); + assert.ok(t.k > 0 && Number.isFinite(t.k)); + }); +}); + +describe("arrowGeometry", () => { + test("the tip lands exactly on the target disc edge", () => { + const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5); + assert.ok(g, "non-degenerate edge"); + assert.ok(Math.abs(g!.tip.x - 94) < 1e-9 && Math.abs(g!.tip.y) < 1e-9); + }); + + test("the shaft starts outside the source disc", () => { + const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!; + assert.ok(Math.abs(g.shaftFrom.x - 6) < 1e-9 && Math.abs(g.shaftFrom.y) < 1e-9); + // Shaft ends at the head base; head is drawn from base to tip. + assert.ok(Math.abs(g.shaftTo.x - 89) < 1e-9); + }); + + test("the head triangle is symmetric around the shaft axis (0.8x wideness)", () => { + const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!; + // halfW = 0.4 x headLength = 2; both base corners share x, mirrored y. + assert.ok(Math.abs(Math.abs(g.left.y) - 2) < 1e-9 && Math.abs(Math.abs(g.right.y) - 2) < 1e-9); + assert.ok(Math.abs(g.left.y + g.right.y) < 1e-9); // mirror + assert.ok(Math.abs(g.left.x - g.right.x) < 1e-9); + }); + + test("works at arbitrary angles (diagonal stays normalized)", () => { + const a = { x: 0, y: 0 }; + const b = { x: 60, y: 80 }; // len 100, dir (0.6, 0.8) + const g = arrowGeometry(a, b, 10, 10, 8)!; + assert.ok(Math.abs(g.tip.x - (60 - 0.6 * 10)) < 1e-9 && Math.abs(g.tip.y - (80 - 0.8 * 10)) < 1e-9); + }); + + test("null for coincident endpoints (never a NaN arrow)", () => { + assert.equal(arrowGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5), null); + }); +}); + +describe("pickNearest (distance-check picking)", () => { + const nodes = [ + { id: "a", x: 10, y: 10 }, + { id: "b", x: 50, y: 50 }, + { id: "c", x: 90, y: 10 }, + ]; + + test("picks the nearest node within the pad", () => { + assert.equal(pickNearest(nodes, 12, 12, 16)?.id, "a"); + assert.equal(pickNearest(nodes, 48, 52, 16)?.id, "b"); + assert.equal(pickNearest(nodes, 88, 8, 16)?.id, "c"); + }); + + test("the picking pad extends the hitbox beyond the disc", () => { + // 20px away, only reachable because the pad covers it. + assert.equal(pickNearest(nodes, 28, 18, 25)?.id, "a"); + // Beyond the pad: nothing. + assert.equal(pickNearest(nodes, 30, 40, 3), null); + }); + + test("ties resolve deterministically (first scanned wins)", () => { + const pair = [ + { id: "first", x: 0, y: 0 }, + { id: "second", x: 10, y: 0 }, + ]; + assert.equal(pickNearest(pair, 5, 0, 10)?.id, "first"); + }); + + test("empty node list picks nothing", () => { + assert.equal(pickNearest([], 0, 0, 100), null); + }); +});