Skip to content

Commit 749bc2a

Browse files
committed
feat: Add Single-Source Shortest Path visualizer (Dijkstra / Bellman-Ford)
New /shortest-path route reusing the /graph editor with weighted edges and per-node distance labels. - Pure planners (dijkstraActions / bellmanFordActions) emit the action log; Bellman-Ford runs V-1 passes plus a detection pass that flags a negative cycle. Dijkstra warns on negative edges. - Inline-editable edge weights (click a weight to type a new value), wired through a new EdgeWeightContext. - Additive enhancements to the shared graph components: distance badge on the node, weight label + relax/negcycle edge states on the edge; /graph BFS/DFS is unchanged. - Weight label anchors to the stable edge orientation so it doesn't jump sides during traversal. - Added home card linking to /shortest-path.
1 parent 305e8a3 commit 749bc2a

7 files changed

Lines changed: 736 additions & 4 deletions

File tree

public/images/shortest-path.png

216 KB
Loading

src/app/components/algorithm-cards.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const algorithms = [
1515
title: 'Graph Traversal',
1616
description: "Build a graph and watch BFS and DFS explore it node by node",
1717
image: '/AlgorithmVisualizer/images/graph-traversal.png?height=200&width=300'
18+
},{
19+
id: 'shortest-path',
20+
title: 'Shortest Path',
21+
description: "Weighted graphs with Dijkstra and Bellman-Ford, including negative-cycle detection",
22+
image: '/AlgorithmVisualizer/images/shortest-path.png?height=200&width=300'
1823
},
1924
{
2025
id: 'recursion-tree',

src/app/graph/floating-edge.jsx

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
import { BaseEdge, getStraightPath, useInternalNode } from '@xyflow/react';
1+
import { createContext, useContext, useState } from 'react';
2+
import { BaseEdge, EdgeLabelRenderer, getStraightPath, useInternalNode } from '@xyflow/react';
23

34
// Edge that connects node centers (no handles), trimmed to each node's border.
45
// 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+
// the edge midpoint pointing in the direction of travel. When data.weight is set
7+
// an editable weight label is shown (used by the shortest-path visualizer).
8+
9+
// Provided only by pages that allow weight editing; null elsewhere.
10+
export const EdgeWeightContext = createContext(null);
611

712
const R = 22; // node radius (node is 44px)
813

9-
const STROKE = { tree: '#f59e0b', path: '#10b981', normal: '#64748b' };
14+
const STROKE = {
15+
relax: '#fbbf24',
16+
tree: '#f59e0b',
17+
path: '#10b981',
18+
negcycle: '#f43f5e',
19+
normal: '#64748b',
20+
};
1021

1122
function center(node) {
1223
const { x, y } = node.internals.positionAbsolute;
@@ -16,6 +27,10 @@ function center(node) {
1627
}
1728

1829
export default function FloatingEdge({ id, source, target, markerEnd, data }) {
30+
const setWeight = useContext(EdgeWeightContext);
31+
const [editing, setEditing] = useState(false);
32+
const [draft, setDraft] = useState('');
33+
1934
const sourceNode = useInternalNode(source);
2035
const targetNode = useInternalNode(target);
2136
if (!sourceNode || !targetNode) return null;
@@ -42,7 +57,7 @@ export default function FloatingEdge({ id, source, target, markerEnd, data }) {
4257
const sy = from.y + uy * R;
4358
const ex = to.x - ux * R;
4459
const ey = to.y - uy * R;
45-
const [path] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: ex, targetY: ey });
60+
const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: ex, targetY: ey });
4661

4762
const stroke = STROKE[state] || STROKE.normal;
4863
// Constant width so the directed arrowhead (sized in stroke-width units)
@@ -54,6 +69,22 @@ export default function FloatingEdge({ id, source, target, markerEnd, data }) {
5469
const my = (sy + ey) / 2;
5570
const deg = (Math.atan2(uy, ux) * 180) / Math.PI;
5671

72+
// weight label offset perpendicular to the edge so it clears the line/arrow.
73+
// Use the stable source->target orientation (not the travel-flipped one) so
74+
// the label stays on the same side during traversal.
75+
const hasWeight = data?.weight != null;
76+
const sdx = tc.x - sc.x;
77+
const sdy = tc.y - sc.y;
78+
const slen = Math.hypot(sdx, sdy) || 1;
79+
const lx = labelX + (-sdy / slen) * 9;
80+
const ly = labelY + (sdx / slen) * 9;
81+
82+
const commit = () => {
83+
setEditing(false);
84+
const v = Number(draft);
85+
if (setWeight && draft.trim() !== '' && Number.isFinite(v)) setWeight(id, v);
86+
};
87+
5788
return (
5889
<>
5990
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth }} />
@@ -64,6 +95,50 @@ export default function FloatingEdge({ id, source, target, markerEnd, data }) {
6495
transform={`translate(${mx}, ${my}) rotate(${deg})`}
6596
/>
6697
)}
98+
{hasWeight && (
99+
<EdgeLabelRenderer>
100+
<div
101+
style={{
102+
position: 'absolute',
103+
transform: `translate(-50%, -50%) translate(${lx}px, ${ly}px)`,
104+
pointerEvents: 'all',
105+
fontSize: 11,
106+
fontWeight: 700,
107+
color: '#0f172a',
108+
background: '#fff',
109+
border: '1px solid #cbd5e1',
110+
borderRadius: 4,
111+
padding: '0 4px',
112+
lineHeight: '16px',
113+
cursor: setWeight ? 'text' : 'default',
114+
}}
115+
className="nodrag nopan"
116+
onClick={(e) => {
117+
if (!setWeight) return;
118+
e.stopPropagation();
119+
setDraft(String(data.weight));
120+
setEditing(true);
121+
}}
122+
>
123+
{editing ? (
124+
<input
125+
autoFocus
126+
type="number"
127+
value={draft}
128+
onChange={(e) => setDraft(e.target.value)}
129+
onBlur={commit}
130+
onKeyDown={(e) => {
131+
if (e.key === 'Enter') commit();
132+
else if (e.key === 'Escape') setEditing(false);
133+
}}
134+
style={{ width: 36, border: 'none', outline: 'none', font: 'inherit', textAlign: 'center' }}
135+
/>
136+
) : (
137+
data.weight
138+
)}
139+
</div>
140+
</EdgeLabelRenderer>
141+
)}
67142
</>
68143
);
69144
}

src/app/graph/graph-node.jsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const FILL = {
1111
frontier: ['#4f46e5', '#4338ca'],
1212
visited: ['#334155', '#475569'],
1313
path: ['#10b981', '#059669'],
14+
negcycle: ['#f43f5e', '#be123c'],
1415
};
1516

1617
function GraphNode({ data }) {
@@ -43,6 +44,23 @@ function GraphNode({ data }) {
4344
{data.role}
4445
</span>
4546
)}
47+
{data.dist !== undefined && (
48+
<span
49+
style={{
50+
position: 'absolute',
51+
bottom: -16,
52+
fontSize: 10,
53+
fontWeight: 700,
54+
color: '#0f172a',
55+
background: '#fff',
56+
borderRadius: 4,
57+
padding: '0 4px',
58+
boxShadow: '0 1px 2px rgba(0,0,0,0.25)',
59+
}}
60+
>
61+
{data.dist === Infinity || data.dist == null ? '∞' : data.dist}
62+
</span>
63+
)}
4664
</div>
4765
);
4866
}

src/app/shortest-path/menu.jsx

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

0 commit comments

Comments
 (0)