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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ CDN usage:
| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine`, `createWaveSpawner`, `createSoundManager`, `createInputManager`, `createSaveManager`, `createScreenTransition` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.ts`, `gameplay/waveSpawner.ts`, `gameplay/soundManager.ts`, `gameplay/inputManager.ts`, `gameplay/saveManager.ts`, `gameplay/screenTransitions.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/waveSpawner.ts`, `examples/soundManager.ts`, `examples/inputManager.ts`, `examples/saveManager.ts`, `examples/screenTransitions.ts` |
| Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` |
| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `diffJsonAdvanced`, `applyJsonDiff`, `applyJsonDiffSelective`, `flatten`, `unflatten`, `diffTree`, `applyTreeDiff` | `data/*.ts` | `examples/jsonDiff.ts`, `examples/treeDiff.ts` |
| Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` |
| Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort`, `computeMinimumSpanningTree` | `graph/traversal.ts`, `graph/kruskal.ts` | `examples/graph.ts`, `examples/kruskal.ts` |
| Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier`, `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`, `mixRgbColors`, `computeForceDirectedLayout`, `computeMarchingSquares`, `computeMarchingCubes` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/color.ts`, `examples/forceDirected.ts`, `examples/marchingSquares.ts`, `examples/marchingCubes.ts` |

## Scripts
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
- [x] Marching squares contour extraction
- [x] Marching cubes isosurface generation
- **Graph algorithms**
- [ ] Minimum spanning tree (Kruskal)
- [x] Minimum spanning tree (Kruskal)
- [ ] Strongly connected components (Tarjan/Kosaraju)
- [ ] Maximum flow (Ford–Fulkerson / Edmonds–Karp)
- **Spatial & collision expansion**
Expand Down
35 changes: 35 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const examples: {
readonly graphBFS: 'examples/graph.ts';
readonly graphDFS: 'examples/graph.ts';
readonly topologicalSort: 'examples/graph.ts';
readonly computeMinimumSpanningTree: 'examples/kruskal.ts';
};
readonly geometry: {
readonly convexHull: 'examples/geometry.ts';
Expand Down Expand Up @@ -2863,6 +2864,40 @@ export function graphDFS(
*/
export function topologicalSort(graph: Graph): string[];

/**
* Weighted edge representation for MST computation.
* Import: graph/kruskal.ts
*/
export interface WeightedEdge {
source: string;
target: string;
weight: number;
}

/**
* Options for computing a minimum spanning tree via Kruskal's algorithm.
* Import: graph/kruskal.ts
*/
export interface KruskalOptions {
nodes: ReadonlyArray<string>;
edges: ReadonlyArray<WeightedEdge>;
}

/**
* Result payload for a minimum spanning tree.
* Import: graph/kruskal.ts
*/
export interface MinimumSpanningTree {
edges: WeightedEdge[];
totalWeight: number;
}

/**
* Computes a minimum spanning tree using Kruskal's algorithm.
* Import: graph/kruskal.ts
*/
export function computeMinimumSpanningTree(options: KruskalOptions): MinimumSpanningTree;

// ============================================================================
// 📐 GEOMETRY & VISUALS
// ============================================================================
Expand Down
16 changes: 16 additions & 0 deletions examples/kruskal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { computeMinimumSpanningTree } from '../src/index.js';

const nodes = ['A', 'B', 'C', 'D', 'E'];
const edges = [
{ source: 'A', target: 'B', weight: 2 },
{ source: 'A', target: 'C', weight: 3 },
{ source: 'B', target: 'C', weight: 1 },
{ source: 'B', target: 'D', weight: 4 },
{ source: 'C', target: 'D', weight: 5 },
{ source: 'C', target: 'E', weight: 6 },
{ source: 'D', target: 'E', weight: 7 },
];

const mst = computeMinimumSpanningTree({ nodes, edges });
console.log('MST weight:', mst.totalWeight);
console.log('Edges:', mst.edges);
97 changes: 97 additions & 0 deletions src/graph/kruskal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
export interface WeightedEdge {
source: string;
target: string;
weight: number;
}

export interface KruskalOptions {
nodes: ReadonlyArray<string>;
edges: ReadonlyArray<WeightedEdge>;
}

export interface MinimumSpanningTree {
edges: WeightedEdge[];
totalWeight: number;
}

export function computeMinimumSpanningTree(options: KruskalOptions): MinimumSpanningTree {
validateOptions(options);
const disjointSet = new DisjointSet(options.nodes);
const edges = [...options.edges].sort((a, b) => a.weight - b.weight);
const result: WeightedEdge[] = [];
let totalWeight = 0;

for (const edge of edges) {
if (disjointSet.union(edge.source, edge.target)) {
result.push(edge);
totalWeight += edge.weight;
}
}

return { edges: result, totalWeight };
}

