Files
learnharness/components/graph-view.tsx
T
amosblomqvist f12480e040 Fix hover flicker: padded smooth hit-testing + leave-debounce
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.
2026-09-02 02:43:13 +03:00

587 lines
24 KiB
TypeScript

"use client";
/**
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
*
* sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous
* forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect
* writes x/y. No layout animation ever starts; the ONLY animation is the
* short hover fade (enter/leave transitions), strictly bounded in time
* and cancelled on unmount — idle CPU stays near zero. Physics is
* placement only: pan/zoom, no dragging, no editing.
*
* Labels render the node `name` (the 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.
* 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 —
* 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, 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). */
const STATE_TOKENS: Record<NodeStateClass, { cssVar: string; size: number }> = {
taught: { cssVar: "--foreground", size: 10 },
known: { cssVar: "--primary", size: 8 },
missed: { cssVar: "--destructive", size: 8 },
"dont-know": { cssVar: "--muted-foreground", size: 6.5 },
candidate: { cssVar: "--ring", size: 5 },
};
const STATE_LABELS: Record<NodeStateClass, string> = {
taught: "taught",
known: "known",
missed: "missed",
"dont-know": "don't know",
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
/** 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 {
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 };
}
// ---- 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<string> };
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. */
function cssColorToHex(css: string): string {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext("2d");
if (!ctx) return "#888888";
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
const { data } = ctx.getImageData(0, 0, 1, 1);
return `#${[data[0]!, data[1]!, data[2]!]
.map((v) => v.toString(16).padStart(2, "0"))
.join("")}`;
}
function tokenHex(cssVar: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
return value ? cssColorToHex(value) : "#888888";
}
// ---- Component ------------------------------------------------------------
interface Selection {
node: GraphNode;
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<HTMLDivElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [selected, setSelected] = useState<Selection | null>(null);
// Fetch the derived graph (the route folds stale chats first).
useEffect(() => {
let cancelled = false;
fetch("/api/graph")
.then((res) => res.json() as Promise<GraphSnapshot>)
.then((data) => {
if (!cancelled) setSnapshot(data);
})
.catch((err) => console.error("[graph] fetch failed:", err));
return () => {
cancelled = true;
};
}, []);
// Build the graphology graph, lay it out once, mount sigma.
useEffect(() => {
if (!snapshot || snapshot.nodes.length === 0) return;
const container = containerRef.current;
if (!container) return;
const edgeColor = tokenHex("--ring");
const labelColor = tokenHex("--muted-foreground");
const hoverTextColor = tokenHex("--foreground");
const stateColors = new Map<NodeStateClass, string>();
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) {
const state: NodeStateClass = snapshot.nodeStates[node.id] ?? "candidate";
// Seeded start positions (FA2 requires non-zero x/y) keep the
// frozen layout stable for a given node set across remounts.
const rand = seededRandom(node.id);
graph.addNode(node.id, {
label: node.name,
size: STATE_TOKENS[state].size,
color: stateColors.get(state) ?? edgeColor,
x: (rand() - 0.5) * 100,
y: (rand() - 0.5) * 100,
});
}
for (const edge of snapshot.edges) {
// Snapshot edges are already merged by (from,to,type); the guard
// is just belt-and-braces for the simple graphology graph.
if (!graph.hasEdge(edge.from, edge.to)) {
graph.addEdge(edge.from, edge.to, { type: "arrow", color: edgeColor, size: 1 });
}
}
// Run-then-freeze: one synchronous pass, writes x/y, then done.
// Barnes-Hut comes on automatically above ~500 nodes (the vision's
// first escalation rung — see scripts/bench-graph.ts for the numbers).
const settings = {
...forceAtlas2.inferSettings(graph),
...(graph.order > 500 ? { barnesHutOptimize: true } : {}),
};
forceAtlas2.assign(graph, { iterations: 100, settings });
let killed = false;
let renderer: import("sigma").default | null = null;
// rAF id of the hover fade transition — the ONLY animation (see the
// file header). Lives out here so the effect cleanup can cancel it.
let hoverAnimId: number | null = null;
// Leave-debounce timer (see HOVER_LEAVE_DELAY_MS): holds the hover
// paint briefly before fading, so boundary bounces never dim it.
let hoverLeaveTimer: ReturnType<typeof setTimeout> | 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
// (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);
});
// 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);
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);
};
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]);
// Position the detail card: below the clicked node, flipped above near
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
// before paint) because the flip needs the card's measured height.
useLayoutEffect(() => {
const card = cardRef.current;
const container = containerRef.current;
if (!selected || !card || !container) return;
const width = card.offsetWidth;
const height = card.offsetHeight;
const margin = 8;
const gap = 12;
const left = Math.min(
Math.max(selected.anchor.x - width / 2, margin),
Math.max(container.clientWidth - width - margin, margin),
);
let top = selected.anchor.y + gap;
if (top + height > container.clientHeight - margin) {
top = Math.max(selected.anchor.y - gap - height, margin);
}
card.style.left = `${left}px`;
card.style.top = `${top}px`;
card.style.visibility = "visible";
}, [selected]);
const isEmpty = snapshot === null || snapshot.nodes.length === 0;
return (
<div className="relative h-full w-full">
{isEmpty ? (
// The original home placeholder — kept verbatim for the empty graph.
<div className="flex h-full w-full items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<p className="max-w-sm text-sm">
Every session you complete will map what you know — and where the edge of your
understanding is — right here.
</p>
</div>
</div>
) : (
<div ref={containerRef} className="h-full w-full" />
)}
{selected && !isEmpty && (
<div
ref={cardRef}
// Positioned imperatively in the layout effect above; hidden
// until placed so the flip never flashes in the wrong spot.
style={{ visibility: "hidden", left: 0, top: 0 }}
className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
>
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
{STATE_LABELS[selected.state]}
</div>
{selected.chatIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
{selected.chatIds.map((chatId) => (
<Link
key={chatId}
href={`/chat/${chatId}`}
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
>
{snapshot?.sessions[chatId] ?? chatId}
</Link>
))}
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
)}
</div>
)}
</div>
);
}