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.