Skip to content

Commit 10a21b4

Browse files
committed
refactor: Extract reusable graph editor into src/components/graph
Pull the shared React Flow editor + action-log executor out of the two graph pages so future graph visualizers can reuse it: - use-graph-editor: hook owning graph state, the keyboard editor, and an algorithm-agnostic executor (run(actions) / getContext()); a `weighted` option gates edge weights + inline editing. - graph-canvas: React Flow canvas + mode/status overlays + EdgeWeightContext. - graph-menu: shared configurable sidebar. - graph-node / floating-edge: moved here from app/graph. - graph.js toFlow unified (weight optional); shortestPath.js reuses it.
1 parent 749bc2a commit 10a21b4

7 files changed

Lines changed: 389 additions & 26 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ReactFlow, Background, Controls } from '@xyflow/react';
2+
import '@xyflow/react/dist/style.css';
3+
import GraphNode from './graph-node';
4+
import FloatingEdge, { EdgeWeightContext } from './floating-edge';
5+
6+
// Renders the editable graph for a useGraphEditor() instance: the React Flow
7+
// canvas, the mode-indicator + status overlays, and (for weighted editors) the
8+
// EdgeWeightContext provider that enables inline weight editing.
9+
10+
const nodeTypes = { graphNode: GraphNode };
11+
const edgeTypes = { floating: FloatingEdge };
12+
13+
const baseHint = {
14+
'add-node': 'Add node — click anywhere on the canvas',
15+
'add-edge': 'Add edge — click two nodes',
16+
};
17+
18+
export default function GraphCanvas({ editor }) {
19+
const idleHint = editor.weighted
20+
? 'Click a node to select · N add node · E add edge · click a weight to edit'
21+
: 'Click a node to select · N add node · E add edge';
22+
const hint = baseHint[editor.mode] || idleHint;
23+
24+
return (
25+
<div className="relative flex-1">
26+
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2">
27+
<div className="rounded-md bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow">
28+
{hint}
29+
</div>
30+
{editor.status && (
31+
<div className="self-start rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white shadow">
32+
{editor.status}
33+
</div>
34+
)}
35+
</div>
36+
<EdgeWeightContext.Provider value={editor.weighted ? editor.setWeight : null}>
37+
<ReactFlow
38+
nodes={editor.nodes}
39+
edges={editor.edges}
40+
onNodesChange={editor.onNodesChange}
41+
onEdgesChange={editor.onEdgesChange}
42+
onNodesDelete={editor.onNodesDelete}
43+
onPaneClick={editor.onPaneClick}
44+
onNodeClick={editor.onNodeClick}
45+
nodeTypes={nodeTypes}
46+
edgeTypes={edgeTypes}
47+
nodesConnectable={false}
48+
fitView
49+
>
50+
<Background />
51+
<Controls />
52+
</ReactFlow>
53+
</EdgeWeightContext.Provider>
54+
</div>
55+
);
56+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { CustomSelect } from '@/components/custom-select';
2+
import { CustomSlider } from '@/components/custom-slider';
3+
import { CustomToggle } from '@/components/custom-toggle';
4+
import { Button } from '@/components/ui/button';
5+
import { Play, RotateCcw } from 'lucide-react';
6+
7+
// Shared sidebar for the graph visualizers. The page owns the menu and wires
8+
// these callbacks to a useGraphEditor() instance + its chosen algorithms.
9+
10+
const BASE_CONTROLS = [
11+
['N then click', 'add node'],
12+
['E then 2 nodes', 'add edge'],
13+
['click + Del', 'delete'],
14+
['click + S', 'set start'],
15+
['click + F', 'set finish'],
16+
['drag', 'move node'],
17+
['Esc', 'cancel'],
18+
];
19+
20+
export default function GraphMenu({
21+
title,
22+
algorithms,
23+
presets,
24+
weighted = false,
25+
disabled,
26+
onDirectedChange,
27+
onAlgorithmChange,
28+
onPresetChange,
29+
onSpeedChange,
30+
onVisualize,
31+
onClear,
32+
}) {
33+
const controls = weighted
34+
? [BASE_CONTROLS[0], BASE_CONTROLS[1], ['click weight', 'edit it'], ...BASE_CONTROLS.slice(2)]
35+
: BASE_CONTROLS;
36+
37+
return (
38+
<div className="w-64 bg-gray-100 p-4 space-y-6 overflow-auto">
39+
<h2 className="text-lg font-semibold">{title}</h2>
40+
41+
<div className="space-y-3">
42+
<div className="flex items-center gap-2">
43+
<div className="h-px flex-1 bg-gray-300" />
44+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Config</span>
45+
<div className="h-px flex-1 bg-gray-300" />
46+
</div>
47+
<CustomToggle title="Directed" onCheckedChange={onDirectedChange} disabled={disabled} />
48+
<CustomSelect
49+
title="Algorithm"
50+
options={algorithms}
51+
onChange={onAlgorithmChange}
52+
disabled={disabled}
53+
/>
54+
<CustomSelect
55+
title="Starter graph"
56+
options={presets.map((p) => p.name)}
57+
onChange={onPresetChange}
58+
disabled={disabled}
59+
/>
60+
<CustomSlider
61+
title="Speed"
62+
defaultValue={50}
63+
min={10}
64+
max={100}
65+
step={1}
66+
onChange={onSpeedChange}
67+
/>
68+
</div>
69+
70+
<div className="space-y-3">
71+
<div className="flex items-center gap-2">
72+
<div className="h-px flex-1 bg-gray-300" />
73+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</span>
74+
<div className="h-px flex-1 bg-gray-300" />
75+
</div>
76+
<Button className="w-full" onClick={onVisualize} disabled={disabled}>
77+
<Play /> Visualize
78+
</Button>
79+
<Button className="w-full" variant="outline" onClick={onClear} disabled={disabled}>
80+
<RotateCcw /> Clear
81+
</Button>
82+
</div>
83+
84+
<div className="space-y-2">
85+
<div className="flex items-center gap-2">
86+
<div className="h-px flex-1 bg-gray-300" />
87+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Controls</span>
88+
<div className="h-px flex-1 bg-gray-300" />
89+
</div>
90+
<dl className="text-xs text-gray-600 space-y-1">
91+
{controls.map(([key, desc]) => (
92+
<div key={key} className="flex justify-between gap-2">
93+
<dt className="font-mono text-gray-800 whitespace-nowrap">{key}</dt>
94+
<dd className="text-right">{desc}</dd>
95+
</div>
96+
))}
97+
</dl>
98+
</div>
99+
</div>
100+
);
101+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { MarkerType, useNodesState, useEdgesState, useReactFlow } from '@xyflow/react';
3+
import { newNodeId, edgeId, toFlow } from '@/lib/algorithms/graph';
4+
5+
// Reusable graph editor + action-log executor for the graph visualizers.
6+
// Owns all editing (keyboard add/delete, set start/finish, drag, directed
7+
// toggle, presets, clear) and the executor (run/applyAction). It is
8+
// algorithm-agnostic: callers build an action list and hand it to `run`.
9+
//
10+
// Must be used inside <ReactFlowProvider> (uses useReactFlow).
11+
12+
const ARROW = { type: MarkerType.ArrowClosed, color: '#64748b', width: 16, height: 16 };
13+
const toDelay = (s) => 1100 - s * 10;
14+
15+
// preset -> { nodes, edges, startId } with the first node marked as start
16+
function seed(preset) {
17+
const { nodes, edges } = toFlow(preset);
18+
if (nodes[0]) nodes[0] = { ...nodes[0], data: { ...nodes[0].data, role: 'start' } };
19+
return { nodes, edges, startId: nodes[0]?.id ?? null };
20+
}
21+
22+
export function useGraphEditor({ weighted = false, initialPreset }) {
23+
const [initial] = useState(() => seed(initialPreset));
24+
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
25+
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
26+
const [directed, setDirectedState] = useState(false);
27+
const [mode, setMode] = useState('idle');
28+
const [status, setStatus] = useState('');
29+
const [isRunning, setIsRunning] = useState(false);
30+
31+
const { screenToFlowPosition } = useReactFlow();
32+
33+
const nodesRef = useRef(initial.nodes);
34+
const edgesRef = useRef(initial.edges);
35+
const isRunningRef = useRef(false);
36+
const speedRef = useRef(toDelay(50));
37+
const modeRef = useRef('idle');
38+
const pendingEdgeRef = useRef(null);
39+
const directedRef = useRef(false);
40+
const startIdRef = useRef(initial.startId);
41+
const finishIdRef = useRef(null);
42+
const labelRef = useRef(0);
43+
44+
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
45+
useEffect(() => { edgesRef.current = edges; }, [edges]);
46+
const setModeBoth = (m) => { modeRef.current = m; setMode(m); };
47+
48+
// --- action appliers ---
49+
const markNode = (id, state) =>
50+
setNodes((ns) => ns.map((n) => (n.id === id ? { ...n, data: { ...n.data, state } } : n)));
51+
const markEdge = (id, state, to) =>
52+
setEdges((es) => es.map((e) => (e.id === id ? { ...e, data: { ...e.data, state, travelTo: to ?? null } } : e)));
53+
const setDist = (id, dist) =>
54+
setNodes((ns) => ns.map((n) => (n.id === id ? { ...n, data: { ...n.data, dist } } : n)));
55+
const clearMarks = () => {
56+
setNodes((ns) => ns.map((n) => ({ ...n, data: { ...n.data, state: 'normal', dist: undefined } })));
57+
setEdges((es) => es.map((e) => ({ ...e, data: { ...e.data, state: 'normal', travelTo: null } })));
58+
setStatus('');
59+
};
60+
61+
const applyAction = (action) => {
62+
if (action.type === 'markNode') markNode(action.id, action.state);
63+
else if (action.type === 'markEdge') markEdge(action.id, action.state, action.to);
64+
else if (action.type === 'setDist') setDist(action.id, action.dist);
65+
else if (action.type === 'status') setStatus(action.text);
66+
else if (action.type === 'clear') clearMarks();
67+
};
68+
69+
const run = async (actions) => {
70+
if (!actions || !actions.length || isRunningRef.current) return;
71+
isRunningRef.current = true;
72+
setIsRunning(true);
73+
clearMarks();
74+
for (const action of actions) {
75+
applyAction(action);
76+
await sleep(speedRef.current);
77+
}
78+
isRunningRef.current = false;
79+
setIsRunning(false);
80+
};
81+
82+
// authoritative graph snapshot for building actions at click time
83+
const getContext = () => ({
84+
nodes: nodesRef.current,
85+
edges: edgesRef.current,
86+
directed: directedRef.current,
87+
startId: startIdRef.current,
88+
finishId: finishIdRef.current,
89+
});
90+
91+
// --- editing ---
92+
const addNodeAt = (position) => {
93+
const id = newNodeId();
94+
labelRef.current += 1;
95+
const label = String(labelRef.current);
96+
setNodes((ns) => [
97+
...ns,
98+
{ id, type: 'graphNode', position, data: { label, state: 'normal', role: null } },
99+
]);
100+
};
101+
102+
const addEdge = (a, b) => {
103+
if (a === b) return;
104+
const exists = edgesRef.current.some(
105+
(e) =>
106+
(e.source === a && e.target === b) ||
107+
(!directedRef.current && e.source === b && e.target === a),
108+
);
109+
if (exists) return;
110+
const data = { state: 'normal' };
111+
if (weighted) data.weight = 1;
112+
setEdges((es) => [
113+
...es,
114+
{ id: edgeId(a, b), source: a, target: b, type: 'floating', data, markerEnd: directedRef.current ? ARROW : undefined },
115+
]);
116+
};
117+
118+
const setWeight = (id, w) => {
119+
if (!weighted || isRunningRef.current) return;
120+
setEdges((es) => es.map((e) => (e.id === id ? { ...e, data: { ...e.data, weight: w } } : e)));
121+
};
122+
123+
const setRole = (id, role) => {
124+
if (role === 'start') { startIdRef.current = id; if (finishIdRef.current === id) finishIdRef.current = null; }
125+
if (role === 'finish') { finishIdRef.current = id; if (startIdRef.current === id) startIdRef.current = null; }
126+
setNodes((ns) =>
127+
ns.map((n) => {
128+
if (n.id === id) return { ...n, data: { ...n.data, role } };
129+
if (n.data.role === role) return { ...n, data: { ...n.data, role: null } };
130+
return n;
131+
}),
132+
);
133+
};
134+
135+
const onPaneClick = (event) => {
136+
if (isRunningRef.current) return;
137+
if (modeRef.current === 'add-node') {
138+
addNodeAt(screenToFlowPosition({ x: event.clientX, y: event.clientY }));
139+
setModeBoth('idle');
140+
}
141+
pendingEdgeRef.current = null;
142+
};
143+
144+
const onNodeClick = (_event, node) => {
145+
if (isRunningRef.current) return;
146+
if (modeRef.current === 'add-edge') {
147+
if (pendingEdgeRef.current == null) {
148+
pendingEdgeRef.current = node.id;
149+
} else {
150+
addEdge(pendingEdgeRef.current, node.id);
151+
pendingEdgeRef.current = null;
152+
setModeBoth('idle');
153+
}
154+
}
155+
};
156+
157+
const onNodesDelete = (deleted) => {
158+
for (const n of deleted) {
159+
if (n.id === startIdRef.current) startIdRef.current = null;
160+
if (n.id === finishIdRef.current) finishIdRef.current = null;
161+
}
162+
};
163+
164+
useEffect(() => {
165+
const onKey = (e) => {
166+
if (isRunningRef.current) return;
167+
const el = e.target;
168+
if (el && el.closest && el.closest('input,select,textarea,[role="combobox"],button')) return;
169+
170+
const selected = nodesRef.current.find((n) => n.selected);
171+
const k = e.key.toLowerCase();
172+
if (k === 'n') { setModeBoth('add-node'); pendingEdgeRef.current = null; }
173+
else if (k === 'e') { setModeBoth('add-edge'); pendingEdgeRef.current = null; }
174+
else if (k === 's' && selected) setRole(selected.id, 'start');
175+
else if (k === 'f' && selected) setRole(selected.id, 'finish');
176+
else if (e.key === 'Escape') { setModeBoth('idle'); pendingEdgeRef.current = null; }
177+
};
178+
window.addEventListener('keydown', onKey);
179+
return () => window.removeEventListener('keydown', onKey);
180+
// eslint-disable-next-line react-hooks/exhaustive-deps
181+
}, []);
182+
183+
// --- menu-facing controls ---
184+
const setDirected = (val) => {
185+
directedRef.current = val;
186+
setDirectedState(val);
187+
setEdges((es) => es.map((e) => ({ ...e, markerEnd: val ? ARROW : undefined })));
188+
};
189+
const loadPreset = (preset) => {
190+
if (isRunningRef.current) return;
191+
const g = seed(preset);
192+
const e = directedRef.current ? g.edges.map((x) => ({ ...x, markerEnd: ARROW })) : g.edges;
193+
nodesRef.current = g.nodes; edgesRef.current = e;
194+
startIdRef.current = g.startId; finishIdRef.current = null;
195+
labelRef.current = 0;
196+
setNodes(g.nodes); setEdges(e);
197+
setModeBoth('idle'); setStatus(''); pendingEdgeRef.current = null;
198+
};
199+
const clear = () => {
200+
if (isRunningRef.current) return;
201+
nodesRef.current = []; edgesRef.current = [];
202+
startIdRef.current = null; finishIdRef.current = null;
203+
labelRef.current = 0;
204+
setNodes([]); setEdges([]);
205+
setModeBoth('idle'); setStatus(''); pendingEdgeRef.current = null;
206+
};
207+
const setSpeed = (s) => { speedRef.current = toDelay(s); };
208+
209+
return {
210+
// reactive
211+
nodes, edges, directed, mode, status, isRunning, weighted,
212+
// react flow wiring
213+
onNodesChange, onEdgesChange, onNodesDelete, onPaneClick, onNodeClick,
214+
// methods
215+
run, getContext, setWeight, setDirected, loadPreset, clear, setSpeed,
216+
};
217+
}
218+
219+
function sleep(ms) {
220+
return new Promise((resolve) => setTimeout(resolve, ms));
221+
}

0 commit comments

Comments
 (0)