diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 60a6d1e..99666be 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -5,21 +5,51 @@ * * 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). + * 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. * - * 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: 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 — 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.) + * + * 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 +69,226 @@ 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, muted at rest; on hover the node pulses a soft glow in its +// own state color and the name brightens (no background box). + +const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip +const LABEL_GAP = 6; // px between node edge and first label line +const LABEL_LINE_HEIGHT = 1.3; +/** Camera ratio at/below which ALL labels appear at once (1 = fit zoom). */ +const LABELS_CAMERA_RATIO = 1; +/** Zoom span (camera-ratio units) over which labels fade out together. */ +const LABELS_FADE_SPAN = 0.5; + +// Hover glow: a soft halo ring in the node's own color at a CONSTANT +// radius, starting flush at the disc edge (no gap — a gap reads as a +// false outline/border between node and glow). Only the halo's OPACITY +// animates (hover enter/leave); it never grows and never pulses. +const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant) +const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward +const HOVER_FADE_MS = 180; // hover halo opacity fade length + +// ---- Padded picking (WebGL) ---------------------------------------------- +// +// sigma picks nodes by reading a single pixel from a picking framebuffer +// rendered at HALF the CSS resolution: small nodes get tiny, ragged hit +// edges, and our glow halo (GLOW_PAD beyond the disc) is NOT pickable, so +// moving the cursor from the disc onto its own halo de-hovered it — an +// oscillation loop (the hover flicker). sigma v3 has no setting for hit +// padding; the maintainer-endorsed pattern (#1493, fixed officially in v4 +// as `nodePickingPadding`) is a node program whose vertex shader enlarges +// the disc ONLY in the picking shader pass. + +/** Extra radius (px) added to each node's PICKABLE disc (picking layer + * only — the visible disc is untouched). >= GLOW_PAD so the entire hover + * halo is hit-testable. */ +const PICKING_PAD_PX = 10; + +/** + * The node-circle vertex shader, verbatim from sigma 3.0.3 + * (dist/index-fad77a13.esm.js, SHADER_SOURCE$5) with ONE change: under + * `#ifdef PICKING_MODE` (sigma compiles this source twice — visible render + * vs picking readback — so the visible pass never takes this branch) the + * size gains PICKING_PAD_PX of radius, which is the same on-screen unit as + * `data.size` (the px radius the canvas drawers use for the halo). + */ +const PICKING_VERTEX_SHADER = /* glsl */ ` +attribute vec4 a_id; +attribute vec4 a_color; +attribute vec2 a_position; +attribute float a_size; +attribute float a_angle; + +uniform mat3 u_matrix; +uniform float u_sizeRatio; +uniform float u_correctionRatio; + +varying vec4 v_color; +varying vec2 v_diffVector; +varying float v_radius; +varying float v_border; + +const float bias = 255.0 / 254.0; + +void main() { + float pickedSize = a_size; +#ifdef PICKING_MODE + // Picking pass only: enlarge the hitbox by PICKING_PAD_PX of radius. + pickedSize += ${PICKING_PAD_PX.toFixed(1)}; +#endif + float size = pickedSize * u_correctionRatio / u_sizeRatio * 4.0; + vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle)); + vec2 position = a_position + diffVector; + gl_Position = vec4( + (u_matrix * vec3(position, 1)).xy, + 0, + 1 + ); + + v_diffVector = diffVector; + v_radius = size / 2.0; + +#ifdef PICKING_MODE + // For picking mode, we use the ID as the color: + v_color = a_id; +#else + // For normal mode, we use the color: + v_color = a_color; +#endif + + v_color.a *= bias; +} +`; + +/** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */ +function rgbaFromHex(hex: string, alpha: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Word-wrap for canvas text. Never truncates: overlong single words are + * hard-broken by character so no line ever exceeds maxWidth. */ +function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const lines: string[] = []; + for (const word of text.split(/\s+/).filter(Boolean)) { + const last = lines.length > 0 ? lines[lines.length - 1]! : null; + if (last !== null) { + const joined = `${last} ${word}`; + if (context.measureText(joined).width <= maxWidth) { + lines[lines.length - 1] = joined; + continue; + } + } + let chunk = ""; + for (const char of word) { + if (chunk && context.measureText(`${chunk}${char}`).width > maxWidth) { + lines.push(chunk); + chunk = char; + } else { + chunk += char; + } + } + if (chunk) lines.push(chunk); + } + return lines; +} + +/** The wrapped node name, centered below the node — used by both drawers + * with identical geometry so hover only changes the color, nothing jumps. */ +function drawName( + context: CanvasRenderingContext2D, + data: { label?: string | null; x: number; y: number; size: number }, + settings: { labelSize: number; labelWeight: string; labelFont: string }, + color: string, +): void { + if (!data.label) return; + const size = settings.labelSize; + context.save(); + context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`; + const lines = wrapLabel(context, data.label, LABEL_MAX_WIDTH); + context.fillStyle = color; + context.textAlign = "center"; + context.textBaseline = "top"; + const firstTop = data.y + data.size + LABEL_GAP; + for (let i = 0; i < lines.length; i++) { + context.fillText(lines[i]!, data.x, firstTop + i * size * LABEL_LINE_HEIGHT); + } + context.restore(); +} + +/** The hovered-state paint at a given transition factor (0 = resting, + * 1 = fully hovered): a glow halo in the node's own state color at a + * CONSTANT radius (opacity fades in/out; the halo never grows) starting + * flush at the disc edge (the evenodd punch-out keeps the disc itself + * unpainted). The name is drawn at partial alpha in the bright hover + * color — the muted resting label on the layer beneath shows through, + * so text and halo cross-fade on the SAME factor and stay in sync. */ +function paintHover( + context: CanvasRenderingContext2D, + data: { label?: string | null; x: number; y: number; size: number; color: string }, + settings: { labelSize: number; labelWeight: string; labelFont: string }, + fade: number, + hoverTextColor: string, +): void { + context.save(); + if (fade > 0.02) { + const inner = data.size; + const outer = data.size + GLOW_PAD; + const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer); + glow.addColorStop(0, rgbaFromHex(data.color, GLOW_ALPHA * fade)); + glow.addColorStop(1, rgbaFromHex(data.color, 0)); + context.fillStyle = glow; + context.beginPath(); + context.arc(data.x, data.y, outer, 0, Math.PI * 2); + context.arc(data.x, data.y, inner, 0, Math.PI * 2, true); + context.fill("evenodd"); + } + // Same fade factor as the halo: text and glow rise together. The + // bright name at partial alpha over the muted resting label beneath is + // a smooth muted→bright cross-fade rather than an instant color swap. + context.globalAlpha = fade; + drawName(context, data, settings, hoverTextColor); + context.restore(); +} + +/** Mutable hover-transition state shared between the drawers and the + * effect's enter/leave animation. */ +interface HoverState { + fade: number; + /** Set on every sigma-driven hover paint; the leave animation draws + * through it directly, since sigma stops calling drawHover once the + * mouse has left the node. */ + repaint: ((fade: number) => void) | null; +} + +/** Make the pair of canvas label drawers bound to the theme's colors. */ +function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: HoverState) { + const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => { + drawName(context, data, settings, labelColor); + }; + + const drawHover: NodeHoverDrawingFunction = (context, data, settings) => { + hover.repaint = (fade) => { + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + if (fade > 0) paintHover(context, data, settings, fade, hoverTextColor); + }; + paintHover(context, data, settings, hover.fade, hoverTextColor); + }; + + return { drawLabel, drawHover }; +} + +// ---- Color helpers (WebGL/canvas want hex, not oklch) -------------------- + /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ function cssColorToHex(css: string): string { const canvas = document.createElement("canvas"); @@ -59,14 +309,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 +347,14 @@ export function GraphView() { const edgeColor = tokenHex("--ring"); const labelColor = tokenHex("--muted-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)); } + // 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) { @@ -130,34 +389,141 @@ export function GraphView() { let killed = false; let renderer: import("sigma").default | null = null; - void import("sigma").then(({ default: Sigma }) => { - if (killed) return; - renderer = new Sigma(graph, container, { - defaultEdgeType: "arrow", - labelRenderedSizeThreshold: 12, - labelColor: { color: labelColor }, - labelSize: 12, - renderEdgeLabels: false, - }); - renderer.on("clickNode", ({ node }) => { - const graphNode = snapshot.nodes.find((n) => n.id === node); - if (!graphNode) return; - setSelected({ - node: graphNode, - state: snapshot.nodeStates[node] ?? "candidate", - chatIds: snapshot.nodeSessions[node] ?? [], + // 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 }]) => { + 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, + }; + } + }; + 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)); }); - // Clicking the empty stage closes the node overlay. - renderer.on("clickStage", () => setSelected(null)); - }); return () => { killed = true; + if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); renderer?.kill(); }; }, [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 +544,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.
+ )} +
)} );