Download frontend/src/components/ForceGraph.tsx from omaib/MuSProt: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/omaib/MuSProt/resolve/main/frontend/src/components/ForceGraph.tsx
- Command line
-
hf download hf://spaces/omaib/MuSProt/frontend/src/components/ForceGraph.tsx
-
curl -L -o ForceGraph.tsx https://huggingface.co/spaces/omaib/MuSProt/resolve/main/frontend/src/components/ForceGraph.tsx
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> | |
| ); | |
| } | |