"use client"; /** * The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)"). * * 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). * * 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 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. * * 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 * five sliders (node size, edge width, arrowhead length, label size, the * zoom threshold) plus the cluster-labels checkbox. SERVER-persisted * (data/render-settings.json via /api/graph/render-settings): loaded * once after mount, saved debounced on every user change — one source * of truth shared by every browser. The label-zoom slider is log-scaled * (its useful band 0.25–0.5 sits mid-slider, not crammed at the left) * and displays normalized units where the default 0.5 reads as "1×". * Settings apply by just redrawing — no rebuild, no re-layout, and the * camera never moves. */ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; import { DEFAULT_REGION_TUNING, GraphCanvasRenderer, type GraphRenderSettings, type RegionTuning, } from "./graph-canvas.ts"; import { ARROW_LENGTH_RANGE, DEFAULT_RENDER_SETTINGS, EDGE_WIDTH_RANGE, LABEL_SIZE_RANGE, LABEL_ZOOM_MAX, LABEL_ZOOM_MIN, NODE_SIZE_RANGE, fromSliderPosition, labelZoomDisplay, sanitizeRenderSettings, toSliderPosition, } from "@/src/core"; /** 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) ------------------------ // // The shape, defaults, sanitizer, and slider mappings live in // src/core/render-settings.ts (shared with the server store + tests). type RenderSettings = GraphRenderSettings; /** * 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 visibility feed just consumes its verdict. */ export function isNodeVisible(node: Pick, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } // ---- 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 { 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; /** 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 (at click time; camera moves * dismiss the card, so it never needs re-anchoring). */ anchor: { x: number; y: number }; } /** One labeled range row for the rendering-settings panel. */ /** Compact size display for the log sliders: "2", "1.6", "0.75". */ function sizeDisplay(v: number): string { return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`; } /** Dev number input: raw value in, raw value out (NaN while empty → the * renderer's sanitizer holds the last sane tuning). Development tool. */ function NumberField({ label, value, step, onChange, }: { label: string; value: number; step: number; onChange: (v: number) => void; }) { return ( ); } function SettingSlider({ label, min, max, step, value, display, onChange, }: { label: string; min: number; max: number; step: number; value: number; /** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */ display?: string; onChange: (v: number) => void; }) { return ( ); } 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); // Region tuning: DEV-ONLY, session state, never persisted (unlike the // render settings, which live on the server). Number inputs so any // value can be tried quickly. const [regionTuning, setRegionTuning] = useState(DEFAULT_REGION_TUNING); // Render settings: SERVER-persisted (data/render-settings.json via // /api/graph/render-settings). Loaded once after mount; every user // change saves back, debounced. Defaults render until the load // resolves — a fraction of a second at worst. const [settings, setSettings] = useState(DEFAULT_RENDER_SETTINGS); const settingsRef = useRef(settings); settingsRef.current = settings; // True once the user has moved a control this mount: the server copy // must never clobber a (possibly newer) local change. const userTouched = useRef(false); // True once the initial GET settled — saves are held back until then, // so merely mounting never PUTs defaults over the server copy. const serverLoaded = useRef(false); const savePending = useRef(false); const changeSettings = (update: (cur: RenderSettings) => RenderSettings) => { userTouched.current = true; setSettings(update); }; const saveToServer = (s: RenderSettings) => { fetch("/api/graph/render-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(s), }).catch((err) => console.error("[graph] settings save failed:", err)); }; useEffect(() => { fetch("/api/graph/render-settings") .then((res) => (res.ok ? (res.json() as Promise) : null)) .then((raw) => { // Apply only if the user hasn't already moved a slider — // if they outran the network, their change wins. if (raw !== null && !userTouched.current) setSettings(sanitizeRenderSettings(raw)); }) .catch(() => { // server unreachable → in-memory defaults keep working }) .finally(() => { serverLoaded.current = true; // A change made during the fetch was held back — send now. if (savePending.current) { savePending.current = false; saveToServer(settingsRef.current); } }); }, []); useEffect(() => { if (!userTouched.current) return; // mount-run / server-apply: no save if (!serverLoaded.current) { savePending.current = true; // hold until the GET settles return; } const id = setTimeout(() => saveToServer(settings), 400); return () => clearTimeout(id); }, [settings]); // 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 // visibility feed and the "no matches" hint. const visibleIds = useMemo(() => { const set = new Set(); if (snapshot) for (const node of snapshot.nodes) { if (isNodeVisible(node, query)) set.add(node.id); } return set; }, [snapshot, query]); // Fetch the derived graph. Re-runs when a background pipeline run // finishes (refreshTick) — its merges just landed in the ledger. const [refreshTick, setRefreshTick] = useState(0); 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; }; }, [refreshTick]); // Notes import (the format-agnostic front door): file picker → POST // /api/graph/import; the runs surface in the status chips below, and the // graph refetches when they finish (the runs-empty transition). const fileInputRef = useRef(null); const [importing, setImporting] = useState(false); const onImportFiles = (files: FileList | null) => { if (!files || files.length === 0) return; setImporting(true); const form = new FormData(); for (const file of Array.from(files)) form.append("files", file); fetch("/api/graph/import", { method: "POST", body: form }) .then(async (res) => { const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] }; const failed = body.results.filter((r) => !r.ok); if (failed.length > 0) console.error("notes import failed for:", failed); }) .catch((err) => console.error("import failed:", err)) .finally(() => setImporting(false)); }; // Background pipeline status: poll /api/graph/status; when a run // finishes (some -> none), refetch the graph. const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]); useEffect(() => { let cancelled = false; let hadRuns = false; const poll = () => fetch("/api/graph/status") .then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>) .then(({ runs }) => { if (cancelled) return; setPipelineRuns(runs); if (hadRuns && runs.length === 0) setRefreshTick((t) => t + 1); hadRuns = runs.length > 0; }) .catch(() => {}); // status is cosmetic — never surface poll errors poll(); const timer = setInterval(poll, 2000); return () => { cancelled = true; clearInterval(timer); }; }, []); // Layout (Option A): the positions ledger (written by the server's // incremental settle after every merge) IS the layout — the renderer // receives it as frozen data. The local one-shot forceAtlas2 pass runs // ONLY as a fallback when the ledger is absent (a graph never merged // through the pipeline, e.g. tests or a fresh install). useEffect(() => { if (!snapshot || snapshot.nodes.length === 0) return; // The ledger path: every positioned node uses its resting position; // nodes the ledger doesn't know yet (raced mid-settle) fall through // to seeded scatter below. const ledger = snapshot.positions; if (ledger) { const positions = new Map(); for (const node of snapshot.nodes) { const p = ledger[node.id]; if (p) positions.set(node.id, { x: p.x, y: p.y }); } if (positions.size > 0) { posRef.current = positions; return () => { posRef.current = null; }; } } const graph = new Graph(); for (const node of snapshot.nodes) { // 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, { 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); } } // 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 layoutSettings = { ...forceAtlas2.inferSettings(graph), ...(graph.order > 500 ? { barnesHutOptimize: true } : {}), }; forceAtlas2.assign(graph, { iterations: 100, settings: layoutSettings }); const positions = new Map(); graph.forEachNode((id, attrs) => positions.set(id, { x: attrs.x, y: attrs.y })); posRef.current = positions; return () => { posRef.current = null; }; }, [snapshot]); // 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 (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, }); }, onStageClick: () => setSelected(null), // Any camera move detaches the card from its node — close it. onCameraMove: () => setSelected(null), // Live physics settled: persist the new rest state. Fire-and- // forget — the next snapshot fetch carries the ledger anyway. onCommit: (positions) => { void fetch("/api/graph/positions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(positions), }).catch((err) => console.error("[graph] positions commit failed", err)); }, }); rendererRef.current = renderer; return () => { rendererRef.current = null; renderer.destroy(); }; }, [isEmpty]); // 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 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)); }, [visibleIds, query]); // Feed #4 — region tuning (dev number inputs): its own update path, // independent of the server-persisted render settings. useEffect(() => { rendererRef.current?.setRegionTuning(regionTuning); }, [regionTuning]); // 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]); 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.

) : (
{/* 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. */} {!isEmpty && (
setQuery(e.target.value)} placeholder="Search nodes…" spellCheck={false} className="h-8 w-full rounded-lg border bg-background pl-8 pr-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{settingsOpen && (
{/* All sliders: log-scaled 0-100, step 5, so every DEFAULT sits at 50 (src/core/render-settings.ts). */} changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))} /> changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))} /> {/* Displayed units normalize the default (0.5) to "1×". */} changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} /> {/* Regions (dev): quick-tuning number inputs. Session state only - never persisted. Bad/empty values fall back to the last sane tuning inside the renderer. */}
Regions (dev) setRegionTuning((t) => ({ ...t, dilation: v }))} /> setRegionTuning((t) => ({ ...t, blur: v }))} /> setRegionTuning((t) => ({ ...t, bitmap: v }))} /> setRegionTuning((t) => ({ ...t, edgeAlpha: v }))} />
)} {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
No matching nodes.
)}
{ onImportFiles(e.target.files); e.target.value = ""; // allow re-picking the same file }} />
)} {/* Background pipeline status: what is currently running. */} {pipelineRuns.length > 0 && (
{pipelineRuns.map((run) => (
{run.phase} {run.title} {Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s
))}
)} {selected && !isEmpty && ( )}
); }