diff --git a/apps/web/package.json b/apps/web/package.json
index 56febbb..e9854eb 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -25,6 +25,7 @@
"@tiptap/extension-collaboration-caret": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
+ "d3-force": "^3.0.0",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.24.0",
@@ -42,6 +43,7 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
+ "@types/d3-force": "^3.0.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 02cf501..1288a06 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
+import { PondGraphPage } from './graph/PondGraphPage';
import { MAINTENANCE_EVENT } from './lib/api';
import { MaintenancePage } from './pages/MaintenancePage';
import { AppLayout } from './layout/AppLayout';
@@ -90,6 +91,8 @@ export function App(): React.JSX.Element {
} />
{/* Static "settings" wins over :pageSlug, like "trash" above. */}
} />
+ {/* Static "graph" wins over :pageSlug, like "trash" above (#112). */}
+ } />
} />
}>
diff --git a/apps/web/src/graph/ForceGraph.tsx b/apps/web/src/graph/ForceGraph.tsx
new file mode 100644
index 0000000..123329e
--- /dev/null
+++ b/apps/web/src/graph/ForceGraph.tsx
@@ -0,0 +1,227 @@
+import {
+ forceCenter,
+ forceCollide,
+ forceLink,
+ forceManyBody,
+ forceSimulation,
+ type SimulationLinkDatum,
+ type SimulationNodeDatum,
+} from 'd3-force';
+import { useMemo, useRef, useState } from 'react';
+
+/**
+ * Self-contained SVG force graph (issue #112). Only `d3-force` is bundled —
+ * no d3 DOM/zoom modules, no external requests: layout runs synchronously
+ * (deterministic phyllotaxis seed), 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).
+ */
+
+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;
+}
+
+interface LayoutNode extends SimulationNodeDatum {
+ id: string;
+}
+
+const TICKS = 250;
+const MIN_ZOOM = 0.2;
+const MAX_ZOOM = 5;
+
+/** Runs the force layout to rest and returns node positions keyed by id. */
+function layout(
+ nodeIds: string[],
+ edges: ForceGraphEdge[],
+ width: number,
+ height: number,
+): Map {
+ const nodes: LayoutNode[] = nodeIds.map((id) => ({ id }));
+ const links: SimulationLinkDatum[] = edges.map((edge) => ({
+ source: edge.from,
+ target: edge.to,
+ }));
+ const simulation = forceSimulation(nodes)
+ .force('charge', forceManyBody().strength(-160))
+ .force(
+ 'link',
+ forceLink>(links)
+ .id((node) => node.id)
+ .distance(70),
+ )
+ .force('center', forceCenter(0, 0))
+ .force('collide', forceCollide(22))
+ .stop();
+ simulation.tick(TICKS);
+ // Keep the resting layout inside the viewBox for typical pond sizes.
+ const scale = Math.min(
+ 1,
+ ...nodes.map((n) =>
+ Math.min(Math.abs((width / 2 - 30) / (n.x || 1)), Math.abs((height / 2 - 30) / (n.y || 1))),
+ ),
+ );
+ return new Map(nodes.map((n) => [n.id, { x: (n.x ?? 0) * scale, y: (n.y ?? 0) * scale }]));
+}
+
+export function ForceGraph({
+ nodes,
+ edges,
+ width = 800,
+ height = 560,
+ onNodeClick,
+}: {
+ nodes: ForceGraphNode[];
+ edges: ForceGraphEdge[];
+ width?: number;
+ height?: number;
+ onNodeClick?: (id: string) => void;
+}): React.JSX.Element {
+ // Callers memoize nodes/edges (they come from query-derived useMemos), so
+ // the layout runs only when the graph's structure actually changes.
+ const basePositions = useMemo(
+ () =>
+ layout(
+ nodes.map((n) => n.id),
+ edges,
+ width,
+ height,
+ ),
+ [nodes, edges, width, height],
+ );
+ /** Manual node drags overlay the computed layout. */
+ const [moved, setMoved] = useState
- {/* The trash stays a text link, pinned to the sidebar's bottom
- (M10 follow-up; pond settings moved to the TopBar gear icon). */}
- {isOwner && (
-
+ {/* Graph + trash stay text links, pinned to the sidebar's bottom
+ (M10 follow-up; pond settings moved to the TopBar gear icon).
+ The graph is for every member (#112); the trash is owner-only. */}
+