From f12480e040e342adfbb0f89b8b4d87b016a3acea Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 2 Sep 2026 02:43:13 +0300 Subject: [PATCH] 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]);