@graphty/algorithms 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/algorithms.js +46 -35
- package/dist/algorithms.js.map +1 -1
- package/dist/src/algorithms/community/girvan-newman.d.ts.map +1 -1
- package/dist/src/algorithms/community/girvan-newman.js +10 -7
- package/dist/src/algorithms/community/girvan-newman.js.map +1 -1
- package/dist/src/clustering/mcl.d.ts.map +1 -1
- package/dist/src/clustering/mcl.js +33 -21
- package/dist/src/clustering/mcl.js.map +1 -1
- package/dist/src/clustering/spectral.d.ts +1 -0
- package/dist/src/clustering/spectral.d.ts.map +1 -1
- package/dist/src/clustering/spectral.js +18 -13
- package/dist/src/clustering/spectral.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +32 -4
- package/src/algorithms/community/girvan-newman.ts +19 -8
- package/src/clustering/mcl.ts +38 -24
- package/src/clustering/spectral.ts +20 -10
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/algorithms",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Graph algorithms library for browser environments implemented in TypeScript",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"main": "dist/algorithms.js",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
+
"types": "./dist/algorithms.d.ts",
|
|
10
11
|
"import": "./dist/algorithms.js",
|
|
11
|
-
"
|
|
12
|
+
"default": "./dist/algorithms.js"
|
|
12
13
|
}
|
|
13
14
|
},
|
|
14
15
|
"types": "dist/algorithms.d.ts",
|
|
@@ -56,10 +57,35 @@
|
|
|
56
57
|
}
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
60
|
+
"@chromatic-com/storybook": "^4.0.0",
|
|
61
|
+
"@eslint/js": "^9.29.0",
|
|
62
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
63
|
+
"@semantic-release/git": "^10.0.1",
|
|
64
|
+
"@storybook/addon-docs": "^9.0.11",
|
|
65
|
+
"@storybook/html-vite": "^9.0.11",
|
|
66
|
+
"@storybook/test": "^8.6.14",
|
|
59
67
|
"@types/benchmark": "^2.1.5",
|
|
60
68
|
"benchmark": "^2.1.4",
|
|
69
|
+
"chromatic": "^11.0.0",
|
|
70
|
+
"eslint": "^9.29.0",
|
|
71
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
61
72
|
"gh-pages": "^6.3.0",
|
|
62
|
-
"
|
|
73
|
+
"globals": "^15.12.0",
|
|
74
|
+
"happy-dom": "^18.0.1",
|
|
75
|
+
"knip": "^5.61.3",
|
|
76
|
+
"playwright": "^1.53.0",
|
|
77
|
+
"semantic-release": "^25.0.2",
|
|
78
|
+
"storybook": "^9.0.11",
|
|
79
|
+
"ts-node": "^10.9.2",
|
|
80
|
+
"tsx": "^4.20.3",
|
|
81
|
+
"typedoc": "^0.28.15",
|
|
82
|
+
"typedoc-plugin-markdown": "^4.9.0",
|
|
83
|
+
"typedoc-vitepress-theme": "^1.1.2",
|
|
84
|
+
"typescript": "^5.8.3",
|
|
85
|
+
"typescript-eslint": "^8.34.1",
|
|
86
|
+
"vite": "^7.0.5",
|
|
87
|
+
"vitepress": "^1.6.3",
|
|
88
|
+
"vitest": "^3.2.4"
|
|
63
89
|
},
|
|
64
90
|
"dependencies": {
|
|
65
91
|
"pupt": "^1.3.2",
|
|
@@ -112,6 +138,8 @@
|
|
|
112
138
|
"docs:dev": "vitepress dev docs",
|
|
113
139
|
"docs:watch": "npm run docs:api && (npm run docs:api:watch & npm run docs:dev & wait)",
|
|
114
140
|
"docs:build": "npm run docs:api && vitepress build docs",
|
|
115
|
-
"docs:preview": "vitepress preview docs"
|
|
141
|
+
"docs:preview": "vitepress preview docs",
|
|
142
|
+
"storybook": ". ../.env 2>/dev/null; storybook dev -p ${PORT:-6006} --host ${HOST:-localhost} ${HTTPS_CERT_PATH:+--https --ssl-cert $HTTPS_CERT_PATH --ssl-key $HTTPS_KEY_PATH} --no-open",
|
|
143
|
+
"build-storybook": "storybook build"
|
|
116
144
|
}
|
|
117
145
|
}
|
|
@@ -39,7 +39,7 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
|
|
|
39
39
|
while (Array.from(workingGraph.edges()).length > 0 && iterations < maxIterations) {
|
|
40
40
|
iterations++;
|
|
41
41
|
// Calculate edge betweenness centrality
|
|
42
|
-
const edgeBetweenness = calculateEdgeBetweenness(workingGraph);
|
|
42
|
+
const { betweenness: edgeBetweenness, edgeEndpoints } = calculateEdgeBetweenness(workingGraph);
|
|
43
43
|
|
|
44
44
|
if (edgeBetweenness.size === 0) {
|
|
45
45
|
break;
|
|
@@ -51,9 +51,10 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
|
|
|
51
51
|
|
|
52
52
|
for (const [edgeKey, centrality] of edgeBetweenness) {
|
|
53
53
|
if (Math.abs(centrality - maxBetweenness) < 1e-10) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// Use original edge endpoints to preserve node ID types (number vs string)
|
|
55
|
+
const endpoints = edgeEndpoints.get(edgeKey);
|
|
56
|
+
if (endpoints) {
|
|
57
|
+
edgesToRemove.push(endpoints);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -91,21 +92,31 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
|
|
|
91
92
|
return dendrogram;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Edge betweenness result containing centrality values and original edge endpoints
|
|
97
|
+
*/
|
|
98
|
+
interface EdgeBetweennessResult {
|
|
99
|
+
betweenness: Map<string, number>;
|
|
100
|
+
edgeEndpoints: Map<string, { source: NodeId; target: NodeId }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
94
103
|
/**
|
|
95
104
|
* Calculate edge betweenness centrality for all edges in the graph
|
|
96
105
|
*
|
|
97
106
|
* Edge betweenness is the fraction of shortest paths that pass through the edge.
|
|
98
107
|
* We adapt node betweenness centrality calculation to work with edges.
|
|
99
108
|
* @param graph - The input graph to analyze
|
|
100
|
-
* @returns
|
|
109
|
+
* @returns Object with betweenness map and edge endpoints map (preserving original ID types)
|
|
101
110
|
*/
|
|
102
|
-
function calculateEdgeBetweenness(graph: Graph):
|
|
111
|
+
function calculateEdgeBetweenness(graph: Graph): EdgeBetweennessResult {
|
|
103
112
|
const edgeBetweenness = new Map<string, number>();
|
|
113
|
+
const edgeEndpoints = new Map<string, { source: NodeId; target: NodeId }>();
|
|
104
114
|
|
|
105
|
-
// Initialize all edges with 0 betweenness
|
|
115
|
+
// Initialize all edges with 0 betweenness and store original endpoints
|
|
106
116
|
for (const edge of graph.edges()) {
|
|
107
117
|
const edgeKey = getEdgeKey(edge.source, edge.target);
|
|
108
118
|
edgeBetweenness.set(edgeKey, 0);
|
|
119
|
+
edgeEndpoints.set(edgeKey, { source: edge.source, target: edge.target });
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
// For each node as source, calculate shortest paths and accumulate edge betweenness
|
|
@@ -182,7 +193,7 @@ function calculateEdgeBetweenness(graph: Graph): Map<string, number> {
|
|
|
182
193
|
}
|
|
183
194
|
}
|
|
184
195
|
|
|
185
|
-
return edgeBetweenness;
|
|
196
|
+
return { betweenness: edgeBetweenness, edgeEndpoints };
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
/**
|
package/src/clustering/mcl.ts
CHANGED
|
@@ -111,7 +111,7 @@ function buildTransitionMatrix(graph: Graph, nodeIds: NodeId[], selfLoops: boole
|
|
|
111
111
|
// Fill adjacency values
|
|
112
112
|
for (let i = 0; i < n; i++) {
|
|
113
113
|
const nodeId = nodeIds[i];
|
|
114
|
-
if (
|
|
114
|
+
if (nodeId === undefined) {
|
|
115
115
|
continue;
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -398,11 +398,16 @@ function extractClusters(
|
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
// Assign nodes to clusters based on
|
|
402
|
-
|
|
401
|
+
// Assign nodes (columns) to clusters based on which attractor (row) they flow to
|
|
402
|
+
// After MCL convergence, matrix[i][j] > 0 means node j belongs to attractor i's cluster
|
|
403
|
+
const attractorToCommunity = new Map<number, number[]>();
|
|
404
|
+
|
|
403
405
|
for (let j = 0; j < n; j++) {
|
|
404
|
-
// Find
|
|
405
|
-
|
|
406
|
+
// Find the attractor for this column (node j)
|
|
407
|
+
// Look for the row with the highest non-zero value in column j
|
|
408
|
+
let maxVal = 0;
|
|
409
|
+
let attractorRow = -1;
|
|
410
|
+
|
|
406
411
|
for (let i = 0; i < n; i++) {
|
|
407
412
|
const matrixRow = matrix[i];
|
|
408
413
|
if (!matrixRow) {
|
|
@@ -410,34 +415,43 @@ function extractClusters(
|
|
|
410
415
|
}
|
|
411
416
|
|
|
412
417
|
const val = matrixRow[j];
|
|
413
|
-
if (val !== undefined && val >
|
|
414
|
-
|
|
415
|
-
|
|
418
|
+
if (val !== undefined && val > maxVal) {
|
|
419
|
+
maxVal = val;
|
|
420
|
+
attractorRow = i;
|
|
416
421
|
}
|
|
417
422
|
}
|
|
418
423
|
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
);
|
|
428
|
-
clusterIndex++;
|
|
424
|
+
if (attractorRow >= 0) {
|
|
425
|
+
// Node j belongs to the cluster of attractor at row attractorRow
|
|
426
|
+
let community = attractorToCommunity.get(attractorRow);
|
|
427
|
+
if (!community) {
|
|
428
|
+
community = [];
|
|
429
|
+
attractorToCommunity.set(attractorRow, community);
|
|
430
|
+
}
|
|
431
|
+
community.push(j);
|
|
432
|
+
nodeToCluster.set(j, attractorRow);
|
|
429
433
|
}
|
|
430
434
|
}
|
|
431
435
|
|
|
432
|
-
//
|
|
433
|
-
for (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
+
// Convert attractor communities to node ID arrays
|
|
437
|
+
for (const [, memberIndices] of attractorToCommunity) {
|
|
438
|
+
communities.push(
|
|
439
|
+
memberIndices
|
|
440
|
+
.map((idx) => {
|
|
441
|
+
const nodeId = nodeIds[idx];
|
|
442
|
+
return nodeId;
|
|
443
|
+
})
|
|
444
|
+
.filter((node): node is NodeId => node !== undefined),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Handle isolated nodes (nodes with all-zero columns)
|
|
449
|
+
for (let j = 0; j < n; j++) {
|
|
450
|
+
if (!nodeToCluster.has(j)) {
|
|
451
|
+
const nodeId = nodeIds[j];
|
|
436
452
|
if (nodeId !== undefined) {
|
|
437
453
|
communities.push([nodeId]);
|
|
438
454
|
}
|
|
439
|
-
|
|
440
|
-
nodeToCluster.set(i, clusterIndex++);
|
|
441
455
|
}
|
|
442
456
|
}
|
|
443
457
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Graph } from "../core/graph.js";
|
|
2
2
|
import type { NodeId } from "../types/index.js";
|
|
3
|
-
import { euclideanDistance } from "../utils/math-utilities.js";
|
|
3
|
+
import { euclideanDistance, SeededRandom } from "../utils/math-utilities.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Spectral Clustering implementation
|
|
@@ -17,6 +17,7 @@ export interface SpectralClusteringOptions {
|
|
|
17
17
|
laplacianType?: "unnormalized" | "normalized" | "randomWalk"; // Type of Laplacian
|
|
18
18
|
maxIterations?: number; // Max iterations for k-means (default: 100)
|
|
19
19
|
tolerance?: number; // Convergence tolerance (default: 1e-4)
|
|
20
|
+
seed?: number; // Random seed for reproducible results
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface SpectralClusteringResult {
|
|
@@ -41,7 +42,10 @@ export interface SpectralClusteringResult {
|
|
|
41
42
|
* @returns Spectral clustering result with communities and cluster assignments
|
|
42
43
|
*/
|
|
43
44
|
export function spectralClustering(graph: Graph, options: SpectralClusteringOptions): SpectralClusteringResult {
|
|
44
|
-
const { k, laplacianType = "normalized", maxIterations = 100, tolerance = 1e-4 } = options;
|
|
45
|
+
const { k, laplacianType = "normalized", maxIterations = 100, tolerance = 1e-4, seed } = options;
|
|
46
|
+
|
|
47
|
+
// Create random function - use seeded PRNG if seed provided, otherwise Math.random
|
|
48
|
+
const random = seed !== undefined ? SeededRandom.createGenerator(seed) : Math.random;
|
|
45
49
|
|
|
46
50
|
// Input validation
|
|
47
51
|
if (k < 1 || !Number.isInteger(k)) {
|
|
@@ -67,7 +71,7 @@ export function spectralClustering(graph: Graph, options: SpectralClusteringOpti
|
|
|
67
71
|
const laplacianMatrix = buildLaplacianMatrix(adjacencyMatrix, laplacianType);
|
|
68
72
|
|
|
69
73
|
// Find k smallest eigenvectors
|
|
70
|
-
const eigenResult = findSmallestEigenvectors(laplacianMatrix, k);
|
|
74
|
+
const eigenResult = findSmallestEigenvectors(laplacianMatrix, k, random);
|
|
71
75
|
|
|
72
76
|
// Perform k-means clustering on the eigenvectors
|
|
73
77
|
// For spectral clustering, we need to transpose the eigenvector matrix
|
|
@@ -89,7 +93,7 @@ export function spectralClustering(graph: Graph, options: SpectralClusteringOpti
|
|
|
89
93
|
normalizeRows(dataPoints);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
const kmeans = kMeansClustering(dataPoints, k, maxIterations, tolerance);
|
|
96
|
+
const kmeans = kMeansClustering(dataPoints, k, maxIterations, tolerance, random);
|
|
93
97
|
|
|
94
98
|
// Build communities
|
|
95
99
|
const communities: NodeId[][] = Array.from({ length: k }, () => []);
|
|
@@ -293,11 +297,13 @@ function buildLaplacianMatrix(adjacency: number[][], type: string): number[][] {
|
|
|
293
297
|
* This is a simplified implementation - in practice, you'd use LAPACK or similar
|
|
294
298
|
* @param matrix - The Laplacian matrix for eigendecomposition
|
|
295
299
|
* @param k - Number of smallest eigenvectors to find
|
|
300
|
+
* @param random - Random number generator function
|
|
296
301
|
* @returns Object containing eigenvalues and corresponding eigenvectors
|
|
297
302
|
*/
|
|
298
303
|
function findSmallestEigenvectors(
|
|
299
304
|
matrix: number[][],
|
|
300
305
|
k: number,
|
|
306
|
+
random: () => number,
|
|
301
307
|
): {
|
|
302
308
|
eigenvalues: number[];
|
|
303
309
|
eigenvectors: number[][];
|
|
@@ -318,7 +324,7 @@ function findSmallestEigenvectors(
|
|
|
318
324
|
// For spectral clustering, we need proper eigenvectors
|
|
319
325
|
// Special handling for small k values which are common in clustering
|
|
320
326
|
if (k <= 3 && n > k) {
|
|
321
|
-
return computeSmallestEigenvectorsSimple(matrix, k, n);
|
|
327
|
+
return computeSmallestEigenvectorsSimple(matrix, k, n, random);
|
|
322
328
|
}
|
|
323
329
|
|
|
324
330
|
// For larger k, use power iteration
|
|
@@ -330,7 +336,7 @@ function findSmallestEigenvectors(
|
|
|
330
336
|
// Initialize random vector
|
|
331
337
|
let vector = Array(n)
|
|
332
338
|
.fill(0)
|
|
333
|
-
.map(() =>
|
|
339
|
+
.map(() => random() - 0.5);
|
|
334
340
|
|
|
335
341
|
// Normalize initial vector
|
|
336
342
|
const initNorm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
|
|
@@ -447,6 +453,7 @@ function normalizeRows(matrix: number[][]): void {
|
|
|
447
453
|
* @param k - Number of clusters to form
|
|
448
454
|
* @param maxIterations - Maximum number of iterations
|
|
449
455
|
* @param tolerance - Convergence tolerance for centroid movement
|
|
456
|
+
* @param random - Random number generator function
|
|
450
457
|
* @returns Object containing cluster assignments and final centroids
|
|
451
458
|
*/
|
|
452
459
|
function kMeansClustering(
|
|
@@ -454,6 +461,7 @@ function kMeansClustering(
|
|
|
454
461
|
k: number,
|
|
455
462
|
maxIterations: number,
|
|
456
463
|
tolerance = 1e-4,
|
|
464
|
+
random: () => number = Math.random,
|
|
457
465
|
): { assignments: number[]; centroids: number[][] } {
|
|
458
466
|
const n = data.length;
|
|
459
467
|
const d = data[0]?.length ?? 0;
|
|
@@ -476,7 +484,7 @@ function kMeansClustering(
|
|
|
476
484
|
const selectedIndices = new Set<number>();
|
|
477
485
|
|
|
478
486
|
while (centroids.length < k && selectedIndices.size < n) {
|
|
479
|
-
const idx = Math.floor(
|
|
487
|
+
const idx = Math.floor(random() * n);
|
|
480
488
|
if (!selectedIndices.has(idx) && data[idx]) {
|
|
481
489
|
selectedIndices.add(idx);
|
|
482
490
|
centroids.push([...data[idx]]);
|
|
@@ -487,7 +495,7 @@ function kMeansClustering(
|
|
|
487
495
|
while (centroids.length < k) {
|
|
488
496
|
const centroid = Array(d).fill(0) as number[];
|
|
489
497
|
for (let j = 0; j < d; j++) {
|
|
490
|
-
centroid[j] =
|
|
498
|
+
centroid[j] = random() - 0.5;
|
|
491
499
|
}
|
|
492
500
|
centroids.push(centroid);
|
|
493
501
|
}
|
|
@@ -611,12 +619,14 @@ function kMeansClustering(
|
|
|
611
619
|
* @param matrix - The Laplacian matrix
|
|
612
620
|
* @param k - Number of eigenvectors to compute (1-3)
|
|
613
621
|
* @param n - Size of the matrix
|
|
622
|
+
* @param random - Random number generator function
|
|
614
623
|
* @returns Object containing eigenvalues and eigenvectors
|
|
615
624
|
*/
|
|
616
625
|
function computeSmallestEigenvectorsSimple(
|
|
617
626
|
matrix: number[][],
|
|
618
627
|
k: number,
|
|
619
628
|
n: number,
|
|
629
|
+
random: () => number,
|
|
620
630
|
): {
|
|
621
631
|
eigenvalues: number[];
|
|
622
632
|
eigenvectors: number[][];
|
|
@@ -635,7 +645,7 @@ function computeSmallestEigenvectorsSimple(
|
|
|
635
645
|
const maxEig = 2; // For normalized Laplacian, max eigenvalue <= 2
|
|
636
646
|
let vector = Array(n)
|
|
637
647
|
.fill(0)
|
|
638
|
-
.map(() =>
|
|
648
|
+
.map(() => random() - 0.5);
|
|
639
649
|
|
|
640
650
|
// Make orthogonal to first eigenvector
|
|
641
651
|
const dot1 = vector.reduce((sum, val) => sum + val / Math.sqrt(n), 0);
|
|
@@ -681,7 +691,7 @@ function computeSmallestEigenvectorsSimple(
|
|
|
681
691
|
if (k >= 3) {
|
|
682
692
|
let vector = Array(n)
|
|
683
693
|
.fill(0)
|
|
684
|
-
.map(() =>
|
|
694
|
+
.map(() => random() - 0.5);
|
|
685
695
|
|
|
686
696
|
// Orthogonalize against previous eigenvectors
|
|
687
697
|
for (const prev of eigenvectors) {
|