•6 min read

Voronoi Proximity Picking & Tooltip Repulsion in Graphs

Bounded Voronoi Proximity Picking replaces fragile 8px geometry hit tests with unified 24px mouse and 36px touch capture radii across five heterogeneous graph engines [Fit54] [GB05]. Centralizing pointer arbitration in vis-tooltip.tsx unifies hover, two-step touch tap, 8px deadzone dragging, and 8-sector tooltip repulsion across SVG, Canvas, and WebGL [BOH11].

Table
Graph EngineScreen Projection (getScreenNodes)Native Drag SuppressionInverse Drag Mapping (onNodeDragMove)
d3/engine.tsd3.zoomTransform(svg).apply([x, y]) [BOH11]Omitted d3.drag() behavior on <g> nodeszoomT.invert([sx, sy]) via screenToGraph
vis-network/engine.tsnetwork.canvasToDOM(positions[id])interaction: { dragNodes: false, hover: false }network.DOMtoCanvas({ x, y }) + moveNode()
cytoscape/engine.tsnode.renderedPosition()autoungrabify: true, autounselectify: true(s - pan) / zoom via renderedToModel
sigma/engine.tssigma.graphToViewport({ x, y })Custom pointer capture overrides camera pansigma.viewportToGraph({ x, y })
3d-force-graph/engine.tsgraph.graph2ScreenCoords(x, y, z).enableNodeDrag(false) + controls.enabled = falsegraph.screen2GraphCoords(sx, sy, dragDepth)

Decoupling target acquisition from renderer-specific hit buffers guarantees identical pointer ergonomics across 2D and 3D network layouts.

Why Do Exact 8px Hit Tests Fail Fitts’s Law on Mouse and Touch?

Exact 8px node hit tests fail Fitts’s Law because tiny visual targets inflate the index of difficulty ID=log⁡2(1+D/W)\text{ID} = \log_2(1 + D/W) during rapid cursor movement and finger touch [Fit54]. By evaluating bounded Voronoi cells (24px mouse and 36px touch radii) in findNearest, we unify hover, tap, and drag acquisition [GB05].

SVG
VORONOI PROXIMITY CAPTURE VS. EXACT HIT Bounded Area Cursors Lower Fitts Index of Difficulty Across 5 Engines EXACT 8PX HIT W = 16px (High ID) Missed Grab Hover != Drag VORONOI PICKING 24px Mouse / 36px Touch Unified Capture findNearest(px, py, r) Hover, Tap & Drag Share One Voronoi Solver

Under Fitts’s formulation ID=log⁡2(1+D/W)\text{ID} = \log_2(1 + D/W), an 8px node radius (W=16pxW = 16\text{px}) forces users to decelerate sharply at the end of a ballistic movement, while human fingertips occlude the target completely [Fit54]. Area cursors and the Bubble Cursor [GB05] resolve this bottleneck by expanding the effective target width WW to the nearest Voronoi cell boundary in screen space (MOUSE_PROXIMITY_RADIUS = 24 and TOUCH_PROXIMITY_RADIUS = 36).

Crucially, early iterations only applied proximity picking to hover previews while leaving node dragging wired to each library’s native 8px geometry picker. That created a jarring interaction mismatch: a user could highlight a node from 20px away, press the mouse button or touch the screen to drag it, and grab empty air because the native drag handler required an exact 8px hit. We eliminated this split by disabling native node dragging across all five engines—omitting d3.drag() in d3/engine.ts [BOH11], setting dragNodes: false in vis-network/engine.ts, setting autoungrabify: true in cytoscape/engine.ts, bypassing camera pan in sigma/engine.ts, and calling .enableNodeDrag(false) in 3d-force-graph/engine.ts—so bindProximityInteraction in vis-tooltip.tsx drives hover, tap, and drag from the exact same findNearest query.

How Does an 8px Drag Deadzone Reconcile Touch Scrolling with Node Dragging?

Mobile browsers abort pointer streams with pointercancel after roughly 5px of vertical movement whenever touch-action: pan-y is enabled for page scrolling. We reconcile native vertical scrolling, two-step touch taps, and single-finger node dragging by combining DRAG_DEADZONE_PX = 8, selective e.preventDefault() on non-passive touchmove, and an 800ms synthetic mouse guard in vis-tooltip.tsx.

SVG
8PX TOUCH DEADZONE & GESTURE ARBITRATION Reconciling pan-y Page Scroll, 2-Step Tap, and Single-Finger Drag DRAG_DEADZONE = 8px No Hit (>8px) Move <= 8px Hit (>8px) Native Scroll pan-y Active 2-Step Tap 1st Show / 2nd Go Node Drag preventDefault() 800ms lastTouchTime Guard Blocks Synthetic Mouse

Supporting three distinct touch intents on a single canvas requires careful event arbitration. When a finger touches empty canvas outside the 36px Voronoi radius (dragCandidate === null), we never cancel touchmove, allowing the browser’s compositor to scroll the page smoothly via touch-action: pan-y. When a finger lands inside a node’s 36px Voronoi cell, onTouchStart records dragCandidate; if movement remains within DRAG_DEADZONE_PX = 8 on release, handleTouchTap executes our two-step tap flow (first tap reveals the tooltip and ego-network highlight; second tap on the active node or tooltip card navigates to the target URL). Once displacement exceeds 8px with an active dragCandidate, the non-passive touchmove listener calls e.preventDefault() to lock out browser scrolling and streams inverse coordinates to onNodeDragMove:

