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)} + />