class DisjointSet {
private parent: Map<string, string> = new Map();
private rank: Map<string, number> = new Map();

constructor(nodes: ReadonlyArray<string>) {
for (const node of nodes) {
this.parent.set(node, node);
this.rank.set(node, 0);
}
}

find(node: string): string {
const parent = this.parent.get(node);
if (parent === undefined) {
throw new Error(`Unknown node: ${node}`);
}
if (parent !== node) {
const root = this.find(parent);
this.parent.set(node, root);
return root;
}
return parent;
}

union(a: string, b: string): boolean {
const rootA = this.find(a);
const rootB = this.find(b);
if (rootA === rootB) {
return false;
}

const rankA = this.rank.get(rootA) ?? 0;
const rankB = this.rank.get(rootB) ?? 0;
if (rankA < rankB) {
this.parent.set(rootA, rootB);
} else if (rankA > rankB) {
this.parent.set(rootB, rootA);
} else {
this.parent.set(rootB, rootA);
this.rank.set(rootA, rankA + 1);
}
return true;
}
}

function validateOptions(options: KruskalOptions): void {
if (!Array.isArray(options.nodes) || options.nodes.length === 0) {
throw new Error('nodes must contain at least one node.');
}
if (!Array.isArray(options.edges)) {
throw new Error('edges must be an array.');
}
const seenNodes = new Set(options.nodes);
const edges: ReadonlyArray<WeightedEdge> = options.edges;
for (const edge of edges) {
const { source, target, weight } = edge;
if (!seenNodes.has(source) || !seenNodes.has(target)) {
throw new Error(`Edge references unknown node: ${source}-${target}`);
}
if (!Number.isFinite(weight)) {
throw new Error('Edge weights must be finite numbers.');
}
}
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const examples = {
graphBFS: 'examples/graph.ts',
graphDFS: 'examples/graph.ts',
topologicalSort: 'examples/graph.ts',
computeMinimumSpanningTree: 'examples/kruskal.ts',
},
geometry: {
convexHull: 'examples/geometry.ts',
Expand Down Expand Up @@ -995,6 +996,13 @@ export type {
*/
export { graphBFS, graphDFS, topologicalSort } from './graph/traversal.js';

/**
* Minimum spanning tree computation via Kruskal's algorithm.
*/
export { computeMinimumSpanningTree } from './graph/kruskal.js';

export type { KruskalOptions, MinimumSpanningTree, WeightedEdge } from './graph/kruskal.js';

// ============================================================================
// 📐 GEOMETRY UTILITIES
// ============================================================================
Expand Down
47 changes: 47 additions & 0 deletions tests/kruskal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';

import { computeMinimumSpanningTree } from '../src/index.js';

describe('computeMinimumSpanningTree', () => {
it('builds minimum spanning tree for connected graph', () => {
const nodes = ['A', 'B', 'C', 'D'];
const edges = [
{ source: 'A', target: 'B', weight: 1 },
{ source: 'A', target: 'C', weight: 5 },
{ source: 'B', target: 'C', weight: 2 },
{ source: 'B', target: 'D', weight: 4 },
{ source: 'C', target: 'D', weight: 3 },
];

const result = computeMinimumSpanningTree({ nodes, edges });
expect(result.edges).toHaveLength(nodes.length - 1);
expect(result.totalWeight).toBe(1 + 2 + 3);

const mstEdges = result.edges.map((edge) => [edge.source, edge.target].sort().join('-'));
expect(mstEdges).toEqual(['A-B', 'B-C', 'C-D']);
});

it('ignores heavier edges that form cycles', () => {
const nodes = ['X', 'Y', 'Z'];
const edges = [
{ source: 'X', target: 'Y', weight: 10 },
{ source: 'Y', target: 'Z', weight: 2 },
{ source: 'X', target: 'Z', weight: 1 },
];

const result = computeMinimumSpanningTree({ nodes, edges });
expect(result.totalWeight).toBe(3);
const sorted = result.edges.map((edge) => [edge.source, edge.target].sort().join('-')).sort();
expect(sorted).toEqual(['X-Z', 'Y-Z']);
});

it('validates inputs', () => {
expect(() => computeMinimumSpanningTree({ nodes: [], edges: [] })).toThrow('nodes must contain at least one node.');
expect(() =>
computeMinimumSpanningTree({
nodes: ['A'],
edges: [{ source: 'A', target: 'B', weight: 1 }],
})
).toThrow('Edge references unknown node');
});
});