"use client"; /** * 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. * * Labels render the node `name` (the short header, never the statement) * through custom canvas drawers that 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. * - defaultDrawNodeHover: NO background box (sigma's default is a * hardcoded white one). Instead the hovered node gets a soft glow * halo in its own state 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. * Hit-testing is also custom: sigma's built-in picking reads a * HALF-RESOLUTION framebuffer, giving small nodes ragged, quantized hit * edges — cursor jitter at a boundary flipped hover on/off constantly * (the hover flicker). patchNodePicking() replaces it with a smooth * distance test padded by HIT_PAD_PX, and a short leave-debounce * (hold the paint, then fade) makes any residual bounce invisible. * 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.) * * 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. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import Link from "next/link"; import { 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, type NodeStateClass } from "@/src/core"; /** Per-state visuals: color token + node size (WebGL wants hex, not oklch). */ const STATE_TOKENS: Record = { taught: { cssVar: "--foreground", size: 10 }, known: { cssVar: "--primary", size: 8 }, missed: { cssVar: "--destructive", size: 8 }, "dont-know": { cssVar: "--muted-foreground", size: 6.5 }, candidate: { cssVar: "--ring", size: 5 }, }; const STATE_LABELS: Record = { taught: "taught", known: "known", missed: "missed", "dont-know": "don't know", candidate: "candidate", }; // ---- 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; /** Camera ratio at/below which ALL labels appear at once (1 = fit zoom). */ const LABELS_CAMERA_RATIO = 1; /** 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 /** Padding (px) around each node disc for hover/click hit-testing — * small (candidate) nodes are ~5px across; a padded, smooth test makes * them comfortable to hover and removes the ragged half-resolution hit * edges of sigma's built-in picking. */ const HIT_PAD_PX = 6; /** Grace period after the cursor leaves a node before the hover fades: a * quick bounce back within the window cancels the fade, so boundary * oscillation never visibly dims anything. */ const HOVER_LEAVE_DELAY_MS = 90; /** "#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, 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 }; } // ---- Padded, smooth node hit-testing --------------------------------------- // // sigma's getNodeAtPosition reads the picking framebuffer, which is rendered // at HALF the device resolution (pickingDownSizingRatio = 2 * pixelRatio). // For small nodes the hit circle is a couple of device pixels across in that // buffer, with ragged quantized edges — near a node's boundary, tiny cursor // movements flip the hover state on and off repeatedly (the hover flicker). // There is no setting for hit padding in sigma v3, so we replace the method // with a smooth distance test (node's rendered radius + HIT_PAD_PX) using // only public renderer APIs. Patches the prototype once per session. /** Structural view of the public sigma methods the hit-test needs. */ interface PickingSurface { getGraph(): { nodes(): Iterable }; getNodeDisplayData(key: string): { x: number; y: number; size: number; hidden?: boolean } | undefined; framedGraphToViewport(data: { x: number; y: number }): { x: number; y: number }; scaleSize(size: number): number; } let nodePickingPatched = false; /** Patch Sigma.prototype.getNodeAtPosition with a padded distance test. */ function patchNodePicking(SigmaClass: { prototype: object }): void { if (nodePickingPatched) return; nodePickingPatched = true; const proto = SigmaClass.prototype as unknown as { getNodeAtPosition: (this: PickingSurface, event: { x: number; y: number }) => string | null; }; proto.getNodeAtPosition = function (event) { // Closest node by surface depth: how far the cursor sits inside the // node's padded hit circle (negative = over the disc itself). let best: { id: string; depth: number } | null = null; for (const id of this.getGraph().nodes()) { const data = this.getNodeDisplayData(id); if (!data || data.hidden) continue; const { x, y } = this.framedGraphToViewport(data); const size = this.scaleSize(data.size); const depth = Math.hypot(x - event.x, y - event.y) - size; if (depth <= HIT_PAD_PX && (best === null || depth < best.depth)) { best = { id, depth }; } } return best ? best.id : null; }; } // ---- Color helpers (WebGL/canvas want hex, not oklch) -------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ function cssColorToHex(css: string): string { const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext("2d"); if (!ctx) return "#888888"; ctx.fillStyle = css; ctx.fillRect(0, 0, 1, 1); const { data } = ctx.getImageData(0, 0, 1, 1); return `#${[data[0]!, data[1]!, data[2]!] .map((v) => v.toString(16).padStart(2, "0")) .join("")}`; } function tokenHex(cssVar: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); return value ? cssColorToHex(value) : "#888888"; } // ---- Component ------------------------------------------------------------ interface Selection { node: GraphNode; state: NodeStateClass; chatIds: string[]; /** Node position in container pixels (from graphToViewport at click time). */ anchor: { x: number; y: number }; } export function GraphView() { const containerRef = useRef(null); const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); // Fetch the derived graph (the route folds stale chats first). useEffect(() => { let cancelled = false; fetch("/api/graph") .then((res) => res.json() as Promise) .then((data) => { if (!cancelled) setSnapshot(data); }) .catch((err) => console.error("[graph] fetch failed:", err)); return () => { cancelled = true; }; }, []); // Build the graphology graph, lay it out once, mount sigma. useEffect(() => { if (!snapshot || snapshot.nodes.length === 0) return; const container = containerRef.current; if (!container) return; const edgeColor = tokenHex("--ring"); const labelColor = tokenHex("--muted-foreground"); const hoverTextColor = tokenHex("--foreground"); const stateColors = new Map(); for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) { stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar)); } // Mutable hover-transition state: 0 = resting, 1 = fully hovered. const hover: HoverState = { fade: 0, repaint: null }; const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover); const graph = new Graph(); for (const node of snapshot.nodes) { const state: NodeStateClass = snapshot.nodeStates[node.id] ?? "candidate"; // Seeded start positions (FA2 requires non-zero x/y) keep the // frozen layout stable for a given node set across remounts. const rand = seededRandom(node.id); graph.addNode(node.id, { label: node.name, size: STATE_TOKENS[state].size, color: stateColors.get(state) ?? edgeColor, x: (rand() - 0.5) * 100, y: (rand() - 0.5) * 100, }); } for (const edge of snapshot.edges) { // 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: edgeColor, size: 1 }); } } // Run-then-freeze: one synchronous pass, writes x/y, then done. // Barnes-Hut comes on automatically above ~500 nodes (the vision's // first escalation rung — see scripts/bench-graph.ts for the numbers). const settings = { ...forceAtlas2.inferSettings(graph), ...(graph.order > 500 ? { barnesHutOptimize: true } : {}), }; forceAtlas2.assign(graph, { iterations: 100, settings }); let killed = false; let renderer: import("sigma").default | 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; // Leave-debounce timer (see HOVER_LEAVE_DELAY_MS): holds the hover // paint briefly before fading, so boundary bounces never dim it. let hoverLeaveTimer: ReturnType | null = null; void import("sigma").then(({ default: Sigma }) => { if (killed) return; // Bigger, smoother hitboxes before the instance starts listening. patchNodePicking(Sigma); renderer = new Sigma(graph, container, { defaultEdgeType: "arrow", // All labels appear/disappear together: no per-node size gating // (threshold 0) — a single camera-ratio switch toggles them below. labelRenderedSizeThreshold: 0, // 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, }); 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, state: snapshot.nodeStates[node] ?? "candidate", chatIds: 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 fit zoom → 1 at the end of the fade span const fade = (ratio - LABELS_CAMERA_RATIO) / 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); }; syncRenderLabels(renderer.getCamera().getState().ratio); renderer.getCamera().on("updated", (state) => { syncRenderLabels(state.ratio); setSelected(null); }); // Hover fade + leave-debounce. Entering schedules renders (sigma // re-invokes drawHover at the new fade). Leaving does NOT fade // immediately: sigma clears the hover layer the moment its // hoveredNode flips to null, so we (1) keep repainting the // captured paint at the current fade (holdHoverPaint) through a // short grace period, and only (2) fade out if the cursor hasn't // bounced back. A re-enter within the window cancels the fade — // boundary oscillation stays invisible. Every loop is bounded // and stops on completion, so idle CPU returns to zero. const stopHoverLoop = () => { if (hoverAnimId !== null) { cancelAnimationFrame(hoverAnimId); hoverAnimId = null; } }; const animateHoverTo = (target: number) => { stopHoverLoop(); 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); }; const holdHoverPaint = () => { stopHoverLoop(); const tick = () => { hover.repaint?.(hover.fade); hoverAnimId = requestAnimationFrame(tick); }; hoverAnimId = requestAnimationFrame(tick); }; const cancelLeave = () => { if (hoverLeaveTimer !== null) { clearTimeout(hoverLeaveTimer); hoverLeaveTimer = null; } }; const onEnterNode = () => { cancelLeave(); animateHoverTo(1); }; const onLeaveHover = () => { if (hoverLeaveTimer !== null) return; // already in the grace window if (hover.fade <= 0) { hover.repaint?.(0); return; } holdHoverPaint(); hoverLeaveTimer = setTimeout(() => { hoverLeaveTimer = null; animateHoverTo(0); }, HOVER_LEAVE_DELAY_MS); }; renderer.on("enterNode", onEnterNode); renderer.on("leaveNode", onLeaveHover); renderer.on("leaveStage", onLeaveHover); }); return () => { killed = true; if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); if (hoverLeaveTimer !== null) clearTimeout(hoverLeaveTimer); renderer?.kill(); }; }, [snapshot]); // Position the detail card: below the clicked node, flipped above near // the bottom edge, clamped horizontally. Imperative (ref + layout effect, // before paint) because the flip needs the card's measured height. useLayoutEffect(() => { const card = cardRef.current; const container = containerRef.current; if (!selected || !card || !container) return; const width = card.offsetWidth; const height = card.offsetHeight; const margin = 8; const gap = 12; const left = Math.min( Math.max(selected.anchor.x - width / 2, margin), Math.max(container.clientWidth - width - margin, margin), ); let top = selected.anchor.y + gap; if (top + height > container.clientHeight - margin) { top = Math.max(selected.anchor.y - gap - height, margin); } card.style.left = `${left}px`; card.style.top = `${top}px`; card.style.visibility = "visible"; }, [selected]); const isEmpty = snapshot === null || snapshot.nodes.length === 0; return (
{isEmpty ? ( // The original home placeholder — kept verbatim for the empty graph.
Knowledge graph

Every session you complete will map what you know — and where the edge of your understanding is — right here.

) : (
)} {selected && !isEmpty && ( )}
); }