Knowledge graph: live Obsidian-like simulation with tunable physics
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m28s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 6m42s
CI / Import/export fidelity gate (push) Successful in 49s
Release / Build release images and notes (push) Successful in 1m7s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 21s

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.<pondId>) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
This commit is contained in:
Claude Fable 5 2026-07-15 11:10:11 +02:00
parent d4fb2a3c51
commit 1781f12f6e
7 changed files with 407 additions and 86 deletions

View File

@ -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); expect(await page.locator('.force-graph__edge').count()).toBeGreaterThanOrEqual(2);
await expect(page.locator('.graph-legend')).toBeVisible(); 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. // A node click opens the page.
await page.getByTestId(`graph-node-${target.slug}`).click(); await page.getByTestId(`graph-node-${target.slug}`).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${target.slug}$`)); 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'), page.getByTestId(`local-graph-node-${a.slug}`).locator('.force-graph__ring'),
).toHaveCount(1); ).toHaveCount(1);
await panel.getByRole('button', { name: /two hops|zwei ebenen/i }).click(); // #123: the hop depth is a 15 slider now.
await panel.locator('.local-graph__hops input').fill('2');
await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toBeVisible(); await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toBeVisible();
await page.getByTestId(`local-graph-node-${b.slug}`).click(); await page.getByTestId(`local-graph-node-${b.slug}`).click();

View File

@ -4,17 +4,30 @@ import {
forceLink, forceLink,
forceManyBody, forceManyBody,
forceSimulation, forceSimulation,
forceX,
forceY,
type ForceCollide,
type ForceLink,
type ForceManyBody,
type Simulation,
type SimulationLinkDatum, type SimulationLinkDatum,
type SimulationNodeDatum, type SimulationNodeDatum,
} from 'd3-force'; } 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 * Self-contained SVG force graph (issue #112). Only `d3-force` is bundled
* no d3 DOM/zoom modules, no external requests: layout runs synchronously * no d3 DOM/zoom modules, no external requests: zoom/pan/drag are plain
* (deterministic phyllotaxis seed), zoom/pan/drag are plain pointer math. * pointer math. SVG over canvas deliberately: nodes carry data-testids, so
* SVG over canvas deliberately: nodes carry data-testids, so the e2e packs * the e2e packs can click them. Shared by the pond graph view and the local
* can click them. Shared by the pond graph view and the local panel (#113). * 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 { export interface ForceGraphNode {
@ -35,47 +48,34 @@ export interface ForceGraphEdge {
to: string; 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; id: string;
} }
type SimLink = SimulationLinkDatum<SimNode>;
const TICKS = 250;
const MIN_ZOOM = 0.2; const MIN_ZOOM = 0.2;
const MAX_ZOOM = 5; const MAX_ZOOM = 5;
const LINK_DISTANCE = 70;
/** Runs the force layout to rest and returns node positions keyed by id. */ const edgeKey = (edge: ForceGraphEdge): string => `${edge.from}${edge.to}`;
function layout(
nodeIds: string[],
edges: ForceGraphEdge[],
width: number,
height: number,
): Map<string, { x: number; y: number }> {
const nodes: LayoutNode[] = nodeIds.map((id) => ({ id }));
const links: SimulationLinkDatum<LayoutNode>[] = edges.map((edge) => ({
source: edge.from,
target: edge.to,
}));
const simulation = forceSimulation(nodes)
.force('charge', forceManyBody().strength(-160))
.force(
'link',
forceLink<LayoutNode, SimulationLinkDatum<LayoutNode>>(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({ export function ForceGraph({
nodes, nodes,
@ -83,28 +83,22 @@ export function ForceGraph({
width = 800, width = 800,
height = 560, height = 560,
onNodeClick, onNodeClick,
settings = GRAPH_SETTINGS_DEFAULTS,
}: { }: {
nodes: ForceGraphNode[]; nodes: ForceGraphNode[];
edges: ForceGraphEdge[]; edges: ForceGraphEdge[];
width?: number; width?: number;
height?: number; height?: number;
onNodeClick?: (id: string) => void; onNodeClick?: (id: string) => void;
settings?: ForceGraphSettings;
}): React.JSX.Element { }): 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<Map<string, { x: number; y: number }>>(new Map());
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 }); 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<string, { x: number; y: number }>());
const nodeElsRef = useRef(new Map<string, SVGGElement>());
const edgeElsRef = useRef(new Map<string, SVGLineElement>());
const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
const simNodesRef = useRef(new Map<string, SimNode>());
const drag = useRef< const drag = useRef<
| { kind: 'pan'; startX: number; startY: number; tx: number; ty: number } | { kind: 'pan'; startX: number; startY: number; tx: number; ty: number }
| { kind: 'node'; id: string; startX: number; startY: number; x: number; y: 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 dragged = useRef(false);
const positionOf = (id: string): { x: number; y: number } => 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<SimNode, SimLink>(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<string, number>();
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<SimNode>).strength(-settings.repulsion);
(simulation.force('link') as ForceLink<SimNode, SimLink>).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<SimNode>).radius(settings.nodeRadius + 14);
simulation.alpha(0.5).restart();
}, [settings.repulsion, settings.attraction, settings.nodeRadius, nodes, edges]);
function onWheel(event: React.WheelEvent<SVGSVGElement>): void { function onWheel(event: React.WheelEvent<SVGSVGElement>): void {
const factor = Math.exp(-event.deltaY * 0.002); const factor = Math.exp(-event.deltaY * 0.002);
@ -138,6 +210,12 @@ export function ForceGraph({
startY: event.clientY, startY: event.clientY,
...pos, ...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 { } else {
drag.current = { drag.current = {
kind: 'pan', kind: 'pan',
@ -154,20 +232,38 @@ export function ForceGraph({
if (!current) return; if (!current) return;
const dx = event.clientX - current.startX; const dx = event.clientX - current.startX;
const dy = event.clientY - current.startY; 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') { if (current.kind === 'pan') {
setView((prev) => ({ ...prev, tx: current.tx + dx, ty: current.ty + dy })); setView((prev) => ({ ...prev, tx: current.tx + dx, ty: current.ty + dy }));
} else { } else {
const next = { x: current.x + dx / view.k, y: current.y + dy / view.k }; const node = simNodesRef.current.get(current.id);
setMoved((prev) => new Map(prev).set(current.id, next)); if (node) {
node.fx = current.x + dx / view.k;
node.fy = current.y + dy / view.k;
}
} }
} }
function onPointerUp(event: React.PointerEvent<SVGSVGElement>): void { function onPointerUp(event: React.PointerEvent<SVGSVGElement>): void {
const current = drag.current; const current = drag.current;
drag.current = null; drag.current = null;
// A click (no real drag) on a node opens it. if (current?.kind === 'node') {
if (current?.kind === 'node' && !dragged.current) onNodeClick?.(current.id); 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); event.currentTarget.releasePointerCapture(event.pointerId);
} }
@ -183,11 +279,16 @@ export function ForceGraph({
> >
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.k})`}> <g transform={`translate(${view.tx} ${view.ty}) scale(${view.k})`}>
{edges.map((edge) => { {edges.map((edge) => {
const key = edgeKey(edge);
const from = positionOf(edge.from); const from = positionOf(edge.from);
const to = positionOf(edge.to); const to = positionOf(edge.to);
return ( return (
<line <line
key={`${edge.from}${edge.to}`} key={key}
ref={(element) => {
if (element) edgeElsRef.current.set(key, element);
else edgeElsRef.current.delete(key);
}}
className="force-graph__edge" className="force-graph__edge"
x1={from.x} x1={from.x}
y1={from.y} y1={from.y}
@ -201,6 +302,10 @@ export function ForceGraph({
return ( return (
<g <g
key={node.id} key={node.id}
ref={(element) => {
if (element) nodeElsRef.current.set(node.id, element);
else nodeElsRef.current.delete(node.id);
}}
className={ className={
node.dashed ? 'force-graph__node force-graph__node--phantom' : 'force-graph__node' 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-node-id={node.id}
data-testid={node.testId} data-testid={node.testId}
> >
{node.highlight && <circle className="force-graph__ring" r={13} />} {/* Invisible hit area: the <g> 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. */}
<circle r={settings.nodeRadius + 12} fill="transparent" />
{node.highlight && (
<circle className="force-graph__ring" r={settings.nodeRadius + 5} />
)}
<circle <circle
className="force-graph__dot" className="force-graph__dot"
r={8} r={settings.nodeRadius}
fill={node.dashed ? 'transparent' : node.color} fill={node.dashed ? 'transparent' : node.color}
stroke={node.color} stroke={node.color}
/> />
<text className="force-graph__label" y={22} textAnchor="middle"> <text
className="force-graph__label"
y={settings.nodeRadius + 14}
textAnchor="middle"
style={{ fontSize: settings.fontSize }}
>
{node.label} {node.label}
</text> </text>
</g> </g>

View File

@ -7,9 +7,18 @@ import { useNavigate } from 'react-router-dom';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet } from '../lib/api'; 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 PHANTOM_PREFIX = 'phantom:';
const MAX_HOPS = 5;
/** /**
* The current page's neighborhood graph (issue #113): direct wikilink * The current page's neighborhood graph (issue #113): direct wikilink
@ -29,7 +38,13 @@ export function LocalGraphPanel({
}): React.JSX.Element | null { }): React.JSX.Element | null {
const { t } = useTranslation('graph'); const { t } = useTranslation('graph');
const navigate = useNavigate(); 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<ForceGraphSettings>(
graphSettingsKey(pondId),
GRAPH_SETTINGS_DEFAULTS,
);
const graph = useQuery({ const graph = useQuery({
queryKey: ['pond-links', pondId], queryKey: ['pond-links', pondId],
@ -114,17 +129,18 @@ export function LocalGraphPanel({
<details className="local-graph" open> <details className="local-graph" open>
<summary>{t('localTitle')}</summary> <summary>{t('localTitle')}</summary>
<div className="local-graph__controls"> <div className="local-graph__controls">
{([1, 2] as const).map((depth) => ( <label className="local-graph__hops">
<button <span>{t('localHops', { count: hops })}</span>
key={depth} <input
type="button" type="range"
className={`sidebar__view-btn${hops === depth ? ' sidebar__view-btn--active' : ''}`} min={1}
aria-pressed={hops === depth} max={MAX_HOPS}
onClick={() => setHops(depth)} step={1}
> value={hops}
{t(depth === 1 ? 'localDepth1' : 'localDepth2')} aria-label={t('localHopsLabel')}
</button> onChange={(event) => setHops(Number(event.target.value))}
))} />
</label>
</div> </div>
<div className="local-graph__canvas"> <div className="local-graph__canvas">
<ForceGraph <ForceGraph
@ -132,6 +148,7 @@ export function LocalGraphPanel({
edges={edges} edges={edges}
width={560} width={560}
height={320} height={320}
settings={settings}
onNodeClick={(id) => { onNodeClick={(id) => {
const slug = slugById.get(id); const slug = slugById.get(id);
if (slug && id !== pageId) navigate(`/p/${pondSlug}/${slug}`); if (slug && id !== pageId) navigate(`/p/${pondSlug}/${slug}`);

View File

@ -8,7 +8,132 @@ import { useNavigate, useParams } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPost } from '../lib/api'; 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 (
<label className="graph-controls__field">
<span>{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
/** The tunable physics/rendering controls (#123). */
function GraphControls({
settings,
setSettings,
}: {
settings: ForceGraphSettings;
setSettings: (value: ForceGraphSettings) => void;
}): React.JSX.Element {
const { t } = useTranslation('graph');
return (
<div className="graph-controls">
<GraphSlider
label={t('controls.attraction')}
min={0.1}
max={3}
step={0.1}
value={settings.attraction}
onChange={(attraction) => setSettings({ ...settings, attraction })}
/>
<GraphSlider
label={t('controls.repulsion')}
min={20}
max={600}
step={10}
value={settings.repulsion}
onChange={(repulsion) => setSettings({ ...settings, repulsion })}
/>
<GraphSlider
label={t('controls.nodeRadius')}
min={3}
max={20}
step={1}
value={settings.nodeRadius}
onChange={(nodeRadius) => setSettings({ ...settings, nodeRadius })}
/>
<GraphSlider
label={t('controls.fontSize')}
min={7}
max={24}
step={1}
value={settings.fontSize}
onChange={(fontSize) => setSettings({ ...settings, fontSize })}
/>
<button
type="button"
className="linklike graph-controls__reset"
onClick={() => setSettings(GRAPH_SETTINGS_DEFAULTS)}
>
{t('controls.reset')}
</button>
</div>
);
}
/**
* 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<ForceGraphSettings>(
graphSettingsKey(pondId),
GRAPH_SETTINGS_DEFAULTS,
);
return (
<>
<GraphControls settings={settings} setSettings={setSettings} />
<div className="graph-page__canvas">
<ForceGraph nodes={nodes} edges={edges} settings={settings} onNodeClick={onNodeClick} />
</div>
</>
);
}
/** Above this the layout cost stops being worth it show a notice instead /** Above this the layout cost stops being worth it show a notice instead
* (issue #112; ponds do not realistically reach it). */ * (issue #112; ponds do not realistically reach it). */
@ -123,9 +248,13 @@ export function PondGraphPage(): React.JSX.Element {
) : ( ) : (
<> <>
<p className="graph-page__hint">{t('hint')}</p> <p className="graph-page__hint">{t('hint')}</p>
<div className="graph-page__canvas"> <GraphView
<ForceGraph nodes={nodes} edges={edges} onNodeClick={(id) => void onNodeClick(id)} /> key={pond.data.id}
</div> pondId={pond.data.id}
nodes={nodes}
edges={edges}
onNodeClick={(id) => void onNodeClick(id)}
/>
<footer className="graph-legend" aria-label={t('legend')}> <footer className="graph-legend" aria-label={t('legend')}>
{legend.map((entry) => ( {legend.map((entry) => (
<span key={entry.name} className="graph-legend__entry"> <span key={entry.name} className="graph-legend__entry">

View File

@ -1794,6 +1794,27 @@ button {
overflow: hidden; overflow: hidden;
} }
/* Physics/rendering sliders (#123). */
.graph-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
font-size: 0.85rem;
color: var(--color-text-muted);
}
.graph-controls__field {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.graph-controls__field input[type='range'] {
width: 7rem;
}
.force-graph { .force-graph {
display: block; display: block;
width: 100%; width: 100%;
@ -1930,6 +1951,19 @@ button {
margin: var(--space-2) 0; margin: var(--space-2) 0;
} }
/* Hop-depth slider (#123): replaces the two fixed depth buttons. */
.local-graph__hops {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85rem;
color: var(--color-text-muted);
}
.local-graph__hops input[type='range'] {
width: 7rem;
}
.local-graph__canvas { .local-graph__canvas {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: var(--radius);

View File

@ -10,6 +10,14 @@
"phantomLegend": "Fehlende Seite (Wikilink-Ziel)", "phantomLegend": "Fehlende Seite (Wikilink-Ziel)",
"createPhantom": "Seite „{{slug}}“ anlegen? Die darauf zeigenden Wikilinks werden aufgelöst.", "createPhantom": "Seite „{{slug}}“ anlegen? Die darauf zeigenden Wikilinks werden aufgelöst.",
"localTitle": "Lokaler Graph", "localTitle": "Lokaler Graph",
"localDepth1": "Direkte Nachbarn", "localHops_one": "Umkreis: {{count}} Ebene",
"localDepth2": "Zwei Ebenen" "localHops_other": "Umkreis: {{count}} Ebenen",
"localHopsLabel": "Nachbarschafts-Tiefe",
"controls": {
"attraction": "Anziehung",
"repulsion": "Abstoßung",
"nodeRadius": "Knotengröße",
"fontSize": "Schriftgröße",
"reset": "Zurücksetzen"
}
} }

View File

@ -10,6 +10,14 @@
"phantomLegend": "Missing page (wikilink target)", "phantomLegend": "Missing page (wikilink target)",
"createPhantom": "Create the page “{{slug}}”? The wikilinks pointing at it will resolve.", "createPhantom": "Create the page “{{slug}}”? The wikilinks pointing at it will resolve.",
"localTitle": "Local graph", "localTitle": "Local graph",
"localDepth1": "Direct neighbors", "localHops_one": "Neighborhood: {{count}} hop",
"localDepth2": "Two hops" "localHops_other": "Neighborhood: {{count}} hops",
"localHopsLabel": "Neighborhood depth",
"controls": {
"attraction": "Attraction",
"repulsion": "Repulsion",
"nodeRadius": "Node size",
"fontSize": "Font size",
"reset": "Reset"
}
} }