Skip to content

Commit 01a17e5

Browse files
committed
feat: Add Graph Traversal visualizer (BFS / DFS)
New /graph route built on React Flow: a hand-positioned starter graph that users can edit with keyboard controls (N add node, E add edge, S/F set start/finish, Del delete, drag to move) and run BFS or DFS over. - Pure planners (bfsActions/dfsActions) return a flat action log; the page is a generic executor applying each step with a delay - Directed/undirected toggle; finish node stops the run and highlights the start->finish path - Floating edges (no handles) with a big mid-edge arrow showing travel direction, for both directed and undirected graphs - Added home card linking to /graph
1 parent 1725c7b commit 01a17e5

10 files changed

Lines changed: 1223 additions & 368 deletions

File tree

package-lock.json

Lines changed: 543 additions & 368 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@testing-library/jest-dom": "^5.11.5",
1212
"@testing-library/react": "^11.1.0",
1313
"@testing-library/user-event": "^12.1.10",
14+
"@xyflow/react": "^12.11.0",
1415
"autoprefixer": "^10.4.20",
1516
"bootstrap": "^5.3.3",
1617
"class-variance-authority": "^0.7.1",

public/images/graph-traversal.png

216 KB
Loading

public/images/graph.png

-103 KB
Loading

src/app/components/algorithm-cards.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ const algorithms = [
1010
title: "Pathfinder",
1111
description: "Visualize graph algorithms like dijkstra, BFS, DFS",
1212
image: '/AlgorithmVisualizer/images/graph.png?height=200&width=300'
13+
},{
14+
id: 'graph',
15+
title: 'Graph Traversal',
16+
description: "Build a graph and watch BFS and DFS explore it node by node",
17+
image: '/AlgorithmVisualizer/images/graph-traversal.png?height=200&width=300'
1318
},
1419
{
1520
id: 'recursion-tree',

src/app/graph/floating-edge.jsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { BaseEdge, getStraightPath, useInternalNode } from '@xyflow/react';
2+
3+
// Edge that connects node centers (no handles), trimmed to each node's border.
4+
// Stroke color encodes the edge state; during traversal a big arrow is drawn at
5+
// the edge midpoint pointing in the direction of travel.
6+
7+
const R = 22; // node radius (node is 44px)
8+
9+
const STROKE = { tree: '#f59e0b', path: '#10b981', normal: '#64748b' };
10+
11+
function center(node) {
12+
const { x, y } = node.internals.positionAbsolute;
13+
const w = node.measured?.width ?? 44;
14+
const h = node.measured?.height ?? 44;
15+
return { x: x + w / 2, y: y + h / 2 };
16+
}
17+
18+
export default function FloatingEdge({ id, source, target, markerEnd, data }) {
19+
const sourceNode = useInternalNode(source);
20+
const targetNode = useInternalNode(target);
21+
if (!sourceNode || !targetNode) return null;
22+
23+
const sc = center(sourceNode);
24+
const tc = center(targetNode);
25+
26+
const state = data?.state || 'normal';
27+
const travelTo = data?.travelTo;
28+
29+
// Orient along the travel direction so the midpoint arrow points the way we
30+
// move (handles edges stored opposite to the traversal direction).
31+
let from = sc;
32+
let to = tc;
33+
if (travelTo === source) { from = tc; to = sc; }
34+
35+
const dx = to.x - from.x;
36+
const dy = to.y - from.y;
37+
const len = Math.hypot(dx, dy) || 1;
38+
const ux = dx / len;
39+
const uy = dy / len;
40+
41+
const sx = from.x + ux * R;
42+
const sy = from.y + uy * R;
43+
const ex = to.x - ux * R;
44+
const ey = to.y - uy * R;
45+
const [path] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: ex, targetY: ey });
46+
47+
const stroke = STROKE[state] || STROKE.normal;
48+
// Constant width so the directed arrowhead (sized in stroke-width units)
49+
// doesn't grow during traversal; state is conveyed by color + the mid arrow.
50+
const strokeWidth = 2;
51+
52+
const showTravelArrow = state === 'tree' || state === 'path';
53+
const mx = (sx + ex) / 2;
54+
const my = (sy + ey) / 2;
55+
const deg = (Math.atan2(uy, ux) * 180) / Math.PI;
56+
57+
return (
58+
<>
59+
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth }} />
60+
{showTravelArrow && (
61+
<path
62+
d="M -7 -6 L 7 0 L -7 6 z"
63+
fill={stroke}
64+
transform={`translate(${mx}, ${my}) rotate(${deg})`}
65+
/>
66+
)}
67+
</>
68+
);
69+
}

