From dbbe229cb943a4cc8422ca39c036354f12e3a6df Mon Sep 17 00:00:00 2001
From: Claude Fable 5
Date: Tue, 14 Jul 2026 10:59:07 +0200
Subject: [PATCH] Pond knowledge graph view (#112)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/p/:pondSlug/graph (static segment ranked above :pageSlug, same
documented reserved-slug gap as trash/settings) renders the pond's
readable wikilink graph from GET /ponds/:id/links: pages as nodes
colored by their first label (legend included, DEFAULT_LABEL_COLOR for
unlabeled), resolved links as edges, phantom targets as dashed nodes —
clicking one offers to create the page, which resolves its links.
Rendering is a self-contained SVG force graph: only d3-force is
bundled (no d3 DOM/zoom modules, zero external requests); the layout
runs synchronously to rest, zoom/pan/node-drag are plain pointer math.
SVG over canvas deliberately — every node carries a data-testid the
e2e packs can click. Ponds beyond 500 pages get a capped-view notice.
Sidebar footer links every member to the graph (trash stays
owner-only). New i18n namespace graph (de+en).
Verified live: nodes/edges/legend render, node click opens the page,
phantom click creates it and the node turns solid.
Co-Authored-By: Claude Fable 5
---
apps/web/package.json | 2 +
apps/web/src/App.tsx | 3 +
apps/web/src/graph/ForceGraph.tsx | 227 +++++++++++++++++++++++++++
apps/web/src/graph/PondGraphPage.tsx | 151 ++++++++++++++++++
apps/web/src/i18n/index.ts | 4 +
apps/web/src/layout/Sidebar.tsx | 16 +-
apps/web/src/styles/base.css | 98 ++++++++++++
packages/shared/i18n/de/graph.json | 15 ++
packages/shared/i18n/en/graph.json | 15 ++
pnpm-lock.yaml | 6 +
10 files changed, 531 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/graph/ForceGraph.tsx
create mode 100644 apps/web/src/graph/PondGraphPage.tsx
create mode 100644 packages/shared/i18n/de/graph.json
create mode 100644 packages/shared/i18n/en/graph.json
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. */}
+