From 6bfec1555d4c618b825f9b2324dcec3e58a433b2 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 17:55:44 +0300 Subject: [PATCH 01/10] Graph labels: wrapped names, dark hover pill, node-anchored detail card - Custom sigma label drawers replace the defaults: every label renders the node name word-wrapped under the node (max 170px line, overlong words hard-broken, never truncated) instead of one long strip to the right of the node. - Hover swaps sigma's hardcoded white box for a dark rounded pill using the app's popover tokens; hover always shows the name, even below the zoom threshold. - labelRenderedSizeThreshold 12 -> 6 (names are short now) and labels use the app's body font at weight 500. - Click card anchors just below the clicked node's viewport position (flipping above near the bottom edge, clamped horizontally, measured in a layout effect so the flip never flashes); any pan/zoom dismisses it via the camera 'updated' event. --- components/graph-view.tsx | 258 +++++++++++++++++++++++++++++++------- 1 file changed, 215 insertions(+), 43 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 60a6d1e..aa07bf0 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -6,20 +6,30 @@ * sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous * forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect * writes x/y, and no animation loop ever starts — near-zero idle CPU. - * Physics is placement only: pan/zoom, no dragging, no editing. Labels are - * zoom-gated via labelRenderedSizeThreshold (the Obsidian text-fade feel). + * Physics is placement only: pan/zoom, no dragging, no editing. * - * sigma is imported dynamically inside the effect so SSR never touches - * WebGL (its module references WebGL2RenderingContext at load time). Nodes - * are colored by derived state class using the app's CSS variables; the - * `requires` edges render as arrows pointing prerequisite → dependent. + * Labels render the node `name` (the short header, never the statement) + * through custom canvas drawers that replace sigma's defaults: + * - defaultDrawNodeLabel: plain wrapped text, centered below the node, + * word-wrapped at a max line width — no truncation, no one-line sprawl. + * - defaultDrawNodeHover: the same wrapped name on a dark pill (the app's + * popover tokens) instead of sigma's hardcoded white box. Hover always + * shows the name, even below the zoom threshold. + * Labels are zoom-gated via labelRenderedSizeThreshold (lowered to 6 since + * names are short; the Obsidian text-fade feel, one stop earlier). + * + * Clicking a node opens the detail card anchored just below the node's + * viewport position (flipping above near the bottom edge); any camera move + * (pan/zoom) dismisses it. The `requires` edges render as arrows pointing + * prerequisite → dependent. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import Link from "next/link"; import { WaypointsIcon } from "lucide-react"; import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; +import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering"; import { seededRandom, type GraphNode, type GraphSnapshot, type NodeStateClass } from "@/src/core"; /** Per-state visuals: color token + node size (WebGL wants hex, not oklch). */ @@ -39,6 +49,130 @@ const STATE_LABELS: Record = { candidate: "candidate", }; +// ---- Label drawing (canvas) --------------------------------------------- +// +// sigma's defaults draw the label on ONE line to the right of the node with +// a white box on hover. We replace both: names wrap under the node at a max +// line width, plain when unhovered, dark pill when hovered. + +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; +const PILL_PAD_X = 8; +const PILL_PAD_Y = 5; + +/** 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; +} + +/** Draw a rounded rectangle path (ctx.roundRect is not universal). */ +function roundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +): void { + const r = Math.min(radius, width / 2, height / 2); + context.beginPath(); + context.moveTo(x + r, y); + context.arcTo(x + width, y, y + width, y + height, r); + context.arcTo(x + width, y + height, x, y + height, r); + context.arcTo(x, y + height, x, y, r); + context.arcTo(x, y, x + width, y, r); + context.closePath(); +} + +/** Make the pair of canvas label drawers bound to the theme's colors. */ +function makeLabelDrawers(labelColor: string, pillColor: string, pillTextColor: string) { + const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => { + 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 = labelColor; + 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(); + }; + + const drawHover: NodeHoverDrawingFunction = (context, data, settings) => { + const size = settings.labelSize; + context.save(); + context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`; + + if (!data.label) { + // No name to show: just a soft ring around the node itself. + context.fillStyle = pillColor; + context.beginPath(); + context.arc(data.x, data.y, data.size + PILL_PAD_Y, 0, Math.PI * 2); + context.fill(); + context.restore(); + return; + } + + const lines = wrapLabel(context, data.label, LABEL_MAX_WIDTH); + const lineH = size * LABEL_LINE_HEIGHT; + const boxWidth = + Math.max(...lines.map((line) => context.measureText(line).width)) + 2 * PILL_PAD_X; + const boxHeight = lines.length * lineH + 2 * PILL_PAD_Y; + const boxX = data.x - boxWidth / 2; + const boxY = data.y + data.size + LABEL_GAP - PILL_PAD_Y; + + context.shadowOffsetX = 0; + context.shadowOffsetY = 2; + context.shadowBlur = 10; + context.shadowColor = "rgba(0, 0, 0, 0.35)"; + context.fillStyle = pillColor; + roundedRect(context, boxX, boxY, boxWidth, boxHeight, 8); + context.fill(); + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + context.shadowBlur = 0; + + context.fillStyle = pillTextColor; + context.textAlign = "center"; + context.textBaseline = "top"; + for (let i = 0; i < lines.length; i++) { + context.fillText(lines[i]!, data.x, boxY + PILL_PAD_Y + i * lineH); + } + context.restore(); + }; + + 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"); @@ -59,14 +193,19 @@ function tokenHex(cssVar: string): string { return value ? cssColorToHex(value) : "#888888"; } +// ---- Component ------------------------------------------------------------ + interface Selection { node: GraphNode; state: NodeStateClass; chatIds: string[]; + /** Node position in container pixels (from graphToViewport at click time). */ + anchor: { x: number; y: number }; } export function GraphView() { const containerRef = useRef(null); + const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); @@ -92,10 +231,13 @@ export function GraphView() { const edgeColor = tokenHex("--ring"); const labelColor = tokenHex("--muted-foreground"); + const pillColor = tokenHex("--popover"); + const pillTextColor = tokenHex("--popover-foreground"); const stateColors = new Map(); for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) { stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar)); } + const { drawLabel, drawHover } = makeLabelDrawers(labelColor, pillColor, pillTextColor); const graph = new Graph(); for (const node of snapshot.nodes) { @@ -134,22 +276,33 @@ export function GraphView() { if (killed) return; renderer = new Sigma(graph, container, { defaultEdgeType: "arrow", - labelRenderedSizeThreshold: 12, - labelColor: { color: labelColor }, + // Names are short now — show them one zoom-stop earlier. + labelRenderedSizeThreshold: 6, + // Match the app typography (Geist via the body's stack). + labelFont: getComputedStyle(document.body).fontFamily, + labelWeight: "500", labelSize: 12, + labelColor: { color: labelColor }, renderEdgeLabels: false, + defaultDrawNodeLabel: drawLabel, + defaultDrawNodeHover: drawHover, }); renderer.on("clickNode", ({ node }) => { const graphNode = snapshot.nodes.find((n) => n.id === node); if (!graphNode) return; + const pos = graph.getNodeAttributes(node); + const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 }); setSelected({ node: graphNode, state: snapshot.nodeStates[node] ?? "candidate", chatIds: snapshot.nodeSessions[node] ?? [], + anchor: anchor ?? { x: 0, y: 0 }, }); }); - // Clicking the empty stage closes the node overlay. + // Clicking the empty stage closes the node card. renderer.on("clickStage", () => setSelected(null)); + // Any pan/zoom detaches the card from its node — close instead. + renderer.getCamera().on("updated", () => setSelected(null)); }); return () => { @@ -158,6 +311,30 @@ export function GraphView() { }; }, [snapshot]); + // Position the detail card: below the clicked node, flipped above near + // the bottom edge, clamped horizontally. Imperative (ref + layout effect, + // before paint) because the flip needs the card's measured height. + useLayoutEffect(() => { + const card = cardRef.current; + const container = containerRef.current; + if (!selected || !card || !container) return; + const width = card.offsetWidth; + const height = card.offsetHeight; + const margin = 8; + const gap = 12; + const left = Math.min( + Math.max(selected.anchor.x - width / 2, margin), + Math.max(container.clientWidth - width - margin, margin), + ); + let top = selected.anchor.y + gap; + if (top + height > container.clientHeight - margin) { + top = Math.max(selected.anchor.y - gap - height, margin); + } + card.style.left = `${left}px`; + card.style.top = `${top}px`; + card.style.visibility = "visible"; + }, [selected]); + const isEmpty = snapshot === null || snapshot.nodes.length === 0; return ( @@ -178,41 +355,36 @@ export function GraphView() {
)} - {selected && ( - // Background click closes; the card swallows its own clicks. - + {selected.chatIds.length > 0 ? ( +
+
Sessions
+ {selected.chatIds.map((chatId) => ( + + {snapshot?.sessions[chatId] ?? chatId} + + ))} +
+ ) : ( +
No sessions recorded.
+ )} +
)} ); From 42c112f8c5dcec7ed98618d7221abc71d84729d6 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 18:25:04 +0300 Subject: [PATCH 02/10] Labels appear all at once: one global zoom gate, no per-node size gating labelRenderedSizeThreshold gated each node's label by its own rendered size, so big (taught) nodes showed names several zoom stops before small (candidate) ones. Zero the per-node threshold and instead toggle renderLabels from the camera ratio: every name appears at fit zoom or closer and they all vanish together when zooming further out. Hover still reveals any node's name regardless of zoom. --- components/graph-view.tsx | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index aa07bf0..da26957 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -14,9 +14,11 @@ * word-wrapped at a max line width — no truncation, no one-line sprawl. * - defaultDrawNodeHover: the same wrapped name on a dark pill (the app's * popover tokens) instead of sigma's hardcoded white box. Hover always - * shows the name, even below the zoom threshold. - * Labels are zoom-gated via labelRenderedSizeThreshold (lowered to 6 since - * names are short; the Obsidian text-fade feel, one stop earlier). + * shows the name, even when labels are zoomed out. + * Labels are zoom-gated GLOBALLY: all names appear at once when the camera + * is at fit zoom or closer (ratio <= 1) and all disappear at once when + * zoomed further out — no per-node size gating, so labels never pop in one + * by one. (The Obsidian text-fade feel, applied to the whole graph.) * * Clicking a node opens the detail card anchored just below the node's * viewport position (flipping above near the bottom edge); any camera move @@ -60,6 +62,8 @@ const LABEL_GAP = 6; // px between node edge and first label line const LABEL_LINE_HEIGHT = 1.3; const PILL_PAD_X = 8; const PILL_PAD_Y = 5; +/** Camera ratio at/below which ALL labels appear at once (1 = fit zoom). */ +const LABELS_CAMERA_RATIO = 1; /** Word-wrap for canvas text. Never truncates: overlong single words are * hard-broken by character so no line ever exceeds maxWidth. */ @@ -276,8 +280,9 @@ export function GraphView() { if (killed) return; renderer = new Sigma(graph, container, { defaultEdgeType: "arrow", - // Names are short now — show them one zoom-stop earlier. - labelRenderedSizeThreshold: 6, + // All labels appear/disappear together: no per-node size gating + // (threshold 0) — a single camera-ratio switch toggles them below. + labelRenderedSizeThreshold: 0, // Match the app typography (Geist via the body's stack). labelFont: getComputedStyle(document.body).fontFamily, labelWeight: "500", @@ -301,8 +306,17 @@ export function GraphView() { }); // Clicking the empty stage closes the node card. renderer.on("clickStage", () => setSelected(null)); - // Any pan/zoom detaches the card from its node — close instead. - renderer.getCamera().on("updated", () => setSelected(null)); + // One global label gate: names show at fit zoom or closer and all + // vanish together when zoomed further out. Any pan/zoom also + // detaches the card from its node — close it. + const syncRenderLabels = (ratio: number) => { + renderer?.setSetting("renderLabels", ratio <= LABELS_CAMERA_RATIO); + }; + syncRenderLabels(renderer.getCamera().getState().ratio); + renderer.getCamera().on("updated", (state) => { + syncRenderLabels(state.ratio); + setSelected(null); + }); }); return () => { From 08e417134b80ab61bee1f0341ec8746da51320eb Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 19:01:25 +0300 Subject: [PATCH 03/10] Fix hover pill geometry; fade labels in/out over a zoom span - roundedRect typo: the first arcTo's second control point used y+width as the x-coordinate (y + width instead of x + width). Since arcTo tangent math is in absolute coordinates, the pill outline sprouted a stray wedge/notch whose size scaled with the pill's on-screen position (diagnosed live by the tester subagent: ~1000px left spike on a top-right node, right-side bites near the left edge). - Labels now fade instead of popping: fully visible at fit zoom or closer, then the labels canvas layer's opacity is interpolated across a 0.5 camera-ratio zoom span before renderLabels switches off, with the setSetting call guarded so pans don't spam refreshes. --- components/graph-view.tsx | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index da26957..194dc69 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -15,10 +15,11 @@ * - defaultDrawNodeHover: the same wrapped name on a dark pill (the app's * popover tokens) instead of sigma's hardcoded white box. Hover always * shows the name, even when labels are zoomed out. - * Labels are zoom-gated GLOBALLY: all names appear at once when the camera - * is at fit zoom or closer (ratio <= 1) and all disappear at once when - * zoomed further out — no per-node size gating, so labels never pop in one - * by one. (The Obsidian text-fade feel, applied to the whole graph.) + * Labels are zoom-gated GLOBALLY with a fade: all names appear at once + * when the camera is at fit zoom or closer (ratio <= 1), then fade out + * together across a short zoom span before label rendering switches off — + * no per-node size gating, so labels never pop in one by one. (The Obsidian + * text-fade feel, applied to the whole graph.) * * Clicking a node opens the detail card anchored just below the node's * viewport position (flipping above near the bottom edge); any camera move @@ -64,6 +65,8 @@ const PILL_PAD_X = 8; const PILL_PAD_Y = 5; /** 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; /** Word-wrap for canvas text. Never truncates: overlong single words are * hard-broken by character so no line ever exceeds maxWidth. */ @@ -104,7 +107,7 @@ function roundedRect( const r = Math.min(radius, width / 2, height / 2); context.beginPath(); context.moveTo(x + r, y); - context.arcTo(x + width, y, y + width, y + height, r); + context.arcTo(x + width, y, x + width, y + height, r); context.arcTo(x + width, y + height, x, y + height, r); context.arcTo(x, y + height, x, y, r); context.arcTo(x, y, x + width, y, r); @@ -306,11 +309,23 @@ export function GraphView() { }); // Clicking the empty stage closes the node card. renderer.on("clickStage", () => setSelected(null)); - // One global label gate: names show at fit zoom or closer and all - // vanish together when zoomed further out. Any pan/zoom also - // detaches the card from its node — close it. + // 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) => { - renderer?.setSetting("renderLabels", ratio <= LABELS_CAMERA_RATIO); + // 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) => { From aa14351474e9fa2e933162e548f857bd15212706 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 2 Sep 2026 01:57:25 +0300 Subject: [PATCH 04/10] Hover affordance: pulsing node glow + brighter name, drop the dark pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - defaultDrawNodeHover no longer paints a background box: the hovered node now pulses a soft radial-gradient halo ring in its own state color (evenodd punch-out keeps the node disc untouched), phase driven by performance.now() inside the drawer. - The hovered label text brightens (muted-foreground -> foreground) at identical geometry — drawName is shared by both drawers so nothing shifts when hover begins/ends. - The pulse is animated by a rAF loop that only runs while a node is hovered (enterNode starts, leaveNode/leaveStage stop, cleanup cancels) — idle CPU stays near zero, preserving run-then-freeze. - roundedRect removed with the pill (its only remaining consumer). --- components/graph-view.tsx | 169 ++++++++++++++++++++------------------ 1 file changed, 91 insertions(+), 78 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 194dc69..9c8bed0 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -5,16 +5,20 @@ * * sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous * forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect - * writes x/y, and no animation loop ever starts — near-zero idle CPU. - * Physics is placement only: pan/zoom, no dragging, no editing. + * writes x/y. No layout animation ever starts; the ONLY animation loop + * is the hover glow pulse, which runs strictly while a node is hovered + * and stops on leave — idle CPU stays near zero. Physics is placement + * only: pan/zoom, no dragging, no editing. * * Labels render the node `name` (the short header, never the statement) * through custom canvas drawers that replace sigma's defaults: * - defaultDrawNodeLabel: plain wrapped text, centered below the node, * word-wrapped at a max line width — no truncation, no one-line sprawl. - * - defaultDrawNodeHover: the same wrapped name on a dark pill (the app's - * popover tokens) instead of sigma's hardcoded white box. Hover always - * shows the name, even when labels are zoomed out. + * - defaultDrawNodeHover: NO background box (sigma's default is a + * hardcoded white one). Instead the hovered node pulses a soft glow + * halo in its own state color and its name brightens — the "this is + * clickable, click for the full fact" affordance. Hover always shows + * the name, even when labels are zoomed out. * 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 — @@ -56,18 +60,34 @@ const STATE_LABELS: Record = { // // 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, plain when unhovered, dark pill when hovered. +// 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; -const PILL_PAD_X = 8; -const PILL_PAD_Y = 5; /** 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 (never over the node disc), pulsing in the +// node's own color. The phase comes from performance.now() inside the +// drawer, so the effect only has to keep scheduling renders while hovered. +const GLOW_PAD_MIN = 4; // px beyond the node disc at the pulse's dimmest +const GLOW_PAD_SWING = 6; // extra px at the pulse's peak +const GLOW_ALPHA_MIN = 0.18; +const GLOW_ALPHA_SWING = 0.3; +const GLOW_PULSE_MS = 1600; // one full pulse cycle + +/** "#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[] { @@ -95,83 +115,53 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu return lines; } -/** Draw a rounded rectangle path (ctx.roundRect is not universal). */ -function roundedRect( +/** The wrapped node name, centered below the node — used by both drawers + * with identical geometry so hover only changes the color, nothing jumps. */ +function drawName( context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, + data: { label?: string | null; x: number; y: number; size: number }, + settings: { labelSize: number; labelWeight: string; labelFont: string }, + color: string, ): void { - const r = Math.min(radius, width / 2, height / 2); - context.beginPath(); - context.moveTo(x + r, y); - context.arcTo(x + width, y, x + width, y + height, r); - context.arcTo(x + width, y + height, x, y + height, r); - context.arcTo(x, y + height, x, y, r); - context.arcTo(x, y, x + width, y, r); - context.closePath(); + 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(); } /** Make the pair of canvas label drawers bound to the theme's colors. */ -function makeLabelDrawers(labelColor: string, pillColor: string, pillTextColor: string) { +function makeLabelDrawers(labelColor: string, hoverTextColor: string) { const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => { - 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 = labelColor; - 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(); + drawName(context, data, settings, labelColor); }; const drawHover: NodeHoverDrawingFunction = (context, data, settings) => { - const size = settings.labelSize; context.save(); - context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`; - - if (!data.label) { - // No name to show: just a soft ring around the node itself. - context.fillStyle = pillColor; - context.beginPath(); - context.arc(data.x, data.y, data.size + PILL_PAD_Y, 0, Math.PI * 2); - context.fill(); - context.restore(); - return; - } - - const lines = wrapLabel(context, data.label, LABEL_MAX_WIDTH); - const lineH = size * LABEL_LINE_HEIGHT; - const boxWidth = - Math.max(...lines.map((line) => context.measureText(line).width)) + 2 * PILL_PAD_X; - const boxHeight = lines.length * lineH + 2 * PILL_PAD_Y; - const boxX = data.x - boxWidth / 2; - const boxY = data.y + data.size + LABEL_GAP - PILL_PAD_Y; - - context.shadowOffsetX = 0; - context.shadowOffsetY = 2; - context.shadowBlur = 10; - context.shadowColor = "rgba(0, 0, 0, 0.35)"; - context.fillStyle = pillColor; - roundedRect(context, boxX, boxY, boxWidth, boxHeight, 8); - context.fill(); - context.shadowOffsetX = 0; - context.shadowOffsetY = 0; - context.shadowBlur = 0; - - context.fillStyle = pillTextColor; - context.textAlign = "center"; - context.textBaseline = "top"; - for (let i = 0; i < lines.length; i++) { - context.fillText(lines[i]!, data.x, boxY + PILL_PAD_Y + i * lineH); - } + // Pulsing glow halo in the node's own state color: a radial-gradient + // ring (the evenodd punch-out keeps the node disc itself untouched). + const pulse = 0.5 + 0.5 * Math.sin((performance.now() / GLOW_PULSE_MS) * Math.PI * 2); + const inner = data.size + 1; + const outer = data.size + GLOW_PAD_MIN + pulse * GLOW_PAD_SWING; + const alpha = GLOW_ALPHA_MIN + pulse * GLOW_ALPHA_SWING; + const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer); + glow.addColorStop(0, rgbaFromHex(data.color, alpha)); + 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"); + // Brighter name, no background box — the clickable affordance. + drawName(context, data, settings, hoverTextColor); context.restore(); }; @@ -238,13 +228,12 @@ export function GraphView() { const edgeColor = tokenHex("--ring"); const labelColor = tokenHex("--muted-foreground"); - const pillColor = tokenHex("--popover"); - const pillTextColor = tokenHex("--popover-foreground"); + const hoverTextColor = tokenHex("--foreground"); const stateColors = new Map(); for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) { stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar)); } - const { drawLabel, drawHover } = makeLabelDrawers(labelColor, pillColor, pillTextColor); + const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor); const graph = new Graph(); for (const node of snapshot.nodes) { @@ -279,6 +268,9 @@ export function GraphView() { let killed = false; let renderer: import("sigma").default | null = null; + // rAF id of the hover glow pulse (the ONLY animation loop — see the + // file header). Lives out here so the effect cleanup can cancel it. + let hoverAnimId: number | null = null; void import("sigma").then(({ default: Sigma }) => { if (killed) return; renderer = new Sigma(graph, container, { @@ -332,10 +324,31 @@ export function GraphView() { syncRenderLabels(state.ratio); setSelected(null); }); + // The hover glow is a slow pulse driven by performance.now() inside + // drawHover; this loop just keeps scheduling renders while a node is + // hovered and stops on leave, so idle CPU returns to zero. + const startHoverPulse = () => { + if (hoverAnimId !== null) return; + const tick = () => { + renderer?.scheduleRefresh(); + hoverAnimId = requestAnimationFrame(tick); + }; + hoverAnimId = requestAnimationFrame(tick); + }; + const stopHoverPulse = () => { + if (hoverAnimId !== null) { + cancelAnimationFrame(hoverAnimId); + hoverAnimId = null; + } + }; + renderer.on("enterNode", startHoverPulse); + renderer.on("leaveNode", stopHoverPulse); + renderer.on("leaveStage", stopHoverPulse); }); return () => { killed = true; + if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); renderer?.kill(); }; }, [snapshot]); From 0a8c3605469c5c80ed7a313d61515f662221c2f6 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:22:18 +0300 Subject: [PATCH 05/10] Hover glow: static (no pulse), fade-in/out transition, flush with disc edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The glow no longer pulses: it's a static halo (9px beyond the disc, 0.45 alpha at the edge fading outward) in the node's state color. - Hover enter/leave now cross-fades over ~180ms instead of appearing instantly: entering schedules sigma renders so drawHover repaints at the interpolated fade; leaving draws through a repainter captured on the last hover paint (sigma stops calling drawHover once unhovered). The text cross-fades too — the brighter hover name is drawn at partial alpha over the muted resting label on the layer beneath. - The halo starts flush at the node disc edge (was size+1, leaving a dark gap ring that read as a gray border between node and glow). - Both fade directions cancel each other and stop on completion, so there is no continuous loop — idle CPU stays near zero. --- components/graph-view.tsx | 144 ++++++++++++++++++++++++-------------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 9c8bed0..8d9d1b6 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -5,20 +5,22 @@ * * 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 loop - * is the hover glow pulse, which runs strictly while a node is hovered - * and stops on leave — idle CPU stays near zero. Physics is placement - * only: pan/zoom, no dragging, no editing. + * writes x/y. No layout animation ever starts; the ONLY animation is the + * short hover fade (enter/leave transitions), strictly bounded in time + * and cancelled on unmount — idle CPU stays near zero. Physics is + * placement only: pan/zoom, no dragging, no editing. * * Labels render the node `name` (the short header, never the statement) * through custom canvas drawers that replace sigma's defaults: * - defaultDrawNodeLabel: plain wrapped text, centered below the node, * word-wrapped at a max line width — no truncation, no one-line sprawl. * - defaultDrawNodeHover: NO background box (sigma's default is a - * hardcoded white one). Instead the hovered node pulses a soft glow - * halo in its own state color and its name brightens — the "this is - * clickable, click for the full fact" affordance. Hover always shows - * the name, even when labels are zoomed out. + * hardcoded white one). Instead the hovered node gets a STATIC soft + * glow halo in its own state color, flush with the disc edge, and + * its name brightens — the "this is clickable, click for the full + * fact" affordance. Entering/leaving hover cross-fades (glow and text + * together) over a short transition; the glow itself never pulses. + * Hover always shows the name, even when labels are zoomed out. * 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 — @@ -71,14 +73,13 @@ 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 (never over the node disc), pulsing in the -// node's own color. The phase comes from performance.now() inside the -// drawer, so the effect only has to keep scheduling renders while hovered. -const GLOW_PAD_MIN = 4; // px beyond the node disc at the pulse's dimmest -const GLOW_PAD_SWING = 6; // extra px at the pulse's peak -const GLOW_ALPHA_MIN = 0.18; -const GLOW_ALPHA_SWING = 0.3; -const GLOW_PULSE_MS = 1600; // one full pulse cycle +// Hover glow: a STATIC soft halo ring in the node's own color, starting +// flush at the disc edge (no gap — a gap reads as a false outline/border +// between node and glow). The hovered/unhovered transition cross-fades +// glow + text over a short window; the glow never pulses. +const GLOW_PAD = 9; // px the halo extends beyond the node disc +const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward +const HOVER_FADE_MS = 180; // hover enter/leave transition length /** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ function rgbaFromHex(hex: string, alpha: number): string { @@ -138,31 +139,62 @@ function drawName( context.restore(); } -/** Make the pair of canvas label drawers bound to the theme's colors. */ -function makeLabelDrawers(labelColor: string, hoverTextColor: string) { - const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => { - drawName(context, data, settings, labelColor); - }; - - const drawHover: NodeHoverDrawingFunction = (context, data, settings) => { - context.save(); - // Pulsing glow halo in the node's own state color: a radial-gradient - // ring (the evenodd punch-out keeps the node disc itself untouched). - const pulse = 0.5 + 0.5 * Math.sin((performance.now() / GLOW_PULSE_MS) * Math.PI * 2); - const inner = data.size + 1; - const outer = data.size + GLOW_PAD_MIN + pulse * GLOW_PAD_SWING; - const alpha = GLOW_ALPHA_MIN + pulse * GLOW_ALPHA_SWING; +/** The hovered-state paint at a given transition factor (0 = resting, + * 1 = fully hovered): a static glow halo in the node's own state color + * starting flush at the disc edge (the evenodd punch-out keeps the disc + * itself unpainted), plus the name at partial alpha — the muted label on + * the layer beneath shows through, so the text cross-fades toward the + * brighter hover color as the glow blooms. */ +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 * fade; const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer); - glow.addColorStop(0, rgbaFromHex(data.color, alpha)); + 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"); - // Brighter name, no background box — the clickable affordance. - drawName(context, data, settings, hoverTextColor); - context.restore(); + } + 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 }; @@ -233,7 +265,9 @@ export function GraphView() { for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) { stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar)); } - const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor); + // Mutable hover-transition state: 0 = resting, 1 = fully hovered. + const hover: HoverState = { fade: 0, repaint: null }; + const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover); const graph = new Graph(); for (const node of snapshot.nodes) { @@ -268,7 +302,7 @@ export function GraphView() { let killed = false; let renderer: import("sigma").default | null = null; - // rAF id of the hover glow pulse (the ONLY animation loop — see the + // 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; void import("sigma").then(({ default: Sigma }) => { @@ -324,26 +358,34 @@ export function GraphView() { syncRenderLabels(state.ratio); setSelected(null); }); - // The hover glow is a slow pulse driven by performance.now() inside - // drawHover; this loop just keeps scheduling renders while a node is - // hovered and stops on leave, so idle CPU returns to zero. - const startHoverPulse = () => { - if (hoverAnimId !== null) return; - const tick = () => { - renderer?.scheduleRefresh(); - hoverAnimId = requestAnimationFrame(tick); - }; - hoverAnimId = requestAnimationFrame(tick); - }; - const stopHoverPulse = () => { + // 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", startHoverPulse); - renderer.on("leaveNode", stopHoverPulse); - renderer.on("leaveStage", stopHoverPulse); + renderer.on("enterNode", () => animateHoverTo(1)); + renderer.on("leaveNode", () => animateHoverTo(0)); + renderer.on("leaveStage", () => animateHoverTo(0)); }); return () => { From 96da582d35398650ee66ca28eb6b43a3bc219011 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:31:08 +0300 Subject: [PATCH 06/10] Hover polish: constant-radius glow (opacity fade only), instant text swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The halo no longer grows during the fade: radius is constant at size + 9px; only its opacity fades in/out over ~180ms. - The hover name is drawn opaque in the bright color instead of cross-fading over the muted resting label. Partial-alpha text oscillated whenever hover toggles rapidly at a small node's hit boundary (sigma fires enterNode/leaveNode on every state change) — an un-animated color can't flicker. --- components/graph-view.tsx | 40 ++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 8d9d1b6..8b27e8b 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -15,12 +15,14 @@ * - 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 STATIC soft - * glow halo in its own state color, flush with the disc edge, and - * its name brightens — the "this is clickable, click for the full - * fact" affordance. Entering/leaving hover cross-fades (glow and text - * together) over a short transition; the glow itself never pulses. - * Hover always shows the name, even when labels are zoomed out. + * hardcoded white one). Instead the hovered node gets a soft glow + * halo in its own state color at a constant radius, flush with the + * disc edge, and its name brightens instantly — the "this is + * clickable, click for the full fact" affordance. Entering/leaving + * hover fades the halo's OPACITY over a short transition (no + * growth, no pulse; the text color is un-animated so rapid hover + * toggles at a node's edge can't flicker it). Hover always shows + * the name, even when labels are zoomed out. * 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 — @@ -73,13 +75,13 @@ 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 STATIC soft halo ring in the node's own color, starting -// flush at the disc edge (no gap — a gap reads as a false outline/border -// between node and glow). The hovered/unhovered transition cross-fades -// glow + text over a short window; the glow never pulses. -const GLOW_PAD = 9; // px the halo extends beyond the node disc +// 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 enter/leave transition length +const HOVER_FADE_MS = 180; // hover halo opacity fade length /** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ function rgbaFromHex(hex: string, alpha: number): string { @@ -140,11 +142,12 @@ function drawName( } /** The hovered-state paint at a given transition factor (0 = resting, - * 1 = fully hovered): a static glow halo in the node's own state color - * starting flush at the disc edge (the evenodd punch-out keeps the disc - * itself unpainted), plus the name at partial alpha — the muted label on - * the layer beneath shows through, so the text cross-fades toward the - * brighter hover color as the glow blooms. */ + * 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 opaque in the bright hover color — no + * alpha animation: partial-alpha text over the resting label flickers + * when hover toggles rapidly at a node's edge. */ function paintHover( context: CanvasRenderingContext2D, data: { label?: string | null; x: number; y: number; size: number; color: string }, @@ -155,7 +158,7 @@ function paintHover( context.save(); if (fade > 0.02) { const inner = data.size; - const outer = data.size + GLOW_PAD * fade; + 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)); @@ -165,7 +168,6 @@ function paintHover( context.arc(data.x, data.y, inner, 0, Math.PI * 2, true); context.fill("evenodd"); } - context.globalAlpha = fade; drawName(context, data, settings, hoverTextColor); context.restore(); } From 2d9c649f0b1ef12adc51b23948a145b5a7d944f8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:34:08 +0300 Subject: [PATCH 07/10] Hover text: cross-fade on the same factor as the glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instant muted->bright color swap felt abrupt next to the fading halo. The hover name is again drawn at partial alpha over the muted resting label, using the SAME transition factor as the halo — text and glow fade in/out in sync. Because every enter/leave interpolates from the current fade value, rapid hover toggles at a node's hit boundary glide instead of snapping (the flicker-damping fix). --- components/graph-view.tsx | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 8b27e8b..1463f33 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -17,12 +17,13 @@ * - defaultDrawNodeHover: NO background box (sigma's default is a * hardcoded white one). Instead the hovered node gets a soft glow * halo in its own state color at a constant radius, flush with the - * disc edge, and its name brightens instantly — the "this is - * clickable, click for the full fact" affordance. Entering/leaving - * hover fades the halo's OPACITY over a short transition (no - * growth, no pulse; the text color is un-animated so rapid hover - * toggles at a node's edge can't flicker it). Hover always shows - * the name, even when labels are zoomed out. + * 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. * 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 — @@ -145,9 +146,9 @@ function drawName( * 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 opaque in the bright hover color — no - * alpha animation: partial-alpha text over the resting label flickers - * when hover toggles rapidly at a node's edge. */ + * 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 }, @@ -168,6 +169,10 @@ function paintHover( 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(); } From f12480e040e342adfbb0f89b8b4d87b016a3acea Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:43:13 +0300 Subject: [PATCH 08/10] Fix hover flicker: padded smooth hit-testing + leave-debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: sigma's getNodeAtPosition reads the picking framebuffer, which is rendered at HALF the device resolution (pickingDownSizingRatio = 2 * pixelRatio). Small nodes' hit circles are a couple of device pixels across in that buffer with ragged, quantized edges — near a node boundary, tiny cursor movements flip the hover state on/off, and each flip makes sigma clear the hover layer immediately. That state churn is the flicker; it survived every animation change because it was never in the animation. Mitigations: - patchNodePicking() replaces Sigma.prototype.getNodeAtPosition with a smooth distance test (rendered radius + HIT_PAD_PX = 6px) built on public renderer APIs only (getGraph, getNodeDisplayData, framedGraphToViewport, scaleSize). No ragged edges, and small candidate nodes get roughly double the hoverable area. - Leave-debounce (90ms): when hover drops, hold repainting the captured hover paint at the current fade (sigma would otherwise clear the layer the instant hoveredNode flips to null), and only fade out if the cursor hasn't bounced back within the window. A re-enter cancels the fade, so boundary oscillation never visibly dims anything. --- components/graph-view.tsx | 122 +++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 1463f33..e76d326 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -24,6 +24,12 @@ * sync (no growth, no pulse; interpolating from the current value * means rapid hover toggles glide instead of snapping). Hover * always shows the name, even when labels are zoomed out. + * Hit-testing is also custom: sigma's built-in picking reads a + * HALF-RESOLUTION framebuffer, giving small nodes ragged, quantized hit + * edges — cursor jitter at a boundary flipped hover on/off constantly + * (the hover flicker). patchNodePicking() replaces it with a smooth + * distance test padded by HIT_PAD_PX, and a short leave-debounce + * (hold the paint, then fade) makes any residual bounce invisible. * Labels are zoom-gated GLOBALLY with a fade: all names appear at once * when the camera is at fit zoom or closer (ratio <= 1), then fade out * together across a short zoom span before label rendering switches off — @@ -83,6 +89,15 @@ const LABELS_FADE_SPAN = 0.5; const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant) const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward const HOVER_FADE_MS = 180; // hover halo opacity fade length +/** Padding (px) around each node disc for hover/click hit-testing — + * small (candidate) nodes are ~5px across; a padded, smooth test makes + * them comfortable to hover and removes the ragged half-resolution hit + * edges of sigma's built-in picking. */ +const HIT_PAD_PX = 6; +/** Grace period after the cursor leaves a node before the hover fades: a + * quick bounce back within the window cancels the fade, so boundary + * oscillation never visibly dims anything. */ +const HOVER_LEAVE_DELAY_MS = 90; /** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ function rgbaFromHex(hex: string, alpha: number): string { @@ -207,6 +222,52 @@ function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: Hov return { drawLabel, drawHover }; } +// ---- Padded, smooth node hit-testing --------------------------------------- +// +// sigma's getNodeAtPosition reads the picking framebuffer, which is rendered +// at HALF the device resolution (pickingDownSizingRatio = 2 * pixelRatio). +// For small nodes the hit circle is a couple of device pixels across in that +// buffer, with ragged quantized edges — near a node's boundary, tiny cursor +// movements flip the hover state on and off repeatedly (the hover flicker). +// There is no setting for hit padding in sigma v3, so we replace the method +// with a smooth distance test (node's rendered radius + HIT_PAD_PX) using +// only public renderer APIs. Patches the prototype once per session. + +/** Structural view of the public sigma methods the hit-test needs. */ +interface PickingSurface { + getGraph(): { nodes(): Iterable }; + getNodeDisplayData(key: string): { x: number; y: number; size: number; hidden?: boolean } | undefined; + framedGraphToViewport(data: { x: number; y: number }): { x: number; y: number }; + scaleSize(size: number): number; +} + +let nodePickingPatched = false; + +/** Patch Sigma.prototype.getNodeAtPosition with a padded distance test. */ +function patchNodePicking(SigmaClass: { prototype: object }): void { + if (nodePickingPatched) return; + nodePickingPatched = true; + const proto = SigmaClass.prototype as unknown as { + getNodeAtPosition: (this: PickingSurface, event: { x: number; y: number }) => string | null; + }; + proto.getNodeAtPosition = function (event) { + // Closest node by surface depth: how far the cursor sits inside the + // node's padded hit circle (negative = over the disc itself). + let best: { id: string; depth: number } | null = null; + for (const id of this.getGraph().nodes()) { + const data = this.getNodeDisplayData(id); + if (!data || data.hidden) continue; + const { x, y } = this.framedGraphToViewport(data); + const size = this.scaleSize(data.size); + const depth = Math.hypot(x - event.x, y - event.y) - size; + if (depth <= HIT_PAD_PX && (best === null || depth < best.depth)) { + best = { id, depth }; + } + } + return best ? best.id : null; + }; +} + // ---- Color helpers (WebGL/canvas want hex, not oklch) -------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ @@ -312,8 +373,13 @@ export function GraphView() { // rAF id of the hover fade transition — the ONLY animation (see the // file header). Lives out here so the effect cleanup can cancel it. let hoverAnimId: number | null = null; + // Leave-debounce timer (see HOVER_LEAVE_DELAY_MS): holds the hover + // paint briefly before fading, so boundary bounces never dim it. + let hoverLeaveTimer: ReturnType | null = null; void import("sigma").then(({ default: Sigma }) => { if (killed) return; + // Bigger, smoother hitboxes before the instance starts listening. + patchNodePicking(Sigma); renderer = new Sigma(graph, container, { defaultEdgeType: "arrow", // All labels appear/disappear together: no per-node size gating @@ -365,16 +431,23 @@ export function GraphView() { 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) => { + // Hover fade + leave-debounce. Entering schedules renders (sigma + // re-invokes drawHover at the new fade). Leaving does NOT fade + // immediately: sigma clears the hover layer the moment its + // hoveredNode flips to null, so we (1) keep repainting the + // captured paint at the current fade (holdHoverPaint) through a + // short grace period, and only (2) fade out if the cursor hasn't + // bounced back. A re-enter within the window cancels the fade — + // boundary oscillation stays invisible. Every loop is bounded + // and stops on completion, so idle CPU returns to zero. + const stopHoverLoop = () => { if (hoverAnimId !== null) { cancelAnimationFrame(hoverAnimId); hoverAnimId = null; } + }; + const animateHoverTo = (target: number) => { + stopHoverLoop(); const from = hover.fade; if (from === target) { if (target === 0) hover.repaint?.(0); @@ -390,14 +463,45 @@ export function GraphView() { }; hoverAnimId = requestAnimationFrame(tick); }; - renderer.on("enterNode", () => animateHoverTo(1)); - renderer.on("leaveNode", () => animateHoverTo(0)); - renderer.on("leaveStage", () => animateHoverTo(0)); + const holdHoverPaint = () => { + stopHoverLoop(); + const tick = () => { + hover.repaint?.(hover.fade); + hoverAnimId = requestAnimationFrame(tick); + }; + hoverAnimId = requestAnimationFrame(tick); + }; + const cancelLeave = () => { + if (hoverLeaveTimer !== null) { + clearTimeout(hoverLeaveTimer); + hoverLeaveTimer = null; + } + }; + const onEnterNode = () => { + cancelLeave(); + animateHoverTo(1); + }; + const onLeaveHover = () => { + if (hoverLeaveTimer !== null) return; // already in the grace window + if (hover.fade <= 0) { + hover.repaint?.(0); + return; + } + holdHoverPaint(); + hoverLeaveTimer = setTimeout(() => { + hoverLeaveTimer = null; + animateHoverTo(0); + }, HOVER_LEAVE_DELAY_MS); + }; + renderer.on("enterNode", onEnterNode); + renderer.on("leaveNode", onLeaveHover); + renderer.on("leaveStage", onLeaveHover); }); return () => { killed = true; if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); + if (hoverLeaveTimer !== null) clearTimeout(hoverLeaveTimer); renderer?.kill(); }; }, [snapshot]); From f1ce776008501843aee9aa7220ae68d63f61e2dc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:54:51 +0300 Subject: [PATCH 09/10] Revert "Fix hover flicker: padded smooth hit-testing + leave-debounce" This reverts commit f12480e040e342adfbb0f89b8b4d87b016a3acea. --- components/graph-view.tsx | 122 +++----------------------------------- 1 file changed, 9 insertions(+), 113 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index e76d326..1463f33 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -24,12 +24,6 @@ * sync (no growth, no pulse; interpolating from the current value * means rapid hover toggles glide instead of snapping). Hover * always shows the name, even when labels are zoomed out. - * Hit-testing is also custom: sigma's built-in picking reads a - * HALF-RESOLUTION framebuffer, giving small nodes ragged, quantized hit - * edges — cursor jitter at a boundary flipped hover on/off constantly - * (the hover flicker). patchNodePicking() replaces it with a smooth - * distance test padded by HIT_PAD_PX, and a short leave-debounce - * (hold the paint, then fade) makes any residual bounce invisible. * Labels are zoom-gated GLOBALLY with a fade: all names appear at once * when the camera is at fit zoom or closer (ratio <= 1), then fade out * together across a short zoom span before label rendering switches off — @@ -89,15 +83,6 @@ const LABELS_FADE_SPAN = 0.5; const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant) const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward const HOVER_FADE_MS = 180; // hover halo opacity fade length -/** Padding (px) around each node disc for hover/click hit-testing — - * small (candidate) nodes are ~5px across; a padded, smooth test makes - * them comfortable to hover and removes the ragged half-resolution hit - * edges of sigma's built-in picking. */ -const HIT_PAD_PX = 6; -/** Grace period after the cursor leaves a node before the hover fades: a - * quick bounce back within the window cancels the fade, so boundary - * oscillation never visibly dims anything. */ -const HOVER_LEAVE_DELAY_MS = 90; /** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ function rgbaFromHex(hex: string, alpha: number): string { @@ -222,52 +207,6 @@ function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: Hov return { drawLabel, drawHover }; } -// ---- Padded, smooth node hit-testing --------------------------------------- -// -// sigma's getNodeAtPosition reads the picking framebuffer, which is rendered -// at HALF the device resolution (pickingDownSizingRatio = 2 * pixelRatio). -// For small nodes the hit circle is a couple of device pixels across in that -// buffer, with ragged quantized edges — near a node's boundary, tiny cursor -// movements flip the hover state on and off repeatedly (the hover flicker). -// There is no setting for hit padding in sigma v3, so we replace the method -// with a smooth distance test (node's rendered radius + HIT_PAD_PX) using -// only public renderer APIs. Patches the prototype once per session. - -/** Structural view of the public sigma methods the hit-test needs. */ -interface PickingSurface { - getGraph(): { nodes(): Iterable }; - getNodeDisplayData(key: string): { x: number; y: number; size: number; hidden?: boolean } | undefined; - framedGraphToViewport(data: { x: number; y: number }): { x: number; y: number }; - scaleSize(size: number): number; -} - -let nodePickingPatched = false; - -/** Patch Sigma.prototype.getNodeAtPosition with a padded distance test. */ -function patchNodePicking(SigmaClass: { prototype: object }): void { - if (nodePickingPatched) return; - nodePickingPatched = true; - const proto = SigmaClass.prototype as unknown as { - getNodeAtPosition: (this: PickingSurface, event: { x: number; y: number }) => string | null; - }; - proto.getNodeAtPosition = function (event) { - // Closest node by surface depth: how far the cursor sits inside the - // node's padded hit circle (negative = over the disc itself). - let best: { id: string; depth: number } | null = null; - for (const id of this.getGraph().nodes()) { - const data = this.getNodeDisplayData(id); - if (!data || data.hidden) continue; - const { x, y } = this.framedGraphToViewport(data); - const size = this.scaleSize(data.size); - const depth = Math.hypot(x - event.x, y - event.y) - size; - if (depth <= HIT_PAD_PX && (best === null || depth < best.depth)) { - best = { id, depth }; - } - } - return best ? best.id : null; - }; -} - // ---- Color helpers (WebGL/canvas want hex, not oklch) -------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ @@ -373,13 +312,8 @@ export function GraphView() { // rAF id of the hover fade transition — the ONLY animation (see the // file header). Lives out here so the effect cleanup can cancel it. let hoverAnimId: number | null = null; - // Leave-debounce timer (see HOVER_LEAVE_DELAY_MS): holds the hover - // paint briefly before fading, so boundary bounces never dim it. - let hoverLeaveTimer: ReturnType | null = null; void import("sigma").then(({ default: Sigma }) => { if (killed) return; - // Bigger, smoother hitboxes before the instance starts listening. - patchNodePicking(Sigma); renderer = new Sigma(graph, container, { defaultEdgeType: "arrow", // All labels appear/disappear together: no per-node size gating @@ -431,23 +365,16 @@ export function GraphView() { syncRenderLabels(state.ratio); setSelected(null); }); - // Hover fade + leave-debounce. Entering schedules renders (sigma - // re-invokes drawHover at the new fade). Leaving does NOT fade - // immediately: sigma clears the hover layer the moment its - // hoveredNode flips to null, so we (1) keep repainting the - // captured paint at the current fade (holdHoverPaint) through a - // short grace period, and only (2) fade out if the cursor hasn't - // bounced back. A re-enter within the window cancels the fade — - // boundary oscillation stays invisible. Every loop is bounded - // and stops on completion, so idle CPU returns to zero. - const stopHoverLoop = () => { + // 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 animateHoverTo = (target: number) => { - stopHoverLoop(); const from = hover.fade; if (from === target) { if (target === 0) hover.repaint?.(0); @@ -463,45 +390,14 @@ export function GraphView() { }; hoverAnimId = requestAnimationFrame(tick); }; - const holdHoverPaint = () => { - stopHoverLoop(); - const tick = () => { - hover.repaint?.(hover.fade); - hoverAnimId = requestAnimationFrame(tick); - }; - hoverAnimId = requestAnimationFrame(tick); - }; - const cancelLeave = () => { - if (hoverLeaveTimer !== null) { - clearTimeout(hoverLeaveTimer); - hoverLeaveTimer = null; - } - }; - const onEnterNode = () => { - cancelLeave(); - animateHoverTo(1); - }; - const onLeaveHover = () => { - if (hoverLeaveTimer !== null) return; // already in the grace window - if (hover.fade <= 0) { - hover.repaint?.(0); - return; - } - holdHoverPaint(); - hoverLeaveTimer = setTimeout(() => { - hoverLeaveTimer = null; - animateHoverTo(0); - }, HOVER_LEAVE_DELAY_MS); - }; - renderer.on("enterNode", onEnterNode); - renderer.on("leaveNode", onLeaveHover); - renderer.on("leaveStage", onLeaveHover); + renderer.on("enterNode", () => animateHoverTo(1)); + renderer.on("leaveNode", () => animateHoverTo(0)); + renderer.on("leaveStage", () => animateHoverTo(0)); }); return () => { killed = true; if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); - if (hoverLeaveTimer !== null) clearTimeout(hoverLeaveTimer); renderer?.kill(); }; }, [snapshot]); From 8f5276040c2928f8c1a3a37b4b8c9a8180436fb8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 03:10:34 +0300 Subject: [PATCH 10/10] Padded node picking: +10px pickable radius via a picking-mode-only shader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the hover flicker the maintainers' way (jacomyal/sigma.js#1493): sigma v3 picks nodes by reading a single pixel from a picking framebuffer rendered at HALF the CSS resolution, and has no setting for hit padding. Small nodes get tiny, ragged hit edges, and our glow halo (9px beyond the disc) was NOT pickable — moving the cursor from the disc onto its own halo de-hovered it, an oscillation loop. NodeCircleProgram subclass (registered via nodeProgramClasses) replaces only VERTEX_SHADER_SOURCE: a verbatim copy of sigma 3.0.3's circle vertex shader that adds PICKING_PAD_PX (10) of radius under #ifdef PICKING_MODE alone. The visible pass never takes that branch, so rendering is pixel-identical (verified headless); the pickable disc is disc + 10px, covering the whole hover halo, so the halo is now hoverable and small candidate nodes are comfortable to hit. sigma/rendering is imported dynamically (its ESM build touches WebGL globals at module scope; SSR-safe like the sigma import). This replaces the reverted prototype-patch approach (f12480e). sigma v4 ships this officially as the nodePickingPadding setting — noted for a future upgrade. --- components/graph-view.tsx | 260 ++++++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 81 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 1463f33..99666be 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -24,6 +24,14 @@ * 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 — @@ -84,6 +92,78 @@ 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); @@ -312,88 +392,106 @@ export function GraphView() { // 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; - void import("sigma").then(({ default: Sigma }) => { - if (killed) return; - renderer = new Sigma(graph, container, { - defaultEdgeType: "arrow", - // All labels appear/disappear together: no per-node size gating - // (threshold 0) — a single camera-ratio switch toggles them below. - labelRenderedSizeThreshold: 0, - // Match the app typography (Geist via the body's stack). - labelFont: getComputedStyle(document.body).fontFamily, - labelWeight: "500", - labelSize: 12, - labelColor: { color: labelColor }, - renderEdgeLabels: false, - defaultDrawNodeLabel: drawLabel, - defaultDrawNodeHover: drawHover, - }); - renderer.on("clickNode", ({ node }) => { - const graphNode = snapshot.nodes.find((n) => n.id === node); - if (!graphNode) return; - const pos = graph.getNodeAttributes(node); - const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 }); - setSelected({ - node: graphNode, - state: snapshot.nodeStates[node] ?? "candidate", - chatIds: snapshot.nodeSessions[node] ?? [], - anchor: anchor ?? { x: 0, y: 0 }, - }); - }); - // Clicking the empty stage closes the node card. - renderer.on("clickStage", () => setSelected(null)); - // One global label gate with a fade: names are fully visible at fit - // zoom or closer, then fade out together across LABELS_FADE_SPAN of - // zoom (the labels canvas layer's opacity), and only once fully - // faded does label rendering switch off. Any pan/zoom also detaches - // the card from its node — close it. - const labelsCanvas = renderer.getCanvases().labels; - let labelsRendering: boolean | null = null; - const syncRenderLabels = (ratio: number) => { - // 0 at fit zoom → 1 at the end of the fade span - const fade = (ratio - LABELS_CAMERA_RATIO) / LABELS_FADE_SPAN; - const alpha = Math.min(Math.max(1 - fade, 0), 1); - const shouldRender = alpha > 0; - if (shouldRender !== labelsRendering) { - labelsRendering = shouldRender; - renderer?.setSetting("renderLabels", shouldRender); - } - if (labelsCanvas) labelsCanvas.style.opacity = alpha.toFixed(3); - }; - syncRenderLabels(renderer.getCamera().getState().ratio); - renderer.getCamera().on("updated", (state) => { - syncRenderLabels(state.ratio); - setSelected(null); - }); - // 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; + // 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 }]) => { + if (killed) return; + // 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, + }; + } }; - hoverAnimId = requestAnimationFrame(tick); - }; - renderer.on("enterNode", () => animateHoverTo(1)); - renderer.on("leaveNode", () => animateHoverTo(0)); - renderer.on("leaveStage", () => animateHoverTo(0)); - }); + renderer = new Sigma(graph, container, { + nodeProgramClasses: { circle: PaddedNodeCircleProgram }, + defaultEdgeType: "arrow", + // All labels appear/disappear together: no per-node size gating + // (threshold 0) — a single camera-ratio switch toggles them below. + labelRenderedSizeThreshold: 0, + // Match the app typography (Geist via the body's stack). + labelFont: getComputedStyle(document.body).fontFamily, + labelWeight: "500", + labelSize: 12, + labelColor: { color: labelColor }, + renderEdgeLabels: false, + defaultDrawNodeLabel: drawLabel, + defaultDrawNodeHover: drawHover, + }); + renderer.on("clickNode", ({ node }) => { + const graphNode = snapshot.nodes.find((n) => n.id === node); + if (!graphNode) return; + const pos = graph.getNodeAttributes(node); + const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 }); + setSelected({ + node: graphNode, + state: snapshot.nodeStates[node] ?? "candidate", + chatIds: snapshot.nodeSessions[node] ?? [], + anchor: anchor ?? { x: 0, y: 0 }, + }); + }); + // Clicking the empty stage closes the node card. + renderer.on("clickStage", () => setSelected(null)); + // One global label gate with a fade: names are fully visible at fit + // zoom or closer, then fade out together across LABELS_FADE_SPAN of + // zoom (the labels canvas layer's opacity), and only once fully + // faded does label rendering switch off. Any pan/zoom also detaches + // the card from its node — close it. + const labelsCanvas = renderer.getCanvases().labels; + let labelsRendering: boolean | null = null; + const syncRenderLabels = (ratio: number) => { + // 0 at fit zoom → 1 at the end of the fade span + const fade = (ratio - LABELS_CAMERA_RATIO) / LABELS_FADE_SPAN; + const alpha = Math.min(Math.max(1 - fade, 0), 1); + const shouldRender = alpha > 0; + if (shouldRender !== labelsRendering) { + labelsRendering = shouldRender; + renderer?.setSetting("renderLabels", shouldRender); + } + if (labelsCanvas) labelsCanvas.style.opacity = alpha.toFixed(3); + }; + syncRenderLabels(renderer.getCamera().getState().ratio); + renderer.getCamera().on("updated", (state) => { + syncRenderLabels(state.ratio); + setSelected(null); + }); + // 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;