src/app/graph/graph-node.jsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { memo } from 'react';
2+
import { Handle, Position } from '@xyflow/react';
3+
4+
// Clean circular node. Fill encodes traversal state; an outer ring marks the
5+
// start/finish role. Hidden handles satisfy React Flow's edge anchoring
6+
// (edges are floating and created via keyboard, not handle dragging).
7+
8+
const FILL = {
9+
normal: ['#0d9488', '#0f766e'],
10+
current: ['#f59e0b', '#d97706'],
11+
frontier: ['#4f46e5', '#4338ca'],
12+
visited: ['#334155', '#475569'],
13+
path: ['#10b981', '#059669'],
14+
};
15+
16+
function GraphNode({ data }) {
17+
const [bg, border] = FILL[data.state] || FILL.normal;
18+
const ring = data.role === 'start' ? '#10b981' : data.role === 'finish' ? '#f43f5e' : null;
19+
20+
return (
21+
<div
22+
style={{
23+
position: 'relative',
24+
width: 44,
25+
height: 44,
26+
borderRadius: '50%',
27+
background: bg,
28+
border: `2px solid ${border}`,
29+
color: '#f8fafc',
30+
display: 'flex',
31+
alignItems: 'center',
32+
justifyContent: 'center',
33+
fontWeight: 600,
34+
fontSize: 15,
35+
boxShadow: ring ? `0 0 0 3px ${ring}` : '0 1px 3px rgba(0,0,0,0.3)',
36+
}}
37+
>
38+
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} isConnectable={false} />
39+
{data.label}
40+
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} isConnectable={false} />
41+
{ring && (
42+
<span style={{ position: 'absolute', top: -15, fontSize: 9, fontWeight: 700, color: ring }}>
43+
{data.role}
44+
</span>
45+
)}
46+
</div>
47+
);
48+
}
49+
50+
export default memo(GraphNode);

src/app/graph/menu.jsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
import { PRESETS } from '@/lib/algorithms/graph';
7+
8+
const CONTROLS = [
9+
['N then click', 'add node'],
10+
['E then 2 nodes', 'add edge'],
11+
['click + Del', 'delete'],
12+
['click + S', 'set start'],
13+
['click + F', 'set finish'],
14+
['drag', 'move node'],
15+
['Esc', 'cancel'],
16+
];
17+
18+
export default function Menu({
19+
disabled,
20+
onDirectedChange,
21+
onAlgorithmChange,
22+
onPresetChange,
23+
onSpeedChange,
24+
onVisualize,
25+
onClear,
26+
}) {
27+
return (
28+
<div className="w-64 bg-gray-100 p-4 space-y-6 overflow-auto">
29+
<h2 className="text-lg font-semibold">Graph Traversal</h2>
30+
31+
<div className="space-y-3">
32+
<div className="flex items-center gap-2">
33+
<div className="h-px flex-1 bg-gray-300" />
34+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Config</span>
35+
<div className="h-px flex-1 bg-gray-300" />
36+
</div>
37+
<CustomToggle
38+
title="Directed"
39+
onCheckedChange={onDirectedChange}
40+
disabled={disabled}
41+
/>
42+
<CustomSelect
43+
title="Algorithm"
44+
options={['BFS', 'DFS']}
45+
onChange={onAlgorithmChange}
46+
disabled={disabled}
47+
/>
48+
<CustomSelect
49+
title="Starter graph"
50+
options={PRESETS.map((p) => p.name)}
51+
onChange={onPresetChange}
52+
disabled={disabled}
53+
/>
54+
<CustomSlider
55+
title="Speed"
56+
defaultValue={50}
57+
min={10}
58+
max={100}
59+
step={1}
60+
onChange={onSpeedChange}
61+
/>
62+
</div>
63+
64+
<div className="space-y-3">
65+
<div className="flex items-center gap-2">
66+
<div className="h-px flex-1 bg-gray-300" />
67+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</span>
68+
<div className="h-px flex-1 bg-gray-300" />
69+
</div>
70+
<Button className="w-full" onClick={onVisualize} disabled={disabled}>
71+
<Play /> Visualize
72+
</Button>
73+
<Button className="w-full" variant="outline" onClick={onClear} disabled={disabled}>
74+
<RotateCcw /> Clear
75+
</Button>
76+
</div>
77+
78+
<div className="space-y-2">
79+
<div className="flex items-center gap-2">
80+
<div className="h-px flex-1 bg-gray-300" />
81+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Controls</span>
82+
<div className="h-px flex-1 bg-gray-300" />
83+
</div>
84+
<dl className="text-xs text-gray-600 space-y-1">
85+
{CONTROLS.map(([key, desc]) => (
86+
<div key={key} className="flex justify-between gap-2">
87+
<dt className="font-mono text-gray-800 whitespace-nowrap">{key}</dt>
88+
<dd className="text-right">{desc}</dd>
89+
</div>
90+
))}
91+
</dl>
92+
</div>
93+
</div>
94+
);
95+
}

0 commit comments

Comments
 (0)