From 1781f12f6e02668312e1d6fffab2770a78cdc14e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 15 Jul 2026 11:10:11 +0200 Subject: [PATCH] Knowledge graph: live Obsidian-like simulation with tunable physics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The force layout used to run once (tick(250)) and freeze; dragging moved a single node with no reaction from its neighbors. The simulation now stays alive: React renders the SVG structure (testids, edge/ring classes — the e2e contract is unchanged) while each tick writes positions imperatively into the element refs, and it settles to rest via alpha decay, which also keeps Playwright's stability wait happy. Dragging pins the node (fx/fy) and reheats the physics, so the neighborhood gets pulled along; a plain click still just opens the page. Surviving nodes keep their positions across data refreshes, e.g. when a phantom becomes a real page. The graph page gains four sliders — attraction, repulsion, node size, font size — persisted per pond (ui.graph.settings.) with a reset; the inner view is keyed by pond id because usePersistentState reads its key only on mount (#108 trap). The local panel adopts those settings (no second set of sliders) and swaps the two fixed hop buttons for a 1–5 depth slider. Fixes #123 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn --- apps/web/e2e/graph.spec.ts | 10 +- apps/web/src/graph/ForceGraph.tsx | 245 ++++++++++++++++++------- apps/web/src/graph/LocalGraphPanel.tsx | 43 +++-- apps/web/src/graph/PondGraphPage.tsx | 137 +++++++++++++- apps/web/src/styles/base.css | 34 ++++ packages/shared/i18n/de/graph.json | 12 +- packages/shared/i18n/en/graph.json | 12 +- 7 files changed, 407 insertions(+), 86 deletions(-) diff --git a/apps/web/e2e/graph.spec.ts b/apps/web/e2e/graph.spec.ts index 114a4fa..bf54bd7 100644 --- a/apps/web/e2e/graph.spec.ts +++ b/apps/web/e2e/graph.spec.ts @@ -72,6 +72,13 @@ test('pond graph renders the link structure and creates phantom pages', async ({ expect(await page.locator('.force-graph__edge').count()).toBeGreaterThanOrEqual(2); await expect(page.locator('.graph-legend')).toBeVisible(); + // #123: the physics/rendering sliders are there and take effect — the + // font-size slider writes straight into the SVG labels. + const controls = page.locator('.graph-controls'); + await expect(controls.locator('input[type="range"]')).toHaveCount(4); + await controls.locator('input[type="range"]').nth(3).fill('20'); + await expect(page.locator('.force-graph__label').first()).toHaveCSS('font-size', '20px'); + // A node click opens the page. await page.getByTestId(`graph-node-${target.slug}`).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${target.slug}$`)); @@ -121,7 +128,8 @@ test('local graph panel shows the neighborhood per hop depth and navigates', asy page.getByTestId(`local-graph-node-${a.slug}`).locator('.force-graph__ring'), ).toHaveCount(1); - await panel.getByRole('button', { name: /two hops|zwei ebenen/i }).click(); + // #123: the hop depth is a 1–5 slider now. + await panel.locator('.local-graph__hops input').fill('2'); await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toBeVisible(); await page.getByTestId(`local-graph-node-${b.slug}`).click(); diff --git a/apps/web/src/graph/ForceGraph.tsx b/apps/web/src/graph/ForceGraph.tsx index 123329e..e464dfc 100644 --- a/apps/web/src/graph/ForceGraph.tsx +++ b/apps/web/src/graph/ForceGraph.tsx @@ -4,17 +4,30 @@ import { forceLink, forceManyBody, forceSimulation, + forceX, + forceY, + type ForceCollide, + type ForceLink, + type ForceManyBody, + type Simulation, type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, 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). + * 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 { @@ -35,47 +48,34 @@ export interface ForceGraphEdge { to: string; } -interface LayoutNode extends SimulationNodeDatum { +/** 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; -const TICKS = 250; const MIN_ZOOM = 0.2; const MAX_ZOOM = 5; +const LINK_DISTANCE = 70; -/** 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 }])); -} +const edgeKey = (edge: ForceGraphEdge): string => `${edge.from}→${edge.to}`; export function ForceGraph({ nodes, @@ -83,28 +83,22 @@ export function ForceGraph({ 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 { - // 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 }); + /** Last known positions — read by React renders, written by sim ticks. */ + const positionsRef = useRef(new Map()); + const nodeElsRef = useRef(new Map()); + const edgeElsRef = useRef(new Map()); + const simRef = useRef | null>(null); + const simNodesRef = useRef(new Map()); 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 } @@ -113,7 +107,85 @@ export function ForceGraph({ const dragged = useRef(false); const positionOf = (id: string): { x: number; y: number } => - moved.get(id) ?? basePositions.get(id) ?? { x: 0, y: 0 }; + 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(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) + .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(); + 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).strength(-settings.repulsion); + (simulation.force('link') as ForceLink).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).radius(settings.nodeRadius + 14); + simulation.alpha(0.5).restart(); + }, [settings.repulsion, settings.attraction, settings.nodeRadius, nodes, edges]); function onWheel(event: React.WheelEvent): void { const factor = Math.exp(-event.deltaY * 0.002); @@ -138,6 +210,12 @@ export function ForceGraph({ 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', @@ -154,20 +232,38 @@ export function ForceGraph({ 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 (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 next = { x: current.x + dx / view.k, y: current.y + dy / view.k }; - setMoved((prev) => new Map(prev).set(current.id, next)); + 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): 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); + 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); } @@ -183,11 +279,16 @@ export function ForceGraph({ > {edges.map((edge) => { + const key = edgeKey(edge); const from = positionOf(edge.from); const to = positionOf(edge.to); return ( { + if (element) edgeElsRef.current.set(key, element); + else edgeElsRef.current.delete(key); + }} className="force-graph__edge" x1={from.x} y1={from.y} @@ -201,6 +302,10 @@ export function ForceGraph({ return ( { + 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' } @@ -208,14 +313,26 @@ export function ForceGraph({ data-node-id={node.id} data-testid={node.testId} > - {node.highlight && } + {/* Invisible hit area: the 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. */} + + {node.highlight && ( + + )} - + {node.label} diff --git a/apps/web/src/graph/LocalGraphPanel.tsx b/apps/web/src/graph/LocalGraphPanel.tsx index 787d5b5..8b62aaf 100644 --- a/apps/web/src/graph/LocalGraphPanel.tsx +++ b/apps/web/src/graph/LocalGraphPanel.tsx @@ -7,9 +7,18 @@ import { useNavigate } from 'react-router-dom'; import { usePondLabels } from '../labels/use-pond-labels'; import { apiGet } from '../lib/api'; -import { ForceGraph, type ForceGraphEdge, type ForceGraphNode } from './ForceGraph'; +import { usePersistentState } from '../lib/use-persistent-state'; +import { + ForceGraph, + GRAPH_SETTINGS_DEFAULTS, + type ForceGraphEdge, + type ForceGraphNode, + type ForceGraphSettings, +} from './ForceGraph'; +import { graphSettingsKey } from './PondGraphPage'; const PHANTOM_PREFIX = 'phantom:'; +const MAX_HOPS = 5; /** * The current page's neighborhood graph (issue #113): direct wikilink @@ -29,7 +38,13 @@ export function LocalGraphPanel({ }): React.JSX.Element | null { const { t } = useTranslation('graph'); const navigate = useNavigate(); - const [hops, setHops] = useState<1 | 2>(1); + const [hops, setHops] = useState(1); + // Shared with the pond graph view (#123): the panel adopts the physics + // settings tuned there — read-only, no second set of sliders. + const [settings] = usePersistentState( + graphSettingsKey(pondId), + GRAPH_SETTINGS_DEFAULTS, + ); const graph = useQuery({ queryKey: ['pond-links', pondId], @@ -114,17 +129,18 @@ export function LocalGraphPanel({
{t('localTitle')}
- {([1, 2] as const).map((depth) => ( - - ))} +
{ const slug = slugById.get(id); if (slug && id !== pageId) navigate(`/p/${pondSlug}/${slug}`); diff --git a/apps/web/src/graph/PondGraphPage.tsx b/apps/web/src/graph/PondGraphPage.tsx index 57ad28b..26ffc21 100644 --- a/apps/web/src/graph/PondGraphPage.tsx +++ b/apps/web/src/graph/PondGraphPage.tsx @@ -8,7 +8,132 @@ 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'; +import { usePersistentState } from '../lib/use-persistent-state'; +import { + ForceGraph, + GRAPH_SETTINGS_DEFAULTS, + type ForceGraphEdge, + type ForceGraphNode, + type ForceGraphSettings, +} from './ForceGraph'; + +/** localStorage key for the per-pond graph physics settings (#123). */ +export const graphSettingsKey = (pondId: string): string => `ui.graph.settings.${pondId}`; + +/** One labelled range slider of the graph controls. */ +function GraphSlider({ + label, + min, + max, + step, + value, + onChange, +}: { + label: string; + min: number; + max: number; + step: number; + value: number; + onChange: (value: number) => void; +}): React.JSX.Element { + return ( + + ); +} + +/** The tunable physics/rendering controls (#123). */ +function GraphControls({ + settings, + setSettings, +}: { + settings: ForceGraphSettings; + setSettings: (value: ForceGraphSettings) => void; +}): React.JSX.Element { + const { t } = useTranslation('graph'); + return ( +
+ setSettings({ ...settings, attraction })} + /> + setSettings({ ...settings, repulsion })} + /> + setSettings({ ...settings, nodeRadius })} + /> + setSettings({ ...settings, fontSize })} + /> + +
+ ); +} + +/** + * Controls + canvas for one pond. A separate component keyed by pond id in + * the page below, because usePersistentState reads its localStorage key only + * on mount (#108 trap) — a client-side pond switch must remount this. + */ +function GraphView({ + pondId, + nodes, + edges, + onNodeClick, +}: { + pondId: string; + nodes: ForceGraphNode[]; + edges: ForceGraphEdge[]; + onNodeClick: (id: string) => void; +}): React.JSX.Element { + const [settings, setSettings] = usePersistentState( + graphSettingsKey(pondId), + GRAPH_SETTINGS_DEFAULTS, + ); + return ( + <> + +
+ +
+ + ); +} /** Above this the layout cost stops being worth it — show a notice instead * (issue #112; ponds do not realistically reach it). */ @@ -123,9 +248,13 @@ export function PondGraphPage(): React.JSX.Element { ) : ( <>

{t('hint')}

-
- void onNodeClick(id)} /> -
+ void onNodeClick(id)} + />