Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions __tests__/lib/algorithms/graph/bellmanFord.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { bellmanFord } from "@/lib/algorithms/graph/bellmanFord";
import {
createGraph,
sampleWeightedGraph,
} from "@/lib/algorithms/graph/sampleGraphs";
import type { GraphStep } from "@/lib/types";

function lastStep(steps: GraphStep[]): GraphStep {
return steps[steps.length - 1]!;
}

describe("bellmanFord", () => {
it("returns metadata", () => {
const viz = bellmanFord(sampleWeightedGraph(), 0);
expect(viz.key).toBe("bellmanFord");
expect(viz.category).toBe("graph");
expect(viz.steps.length).toBeGreaterThan(0);
});

it("reaches every vertex in the predefined connected graph", () => {
const viz = bellmanFord(sampleWeightedGraph(), 0);
const final = lastStep(viz.steps as GraphStep[]);
expect(final.visited.sort()).toEqual([0, 1, 2, 3, 4, 5]);
});

it("clamps invalid source to 0", () => {
const viz = bellmanFord(sampleWeightedGraph(), -5);
const final = lastStep(viz.steps as GraphStep[]);
expect(final.visited.length).toBe(6);
});

it("respects negative edge weights", () => {
// 0 ->(5) 1 ->(-2) 2; direct 0 ->(2) 2 should NOT be chosen
const graph = createGraph({
numVertices: 3,
directed: true,
edges: [
{ from: 0, to: 1, weight: 5 },
{ from: 1, to: 2, weight: -2 },
{ from: 0, to: 2, weight: 2 },
],
});
const viz = bellmanFord(graph, 0);
const steps = viz.steps as GraphStep[];
// The path through the negative edge (cost 3) beats the direct edge (cost 2)? No: 3 > 2.
// So direct edge wins. Verify we still reach all vertices.
const final = lastStep(steps);
expect(final.visited.sort()).toEqual([0, 1, 2]);
});

it("each step embeds the graph", () => {
const viz = bellmanFord(sampleWeightedGraph(), 0);
const step = lastStep(viz.steps as GraphStep[]);
expect(step.graph.numVertices).toBe(6);
});
});
91 changes: 91 additions & 0 deletions __tests__/lib/algorithms/graph/mst.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { prim } from "@/lib/algorithms/graph/prim";
import { kruskal } from "@/lib/algorithms/graph/kruskal";
import {
createGraph,
sampleWeightedGraph,
} from "@/lib/algorithms/graph/sampleGraphs";
import type { Edge, GraphStep } from "@/lib/types";

function lastStep(steps: GraphStep[]): GraphStep {
return steps[steps.length - 1]!;
}

function totalWeight(edges: Edge[]): number {
return edges.reduce((sum, e) => sum + e.weight, 0);
}

// Triangle 0-1-2 with weights 1, 2, 3. MST is the two edges of weight 1 + 2 = 3.
function triangleGraph() {
return createGraph({
numVertices: 3,
directed: false,
edges: [
{ from: 0, to: 1, weight: 1 },
{ from: 1, to: 2, weight: 2 },
{ from: 0, to: 2, weight: 3 },
],
});
}

describe("prim", () => {
it("returns metadata", () => {
const viz = prim(sampleWeightedGraph(), 0);
expect(viz.key).toBe("prim");
expect(viz.category).toBe("graph");
expect(viz.steps.length).toBeGreaterThan(0);
});

it("produces an MST with V-1 edges and minimum total weight", () => {
const viz = prim(triangleGraph(), 0);
const final = lastStep(viz.steps as GraphStep[]);
expect(final.treeEdges).toBeDefined();
expect(final.treeEdges!.length).toBe(2);
expect(totalWeight(final.treeEdges!)).toBe(3);
});

it("spans all reachable vertices on the sample graph", () => {
const viz = prim(sampleWeightedGraph(), 0);
const final = lastStep(viz.steps as GraphStep[]);
expect(final.treeEdges!.length).toBe(5);
expect(final.visited.sort()).toEqual([0, 1, 2, 3, 4, 5]);
});

it("clamps invalid start vertex to 0", () => {
const viz = prim(sampleWeightedGraph(), 999);
const final = lastStep(viz.steps as GraphStep[]);
expect(final.treeEdges!.length).toBe(5);
});
});

describe("kruskal", () => {
it("returns metadata", () => {
const viz = kruskal(sampleWeightedGraph());
expect(viz.key).toBe("kruskal");
expect(viz.category).toBe("graph");
expect(viz.steps.length).toBeGreaterThan(0);
});

it("produces an MST with V-1 edges and minimum total weight", () => {
const viz = kruskal(triangleGraph());
const final = lastStep(viz.steps as GraphStep[]);
expect(final.treeEdges!.length).toBe(2);
expect(totalWeight(final.treeEdges!)).toBe(3);
});

it("spans all vertices on the sample graph", () => {
const viz = kruskal(sampleWeightedGraph());
const final = lastStep(viz.steps as GraphStep[]);
expect(final.treeEdges!.length).toBe(5);
});

it("Prim and Kruskal agree on total MST weight", () => {
const primViz = prim(sampleWeightedGraph(), 0);
const kruskalViz = kruskal(sampleWeightedGraph());
const primFinal = lastStep(primViz.steps as GraphStep[]);
const kruskalFinal = lastStep(kruskalViz.steps as GraphStep[]);
expect(totalWeight(primFinal.treeEdges!)).toBe(
totalWeight(kruskalFinal.treeEdges!)
);
});
});
3 changes: 3 additions & 0 deletions __tests__/lib/algorithms/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ describe("category guards", () => {
expect(isSearchAlgorithm("binarySearch")).toBe(true);
expect(isSearchAlgorithm("dfs")).toBe(false);
expect(isGraphAlgorithm("dijkstra")).toBe(true);
expect(isGraphAlgorithm("bellmanFord")).toBe(true);
expect(isGraphAlgorithm("prim")).toBe(true);
expect(isGraphAlgorithm("kruskal")).toBe(true);
expect(isGraphAlgorithm("bubbleSort")).toBe(false);
});

