Hover glow: static (no pulse), fade-in/out transition, flush with disc edge
- The glow no longer pulses: it's a static halo (9px beyond the disc, 0.45 alpha at the edge fading outward) in the node's state color. - Hover enter/leave now cross-fades over ~180ms instead of appearing instantly: entering schedules sigma renders so drawHover repaints at the interpolated fade; leaving draws through a repainter captured on the last hover paint (sigma stops calling drawHover once unhovered). The text cross-fades too — the brighter hover name is drawn at partial alpha over the muted resting label on the layer beneath. - The halo starts flush at the node disc edge (was size+1, leaving a dark gap ring that read as a gray border between node and glow). - Both fade directions cancel each other and stop on completion, so there is no continuous loop — idle CPU stays near zero.
This commit is contained in:
+93
-51
@@ -5,20 +5,22 @@
|
|||||||
*
|
*
|
||||||
* sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous
|
* sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous
|
||||||
* forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect
|
* forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect
|
||||||
* writes x/y. No layout animation ever starts; the ONLY animation loop
|
* writes x/y. No layout animation ever starts; the ONLY animation is the
|
||||||
* is the hover glow pulse, which runs strictly while a node is hovered
|
* short hover fade (enter/leave transitions), strictly bounded in time
|
||||||
* and stops on leave — idle CPU stays near zero. Physics is placement
|
* and cancelled on unmount — idle CPU stays near zero. Physics is
|
||||||
* only: pan/zoom, no dragging, no editing.
|
* placement only: pan/zoom, no dragging, no editing.
|
||||||
*
|
*
|
||||||
* Labels render the node `name` (the short header, never the statement)
|
* Labels render the node `name` (the short header, never the statement)
|
||||||
* through custom canvas drawers that replace sigma's defaults:
|
* through custom canvas drawers that replace sigma's defaults:
|
||||||
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
||||||
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
||||||
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
||||||
* hardcoded white one). Instead the hovered node pulses a soft glow
|
* hardcoded white one). Instead the hovered node gets a STATIC soft
|
||||||
* halo in its own state color and its name brightens — the "this is
|
* glow halo in its own state color, flush with the disc edge, and
|
||||||
* clickable, click for the full fact" affordance. Hover always shows
|
* its name brightens — the "this is clickable, click for the full
|
||||||
* the name, even when labels are zoomed out.
|
* fact" affordance. Entering/leaving hover cross-fades (glow and text
|
||||||
|
* together) over a short transition; the glow itself never pulses.
|
||||||
|
* Hover always shows the name, even when labels are zoomed out.
|
||||||
* 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 —
|
||||||
@@ -71,14 +73,13 @@ const LABELS_CAMERA_RATIO = 1;
|
|||||||
/** Zoom span (camera-ratio units) over which labels fade out together. */
|
/** Zoom span (camera-ratio units) over which labels fade out together. */
|
||||||
const LABELS_FADE_SPAN = 0.5;
|
const LABELS_FADE_SPAN = 0.5;
|
||||||
|
|
||||||
// Hover glow: a soft halo ring (never over the node disc), pulsing in the
|
// Hover glow: a STATIC soft halo ring in the node's own color, starting
|
||||||
// node's own color. The phase comes from performance.now() inside the
|
// flush at the disc edge (no gap — a gap reads as a false outline/border
|
||||||
// drawer, so the effect only has to keep scheduling renders while hovered.
|
// between node and glow). The hovered/unhovered transition cross-fades
|
||||||
const GLOW_PAD_MIN = 4; // px beyond the node disc at the pulse's dimmest
|
// glow + text over a short window; the glow never pulses.
|
||||||
const GLOW_PAD_SWING = 6; // extra px at the pulse's peak
|
const GLOW_PAD = 9; // px the halo extends beyond the node disc
|
||||||
const GLOW_ALPHA_MIN = 0.18;
|
const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward
|
||||||
const GLOW_ALPHA_SWING = 0.3;
|
const HOVER_FADE_MS = 180; // hover enter/leave transition length
|
||||||
const GLOW_PULSE_MS = 1600; // one full pulse cycle
|
|
||||||
|
|
||||||
/** "#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 {
|
||||||
@@ -138,31 +139,62 @@ function drawName(
|
|||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Make the pair of canvas label drawers bound to the theme's colors. */
|
/** The hovered-state paint at a given transition factor (0 = resting,
|
||||||
function makeLabelDrawers(labelColor: string, hoverTextColor: string) {
|
* 1 = fully hovered): a static glow halo in the node's own state color
|
||||||
const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => {
|
* starting flush at the disc edge (the evenodd punch-out keeps the disc
|
||||||
drawName(context, data, settings, labelColor);
|
* itself unpainted), plus the name at partial alpha — the muted label on
|
||||||
};
|
* the layer beneath shows through, so the text cross-fades toward the
|
||||||
|
* brighter hover color as the glow blooms. */
|
||||||
const drawHover: NodeHoverDrawingFunction = (context, data, settings) => {
|
function paintHover(
|
||||||
context.save();
|
context: CanvasRenderingContext2D,
|
||||||
// Pulsing glow halo in the node's own state color: a radial-gradient
|
data: { label?: string | null; x: number; y: number; size: number; color: string },
|
||||||
// ring (the evenodd punch-out keeps the node disc itself untouched).
|
settings: { labelSize: number; labelWeight: string; labelFont: string },
|
||||||
const pulse = 0.5 + 0.5 * Math.sin((performance.now() / GLOW_PULSE_MS) * Math.PI * 2);
|
fade: number,
|
||||||
const inner = data.size + 1;
|
hoverTextColor: string,
|
||||||
const outer = data.size + GLOW_PAD_MIN + pulse * GLOW_PAD_SWING;
|
): void {
|
||||||
const alpha = GLOW_ALPHA_MIN + pulse * GLOW_ALPHA_SWING;
|
context.save();
|
||||||
|
if (fade > 0.02) {
|
||||||
|
const inner = data.size;
|
||||||
|
const outer = data.size + GLOW_PAD * fade;
|
||||||
const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer);
|
const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer);
|
||||||
glow.addColorStop(0, rgbaFromHex(data.color, alpha));
|
glow.addColorStop(0, rgbaFromHex(data.color, GLOW_ALPHA * fade));
|
||||||
glow.addColorStop(1, rgbaFromHex(data.color, 0));
|
glow.addColorStop(1, rgbaFromHex(data.color, 0));
|
||||||
context.fillStyle = glow;
|
context.fillStyle = glow;
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(data.x, data.y, outer, 0, Math.PI * 2);
|
context.arc(data.x, data.y, outer, 0, Math.PI * 2);
|
||||||
context.arc(data.x, data.y, inner, 0, Math.PI * 2, true);
|
context.arc(data.x, data.y, inner, 0, Math.PI * 2, true);
|
||||||
context.fill("evenodd");
|
context.fill("evenodd");
|
||||||
// Brighter name, no background box — the clickable affordance.
|
}
|
||||||
drawName(context, data, settings, hoverTextColor);
|
context.globalAlpha = fade;
|
||||||
context.restore();
|
drawName(context, data, settings, hoverTextColor);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable hover-transition state shared between the drawers and the
|
||||||
|
* effect's enter/leave animation. */
|
||||||
|
interface HoverState {
|
||||||
|
fade: number;
|
||||||
|
/** Set on every sigma-driven hover paint; the leave animation draws
|
||||||
|
* through it directly, since sigma stops calling drawHover once the
|
||||||
|
* mouse has left the node. */
|
||||||
|
repaint: ((fade: number) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Make the pair of canvas label drawers bound to the theme's colors. */
|
||||||
|
function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: HoverState) {
|
||||||
|
const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => {
|
||||||
|
drawName(context, data, settings, labelColor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawHover: NodeHoverDrawingFunction = (context, data, settings) => {
|
||||||
|
hover.repaint = (fade) => {
|
||||||
|
context.save();
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
||||||
|
context.restore();
|
||||||
|
if (fade > 0) paintHover(context, data, settings, fade, hoverTextColor);
|
||||||
|
};
|
||||||
|
paintHover(context, data, settings, hover.fade, hoverTextColor);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { drawLabel, drawHover };
|
return { drawLabel, drawHover };
|
||||||
@@ -233,7 +265,9 @@ export function GraphView() {
|
|||||||
for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) {
|
for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) {
|
||||||
stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar));
|
stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar));
|
||||||
}
|
}
|
||||||
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor);
|
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
|
||||||
|
const hover: HoverState = { fade: 0, repaint: null };
|
||||||
|
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
|
||||||
|
|
||||||
const graph = new Graph();
|
const graph = new Graph();
|
||||||
for (const node of snapshot.nodes) {
|
for (const node of snapshot.nodes) {
|
||||||
@@ -268,7 +302,7 @@ export function GraphView() {
|
|||||||
|
|
||||||
let killed = false;
|
let killed = false;
|
||||||
let renderer: import("sigma").default | null = null;
|
let renderer: import("sigma").default | null = null;
|
||||||
// rAF id of the hover glow pulse (the ONLY animation loop — 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;
|
||||||
void import("sigma").then(({ default: Sigma }) => {
|
void import("sigma").then(({ default: Sigma }) => {
|
||||||
@@ -324,26 +358,34 @@ export function GraphView() {
|
|||||||
syncRenderLabels(state.ratio);
|
syncRenderLabels(state.ratio);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
});
|
});
|
||||||
// The hover glow is a slow pulse driven by performance.now() inside
|
// The hover fade: entering schedules renders (sigma re-invokes
|
||||||
// drawHover; this loop just keeps scheduling renders while a node is
|
// drawHover with the new fade); leaving draws through the captured
|
||||||
// hovered and stops on leave, so idle CPU returns to zero.
|
// repainter, since sigma stops calling drawHover once unhovered.
|
||||||
const startHoverPulse = () => {
|
// Both directions are bounded (~HOVER_FADE_MS) and cancel each
|
||||||
if (hoverAnimId !== null) return;
|
// other, so idle CPU returns to zero after every transition.
|
||||||
const tick = () => {
|
const animateHoverTo = (target: number) => {
|
||||||
renderer?.scheduleRefresh();
|
|
||||||
hoverAnimId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
hoverAnimId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
const stopHoverPulse = () => {
|
|
||||||
if (hoverAnimId !== null) {
|
if (hoverAnimId !== null) {
|
||||||
cancelAnimationFrame(hoverAnimId);
|
cancelAnimationFrame(hoverAnimId);
|
||||||
hoverAnimId = null;
|
hoverAnimId = null;
|
||||||
}
|
}
|
||||||
|
const from = hover.fade;
|
||||||
|
if (from === target) {
|
||||||
|
if (target === 0) hover.repaint?.(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min((now - start) / HOVER_FADE_MS, 1);
|
||||||
|
hover.fade = from + (target - from) * t;
|
||||||
|
if (target === 0) hover.repaint?.(hover.fade);
|
||||||
|
else renderer?.scheduleRefresh();
|
||||||
|
hoverAnimId = t < 1 ? requestAnimationFrame(tick) : null;
|
||||||
|
};
|
||||||
|
hoverAnimId = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
renderer.on("enterNode", startHoverPulse);
|
renderer.on("enterNode", () => animateHoverTo(1));
|
||||||
renderer.on("leaveNode", stopHoverPulse);
|
renderer.on("leaveNode", () => animateHoverTo(0));
|
||||||
renderer.on("leaveStage", stopHoverPulse);
|
renderer.on("leaveStage", () => animateHoverTo(0));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user