Three sliders behind a gear in the search panel (user-decided 2026-09-13), persisted per browser in localStorage with clamped sanitizing on load. Default node size dropped 8 → 6 for dense graphs. Mechanics: the mount effect is split — the data effect builds the graphology graph + one frozen forceAtlas2 pass per snapshot; the render effect mounts sigma and re-runs on settings change only. Sizes are stamped onto the same graph instance (layout never recomputed), and the arrowhead program is constructed per rebuild with ratios solved from the settings (head px = thickness × lengthToThicknessRatio × 2; wideness keeps the stock 0.8 shape). The camera position carries across rebuilds via a ref, so a slider tweak never re-fits or re-lays-out the view; useDeferredValue smooths slider drags. Live-verified in the browser: sliders persist to localStorage, the renderer rebuilds with zero console errors, hover/picking survives the rebuild, and a zoomed-out camera stays zoomed out across a settings change (labels-gate opacity as the observable). 355/355 tests, typecheck, lint, build green.
948 lines
38 KiB
TypeScript
948 lines
38 KiB
TypeScript
"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 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.
|
||
* - 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.)
|
||
*
|
||
* 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.
|
||
*
|
||
* Rendering settings (2026-09-13): a gear in the search panel reveals three
|
||
* sliders — node size, edge width, arrowhead length — 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.
|
||
*/
|
||
|
||
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 type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||
|
||
/** The uniform node look (no state classes — WebGL 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.
|
||
|
||
interface RenderSettings {
|
||
nodeSize: number;
|
||
edgeWidth: number;
|
||
arrowLength: number;
|
||
}
|
||
const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5 };
|
||
const RENDER_SETTINGS_KEY = "graph-render-settings";
|
||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
||
/** Defensively merge stored settings (stale/partial/corrupt → defaults). */
|
||
function sanitizeRenderSettings(raw: unknown): RenderSettings {
|
||
const r = raw as Partial<RenderSettings> | null;
|
||
return {
|
||
nodeSize: clamp(Number(r?.nodeSize ?? DEFAULT_RENDER_SETTINGS.nodeSize), 2, 14),
|
||
edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4),
|
||
arrowLength: clamp(Number(r?.arrowLength ?? DEFAULT_RENDER_SETTINGS.arrowLength), 2, 15),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
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;
|
||
/** 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
|
||
|
||
// ---- 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) --------------------
|
||
|
||
/** 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 (from graphToViewport at click time). */
|
||
anchor: { x: number; y: number };
|
||
}
|
||
|
||
/** One labeled range row for the rendering-settings panel. */
|
||
function SettingSlider({
|
||
label,
|
||
min,
|
||
max,
|
||
step,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex flex-col gap-1">
|
||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||
{label}
|
||
<span className="tabular-nums">{value}</span>
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
className="h-1.5 w-full accent-primary"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function GraphView() {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||
const [selected, setSelected] = useState<Selection | null>(null);
|
||
// Search panel state: the query text + the settings section toggle.
|
||
const [query, setQuery] = useState("");
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
// Render settings (node size / edge width / arrowhead): loaded from
|
||
// localStorage AFTER mount (an initial-state read would mismatch the
|
||
// server-rendered HTML), saved on every change.
|
||
const [settings, setSettings] = useState<RenderSettings>(DEFAULT_RENDER_SETTINGS);
|
||
useEffect(() => {
|
||
try {
|
||
const stored = localStorage.getItem(RENDER_SETTINGS_KEY);
|
||
if (stored) setSettings(sanitizeRenderSettings(JSON.parse(stored)));
|
||
} catch {
|
||
// corrupt storage → defaults, never crash the view
|
||
}
|
||
}, []);
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify(settings));
|
||
} catch {
|
||
// storage full/blocked → in-memory settings still work
|
||
}
|
||
}, [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<import("sigma").default | null>(null);
|
||
const graphRef = useRef<Graph | null>(null);
|
||
const [rendererEpoch, setRendererEpoch] = useState(0);
|
||
const cameraRef = useRef<{ x: number; y: number; ratio: number; angle: number } | null>(null);
|
||
|
||
// The id set that survives the current search — consumed by the sigma
|
||
// reducers and the "no matches" hint.
|
||
const visibleIds = useMemo(() => {
|
||
const set = new Set<string>();
|
||
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<GraphSnapshot>)
|
||
.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<HTMLInputElement>(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);
|
||
};
|
||
}, []);
|
||
|
||
// 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.
|
||
useEffect(() => {
|
||
if (!snapshot || snapshot.nodes.length === 0) return;
|
||
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, {
|
||
label: node.name,
|
||
size: DEFAULT_RENDER_SETTINGS.nodeSize,
|
||
color: tokenHex(NODE_COLOR_TOKEN),
|
||
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: tokenHex("--ring"),
|
||
size: DEFAULT_RENDER_SETTINGS.edgeWidth,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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 });
|
||
|
||
// 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;
|
||
return () => {
|
||
graphRef.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);
|
||
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;
|
||
// 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,
|
||
});
|
||
// 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);
|
||
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 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);
|
||
});
|
||
// 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));
|
||
});
|
||
|
||
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();
|
||
};
|
||
}, [snapshot, deferredSettings]);
|
||
|
||
// 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.
|
||
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 };
|
||
});
|
||
// The open detail card cannot outlive its node's visibility.
|
||
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
|
||
}, [rendererEpoch, visibleIds]);
|
||
|
||
// 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 (
|
||
<div className="relative h-full w-full">
|
||
{isEmpty ? (
|
||
// The original home placeholder — kept verbatim for the empty graph.
|
||
<div className="flex h-full w-full items-center justify-center px-4">
|
||
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
|
||
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
|
||
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
|
||
<p className="max-w-sm text-sm">
|
||
Every session you complete will map what you know — and where the edge of your
|
||
understanding is — right here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div ref={containerRef} className="h-full w-full" />
|
||
)}
|
||
|
||
{/* Legend + filters: search box and per-state toggle rows. */}
|
||
{!isEmpty && (
|
||
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||
<div className="relative">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
title="Rendering settings"
|
||
onClick={() => setSettingsOpen((o) => !o)}
|
||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
<SettingsIcon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
{settingsOpen && (
|
||
<div className="mt-1.5 flex flex-col gap-2 border-t pt-1.5">
|
||
<SettingSlider
|
||
label="Node size"
|
||
min={2}
|
||
max={14}
|
||
step={1}
|
||
value={settings.nodeSize}
|
||
onChange={(v) => setSettings((cur) => ({ ...cur, nodeSize: v }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Edge width"
|
||
min={0.5}
|
||
max={4}
|
||
step={0.5}
|
||
value={settings.edgeWidth}
|
||
onChange={(v) => setSettings((cur) => ({ ...cur, edgeWidth: v }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Arrowhead size"
|
||
min={2}
|
||
max={15}
|
||
step={1}
|
||
value={settings.arrowLength}
|
||
onChange={(v) => setSettings((cur) => ({ ...cur, arrowLength: v }))}
|
||
/>
|
||
</div>
|
||
)}
|
||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
||
No matching nodes.
|
||
</div>
|
||
)}
|
||
<div className="mt-1.5 border-t pt-1.5">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept=".md,.markdown,.txt"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
onImportFiles(e.target.files);
|
||
e.target.value = ""; // allow re-picking the same file
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={importing}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||
>
|
||
<UploadIcon className="size-3" />
|
||
{importing ? "Importing…" : "Import notes…"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Background pipeline status: what is currently running. */}
|
||
{pipelineRuns.length > 0 && (
|
||
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
|
||
{pipelineRuns.map((run) => (
|
||
<div
|
||
key={run.chatId}
|
||
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||
>
|
||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||
<span className="capitalize">{run.phase}</span>
|
||
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
||
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{selected && !isEmpty && (
|
||
<div
|
||
ref={cardRef}
|
||
// Positioned imperatively in the layout effect above; hidden
|
||
// until placed so the flip never flashes in the wrong spot.
|
||
style={{ visibility: "hidden", left: 0, top: 0 }}
|
||
className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
||
>
|
||
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||
{selected.sourceIds.length > 0 ? (
|
||
<div className="mt-3 flex flex-col gap-1">
|
||
<div className="text-xs text-muted-foreground">Sessions</div>
|
||
{selected.sourceIds.map((sourceId) => {
|
||
// That source's understanding observation — the
|
||
// extractor's named-word note (confidence history).
|
||
const note = snapshot?.nodeObservations?.[selected.node.id]?.[sourceId];
|
||
// An imported-notes file: a STATIC entry — the path,
|
||
// plain text, never a link (no session to open).
|
||
if (snapshot?.fileSources?.[sourceId] !== undefined) {
|
||
return (
|
||
<div key={sourceId} className="flex items-center gap-1.5">
|
||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||
<span className="min-w-0 flex-1 truncate px-1 py-1 text-sm text-muted-foreground">
|
||
{snapshot.fileSources[sourceId]}
|
||
</span>
|
||
{note && (
|
||
<span className="max-w-28 shrink-0 truncate whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
{note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
// A real session: deep-linkable provenance.
|
||
return (
|
||
<div key={sourceId} className="flex items-center gap-1.5">
|
||
<Link
|
||
href={`/chat/${sourceId}`}
|
||
className="min-w-0 flex-1 truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
{snapshot?.sessions[sourceId] ?? sourceId}
|
||
</Link>
|
||
{/* The note is a fixed-scale word (solid/shaky/...) — the
|
||
session name is the main thing; the chip is bounded so
|
||
it can never push or overflow the row. */}
|
||
{note && (
|
||
<span className="max-w-28 shrink-0 truncate whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
{note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|