import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type ForceCollide, type ForceLink, type ForceManyBody, type Simulation, type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; /** * Self-contained SVG force graph (issue #112). Only `d3-force` is bundled — * no d3 DOM/zoom modules, no external requests: zoom/pan/drag are plain * pointer math. SVG over canvas deliberately: nodes carry data-testids, so * the e2e packs can click them. Shared by the pond graph view and the local * panel (#113). * * Since #123 the simulation stays ALIVE: dragging a node pins it (fx/fy) and * reheats the physics, so neighbors get pulled along like in Obsidian, and * the whole graph settles back to rest (alpha decays to zero — important for * Playwright's element-stability wait). React renders the SVG structure; * per-frame positions are written imperatively into the element refs so a * tick never rebuilds the React tree. */ export interface ForceGraphNode { /** Stable id — page id or a `phantom:` key. */ id: string; label: string; color: string; /** Phantom targets render dashed and muted. */ dashed?: boolean; /** The current page in the local graph gets a highlight ring. */ highlight?: boolean; /** Hooks the e2e packs click on. */ testId: string; } export interface ForceGraphEdge { from: string; to: string; } /** User-tunable physics and rendering parameters (#123). */ export interface ForceGraphSettings { /** Node repulsion — many-body strength, kept positive for the UI. */ repulsion: number; /** Link pull factor; 1 matches d3's degree-scaled default. */ attraction: number; nodeRadius: number; fontSize: number; } /** Defaults match the former hardcoded values (charge −160, r 8, 11px). */ export const GRAPH_SETTINGS_DEFAULTS: ForceGraphSettings = { repulsion: 160, attraction: 1, nodeRadius: 8, fontSize: 11, }; interface SimNode extends SimulationNodeDatum { id: string; } type SimLink = SimulationLinkDatum; const MIN_ZOOM = 0.2; const MAX_ZOOM = 5; const LINK_DISTANCE = 70; const edgeKey = (edge: ForceGraphEdge): string => `${edge.from}→${edge.to}`; export function ForceGraph({ nodes, edges, width = 800, height = 560, onNodeClick, settings = GRAPH_SETTINGS_DEFAULTS, }: { nodes: ForceGraphNode[]; edges: ForceGraphEdge[]; width?: number; height?: number; onNodeClick?: (id: string) => void; settings?: ForceGraphSettings; }): React.JSX.Element { const { t } = useTranslation('graph'); const [view, setView] = useState({ k: 1, tx: 0, ty: 0 }); /** Last known positions — read by React renders, written by sim ticks. */ const positionsRef = useRef(new Map()); const nodeElsRef = useRef(new Map()); const edgeElsRef = useRef(new Map()); const simRef = useRef | null>(null); const simNodesRef = useRef(new Map()); const drag = useRef< | { kind: 'pan'; startX: number; startY: number; tx: number; ty: number } | { kind: 'node'; id: string; startX: number; startY: number; x: number; y: number } | null >(null); const dragged = useRef(false); const positionOf = (id: string): { x: number; y: number } => positionsRef.current.get(id) ?? { x: 0, y: 0 }; /** Paint the current simulation state straight into the SVG elements. */ function applyPositions(): void { for (const [id, element] of nodeElsRef.current) { const node = simNodesRef.current.get(id); if (!node) continue; const x = node.x ?? 0; const y = node.y ?? 0; positionsRef.current.set(id, { x, y }); element.setAttribute('transform', `translate(${x} ${y})`); } for (const [key, element] of edgeElsRef.current) { const [fromId, toId] = key.split('→'); const from = simNodesRef.current.get(fromId ?? ''); const to = simNodesRef.current.get(toId ?? ''); if (!from || !to) continue; element.setAttribute('x1', String(from.x ?? 0)); element.setAttribute('y1', String(from.y ?? 0)); element.setAttribute('x2', String(to.x ?? 0)); element.setAttribute('y2', String(to.y ?? 0)); } } // (Re)build the simulation when the structure changes. Surviving nodes // keep their positions (positionsRef), so a data refresh — e.g. a phantom // becoming a real page — nudges the graph instead of rewinding it. useEffect(() => { const previous = positionsRef.current; const simNodes: SimNode[] = nodes.map((n) => ({ id: n.id, ...previous.get(n.id) })); const links: SimLink[] = edges.map((edge) => ({ source: edge.from, target: edge.to })); const simulation = forceSimulation(simNodes) .force('charge', forceManyBody()) .force( 'link', forceLink(links) .id((node) => node.id) .distance(LINK_DISTANCE), ) .force('center', forceCenter(0, 0)) .force('collide', forceCollide()) // Weak homing keeps disconnected components from drifting out of view. .force('x', forceX(0).strength(0.03)) .force('y', forceY(0).strength(0.03)) // Settle noticeably faster than d3's default so e2e clicks and the // reading eye get a resting layout within a couple of seconds. .alphaDecay(0.04) .alpha(previous.size > 0 ? 0.5 : 1); // prefers-reduced-motion (issue #170, WCAG 2.2.2): das Layout wird // synchron zu Ende gerechnet und einmal gemalt statt zu animieren. if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { simulation.stop(); simulation.tick(200); // applyPositions läuft nach dem Mount der SVG-Knoten (unten im // Layout-Effekt ohnehin einmal aufgerufen über den ersten Paint). requestAnimationFrame(applyPositions); } else { simulation.on('tick', applyPositions); } simRef.current = simulation; simNodesRef.current = new Map(simNodes.map((n) => [n.id, n])); return () => { simulation.stop(); }; // applyPositions only touches refs, so [nodes, edges] is complete. }, [nodes, edges]); // Apply the physics settings to the live simulation and reheat so the // change is visible immediately. fontSize is render-only (below). useEffect(() => { const simulation = simRef.current; if (!simulation) return; const degree = new Map(); for (const edge of edges) { degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1); degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1); } (simulation.force('charge') as ForceManyBody).strength(-settings.repulsion); (simulation.force('link') as ForceLink).strength((link) => { // d3's default scales by the smaller endpoint degree so hubs stay // stable; `attraction` multiplies that baseline. const from = (link.source as SimNode).id; const to = (link.target as SimNode).id; const min = Math.min(degree.get(from) ?? 1, degree.get(to) ?? 1); return Math.min(1, settings.attraction / Math.max(1, min)); }); (simulation.force('collide') as ForceCollide).radius(settings.nodeRadius + 14); simulation.alpha(0.5).restart(); }, [settings.repulsion, settings.attraction, settings.nodeRadius, nodes, edges]); function onWheel(event: React.WheelEvent): void { const factor = Math.exp(-event.deltaY * 0.002); setView((prev) => { const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev.k * factor)); return { ...prev, k }; }); } function onPointerDown(event: React.PointerEvent): void { event.currentTarget.setPointerCapture(event.pointerId); dragged.current = false; const nodeId = (event.target as Element) .closest('[data-node-id]') ?.getAttribute('data-node-id'); if (nodeId) { const pos = positionOf(nodeId); drag.current = { kind: 'node', id: nodeId, startX: event.clientX, startY: event.clientY, ...pos, }; // Pin the node so the physics pulls the neighborhood, not the handle. const node = simNodesRef.current.get(nodeId); if (node) { node.fx = pos.x; node.fy = pos.y; } } else { drag.current = { kind: 'pan', startX: event.clientX, startY: event.clientY, tx: view.tx, ty: view.ty, }; } } function onPointerMove(event: React.PointerEvent): void { const current = drag.current; if (!current) return; const dx = event.clientX - current.startX; const dy = event.clientY - current.startY; if (Math.abs(dx) + Math.abs(dy) > 3) { // First real movement of a node grab reheats the simulation — a plain // click must not make the graph jiggle. if (!dragged.current && current.kind === 'node') { simRef.current?.alphaTarget(0.3).restart(); } dragged.current = true; } if (current.kind === 'pan') { setView((prev) => ({ ...prev, tx: current.tx + dx, ty: current.ty + dy })); } else { const node = simNodesRef.current.get(current.id); if (node) { node.fx = current.x + dx / view.k; node.fy = current.y + dy / view.k; } } } function onPointerUp(event: React.PointerEvent): void { const current = drag.current; drag.current = null; if (current?.kind === 'node') { const node = simNodesRef.current.get(current.id); if (node) { node.fx = null; node.fy = null; } if (dragged.current) simRef.current?.alphaTarget(0); // A click (no real drag) on a node opens it. else onNodeClick?.(current.id); } event.currentTarget.releasePointerCapture(event.pointerId); } return ( {edges.map((edge) => { const key = edgeKey(edge); const from = positionOf(edge.from); const to = positionOf(edge.to); return ( { if (element) edgeElsRef.current.set(key, element); else edgeElsRef.current.delete(key); }} className="force-graph__edge" x1={from.x} y1={from.y} x2={to.x} y2={to.y} /> ); })} {nodes.map((node) => { const pos = positionOf(node.id); return ( { if (element) nodeElsRef.current.set(node.id, element); else nodeElsRef.current.delete(node.id); }} className={ node.dashed ? 'force-graph__node force-graph__node--phantom' : 'force-graph__node' } transform={`translate(${pos.x} ${pos.y})`} data-node-id={node.id} data-testid={node.testId} > {/* Invisible hit area: the bounding-box center sits in the gap between dot and label, where nothing is painted — both Playwright's hit-target check and a user's grab need paint there. Also a comfortable drag handle. */} {node.highlight && ( )} {node.label} ); })} ); }