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>(new Map()); + const [view, setView] = useState({ k: 1, tx: 0, ty: 0 }); + 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 } => + moved.get(id) ?? basePositions.get(id) ?? { x: 0, y: 0 }; + + 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, + }; + } 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) dragged.current = true; + if (current.kind === 'pan') { + setView((prev) => ({ ...prev, tx: current.tx + dx, ty: current.ty + dy })); + } else { + const next = { x: current.x + dx / view.k, y: current.y + dy / view.k }; + setMoved((prev) => new Map(prev).set(current.id, next)); + } + } + + function onPointerUp(event: React.PointerEvent): void { + const current = drag.current; + drag.current = null; + // A click (no real drag) on a node opens it. + if (current?.kind === 'node' && !dragged.current) onNodeClick?.(current.id); + event.currentTarget.releasePointerCapture(event.pointerId); + } + + return ( + + + {edges.map((edge) => { + const from = positionOf(edge.from); + const to = positionOf(edge.to); + return ( + + ); + })} + {nodes.map((node) => { + const pos = positionOf(node.id); + return ( + + {node.highlight && } + + + {node.label} + + + ); + })} + + + ); +} diff --git a/apps/web/src/graph/PondGraphPage.tsx b/apps/web/src/graph/PondGraphPage.tsx new file mode 100644 index 0000000..57ad28b --- /dev/null +++ b/apps/web/src/graph/PondGraphPage.tsx @@ -0,0 +1,151 @@ +import type { PageView, PondGraphView, PondView } from '@dorfteich/shared'; +import { DEFAULT_LABEL_COLOR } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { FormError } from '../components/forms'; +import { usePondLabels } from '../labels/use-pond-labels'; +import { apiGet, apiPost } from '../lib/api'; +import { ForceGraph, type ForceGraphEdge, type ForceGraphNode } from './ForceGraph'; + +/** Above this the layout cost stops being worth it — show a notice instead + * (issue #112; ponds do not realistically reach it). */ +const MAX_GRAPH_NODES = 500; + +const PHANTOM_PREFIX = 'phantom:'; + +/** + * The pond's knowledge graph (issue #112): readable pages as nodes, resolved + * wikilinks as edges, phantom targets as dashed nodes (click → create the + * page, which resolves the links, #47). Nodes are colored by their first + * label; the legend lists the colors in use. + */ +export function PondGraphPage(): React.JSX.Element { + const { t } = useTranslation('graph'); + const { pondSlug = '' } = useParams<{ pondSlug: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + const graph = useQuery({ + queryKey: ['pond-links', pond.data?.id], + queryFn: () => apiGet(`/ponds/${pond.data!.id}/links`), + enabled: Boolean(pond.data), + }); + const { byId } = usePondLabels(pond.data?.id); + + const slugById = useMemo( + () => new Map((graph.data?.nodes ?? []).map((node) => [node.id, node.slug])), + [graph.data], + ); + + const { nodes, edges, legend } = useMemo(() => { + const data = graph.data; + if (!data) { + return { nodes: [] as ForceGraphNode[], edges: [] as ForceGraphEdge[], legend: [] }; + } + const usedLabelIds = new Set(); + let hasUnlabeled = false; + const nodes: ForceGraphNode[] = data.nodes.map((node) => { + const label = node.labelIds.map((id) => byId.get(id)).find(Boolean); + if (label) usedLabelIds.add(label.id); + else hasUnlabeled = true; + return { + id: node.id, + label: node.title, + color: label?.color ?? DEFAULT_LABEL_COLOR, + testId: `graph-node-${node.slug}`, + }; + }); + const edges: ForceGraphEdge[] = data.edges.map((edge) => ({ + from: edge.from, + to: edge.to, + })); + for (const phantom of data.phantoms) { + const id = `${PHANTOM_PREFIX}${phantom.targetSlug}`; + nodes.push({ + id, + label: phantom.targetSlug, + color: DEFAULT_LABEL_COLOR, + dashed: true, + testId: `graph-phantom-${phantom.targetSlug}`, + }); + for (const referrer of phantom.referencedBy) edges.push({ from: referrer, to: id }); + } + const legend = [...usedLabelIds] + .map((id) => byId.get(id)!) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((label) => ({ name: label.name, color: label.color })); + if (hasUnlabeled) legend.push({ name: t('unlabeled'), color: DEFAULT_LABEL_COLOR }); + return { nodes, edges, legend }; + }, [graph.data, byId, t]); + + async function onNodeClick(id: string): Promise { + if (id.startsWith(PHANTOM_PREFIX)) { + const slug = id.slice(PHANTOM_PREFIX.length); + if (!window.confirm(t('createPhantom', { slug }))) return; + // Title = the slug, so the generated slug matches and the links resolve. + const page = await apiPost(`/ponds/${pond.data!.id}/pages`, { title: slug }); + await queryClient.invalidateQueries({ queryKey: ['pond-links', pond.data!.id] }); + await queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] }); + navigate(`/p/${pondSlug}/${page.slug}`); + return; + } + const slug = slugById.get(id); + if (slug) navigate(`/p/${pondSlug}/${slug}`); + } + + if (pond.error) return ; + if (graph.error) return ; + if (!pond.data || !graph.data) return <>; + + const pageCount = graph.data.nodes.length; + + return ( +
+
+

+ {t('title')} — {pond.data.name} +

+

+ {t('counts', { pages: pageCount, links: graph.data.edges.length })} +

+
+ {pageCount === 0 ? ( +

{t('empty')}

+ ) : pageCount > MAX_GRAPH_NODES ? ( +

{t('tooLarge', { max: MAX_GRAPH_NODES })}

+ ) : ( + <> +

{t('hint')}

+
+ void onNodeClick(id)} /> +
+
+ {legend.map((entry) => ( + + + {entry.name} + + ))} + {graph.data.phantoms.length > 0 && ( + + + {t('phantomLegend')} + + )} +
+ + )} +
+ ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index b5761c9..befaf1d 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -7,6 +7,7 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deExport from '@dorfteich/shared/i18n/de/export.json'; import deFiles from '@dorfteich/shared/i18n/de/files.json'; import deFont from '@dorfteich/shared/i18n/de/font.json'; +import deGraph from '@dorfteich/shared/i18n/de/graph.json'; import deImport from '@dorfteich/shared/i18n/de/import.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json'; @@ -32,6 +33,7 @@ import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enExport from '@dorfteich/shared/i18n/en/export.json'; import enFiles from '@dorfteich/shared/i18n/en/files.json'; import enFont from '@dorfteich/shared/i18n/en/font.json'; +import enGraph from '@dorfteich/shared/i18n/en/graph.json'; import enImport from '@dorfteich/shared/i18n/en/import.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json'; @@ -74,6 +76,7 @@ void i18n export: enExport, files: enFiles, font: enFont, + graph: enGraph, import: enImport, labels: enLabels, legal: enLegal, @@ -101,6 +104,7 @@ void i18n export: deExport, files: deFiles, font: deFont, + graph: deGraph, import: deImport, labels: deLabels, legal: deLegal, diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 779ae2c..4f18822 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -357,15 +357,19 @@ function SidebarContent({ {announcement}

- {/* 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. */} +
+ + {t('graph:link')} + + {isOwner && ( {t('editor:trash.link')} -
- )} + )} +
); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index c451da4..d3190d1 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1728,6 +1728,104 @@ button { .sidebar__footer { margin-top: auto; padding-top: var(--space-4); + display: flex; + gap: var(--space-3); +} + +.sidebar__graph-link { + font-size: 0.85rem; + white-space: nowrap; +} + +/* Knowledge graph (issue #112) + local graph panel (#113). */ +.graph-page__header { + display: flex; + align-items: baseline; + gap: var(--space-3); + flex-wrap: wrap; +} + +.graph-page__counts { + margin: 0; + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.graph-page__hint { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.graph-page__canvas { + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + overflow: hidden; +} + +.force-graph { + display: block; + width: 100%; + height: auto; + touch-action: none; + cursor: grab; +} + +.force-graph__edge { + stroke: var(--color-border); + stroke-width: 1.2; +} + +.force-graph__node { + cursor: pointer; +} + +.force-graph__dot { + stroke-width: 2; +} + +.force-graph__node--phantom .force-graph__dot { + stroke-dasharray: 3 3; +} + +.force-graph__node--phantom .force-graph__label { + fill: var(--color-text-muted); + font-style: italic; +} + +.force-graph__ring { + fill: none; + stroke: var(--color-accent); + stroke-width: 2; +} + +.force-graph__label { + font-size: 11px; + fill: var(--color-text); + pointer-events: none; + user-select: none; +} + +.graph-legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-3); + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.graph-legend__entry { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.graph-legend__phantom-swatch { + width: 0.8rem; + height: 0.8rem; + border-radius: 50%; + border: 2px dashed var(--color-text-muted); } /* Pond settings page + label manager. */ diff --git a/packages/shared/i18n/de/graph.json b/packages/shared/i18n/de/graph.json new file mode 100644 index 0000000..acf65cb --- /dev/null +++ b/packages/shared/i18n/de/graph.json @@ -0,0 +1,15 @@ +{ + "title": "Wissensgraph", + "link": "Graph", + "hint": "Klick öffnet die Seite, Ziehen ordnet Knoten an, Scrollen zoomt.", + "counts": "{{pages}} Seiten · {{links}} Verknüpfungen", + "empty": "Noch keine Seiten — der Graph erscheint, sobald Seiten aufeinander verweisen.", + "tooLarge": "Dieser Teich hat mehr als {{max}} Seiten — die Graph-Ansicht ist begrenzt.", + "legend": "Labels", + "unlabeled": "Ohne Label", + "phantomLegend": "Fehlende Seite (Wikilink-Ziel)", + "createPhantom": "Seite „{{slug}}“ anlegen? Die darauf zeigenden Wikilinks werden aufgelöst.", + "localTitle": "Lokaler Graph", + "localDepth1": "Direkte Nachbarn", + "localDepth2": "Zwei Ebenen" +} diff --git a/packages/shared/i18n/en/graph.json b/packages/shared/i18n/en/graph.json new file mode 100644 index 0000000..78a0186 --- /dev/null +++ b/packages/shared/i18n/en/graph.json @@ -0,0 +1,15 @@ +{ + "title": "Knowledge graph", + "link": "Graph", + "hint": "Click a page to open it, drag nodes to rearrange, scroll to zoom.", + "counts": "{{pages}} pages · {{links}} links", + "empty": "No pages yet — the graph appears once pages link to each other.", + "tooLarge": "This pond has more than {{max}} pages — the graph view is capped.", + "legend": "Labels", + "unlabeled": "No label", + "phantomLegend": "Missing page (wikilink target)", + "createPhantom": "Create the page “{{slug}}”? The wikilinks pointing at it will resolve.", + "localTitle": "Local graph", + "localDepth1": "Direct neighbors", + "localDepth2": "Two hops" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cddbfc..aeb390f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: '@tiptap/react': specifier: ^3.27.1 version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + d3-force: + specifier: ^3.0.0 + version: 3.0.0 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) @@ -333,6 +336,9 @@ importers: '@playwright/test': specifier: ^1.61.1 version: 1.61.1 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/react': specifier: ^19.0.0 version: 19.2.17