Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4). prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren (WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst KEIN zusätzliches role=status (legal.spec-Locator-Falle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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:<slug>` 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<SimNode>;
|
||
|
||
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<string, { x: number; y: number }>());
|
||
const nodeElsRef = useRef(new Map<string, SVGGElement>());
|
||
const edgeElsRef = useRef(new Map<string, SVGLineElement>());
|
||
const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
|
||
const simNodesRef = useRef(new Map<string, SimNode>());
|
||
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<SimNode, SimLink>(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<string, number>();
|
||
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<SimNode>).strength(-settings.repulsion);
|
||
(simulation.force('link') as ForceLink<SimNode, SimLink>).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<SimNode>).radius(settings.nodeRadius + 14);
|
||
simulation.alpha(0.5).restart();
|
||
}, [settings.repulsion, settings.attraction, settings.nodeRadius, nodes, edges]);
|
||
|
||
function onWheel(event: React.WheelEvent<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): 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 (
|
||
<svg
|
||
className="force-graph"
|
||
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
||
role="img"
|
||
aria-label={t('svgLabel', { nodes: nodes.length, edges: edges.length })}
|
||
onWheel={onWheel}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
>
|
||
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.k})`}>
|
||
{edges.map((edge) => {
|
||
const key = edgeKey(edge);
|
||
const from = positionOf(edge.from);
|
||
const to = positionOf(edge.to);
|
||
return (
|
||
<line
|
||
key={key}
|
||
ref={(element) => {
|
||
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 (
|
||
<g
|
||
key={node.id}
|
||
ref={(element) => {
|
||
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 <g> 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. */}
|
||
<circle r={settings.nodeRadius + 12} fill="transparent" />
|
||
{node.highlight && (
|
||
<circle className="force-graph__ring" r={settings.nodeRadius + 5} />
|
||
)}
|
||
<circle
|
||
className="force-graph__dot"
|
||
r={settings.nodeRadius}
|
||
fill={node.dashed ? 'transparent' : node.color}
|
||
stroke={node.color}
|
||
/>
|
||
<text
|
||
className="force-graph__label"
|
||
y={settings.nodeRadius + 14}
|
||
textAnchor="middle"
|
||
style={{ fontSize: settings.fontSize }}
|
||
>
|
||
{node.label}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|