dorfteich/apps/web/src/graph/ForceGraph.tsx
Claude Fable 5 31b59f0fb6 #170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung
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
2026-07-21 14:39:03 +02:00

358 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}