Renderer swap: drop sigma.js for a hand-rolled single-canvas 2D renderer

Implements Option B from docs/research/graph-rendering.md. sigma.js v3
(WebGL) is replaced by one canvas, one rAF loop with a dirty flag, and
d3-zoom for the camera — eliminating the entire bug class from the
sigma-era bolt-on architecture:

- Hull/node desync during pan (two independent render loops) is now
  structurally impossible: hull fills, dashed hull strokes, edges with
  arrowheads, node discs, region labels, node labels, and hover glow
  are all drawn in ONE frame under ONE camera.
- The settings-change canvas-sizing race (everything shifting up-left)
  is gone: settings changes only redraw; the canvas and camera persist
  (verified live: camera identical to 15 decimals across a slider drag).
- The GPU floor is gone: idle schedules NO frames at all (verified via
  pixel-identity over 600ms idle); pan redraws exactly once per frame.

components/graph-canvas.ts: GraphCanvasRenderer class plus pure helpers
(zoomRatioOf, labelAlpha, fitTransform, arrowGeometry, pickNearest).
The sigma camera-ratio convention (1 = fit, smaller = zoomed in) is kept
verbatim so persisted labelZoom settings survive the swap; node labels
and cluster region labels cross-fade over the same LABELS_FADE_SPAN
(0.5, unchanged). Picking is a nearest-node distance check with the
10px pad that replaces the old padded-picking vertex shader. Hull math
stays in components/cluster-geometry.ts, reused as-is. No ctx.filter,
ever.

graph-view.tsx keeps every feature: fetch/refetch on runs-empty, FA2
run-then-freeze layout, localStorage render settings + sanitizers,
search filtering with edge drops and card-close, detail card with
flipped anchoring and per-source provenance, settings panel, import
button, pipeline status chips. Only the renderer plumbing changed.

tests/graph-canvas.test.ts pins the pure math: ratio direction, label
gate + inverse complement (alphas sum to 1), fit transform padding,
arrowhead tip-on-disc-edge and symmetry, picking pad/ties/null cases.

Live-verified end-to-end on the real 338-node graph (dev server
port 3002): single canvas, paint at fit, hover cursor+glow+fade, click
card open/anchored/close, stage click, search filter to zero with hint,
label gate opens via slider and zoom, cluster-labels toggle, zoom-to-
cursor 0.56px anchor drift, camera persistence across settings, zero
idle repaints. sigma dependency removed from package.json; d3-zoom and
d3-selection added.

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