MuSProt / frontend /src /components /ForceGraph.tsx
WinslowFan's picture
Claude Opus 5.5
Redesign the MuSProt website for the new database release
dd079af
Raw History Blame Contribute Delete
12 kB
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from 'd3-force';
import { Tooltip, useWidth, type TipState } from './charts';
export interface FGNode {
id: string;
r: number;
fill: string;
/** nodes sharing a group are enclosed by a shaded circle */
group?: string | number;
ring?: boolean;
}
export interface FGLink {
source: string;
target: string;
distance: number;
color: string;
width?: number;
opacity?: number;
/** drawn only while one of its endpoints is hovered */
hidden?: boolean;
}
export interface FGGroup {
key: string | number;
color: string;
label?: string;
}
type SimNode = FGNode & SimulationNodeDatum & { phase: number };
type SimLink = SimulationLinkDatum<SimNode> & FGLink;
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
/**
* Live force-directed graph: nodes keep drifting gently and can be dragged.
* Positions are written to the DOM on every tick (no React re-render per frame).
*/
export function ForceGraph({
nodes,
links,
groups = [],
height = 460,
charge = -40,
linkStrength,
onNodeClick,
nodeTip,
ariaLabel,
}: {
nodes: FGNode[];
links: FGLink[];
groups?: FGGroup[];
height?: number;
charge?: number;
linkStrength?: number;
onNodeClick?: (id: string) => void;
nodeTip?: (id: string) => ReactNode;
ariaLabel: string;
}) {
const [wrapRef, width] = useWidth<HTMLDivElement>();
const svgRef = useRef<SVGSVGElement>(null);
const nodeEls = useRef(new Map<string, SVGCircleElement>());
const linkEls = useRef<Array<SVGLineElement | null>>([]);
const hullEls = useRef(new Map<string | number, SVGGElement>());
const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
const simNodes = useRef<SimNode[]>([]);
const positions = useRef(new Map<string, { x: number; y: number }>());
const [hover, setHover] = useState<string | null>(null);
const [tip, setTip] = useState<TipState | null>(null);
const drag = useRef<{ id: string; moved: boolean; x0: number; y0: number } | null>(null);
const W = Math.max(280, width);
const H = height;
useEffect(() => {
if (!width) return;
const keep = positions.current;
const sn: SimNode[] = nodes.map((n, i) => {
const p = keep.get(n.id);
const a = (i / Math.max(1, nodes.length)) * Math.PI * 2;
return { ...n, phase: Math.random() * Math.PI * 2, x: p?.x ?? W / 2 + Math.cos(a) * 80, y: p?.y ?? H / 2 + Math.sin(a) * 80 };
});
simNodes.current = sn;
const sl: SimLink[] = links.map((l) => ({ ...l }));
const still = reducedMotion();
let t = 0;
// slow, per-node sinusoidal nudge keeps the layout alive
const wander = () => {
if (still) return;
t += 1;
for (const n of sn) {
if (n.fx != null) continue;
n.vx = (n.vx ?? 0) + Math.cos(n.phase + t * 0.013) * 0.035;
n.vy = (n.vy ?? 0) + Math.sin(n.phase * 1.3 + t * 0.011) * 0.035;
}
};
// keep each group compact and push overlapping group circles apart
const hasGroups = groups.length > 0;
const hullR = new Map<string | number, number>();
const cohesion = () => {
if (!hasGroups) return;
const acc = new Map<string | number, { x: number; y: number; n: number; members: SimNode[] }>();
for (const n of sn) {
if (n.group == null) continue;
const a = acc.get(n.group) ?? { x: 0, y: 0, n: 0, members: [] };
a.x += n.x!;
a.y += n.y!;
a.n += 1;
a.members.push(n);
acc.set(n.group, a);
}
const cs = [...acc.entries()].map(([k, a]) => ({ k, x: a.x / a.n, y: a.y / a.n, members: a.members }));
for (const c of cs) {
for (const n of c.members) {
if (n.fx != null) continue;
n.vx = (n.vx ?? 0) + (c.x - n.x!) * 0.035;
n.vy = (n.vy ?? 0) + (c.y - n.y!) * 0.035;
}
}
for (let i = 0; i < cs.length; i++) {
for (let j = i + 1; j < cs.length; j++) {
const a = cs[i];
const b = cs[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1;
const overlap = (hullR.get(a.k) ?? 10) + (hullR.get(b.k) ?? 10) + 10 - d;
if (overlap <= 0) continue;
const push = Math.min(overlap, 30) * 0.02;
const ux = (dx / d) * push;
const uy = (dy / d) * push;
for (const n of a.members) {
if (n.fx != null) continue;
n.vx = (n.vx ?? 0) - ux;
n.vy = (n.vy ?? 0) - uy;
}
for (const n of b.members) {
if (n.fx != null) continue;
n.vx = (n.vx ?? 0) + ux;
n.vy = (n.vy ?? 0) + uy;
}
}
}
};
const strength = linkStrength ?? 0.7 / Math.max(1, Math.sqrt(sn.length));
const sim = forceSimulation<SimNode, SimLink>(sn)
.force('link', forceLink<SimNode, SimLink>(sl).id((d) => d.id).distance((l) => l.distance).strength(strength))
.force('charge', forceManyBody<SimNode>().strength(charge))
.force('x', forceX<SimNode>(W / 2).strength(0.035))
.force('y', forceY<SimNode>(H / 2).strength(0.055))
.force('collide', forceCollide<SimNode>((d) => d.r + 2.5))
.force('wander', wander)
.force('cohesion', cohesion)
.velocityDecay(0.32)
.alphaTarget(still ? 0 : 0.02);
const draw = () => {
for (const n of sn) {
n.x = Math.max(n.r + 4, Math.min(W - n.r - 4, n.x!));
n.y = Math.max(n.r + 4, Math.min(H - n.r - 4, n.y!));
keep.set(n.id, { x: n.x, y: n.y });
const el = nodeEls.current.get(n.id);
if (el) {
el.setAttribute('cx', n.x.toFixed(1));
el.setAttribute('cy', n.y.toFixed(1));
}
}
sl.forEach((l, i) => {
const el = linkEls.current[i];
if (!el) return;
const s = l.source as SimNode;
const d = l.target as SimNode;
el.setAttribute('x1', s.x!.toFixed(1));
el.setAttribute('y1', s.y!.toFixed(1));
el.setAttribute('x2', d.x!.toFixed(1));
el.setAttribute('y2', d.y!.toFixed(1));
});
if (hullEls.current.size) {
const byGroup = new Map<string | number, SimNode[]>();
for (const n of sn) {
if (n.group == null) continue;
const arr = byGroup.get(n.group) ?? [];
arr.push(n);
byGroup.set(n.group, arr);
}
for (const [key, members] of byGroup) {
const g = hullEls.current.get(key);
if (!g) continue;
const cx = members.reduce((s, n) => s + n.x!, 0) / members.length;
const cy = members.reduce((s, n) => s + n.y!, 0) / members.length;
const r = Math.max(...members.map((n) => Math.hypot(n.x! - cx, n.y! - cy) + n.r)) + 9;
hullR.set(key, r);
const c = g.firstElementChild as SVGCircleElement;
c.setAttribute('cx', cx.toFixed(1));
c.setAttribute('cy', cy.toFixed(1));
c.setAttribute('r', r.toFixed(1));
const label = g.lastElementChild as SVGTextElement | null;
if (label && label.tagName === 'text') {
label.setAttribute('x', cx.toFixed(1));
label.setAttribute('y', (cy - r - 4).toFixed(1));
}
}
}
};
sim.on('tick', draw);
if (still) sim.tick(200);
draw();
simRef.current = sim;
return () => {
sim.stop();
simRef.current = null;
};
}, [nodes, links, groups, W, H, width, charge, linkStrength]);
const toLocal = (e: React.PointerEvent) => {
const r = svgRef.current!.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
const onPointerDown = (id: string) => (e: React.PointerEvent<SVGCircleElement>) => {
const n = simNodes.current.find((m) => m.id === id);
if (!n) return;
(e.target as Element).setPointerCapture(e.pointerId);
const p = toLocal(e);
drag.current = { id, moved: false, x0: p.x, y0: p.y };
n.fx = n.x;
n.fy = n.y;
simRef.current?.alphaTarget(0.25).restart();
setTip(null);
};
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
const d = drag.current;
if (!d) return;
const p = toLocal(e);
if (Math.hypot(p.x - d.x0, p.y - d.y0) > 3) d.moved = true;
const n = simNodes.current.find((m) => m.id === d.id);
if (n) {
n.fx = Math.max(n.r, Math.min(W - n.r, p.x));
n.fy = Math.max(n.r, Math.min(H - n.r, p.y));
}
};
const onPointerUp = () => {
const d = drag.current;
if (!d) return;
const n = simNodes.current.find((m) => m.id === d.id);
if (n) {
n.fx = null;
n.fy = null;
}
simRef.current?.alphaTarget(reducedMotion() ? 0 : 0.02);
drag.current = null;
if (!d.moved) onNodeClick?.(d.id);
};
const showTip = (id: string, e: React.PointerEvent | React.MouseEvent) => {
if (drag.current || !nodeTip) return;
const r = svgRef.current!.getBoundingClientRect();
setTip({ x: e.clientX - r.left, y: e.clientY - r.top, content: nodeTip(id) });
};
return (
<div className="fg" ref={wrapRef}>
{width > 0 && (
<svg
ref={svgRef}
width={W}
height={H}
role="img"
aria-label={ariaLabel}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
{groups.length > 0 && (
<g className="fg__hulls">
{groups.map((g) => (
<g
key={g.key}
ref={(el) => {
if (el) hullEls.current.set(g.key, el);
else hullEls.current.delete(g.key);
}}
>
<circle
className="fg__hull"
style={{ fill: g.color, stroke: g.color }}
r={0}
/>
{g.label && (
<text className="fg__hull-label" textAnchor="middle">
{g.label}
</text>
)}
</g>
))}
</g>
)}
<g className="fg__links">
{links.map((l, i) => {
const hot = hover != null && (l.source === hover || l.target === hover);
const visible = !l.hidden || hot;
return (
<line
key={`${l.source}-${l.target}`}
ref={(el) => {
linkEls.current[i] = el;
}}
stroke={l.color}
strokeWidth={hot ? (l.width ?? 1) + 0.8 : l.width ?? 1}
strokeOpacity={!visible ? 0 : hover == null ? l.opacity ?? 0.4 : hot ? 0.95 : 0.05}
/>
);
})}
</g>
<g className="fg__nodes">
{nodes.map((n) => (
<circle
key={n.id}
ref={(el) => {
if (el) nodeEls.current.set(n.id, el);
else nodeEls.current.delete(n.id);
}}
r={hover === n.id ? n.r + 2 : n.r}
fill={n.fill}
className={`fg__node ${n.ring ? 'is-ring' : ''}`}
onPointerDown={onPointerDown(n.id)}
onPointerEnter={(e) => {
setHover(n.id);
showTip(n.id, e);
}}
onPointerMove={(e) => showTip(n.id, e)}
onPointerLeave={() => {
setHover(null);
setTip(null);
}}
/>
))}
</g>
</svg>
)}
<Tooltip tip={tip} />
</div>
);
}