TypeScript
// src/commons/client/vis-tooltip.tsx — 8px deadzone & selective touch scroll lock
if (Math.hypot(tx - downX, ty - downY) > DRAG_DEADZONE_PX) {
  dragMoved = true;
  if (dragCandidate && config.onNodeDragStart) {
    if (e.cancelable) {
      e.preventDefault();
    }
    if (!customDragActive) {
      customDragActive = true;
      hideInternal();
      config.onActiveNodeChange(dragCandidate.id);
      config.onNodeDragStart(dragCandidate, tx, ty);
    }
    config.onNodeDragMove?.(dragCandidate, tx, ty);
    return;
  }
}

In 3d-force-graph/engine.ts, onNodeDragStart also sets controls.enabled = false so single-finger node dragging does not simultaneously orbit the Three.js camera, restoring controls.enabled = true on onNodeDragEnd. Furthermore, because mobile browsers synthesize compatibility pointerdown, pointermove, and pointerup events (pointerType === 'mouse') hundreds of milliseconds after a touch tap, bindProximityInteraction records lastTouchTime = performance.now() and ignores non-touch events for 800ms so synthetic mouse callbacks never immediately re-trigger navigation.

How Does 8-Sector Angular Repulsion Prevent Tooltip Occlusion?

Static cursor-offset tooltips frequently cover the focused node’s 1-hop neighbors, active ego-network edges, or container controls. Our computeBestPosition solver in vis-tooltip.tsx evaluates eight compass candidate slots (gap = 22px) around the active node, minimizing a composite cost function over viewport boundaries, UI chrome rects, and incident neighbor edge rays.

SVG
8-SECTOR COMPASS TOOLTIP REPULSION Penalizing Incident 1-Hop Rays, UI Chrome & Viewport Bounds E (Ray) SE (Ray) S (Occupied) SW W NE Optimal Sector minPenalty + Clamped Focus Node 750ms Sine Opacity Fade + 3D Orbit Dismiss

When showInternal renders the Preact VisNetworkTooltip card, findNearest supplies the screen coordinates (x,y)(x, y) of the focused node alongside the projected screen coordinates {(xi,yi)}\{(x_i, y_i)\} of all connected 1-hop neighbors. For each of the eight compass candidates c∈{N, NE, NW, E, W, SE, SW, S}c \in \{\text{N, NE, NW, E, W, SE, SW, S}\} with center (cx,cy)(c_x, c_y) and direction vector u⃗c=(cx−x,cy−y)\vec{u}_c = (c_x - x, c_y - y), computeBestPosition accumulates four penalty terms:

  • Directional Preference & Viewport Overflow: Assigns a small top-first tie-breaking bias (0 for N up to 8 for S) and adds 2000 + 25 * overflowPx whenever a candidate box breaches the 12px container padding.
  • UI Chrome Collision Avoidance: Queries the live bounding rectangles of the domain legend (.z-20:not(#layout-control-container)) and layout switcher (#layout-control-container), adding 1800 if rectsOverlap detects an intersection.
  • Edge Midpoint & Neighbor Proximity: Samples three points along each incident 1-hop edge (t∈{0.35,0.68,1.0}t \in \{0.35, 0.68, 1.0\} with weights 220, 220, and 420), penalizing both bounding-box overlap (margin = 14px) and radial proximity within 130px.
  • Cosine Angular Alignment: Computes the directional cosine similarity cos⁡(Δθi)=(u⃗c⋅e⃗i)/(∥u⃗c∥∥e⃗i∥)\cos(\Delta\theta_i) = (\vec{u}_c \cdot \vec{e}_i) / (\|\vec{u}_c\| \|\vec{e}_i\|) against each incident edge vector e⃗i=(xi−x,yi−y)\vec{e}_i = (x_i - x, y_i - y) and adds 160cos⁡(Δθi)160 \cos(\Delta\theta_i) whenever cos⁡(Δθi)>0.2\cos(\Delta\theta_i) > 0.2.

Selecting the minimum-penalty sector and clamping its coordinates within [pad, clientDim - tooltipDim - pad] anchors the card cleanly in the most open angular sector without cursor-tracking jitter. Finally, animateOpacityTo interpolates tooltip opacity over HIGHLIGHT_TRANSITION_MS = 750 in lockstep with the graph’s ego-network highlight transition, while 3d-force-graph/engine.ts fades the tooltip out immediately if the user orbits the 3D camera—completing the interactive architecture alongside Focus-Aware Hop Attenuation, Five Graph Engines Unified Interaction Physics, 2D Elliptical Bin-Packing Tag Clouds, and our Zero-innerHTML Preact UI Islands across the live D3.js, Vis-Network, and 3D Force-Graph canvases.

References

  • [Fit54]
    Paul Morris Fitts (1954). The information capacity of the human motor system in controlling the amplitude of movement. Journal of Experimental Psychology, 47(6), pp. 381--391. DOI: 10.1037/h0055392.
  • [GB05]
    Tovi Grossman and Ravin Balakrishnan (2005). The bubble cursor: enhancing target acquisition by dynamic resizing of the cursor's activation area. In Proceedings of the SIGCHI Conference on Human Factors in Computing Systems (CHI '05), pp. 281--290. DOI: 10.1145/1054972.1055012.
  • [BOH11]
    Michael Bostock, Vadim Ogievetsky and Jeffrey Heer (2011). D3: Data-Driven Documents. IEEE Transactions on Visualization and Computer Graphics, 17(12), pp. 2301--2309. DOI: 10.1109/TVCG.2011.185.