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]);