The most suitable default is the stricter end — labels only once zoomed in past 2x fit (user-decided 2026-09-13). The slider window extends below it (0.25x) so the default is no longer the floor.
969 lines
39 KiB
TypeScript
969 lines
39 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 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.
|
||
*/
|
||
|
||
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;
|
||
/**
|
||
* 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;
|
||
}
|
||
const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.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),
|
||
labelZoom: clamp(Number(r?.labelZoom ?? DEFAULT_RENDER_SETTINGS.labelZoom), 0.25, 4),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
/** 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,
|
||
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 (
|
||
<label className="flex flex-col gap-1">
|
||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||
{label}
|
||
<span className="tabular-nums">{display ?? 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 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);
|
||
};
|
||
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 }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Labels until zoom"
|
||
min={0.25}
|
||
max={4}
|
||
step={0.25}
|
||
value={settings.labelZoom}
|
||
display={`${settings.labelZoom}×`}
|
||
onChange={(v) => setSettings((cur) => ({ ...cur, labelZoom: 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>
|
||
);
|
||
}
|