Skip to content

Commit 2cca466

Browse files
committed
feat: Add Minimum Spanning Tree visualizer (Kruskal / Prim)
New /mst route on the weighted graph workspace (undirected, hideDirected menu). kruskalActions uses Union-Find — edges accept green in weight order, cycle edges fade, running total in the status line; primActions grows the tree from the S start node. Both report a not-connected forest. Added the home card and a real shortest-path thumbnail.
1 parent bce5474 commit 2cca466

5 files changed

Lines changed: 176 additions & 0 deletions

File tree

public/images/mst.png

201 KB
Loading

public/images/shortest-path.png

26.5 KB
Loading

src/app/components/algorithm-cards.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ const algorithms = [
2020
title: 'Shortest Path',
2121
description: "Weighted graphs with Dijkstra and Bellman-Ford, including negative-cycle detection",
2222
image: '/AlgorithmVisualizer/images/shortest-path.png?height=200&width=300'
23+
},{
24+
id: 'mst',
25+
title: 'Minimum Spanning Tree',
26+
description: "Build a weighted graph and watch Kruskal and Prim grow the minimum spanning tree",
27+
image: '/AlgorithmVisualizer/images/mst.png?height=200&width=300'
2328
},
2429
{
2530
id: 'recursion-tree',

src/app/mst/page.jsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import { useState } from 'react';
4+
import { ReactFlowProvider } from '@xyflow/react';
5+
import Navbar from '@/components/navbar';
6+
import { weightedAdjacency } from '@/lib/algorithms/graph';
7+
import { MST_PRESETS, kruskalActions, primActions } from '@/lib/algorithms/mst';
8+
import { useGraphEditor } from '@/components/graph/use-graph-editor';
9+
import GraphCanvas from '@/components/graph/graph-canvas';
10+
import GraphMenu from '@/components/graph/graph-menu';
11+
12+
function MstInner() {
13+
const g = useGraphEditor({ weighted: true, initialPreset: MST_PRESETS[0] });
14+
const [algo, setAlgo] = useState(0);
15+
16+
const onVisualize = () => {
17+
const { nodes, edges, startId } = g.getContext();
18+
g.run(algo === 1
19+
? primActions(weightedAdjacency(edges, false), startId, nodes)
20+
: kruskalActions(nodes, edges));
21+
};
22+
23+
return (
24+
<div className="flex flex-col h-screen">
25+
<Navbar />
26+
<div className="flex flex-1 overflow-hidden">
27+
<GraphMenu
28+
title="Minimum Spanning Tree"
29+
algorithms={['Kruskal', 'Prim']}
30+
presets={MST_PRESETS}
31+
weighted
32+
hideDirected
33+
disabled={g.isRunning}
34+
onAlgorithmChange={setAlgo}
35+
onPresetChange={(i) => g.loadPreset(MST_PRESETS[i])}
36+
onSpeedChange={g.setSpeed}
37+
onVisualize={onVisualize}
38+
onClear={g.clear}
39+
/>
40+
<GraphCanvas editor={g} />
41+
</div>
42+
</div>
43+
);
44+
}
45+
46+
export default function Mst() {
47+
return (
48+
<ReactFlowProvider>
49+
<MstInner />
50+
</ReactFlowProvider>
51+
);
52+
}

src/lib/algorithms/mst.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Minimum Spanning Tree — presets and Kruskal/Prim planners.
2+
//
3+
// Reuses the action-log pattern and the weighted graph workspace. MST is
4+
// undirected; planners treat the graph as undirected. Edge/node states reused:
5+
// relax -> edge being considered
6+
// path -> edge/node in the spanning tree
7+
// reject -> edge skipped (would form a cycle)
8+
// plus { type: 'status', text } for the running/total weight.
9+
10+
export const MST_PRESETS = [
11+
{
12+
name: 'Mesh',
13+
nodes: [
14+
{ id: 'n1', x: 120, y: 70, label: 'A' },
15+
{ id: 'n2', x: 290, y: 50, label: 'B' },
16+
{ id: 'n3', x: 450, y: 100, label: 'C' },
17+
{ id: 'n4', x: 160, y: 240, label: 'D' },
18+
{ id: 'n5', x: 320, y: 250, label: 'E' },
19+
{ id: 'n6', x: 470, y: 240, label: 'F' },
20+
],
21+
edges: [
22+
['n1', 'n2', 4], ['n2', 'n3', 3], ['n1', 'n4', 2], ['n2', 'n4', 5],
23+
['n2', 'n5', 10], ['n3', 'n5', 6], ['n3', 'n6', 1], ['n4', 'n5', 4], ['n5', 'n6', 2],
24+
],
25+
},
26+
{
27+
name: 'Ring',
28+
nodes: [
29+
{ id: 'n1', x: 250, y: 40, label: 'A' },
30+
{ id: 'n2', x: 360, y: 110, label: 'B' },
31+
{ id: 'n3', x: 360, y: 240, label: 'C' },
32+
{ id: 'n4', x: 250, y: 300, label: 'D' },
33+
{ id: 'n5', x: 140, y: 240, label: 'E' },
34+
{ id: 'n6', x: 140, y: 110, label: 'F' },
35+
],
36+
edges: [
37+
['n1', 'n2', 3], ['n2', 'n3', 5], ['n3', 'n4', 2], ['n4', 'n5', 6],
38+
['n5', 'n6', 4], ['n6', 'n1', 1], ['n1', 'n4', 7], ['n2', 'n5', 8],
39+
],
40+
},
41+
];
42+
43+
// each undirected edge once
44+
function weightedEdges(edges) {
45+
return edges.map((e) => ({ u: e.source, v: e.target, w: e.data?.weight ?? 1, id: e.id }));
46+
}
47+
48+
export function kruskalActions(nodes, edges) {
49+
const actions = [];
50+
const V = nodes.length;
51+
if (V === 0) return actions;
52+
53+
const sorted = weightedEdges(edges).sort((a, b) => (a.w - b.w) || (a.id < b.id ? -1 : 1));
54+
55+
const parent = {};
56+
for (const n of nodes) parent[n.id] = n.id;
57+
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
58+
59+
let total = 0;
60+
let accepted = 0;
61+
for (const { u, v, w, id } of sorted) {
62+
actions.push({ type: 'markEdge', id, state: 'relax' });
63+
const ru = find(u);
64+
const rv = find(v);
65+
if (ru !== rv) {
66+
parent[ru] = rv;
67+
total += w;
68+
accepted += 1;
69+
actions.push({ type: 'markEdge', id, state: 'path' });
70+
actions.push({ type: 'markNode', id: u, state: 'path' });
71+
actions.push({ type: 'markNode', id: v, state: 'path' });
72+
actions.push({ type: 'status', text: `MST weight: ${total}` });
73+
} else {
74+
actions.push({ type: 'markEdge', id, state: 'reject' });
75+
}
76+
}
77+
actions.push({
78+
type: 'status',
79+
text: accepted === V - 1 ? `MST complete · weight ${total}` : `Not connected · forest weight ${total}`,
80+
});
81+
return actions;
82+
}
83+
84+
export function primActions(adj, startId, nodes) {
85+
const actions = [];
86+
const ids = nodes.map((n) => n.id);
87+
const V = ids.length;
88+
const start = startId ?? ids[0];
89+
if (start == null) return actions;
90+
91+
const visited = new Set([start]);
92+
actions.push({ type: 'markNode', id: start, state: 'path' });
93+
let total = 0;
94+
95+
while (visited.size < V) {
96+
// best crossing edge from the visited set to an unvisited node
97+
let best = null;
98+
for (const u of visited) {
99+
for (const { node: v, edge, weight } of adj[u] || []) {
100+
if (visited.has(v)) continue;
101+
if (best == null || weight < best.weight) best = { u, v, edge, weight };
102+
}
103+
}
104+
if (best == null) break; // disconnected
105+
106+
actions.push({ type: 'markEdge', id: best.edge, state: 'relax' });
107+
actions.push({ type: 'markEdge', id: best.edge, state: 'path' });
108+
actions.push({ type: 'markNode', id: best.v, state: 'path' });
109+
visited.add(best.v);
110+
total += best.weight;
111+
actions.push({ type: 'status', text: `MST weight: ${total}` });
112+
}
113+
114+
actions.push({
115+
type: 'status',
116+
text: visited.size === V ? `MST complete · weight ${total}` : `Not connected · weight ${total}`,
117+
});
118+
return actions;
119+
}

0 commit comments

Comments
 (0)