Revert "Fix hover flicker: padded smooth hit-testing + leave-debounce"
This reverts commit f12480e040.
This commit is contained in:
+9
-113
@@ -24,12 +24,6 @@
|
|||||||
* sync (no growth, no pulse; interpolating from the current value
|
* sync (no growth, no pulse; interpolating from the current value
|
||||||
* means rapid hover toggles glide instead of snapping). Hover
|
* means rapid hover toggles glide instead of snapping). Hover
|
||||||
* always shows the name, even when labels are zoomed out.
|
* 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
|
* 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
|
* 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 —
|
* together across a short zoom span before label rendering switches off —
|
||||||
@@ -89,15 +83,6 @@ const LABELS_FADE_SPAN = 0.5;
|
|||||||
const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant)
|
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 GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward
|
||||||
const HOVER_FADE_MS = 180; // hover halo opacity fade length
|
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). */
|
/** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */
|
||||||
function rgbaFromHex(hex: string, alpha: number): string {
|
function rgbaFromHex(hex: string, alpha: number): string {
|
||||||
@@ -222,52 +207,6 @@ function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: Hov
|
|||||||
return { drawLabel, drawHover };
|
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) --------------------
|
// ---- Color helpers (WebGL/canvas want hex, not oklch) --------------------
|
||||||
|
|
||||||
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
||||||
@@ -373,13 +312,8 @@ export function GraphView() {
|
|||||||
// rAF id of the hover fade transition — the ONLY animation (see the
|
// rAF id of the hover fade transition — the ONLY animation (see the
|
||||||
// file header). Lives out here so the effect cleanup can cancel it.
|
// file header). Lives out here so the effect cleanup can cancel it.
|
||||||
let hoverAnimId: number | null = null;
|
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 }) => {
|
void import("sigma").then(({ default: Sigma }) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
// Bigger, smoother hitboxes before the instance starts listening.
|
|
||||||
patchNodePicking(Sigma);
|
|
||||||
renderer = new Sigma(graph, container, {
|
renderer = new Sigma(graph, container, {
|
||||||
defaultEdgeType: "arrow",
|
defaultEdgeType: "arrow",
|
||||||
// All labels appear/disappear together: no per-node size gating
|
// All labels appear/disappear together: no per-node size gating
|
||||||
@@ -431,23 +365,16 @@ export function GraphView() {
|
|||||||
syncRenderLabels(state.ratio);
|
syncRenderLabels(state.ratio);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
});
|
});
|
||||||
// Hover fade + leave-debounce. Entering schedules renders (sigma
|
// The hover fade: entering schedules renders (sigma re-invokes
|
||||||
// re-invokes drawHover at the new fade). Leaving does NOT fade
|
// drawHover with the new fade); leaving draws through the captured
|
||||||
// immediately: sigma clears the hover layer the moment its
|
// repainter, since sigma stops calling drawHover once unhovered.
|
||||||
// hoveredNode flips to null, so we (1) keep repainting the
|
// Both directions are bounded (~HOVER_FADE_MS) and cancel each
|
||||||
// captured paint at the current fade (holdHoverPaint) through a
|
// other, so idle CPU returns to zero after every transition.
|
||||||
// short grace period, and only (2) fade out if the cursor hasn't
|
const animateHoverTo = (target: number) => {
|
||||||
// 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) {
|
if (hoverAnimId !== null) {
|
||||||
cancelAnimationFrame(hoverAnimId);
|
cancelAnimationFrame(hoverAnimId);
|
||||||
hoverAnimId = null;
|
hoverAnimId = null;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
const animateHoverTo = (target: number) => {
|
|
||||||
stopHoverLoop();
|
|
||||||
const from = hover.fade;
|
const from = hover.fade;
|
||||||
if (from === target) {
|
if (from === target) {
|
||||||
if (target === 0) hover.repaint?.(0);
|
if (target === 0) hover.repaint?.(0);
|
||||||
@@ -463,45 +390,14 @@ export function GraphView() {
|
|||||||
};
|
};
|
||||||
hoverAnimId = requestAnimationFrame(tick);
|
hoverAnimId = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
const holdHoverPaint = () => {
|
renderer.on("enterNode", () => animateHoverTo(1));
|
||||||
stopHoverLoop();
|
renderer.on("leaveNode", () => animateHoverTo(0));
|
||||||
const tick = () => {
|
renderer.on("leaveStage", () => animateHoverTo(0));
|
||||||
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 () => {
|
return () => {
|
||||||
killed = true;
|
killed = true;
|
||||||
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
||||||
if (hoverLeaveTimer !== null) clearTimeout(hoverLeaveTimer);
|
|
||||||
renderer?.kill();
|
renderer?.kill();
|
||||||
};
|
};
|
||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|||||||
Reference in New Issue
Block a user