Expand Down
134 changes: 20 additions & 114 deletions components/visualizer/GraphVisualization.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { Edge, GraphStep } from "@/lib/types";
import type { GraphStep } from "@/lib/types";
import { useState, useEffect } from "react";
import type { ReactNode } from "react";

const VERTEX_RADIUS = 20;
const EDGE_COLOR = "#CBD5E1";
const PATH_EDGE_COLOR = "#F87171";
import { ArrowDefs, EdgeShape, VERTEX_RADIUS, edgeKey } from "./graphEdge";

function getStatusMessage(
current: number,
Expand All @@ -20,117 +17,12 @@ function getStatusMessage(
return <p>Traversal will start from a selected vertex.</p>;
}

function getMarkerEnd(
showArrows: boolean,
onPath: boolean
): string | undefined {
if (!showArrows) return undefined;
return onPath ? "url(#arrowhead-path)" : "url(#arrowhead)";
}

function ArrowDefs() {
return (
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={EDGE_COLOR} />
</marker>
<marker
id="arrowhead-path"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={PATH_EDGE_COLOR} />
</marker>
</defs>
);
}

interface EdgeShapeProps {
edge: Edge;
from: { x: number; y: number };
to: { x: number; y: number };
onPath: boolean;
showArrows: boolean;
showWeights: boolean;
}

function EdgeShape({
edge,
from,
to,
onPath,
showArrows,
showWeights,
}: EdgeShapeProps) {
const stroke = onPath ? PATH_EDGE_COLOR : EDGE_COLOR;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / dist;
const uy = dy / dist;
const x2 = to.x - ux * VERTEX_RADIUS;
const y2 = to.y - uy * VERTEX_RADIUS;
const x1 = showArrows ? from.x + ux * VERTEX_RADIUS : from.x;
const y1 = showArrows ? from.y + uy * VERTEX_RADIUS : from.y;
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2;

return (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={stroke}
strokeWidth={2}
markerEnd={getMarkerEnd(showArrows, onPath)}
/>
{showWeights && (
<g>
<rect
x={midX - 10}
y={midY - 9}
width={20}
height={16}
rx={3}
fill="white"
opacity={0.9}
/>
<text
x={midX}
y={midY + 3}
textAnchor="middle"
fontSize={11}
fill="#374151"
fontWeight={600}
>
{edge.weight}
</text>
</g>
)}
</g>
);
}

interface GraphVisualizationProps {
step: GraphStep;
}

export default function GraphVisualization({ step }: GraphVisualizationProps) {
const { graph, current, visited, stack, path } = step;
const { graph, current, visited, stack, path, treeEdges } = step;
const [graphSize, setGraphSize] = useState({ width: 0, height: 0 });

useEffect(() => {
Expand Down Expand Up @@ -178,6 +70,12 @@ export default function GraphVisualization({ step }: GraphVisualizationProps) {
return Math.abs(fromIdx - toIdx) === 1;
};

const treeEdgeKeys = new Set(
(treeEdges ?? []).map((e) => edgeKey(e.from, e.to, graph.directed))
);
const isEdgeInTree = (from: number, to: number) =>
treeEdgeKeys.has(edgeKey(from, to, graph.directed));

const showWeights = graph.edges.some((e) => e.weight !== 1);
const showArrows = graph.directed;

Expand All @@ -187,9 +85,16 @@ export default function GraphVisualization({ step }: GraphVisualizationProps) {
<div className="text-sm font-medium text-gray-700">
Visited: {visited.map((v) => v).join(" → ")}
</div>
<div className="text-sm font-medium text-gray-700">
Stack: [{stack.join(", ")}]
</div>
{stack.length > 0 && (
<div className="text-sm font-medium text-gray-700">
Stack: [{stack.join(", ")}]
</div>
)}
{treeEdges && treeEdges.length > 0 && (
<div className="text-sm font-medium text-gray-700">
Tree edges: {treeEdges.length}
</div>
)}
</div>

<div
Expand All @@ -205,6 +110,7 @@ export default function GraphVisualization({ step }: GraphVisualizationProps) {
from={vertexPositions[edge.from]!}
to={vertexPositions[edge.to]!}
onPath={isEdgeOnPath(edge.from, edge.to)}
inTree={isEdgeInTree(edge.from, edge.to)}
showArrows={showArrows}
showWeights={showWeights}
/>
Expand Down
Loading
Loading