@graphty/layout 1.1.1 → 1.2.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.
Files changed (100) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +3 -7
  3. package/CHANGELOG.md +14 -0
  4. package/DEPLOYMENT.md +59 -0
  5. package/README.md +54 -1
  6. package/dist/vitest.config.js +2 -2
  7. package/dist/vitest.config.js.map +1 -1
  8. package/examples/3d-force-directed.html +611 -0
  9. package/examples/3d-kamada-kawai.html +379 -0
  10. package/examples/3d-layout-comparison.html +448 -0
  11. package/examples/3d-spherical-layout.html +319 -0
  12. package/examples/arf-layout.html +13 -1
  13. package/examples/bfs-layout.html +38 -32
  14. package/examples/bipartite-layout.html +16 -4
  15. package/examples/circular-layout.html +14 -2
  16. package/examples/forceatlas2-layout.html +118 -151
  17. package/examples/index.html +75 -0
  18. package/examples/kamada-kawai-layout.html +13 -1
  19. package/{dist → examples}/layout-helpers.js +58 -29
  20. package/examples/multipartite-layout.html +44 -54
  21. package/examples/planar-layout.html +13 -1
  22. package/examples/random-layout.html +13 -1
  23. package/examples/shell-layout.html +16 -2
  24. package/examples/spectral-layout.html +13 -1
  25. package/examples/spiral-layout.html +13 -1
  26. package/examples/spring-layout.html +15 -13
  27. package/package.json +6 -3
  28. package/src/algorithms/index.ts +6 -0
  29. package/src/algorithms/optimization/index.ts +12 -0
  30. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  31. package/src/algorithms/optimization/lbfgs.ts +68 -0
  32. package/src/algorithms/optimization/line-search.ts +50 -0
  33. package/src/algorithms/optimization/types.ts +8 -0
  34. package/src/algorithms/planarity/check.ts +38 -0
  35. package/src/algorithms/planarity/embedding.ts +216 -0
  36. package/src/algorithms/planarity/index.ts +12 -0
  37. package/src/algorithms/planarity/lr-test.ts +70 -0
  38. package/src/algorithms/planarity/special-graphs.ts +126 -0
  39. package/src/generators/basic.ts +93 -0
  40. package/src/generators/bipartite.ts +45 -0
  41. package/src/generators/grid.ts +42 -0
  42. package/src/generators/index.ts +15 -0
  43. package/src/generators/random.ts +39 -0
  44. package/src/generators/scale-free.ts +72 -0
  45. package/src/index.ts +18 -0
  46. package/src/layouts/basic/index.ts +5 -0
  47. package/src/layouts/basic/random.ts +32 -0
  48. package/src/layouts/force-directed/arf.ts +130 -0
  49. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  50. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  51. package/src/layouts/force-directed/index.ts +9 -0
  52. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  53. package/src/layouts/force-directed/spring.ts +35 -0
  54. package/src/layouts/geometric/circular.ts +77 -0
  55. package/src/layouts/geometric/index.ts +7 -0
  56. package/src/layouts/geometric/shell.ts +80 -0
  57. package/src/layouts/geometric/spiral.ts +93 -0
  58. package/src/layouts/hierarchical/bfs.ts +81 -0
  59. package/src/layouts/hierarchical/bipartite.ts +94 -0
  60. package/src/layouts/hierarchical/index.ts +7 -0
  61. package/src/layouts/hierarchical/multipartite.ts +88 -0
  62. package/src/layouts/index.ts +9 -0
  63. package/src/layouts/specialized/index.ts +6 -0
  64. package/src/layouts/specialized/planar.ts +65 -0
  65. package/src/layouts/specialized/spectral.ts +128 -0
  66. package/src/types/embedding.ts +11 -0
  67. package/src/types/graph.ts +13 -0
  68. package/src/types/index.ts +7 -0
  69. package/src/types/layout.ts +9 -0
  70. package/src/utils/graph.ts +77 -0
  71. package/src/utils/index.ts +9 -0
  72. package/src/utils/numpy.ts +108 -0
  73. package/src/utils/params.ts +26 -0
  74. package/src/utils/random.ts +53 -0
  75. package/src/utils/rescale.ts +137 -0
  76. package/test/arf-layout.test.ts +1 -1
  77. package/test/bfs-layout.test.ts +1 -1
  78. package/test/bipartite-layout.test.ts +1 -1
  79. package/test/circular-layout.test.ts +145 -3
  80. package/test/forceatlas2-layout.test.ts +1 -1
  81. package/test/fruchterman-reingold-layout.test.ts +1 -1
  82. package/test/graph-generators.test.ts +1 -1
  83. package/test/kamada-kawai-layout.test.ts +273 -1
  84. package/test/multipartite-layout.test.ts +1 -1
  85. package/test/planar-layout.test.ts +1 -1
  86. package/test/random-layout.test.ts +1 -1
  87. package/test/rescale-layout.test.ts +1 -1
  88. package/test/shell-layout.test.ts +1 -1
  89. package/test/spectral-layout.test.ts +1 -1
  90. package/test/spiral-layout.test.ts +1 -1
  91. package/test/spring-layout.test.ts +1 -1
  92. package/vite.config.js +36 -0
  93. package/vitest.config.ts +2 -2
  94. package/dist/layout-helpers.d.ts +0 -123
  95. package/dist/layout-helpers.js.map +0 -1
  96. package/dist/layout.d.ts +0 -275
  97. package/dist/layout.js +0 -2280
  98. package/dist/layout.js.map +0 -1
  99. package/layout-helpers.ts +0 -559
  100. package/layout.ts +0 -2867
package/layout.ts DELETED
@@ -1,2867 +0,0 @@
1
- /**
2
- * Layout
3
- * ======
4
- *
5
- * Node positioning algorithms for graph drawing in TypeScript.
6
- *
7
- * For `randomLayout()` the possible resulting shape
8
- * is a square of side [0, scale] (default: [0, 1])
9
- * Changing `center` shifts the layout by that amount.
10
- *
11
- * For the other layout routines, the extent is
12
- * [center - scale, center + scale] (default: [-1, 1]).
13
- *
14
- * Ported from NetworkX Python library.
15
- */
16
-
17
- // Type definitions
18
- export type Node = string | number;
19
- export type Edge = [Node, Node];
20
- export type Graph = {
21
- nodes?: () => Node[];
22
- edges?: () => Edge[];
23
- getEdgeData?: (source: Node, target: Node, attr: string) => any;
24
- };
25
-
26
- type Position = number[];
27
- type PositionMap = Record<Node, Position>;
28
-
29
- interface Embedding {
30
- nodeOrder: Node[];
31
- faceList: Node[][];
32
- nodePositions: Record<Node, Position>;
33
- }
34
-
35
- // Utility array manipulation functions (NumPy-like)
36
- const np = {
37
- zeros: function (shape: number | number[]): number | number[] | number[][] | any[] {
38
- if (typeof shape === 'number') {
39
- return Array(shape).fill(0);
40
- }
41
- if (shape.length === 1) {
42
- return Array(shape[0]).fill(0);
43
- }
44
- return Array(shape[0]).fill(0).map(() => this.zeros(shape.slice(1)));
45
- },
46
-
47
- ones: function (shape: number | number[]): number | any[] {
48
- if (typeof shape === 'number') {
49
- return Array(shape).fill(1);
50
- }
51
- if (shape.length === 1) {
52
- return Array(shape[0]).fill(1);
53
- }
54
- return Array(shape[0]).fill(1).map(() => this.ones(shape.slice(1)));
55
- },
56
-
57
- linspace: function (start: number, stop: number, num: number): number[] {
58
- const step = (stop - start) / (num - 1);
59
- return Array.from({ length: num }, (_, i) => start + i * step);
60
- },
61
-
62
- array: function (arr: any): any[] {
63
- return Array.isArray(arr) ? [...arr] : [arr];
64
- },
65
-
66
- repeat: function (a: any, repeats: number): any[] {
67
- const result: any[] = [];
68
- for (let i = 0; i < repeats; i++) {
69
- result.push(...np.array(a));
70
- }
71
- return result;
72
- },
73
-
74
- mean: function (arr: number[] | number[][], axis: number | null = null): number | number[] {
75
- if (axis === null) {
76
- const flatArr = Array.isArray(arr[0])
77
- ? (arr as number[][]).flat(Infinity) as number[]
78
- : arr as number[];
79
- const sum = flatArr.reduce((a, b) => a + b, 0);
80
- return sum / flatArr.length;
81
- }
82
-
83
- if (axis === 0) {
84
- const result: number[] = [];
85
- const matrix = arr as number[][];
86
- for (let i = 0; i < matrix[0].length; i++) {
87
- let sum = 0;
88
- for (let j = 0; j < matrix.length; j++) {
89
- sum += matrix[j][i];
90
- }
91
- result.push(sum / matrix.length);
92
- }
93
- return result;
94
- }
95
-
96
- return (arr as number[][]).map(row => np.mean(row) as number);
97
- },
98
-
99
- add: function (a: number | number[], b: number | number[]): number | number[] {
100
- if (!Array.isArray(a) && !Array.isArray(b)) {
101
- return a + b;
102
- }
103
- if (!Array.isArray(a)) {
104
- return (b as number[]).map(val => a + val);
105
- }
106
- if (!Array.isArray(b)) {
107
- return (a as number[]).map(val => val + b);
108
- }
109
- return (a as number[]).map((val, i) => val + (b as number[])[i]);
110
- },
111
-
112
- subtract: function (a: number | number[], b: number | number[]): number | number[] {
113
- if (!Array.isArray(a) && !Array.isArray(b)) {
114
- return a - b;
115
- }
116
- if (!Array.isArray(a)) {
117
- return (b as number[]).map(val => a - val);
118
- }
119
- if (!Array.isArray(b)) {
120
- return (a as number[]).map(val => val - b);
121
- }
122
- return (a as number[]).map((val, i) => val - (b as number[])[i]);
123
- },
124
-
125
- max: function (arr: number | number[]): number {
126
- if (!Array.isArray(arr)) return arr;
127
- return Math.max(...(arr as number[]).flat(Infinity) as number[]);
128
- },
129
-
130
- min: function (arr: number | number[]): number {
131
- if (!Array.isArray(arr)) return arr;
132
- return Math.min(...(arr as number[]).flat(Infinity) as number[]);
133
- },
134
-
135
- norm: function (arr: number[]): number {
136
- return Math.sqrt(arr.reduce((sum, val) => sum + val * val, 0));
137
- }
138
- };
139
-
140
- // Random number generator (for seed-based randomization)
141
- class RandomNumberGenerator {
142
- private seed: number;
143
- private m: number;
144
- private a: number;
145
- private c: number;
146
- private _state: number;
147
-
148
- constructor(seed?: number) {
149
- this.seed = seed || Math.floor(Math.random() * 1000000);
150
- this.m = 2 ** 35 - 31;
151
- this.a = 185852;
152
- this.c = 1;
153
- this._state = this.seed % this.m;
154
- }
155
-
156
- _next(): number {
157
- this._state = (this.a * this._state + this.c) % this.m;
158
- return this._state / this.m;
159
- }
160
-
161
- rand(shape: number | number[] | null = null): number | number[] | number[][] {
162
- if (shape === null) {
163
- return this._next();
164
- }
165
-
166
- if (typeof shape === 'number') {
167
- const result: number[] = [];
168
- for (let i = 0; i < shape; i++) {
169
- result.push(this._next());
170
- }
171
- return result;
172
- }
173
-
174
- if (shape.length === 1) {
175
- const result: number[] = [];
176
- for (let i = 0; i < shape[0]; i++) {
177
- result.push(this._next());
178
- }
179
- return result;
180
- }
181
-
182
- const result: any[] = [];
183
- for (let i = 0; i < shape[0]; i++) {
184
- result.push(this.rand(shape.slice(1)));
185
- }
186
- return result;
187
- }
188
- }
189
-
190
- /**
191
- * Extract nodes from a graph object
192
- *
193
- * @param G - Graph or list of nodes
194
- * @returns Array of nodes
195
- */
196
- function getNodesFromGraph(G: Graph): Node[] {
197
- return G.nodes ? G.nodes() : G as Node[];
198
- }
199
-
200
- /**
201
- * Extract edges from a graph object
202
- *
203
- * @param G - Graph or list of nodes
204
- * @returns Array of edges
205
- */
206
- function getEdgesFromGraph(G: Graph): Edge[] {
207
- return G.edges ? G.edges() : [] as Edge[];
208
- }
209
-
210
- // Helper function similar to _process_params in Python version
211
- function _processParams(G: Graph, center: number[] | null, dim: number): { G: Graph; center: number[] } {
212
- if (!center) {
213
- center = Array(dim).fill(0);
214
- }
215
-
216
- if (center.length !== dim) {
217
- throw new Error("length of center coordinates must match dimension of layout");
218
- }
219
-
220
- return { G, center };
221
- }
222
-
223
- /**
224
- * Position nodes uniformly at random in the unit square.
225
- *
226
- * @param G - Graph or list of nodes
227
- * @param center - Coordinate pair around which to center the layout
228
- * @param dim - Dimension of layout
229
- * @param seed - Random seed for reproducible layouts
230
- * @returns Positions dictionary keyed by node
231
- */
232
- function randomLayout(G: Graph, center: number[] | null = null, dim: number = 2, seed: number | null = null): PositionMap {
233
- const processed = _processParams(G, center, dim);
234
- const nodes = getNodesFromGraph(processed.G);
235
- center = processed.center;
236
-
237
- const rng = new RandomNumberGenerator(seed ?? undefined);
238
- const pos: PositionMap = {};
239
-
240
- nodes.forEach((node: Node) => {
241
- pos[node] = (rng.rand(dim) as number[]).map((val: number, i: number) => val + center[i]);
242
- });
243
-
244
- return pos;
245
- }
246
-
247
- /**
248
- * Position nodes on a circle.
249
- *
250
- * @param G - Graph or list of nodes
251
- * @param scale - Scale factor for positions
252
- * @param center - Coordinate pair around which to center the layout
253
- * @param dim - Dimension of layout (currently only supports dim=2)
254
- * @returns Positions dictionary keyed by node
255
- */
256
- function circularLayout(G: Graph, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
257
- if (dim < 2) {
258
- throw new Error("cannot handle dimensions < 2");
259
- }
260
-
261
- const processed = _processParams(G, center, dim);
262
- const nodes = getNodesFromGraph(processed.G);
263
- center = processed.center;
264
-
265
- const pos: PositionMap = {};
266
-
267
- if (nodes.length === 0) {
268
- return pos;
269
- }
270
-
271
- if (nodes.length === 1) {
272
- pos[nodes[0]] = center;
273
- return pos;
274
- }
275
-
276
- // Calculate positions on a circle
277
- const theta = np.linspace(0, 2 * Math.PI, nodes.length + 1).slice(0, -1);
278
-
279
- nodes.forEach((node: Node, i: number) => {
280
- const x: number = Math.cos(theta[i]) * scale + center[0];
281
- const y: number = Math.sin(theta[i]) * scale + center[1];
282
- pos[node] = Array(dim).fill(0).map((_, j: number) => j === 0 ? x : j === 1 ? y : 0);
283
- });
284
-
285
- return pos;
286
- }
287
-
288
- /**
289
- * Position nodes in concentric circles.
290
- *
291
- * @param G - Graph or list of nodes
292
- * @param nlist - List of node lists for each shell
293
- * @param scale - Scale factor for positions
294
- * @param center - Coordinate pair around which to center the layout
295
- * @param dim - Dimension of layout (currently only supports dim=2)
296
- * @returns Positions dictionary keyed by node
297
- */
298
- function shellLayout(G: Graph, nlist: Node[][] | null = null, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
299
- if (dim !== 2) {
300
- throw new Error("can only handle 2 dimensions");
301
- }
302
-
303
- const processed = _processParams(G, center, dim);
304
- const nodes = getNodesFromGraph(processed.G);
305
- center = processed.center;
306
-
307
- const pos: PositionMap = {};
308
-
309
- if (nodes.length === 0) {
310
- return pos;
311
- }
312
-
313
- if (nodes.length === 1) {
314
- pos[nodes[0]] = center;
315
- return pos;
316
- }
317
-
318
- // If no nlist is specified, put all nodes in a single shell
319
- if (!nlist) {
320
- nlist = [nodes];
321
- }
322
-
323
- const radiusBump = scale / nlist.length;
324
- let radius: number;
325
-
326
- if (nlist[0].length === 1) {
327
- // Single node at center
328
- radius = 0;
329
- pos[nlist[0][0]] = [...center];
330
- radius += radiusBump;
331
- } else {
332
- // Start at radius 1
333
- radius = radiusBump;
334
- }
335
-
336
- for (let i = 0; i < nlist.length; i++) {
337
- const shell = nlist[i];
338
- if (shell.length === 0) continue;
339
-
340
- if (shell.length === 1 && i === 0) {
341
- // Already handled the case of a single center node
342
- continue;
343
- }
344
-
345
- // Calculate positions on a circle
346
- const theta = np.linspace(0, 2 * Math.PI, shell.length + 1).slice(0, -1);
347
-
348
- shell.forEach((node: Node, j) => {
349
- const x = Math.cos(theta[j]) * radius + center[0];
350
- const y = Math.sin(theta[j]) * radius + center[1];
351
- pos[node] = [x, y];
352
- });
353
-
354
- radius += radiusBump;
355
- }
356
-
357
- return pos;
358
- }
359
-
360
- /**
361
- * Position nodes using Fruchterman-Reingold force-directed algorithm.
362
- *
363
- * @param {Object} G - Graph or list of nodes
364
- * @param {number} k - Optimal distance between nodes
365
- * @param {Object} pos - Initial positions for nodes
366
- * @param {Array} fixed - Nodes to keep fixed at initial position
367
- * @param {number} iterations - Maximum number of iterations
368
- * @param {number} scale - Scale factor for positions
369
- * @param {Array|null} center - Coordinate pair around which to center the layout
370
- * @param {number} dim - Dimension of layout
371
- * @param {number} seed - Random seed for initial positions
372
- * @returns {Object} Positions dictionary keyed by node
373
- */
374
- function springLayout(
375
- G: Graph,
376
- k: number | null = null,
377
- pos: PositionMap | null = null,
378
- fixed: Node[] | null = null,
379
- iterations: number = 50,
380
- scale: number = 1,
381
- center: number[] | null = null,
382
- dim: number = 2,
383
- seed: number | null = null
384
- ): PositionMap {
385
- // Legacy compatibility alias
386
- return fruchtermanReingoldLayout(G, k, pos, fixed, iterations, scale, center, dim, seed);
387
- }
388
-
389
- /**
390
- * Position nodes using Fruchterman-Reingold force-directed algorithm.
391
- *
392
- * @param {Object} G - Graph or list of nodes
393
- * @param {number} k - Optimal distance between nodes
394
- * @param {Object} pos - Initial positions for nodes
395
- * @param {Array} fixed - Nodes to keep fixed at initial position
396
- * @param {number} iterations - Maximum number of iterations
397
- * @param {number} scale - Scale factor for positions
398
- * @param {Array|null} center - Coordinate pair around which to center the layout
399
- * @param {number} dim - Dimension of layout
400
- * @param {number} seed - Random seed for initial positions
401
- * @returns {Object} Positions dictionary keyed by node
402
- */
403
- function fruchtermanReingoldLayout(
404
- G: Graph,
405
- k: number | null = null,
406
- pos: PositionMap | null = null,
407
- fixed: Node[] | null = null,
408
- iterations: number = 50,
409
- scale: number = 1,
410
- center: number[] | null = null,
411
- dim: number = 2,
412
- seed: number | null = null
413
- ): PositionMap {
414
- const processed = _processParams(G, center, dim);
415
- let graph = processed.G;
416
- center = processed.center;
417
-
418
- const nodes = getNodesFromGraph(graph);
419
- const edges = getEdgesFromGraph(graph);
420
-
421
- if (nodes.length === 0) {
422
- return {};
423
- }
424
-
425
- if (nodes.length === 1) {
426
- const singlePos: PositionMap = {};
427
- singlePos[nodes[0]] = center;
428
- return singlePos;
429
- }
430
-
431
- // Set up initial positions
432
- let positions: PositionMap = {};
433
- if (pos) {
434
- // Use provided positions
435
- for (const node of nodes) {
436
- if (pos[node]) {
437
- positions[node] = [...pos[node]];
438
- } else {
439
- const rng = new RandomNumberGenerator(seed ?? undefined);
440
- positions[node] = rng.rand(dim) as number[];
441
- }
442
- }
443
- } else {
444
- // Random initial positions
445
- const rng = new RandomNumberGenerator(seed ?? undefined);
446
- for (const node of nodes) {
447
- positions[node] = rng.rand(dim) as number[];
448
- }
449
- }
450
-
451
- // Set up fixed nodes
452
- const fixedNodes = new Set(fixed || []);
453
-
454
- // Optimal distance between nodes
455
- if (!k) {
456
- k = 1.0 / Math.sqrt(nodes.length);
457
- }
458
-
459
- // Initialize temperature
460
- let t = 0.1;
461
- // Calculate temperature reduction
462
- const dt = t / (iterations + 1);
463
-
464
- // Simple cooling schedule
465
- for (let i = 0; i < iterations; i++) {
466
- // Calculate repulsive forces
467
- const displacement: Record<Node, number[]> = {};
468
- for (const node of nodes) {
469
- displacement[node] = Array(dim).fill(0);
470
- }
471
-
472
- // Repulsive forces between nodes
473
- for (let v1i = 0; v1i < nodes.length; v1i++) {
474
- const v1 = nodes[v1i];
475
- for (let v2i = v1i + 1; v2i < nodes.length; v2i++) {
476
- const v2 = nodes[v2i];
477
-
478
- // Difference vector
479
- const delta = positions[v1].map((p, i) => p - positions[v2][i]);
480
-
481
- // Distance
482
- const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
483
-
484
- // Force
485
- const force = (k * k) / distance;
486
-
487
- // Add force to displacement
488
- for (let j = 0; j < dim; j++) {
489
- const direction = delta[j] / distance;
490
- displacement[v1][j] += direction * force;
491
- displacement[v2][j] -= direction * force;
492
- }
493
- }
494
- }
495
-
496
- // Attractive forces between connected nodes
497
- for (const [source, target] of edges) {
498
- // Difference vector
499
- const delta = positions[source].map((p, i) => p - positions[target][i]);
500
-
501
- // Distance
502
- const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
503
-
504
- // Force
505
- const force = (distance * distance) / k;
506
-
507
- // Add force to displacement
508
- for (let j = 0; j < dim; j++) {
509
- const direction = delta[j] / distance;
510
- displacement[source][j] -= direction * force;
511
- displacement[target][j] += direction * force;
512
- }
513
- }
514
-
515
- // Update positions
516
- for (const node of nodes) {
517
- if (fixedNodes.has(node)) continue;
518
-
519
- // Calculate displacement magnitude
520
- const magnitude = Math.sqrt(displacement[node].reduce((sum, d) => sum + d * d, 0));
521
-
522
- // Limit maximum displacement by temperature
523
- const limitedMagnitude = Math.min(magnitude, t);
524
-
525
- // Update position
526
- for (let j = 0; j < dim; j++) {
527
- const direction = magnitude === 0 ? 0 : displacement[node][j] / magnitude;
528
- positions[node][j] += direction * limitedMagnitude;
529
- }
530
- }
531
-
532
- // Cool temperature
533
- t -= dt;
534
- }
535
-
536
- // Rescale positions
537
- if (!fixed) {
538
- positions = rescaleLayout(positions, scale, center) as PositionMap;
539
- }
540
-
541
- return positions;
542
- }
543
-
544
- /**
545
- * Position nodes in a spectral layout using eigenvectors of the graph Laplacian.
546
- *
547
- * @param G - Graph
548
- * @param scale - Scale factor for positions
549
- * @param center - Coordinate pair around which to center the layout
550
- * @param dim - Dimension of layout
551
- * @returns Positions dictionary keyed by node
552
- */
553
- function spectralLayout(
554
- G: Graph,
555
- scale: number = 1,
556
- center: number[] | null = null,
557
- dim: number = 2
558
- ): PositionMap {
559
- const processed = _processParams(G, center, dim);
560
- const graph = processed.G;
561
- center = processed.center;
562
-
563
- const nodes = getNodesFromGraph(graph);
564
-
565
- if (nodes.length <= 2) {
566
- if (nodes.length === 0) {
567
- return {};
568
- } else if (nodes.length === 1) {
569
- return { [nodes[0]]: center };
570
- } else {
571
- return {
572
- [nodes[0]]: center.map(v => v - scale),
573
- [nodes[1]]: center.map(v => v + scale)
574
- };
575
- }
576
- }
577
-
578
- // Create adjacency matrix
579
- const N = nodes.length;
580
- const nodeIndices: Record<Node, number> = {};
581
- nodes.forEach((node: Node, i: number) => { nodeIndices[node] = i; });
582
-
583
- const A = Array(N).fill(0).map(() => Array(N).fill(0));
584
- const edges = getEdgesFromGraph(graph);
585
-
586
- for (const [source, target] of edges) {
587
- const i = nodeIndices[source];
588
- const j = nodeIndices[target];
589
- A[i][j] = 1;
590
- A[j][i] = 1; // Make symmetric for undirected graphs
591
- }
592
-
593
- // Create Laplacian matrix: L = D - A where D is degree matrix
594
- const L = Array(N).fill(0).map(() => Array(N).fill(0));
595
- for (let i = 0; i < N; i++) {
596
- // Compute degree (sum of row)
597
- L[i][i] = A[i].reduce((sum, val) => sum + val, 0);
598
- for (let j = 0; j < N; j++) {
599
- L[i][j] -= A[i][j];
600
- }
601
- }
602
-
603
- // Compute eigenvectors using power iteration method
604
- // We need the smallest non-zero eigenvectors of L
605
- const eigenvectors: number[][] = [];
606
-
607
- // For each dimension, find an eigenvector
608
- for (let d = 0; d < dim; d++) {
609
- let vector = Array(N).fill(0).map(() => Math.random() - 0.5);
610
-
611
- // Orthogonalize against previous eigenvectors
612
- for (const ev of eigenvectors) {
613
- const dot = vector.reduce((acc, val, idx) => acc + val * ev[idx], 0);
614
- vector = vector.map((val, idx) => val - dot * ev[idx]);
615
- }
616
-
617
- // Normalize
618
- const norm = Math.sqrt(vector.reduce((acc, val) => acc + val * val, 0));
619
- vector = vector.map(val => val / norm);
620
-
621
- // Apply shifted inverse iteration to find smallest non-zero eigenvector
622
- // This is a simplification of the actual algorithm
623
- for (let iter = 0; iter < 100; iter++) {
624
- // Apply Laplacian
625
- const newVec = Array(N).fill(0);
626
- for (let i = 0; i < N; i++) {
627
- for (let j = 0; j < N; j++) {
628
- newVec[i] += L[i][j] * vector[j];
629
- }
630
- }
631
-
632
- // Orthogonalize against the constant vector (eigenvector with eigenvalue 0)
633
- const mean = newVec.reduce((acc, val) => acc + val, 0) / N;
634
- newVec.forEach((val, idx, arr) => { arr[idx] = val - mean; });
635
-
636
- // Normalize
637
- const newNorm = Math.sqrt(newVec.reduce((acc, val) => acc + val * val, 0));
638
- if (newNorm < 1e-10) continue; // Skip if vector is close to zero
639
-
640
- vector = newVec.map(val => val / newNorm);
641
- }
642
-
643
- eigenvectors.push(vector);
644
- }
645
-
646
- // Create position array from eigenvectors
647
- const positions: number[][] = Array(N).fill(0).map(() => Array(dim).fill(0));
648
- for (let i = 0; i < N; i++) {
649
- for (let d = 0; d < dim; d++) {
650
- positions[i][d] = eigenvectors[d][i];
651
- }
652
- }
653
-
654
- // Rescale and create position dictionary
655
- const scaledPositions = rescaleLayout(positions as any, scale);
656
- const pos: PositionMap = {};
657
- nodes.forEach((node: Node, i: number) => {
658
- pos[node] = (scaledPositions as number[][])[i].map((val: number, j: number) => val + center[j]);
659
- });
660
-
661
- return pos;
662
- }
663
-
664
- /**
665
- * Position nodes in a spiral layout.
666
- *
667
- * @param G - Graph or list of nodes
668
- * @param scale - Scale factor for positions
669
- * @param center - Coordinate pair around which to center the layout
670
- * @param dim - Dimension of layout
671
- * @param resolution - Controls the spacing between spiral elements
672
- * @param equidistant - Whether to place nodes equidistant from each other
673
- * @returns Positions dictionary keyed by node
674
- */
675
- function spiralLayout(
676
- G: Graph,
677
- scale: number = 1,
678
- center: number[] | null = null,
679
- dim: number = 2,
680
- resolution: number = 0.35,
681
- equidistant: boolean = false
682
- ): PositionMap {
683
- if (dim !== 2) {
684
- throw new Error("can only handle 2 dimensions");
685
- }
686
-
687
- const processed = _processParams(G, center || [0, 0], dim);
688
- const nodes = getNodesFromGraph(processed.G);
689
- center = processed.center;
690
-
691
- const pos: PositionMap = {};
692
-
693
- if (nodes.length === 0) {
694
- return pos;
695
- }
696
-
697
- if (nodes.length === 1) {
698
- pos[nodes[0]] = [...center];
699
- return pos;
700
- }
701
-
702
- let positions: number[][] = [];
703
-
704
- if (equidistant) {
705
- // Create equidistant points along the spiral
706
- // This matches the Python implementation logic
707
- const chord = 1;
708
- const step = 0.5;
709
- let theta = resolution;
710
- theta += chord / (step * theta);
711
-
712
- for (let i = 0; i < nodes.length; i++) {
713
- const r = step * theta;
714
- theta += chord / r;
715
- positions.push([Math.cos(theta) * r, Math.sin(theta) * r]);
716
- }
717
- } else {
718
- // Create points with equal angle but increasing distance
719
- const dist = Array.from({ length: nodes.length }, (_, i) => parseFloat(String(i)));
720
- const angle = dist.map(d => resolution * d);
721
-
722
- positions = dist.map((d, i) => [
723
- Math.cos(angle[i]) * d,
724
- Math.sin(angle[i]) * d
725
- ]);
726
- }
727
-
728
- // Convert position array to position matrix for rescaling
729
- const posArray: number[][] = [];
730
- for (let i = 0; i < positions.length; i++) {
731
- posArray.push(positions[i]);
732
- }
733
-
734
- // Rescale positions and add center offset
735
- const scaledPositions = rescaleLayout(posArray as any, scale) as any;
736
- for (let i = 0; i < scaledPositions.length; i++) {
737
- scaledPositions[i][0] += center[0];
738
- scaledPositions[i][1] += center[1];
739
- }
740
-
741
- // Create position dictionary
742
- for (let i = 0; i < nodes.length; i++) {
743
- pos[nodes[i]] = scaledPositions[i];
744
- }
745
-
746
- return pos;
747
- }
748
-
749
- /**
750
- * Rescale node positions to fit in the specified scale and center.
751
- *
752
- * @param pos - Dictionary or array of positions
753
- * @param scale - Scale factor for positions
754
- * @param center - Coordinate pair around which to center the layout
755
- * @returns Rescaled positions dictionary
756
- */
757
- function rescaleLayout(
758
- pos: PositionMap | number[][],
759
- scale: number = 1,
760
- center: number[] = [0, 0]
761
- ): PositionMap | number[][] {
762
- // Check if pos is empty
763
- if (Array.isArray(pos)) {
764
- if (pos.length === 0) return [];
765
- } else {
766
- if (Object.keys(pos).length === 0) return {};
767
- }
768
-
769
- // Extract position values
770
- const posValues: number[][] = Array.isArray(pos) ? pos : Object.values(pos);
771
- const dim = posValues[0].length;
772
-
773
- // Calculate center of positions
774
- const posCenter = Array(dim).fill(0);
775
- for (const p of posValues) {
776
- for (let i = 0; i < dim; i++) {
777
- posCenter[i] += p[i] / posValues.length;
778
- }
779
- }
780
-
781
- // Center positions
782
- let centeredPos: PositionMap | number[][] = {};
783
- if (Array.isArray(pos)) {
784
- centeredPos = pos.map(p => p.map((val, i) => val - posCenter[i]));
785
- } else {
786
- for (const [node, p] of Object.entries(pos)) {
787
- (centeredPos as PositionMap)[node] = p.map((val, i) => val - posCenter[i]);
788
- }
789
- }
790
-
791
- // Find maximum distance from center
792
- let maxDistance = 0;
793
- const centeredValues = Array.isArray(centeredPos) ? centeredPos : Object.values(centeredPos);
794
- for (const p of centeredValues) {
795
- const distance = Math.sqrt(p.reduce((sum, val) => sum + val * val, 0));
796
- maxDistance = Math.max(maxDistance, distance);
797
- }
798
-
799
- // Rescale
800
- let scaledPos: PositionMap | number[][] = Array.isArray(pos) ? [] : {};
801
-
802
- if (maxDistance > 0) {
803
- const scaleFactor = scale / maxDistance;
804
-
805
- if (Array.isArray(pos)) {
806
- (scaledPos as number[][]) = (centeredPos as number[][]).map(p =>
807
- p.map((val, i) => val * scaleFactor + center[i])
808
- );
809
- } else {
810
- for (const [node, p] of Object.entries(centeredPos as PositionMap)) {
811
- (scaledPos as PositionMap)[node] = p.map((val, i) => val * scaleFactor + center[i]);
812
- }
813
- }
814
- } else {
815
- // All nodes at the same position
816
- if (Array.isArray(pos)) {
817
- (scaledPos as number[][]) = Array(pos.length).fill(0).map(() => [...center]);
818
- } else {
819
- for (const node of Object.keys(pos)) {
820
- (scaledPos as PositionMap)[node] = [...center];
821
- }
822
- }
823
- }
824
-
825
- return scaledPos;
826
- }
827
-
828
- /**
829
- * Position nodes in two straight lines (bipartite layout).
830
- *
831
- * @param G - Graph or list of nodes
832
- * @param nodes - Nodes in one node set of the graph
833
- * @param align - The alignment of nodes: 'vertical' or 'horizontal'
834
- * @param scale - Scale factor for positions
835
- * @param center - Coordinate pair around which to center the layout
836
- * @param aspectRatio - The ratio of the width to the height of the layout
837
- * @returns Positions dictionary keyed by node
838
- */
839
- function bipartiteLayout(
840
- G: Graph,
841
- nodes: Node[] | null = null,
842
- align: 'vertical' | 'horizontal' = 'vertical',
843
- scale: number = 1,
844
- center: number[] | null = null,
845
- aspectRatio: number = 4 / 3
846
- ): PositionMap {
847
- if (align !== 'vertical' && align !== 'horizontal') {
848
- throw new Error("align must be either vertical or horizontal");
849
- }
850
-
851
- const processed = _processParams(G, center || [0, 0], 2);
852
- const graph = processed.G;
853
- center = processed.center;
854
-
855
- const allNodes = getNodesFromGraph(graph);
856
-
857
- if (allNodes.length === 0) {
858
- return {};
859
- }
860
-
861
- // If nodes not provided, try to determine bipartite sets
862
- if (!nodes) {
863
- // A simple heuristic for bipartite detection: use nodes with even/odd indices
864
- // This is a simplification, in Python NetworkX has bipartite.sets()
865
- nodes = allNodes.filter((_: Node, i: number): boolean => i % 2 === 0);
866
- }
867
-
868
- const left = new Set(nodes);
869
- const right: Set<Node> = new Set(allNodes.filter((n: Node) => !left.has(n)));
870
-
871
- const height = 1;
872
- const width = aspectRatio * height;
873
- const offset = [width / 2, height / 2];
874
-
875
- const pos: PositionMap = {};
876
-
877
- // Position nodes in the left set
878
- const leftNodes = [...left];
879
- leftNodes.forEach((node, i) => {
880
- const x = 0;
881
- const y = i * height / (leftNodes.length || 1);
882
- pos[node] = [x, y];
883
- });
884
-
885
- // Position nodes in the right set
886
- const rightNodes = [...right];
887
- rightNodes.forEach((node, i) => {
888
- const x = width;
889
- const y = i * height / (rightNodes.length || 1);
890
- pos[node] = [x, y];
891
- });
892
-
893
- // Center positions around the origin and apply offset
894
- for (const node in pos) {
895
- pos[node][0] -= offset[0];
896
- pos[node][1] -= offset[1];
897
- }
898
-
899
- // Rescale positions
900
- const scaledPos = rescaleLayout(pos, scale, center) as PositionMap;
901
-
902
- // Handle horizontal alignment
903
- if (align === 'horizontal') {
904
- for (const node in scaledPos) {
905
- const temp = scaledPos[node][0];
906
- scaledPos[node][0] = scaledPos[node][1];
907
- scaledPos[node][1] = temp;
908
- }
909
- }
910
-
911
- return scaledPos;
912
- }
913
-
914
- /**
915
- * Position nodes in layers of straight lines (multipartite layout).
916
- *
917
- * @param G - Graph or list of nodes
918
- * @param subsetKey - Object mapping layers to node sets, or node attribute name
919
- * @param align - The alignment of nodes: 'vertical' or 'horizontal'
920
- * @param scale - Scale factor for positions
921
- * @param center - Coordinate pair around which to center the layout
922
- * @returns Positions dictionary keyed by node
923
- */
924
- function multipartiteLayout(
925
- G: Graph,
926
- subsetKey: Record<number | string, Node | Node[]> | string = 'subset',
927
- align: 'vertical' | 'horizontal' = 'vertical',
928
- scale: number = 1,
929
- center: number[] | null = null
930
- ): PositionMap {
931
- if (align !== 'vertical' && align !== 'horizontal') {
932
- throw new Error("align must be either vertical or horizontal");
933
- }
934
-
935
- const processed = _processParams(G, center || [0, 0], 2);
936
- const graph = processed.G;
937
- center = processed.center;
938
-
939
- const allNodes = getNodesFromGraph(graph);
940
-
941
- if (allNodes.length === 0) {
942
- return {};
943
- }
944
-
945
- // Convert subsetKey to a layer mapping if it's a string
946
- let layers: Record<number | string, Node[]> = {};
947
- if (typeof subsetKey === 'string') {
948
- // In JS we don't have access to node attributes directly
949
- // This is a simplification - in a real implementation we would need
950
- // to access node attributes from the graph
951
- console.warn("Using string subsetKey requires node attributes, using default partitioning");
952
- // Create a simple partitioning as fallback
953
- layers = { 0: allNodes };
954
- } else {
955
- // subsetKey is already a mapping of layers to nodes
956
- // Convert single nodes to arrays
957
- for (const [key, value] of Object.entries(subsetKey)) {
958
- if (Array.isArray(value)) {
959
- layers[key] = value;
960
- } else {
961
- layers[key] = [value];
962
- }
963
- }
964
- }
965
-
966
- const layerCount = Object.keys(layers).length;
967
- let pos: PositionMap = {};
968
-
969
- // Process each layer
970
- Object.entries(layers).forEach(([layer, nodes], layerIdx) => {
971
- const layerNodes = Array.isArray(nodes) ? nodes : [nodes];
972
- const layerSize = layerNodes.length;
973
-
974
- layerNodes.forEach((node, nodeIdx) => {
975
- // Place nodes in a grid: layerIdx determines x-coordinate (column)
976
- // nodeIdx determines y-coordinate (row position within column)
977
- const x = layerIdx - (layerCount - 1) / 2;
978
- const y = nodeIdx - (layerSize - 1) / 2;
979
- pos[node] = [x, y];
980
- });
981
- });
982
-
983
- // Rescale positions
984
- pos = rescaleLayout(pos, scale, center) as PositionMap;
985
-
986
- // Handle horizontal alignment
987
- if (align === 'horizontal') {
988
- for (const node in pos) {
989
- const temp = pos[node][0];
990
- pos[node][0] = pos[node][1];
991
- pos[node][1] = temp;
992
- }
993
- }
994
-
995
- return pos;
996
- }
997
-
998
- /**
999
- * Position nodes according to breadth-first search algorithm.
1000
- *
1001
- * @param G - Graph
1002
- * @param start - Starting node for bfs
1003
- * @param align - The alignment of layers: 'vertical' or 'horizontal'
1004
- * @param scale - Scale factor for positions
1005
- * @param center - Coordinate pair around which to center the layout
1006
- * @returns Positions dictionary keyed by node
1007
- */
1008
- function bfsLayout(
1009
- G: Graph,
1010
- start: Node,
1011
- align: 'vertical' | 'horizontal' = 'vertical',
1012
- scale: number = 1,
1013
- center: number[] | null = null
1014
- ): PositionMap {
1015
- const processed = _processParams(G, center || [0, 0], 2);
1016
- const graph = processed.G;
1017
- center = processed.center;
1018
-
1019
- const allNodes = getNodesFromGraph(graph);
1020
-
1021
- if (allNodes.length === 0) {
1022
- return {};
1023
- }
1024
-
1025
- // Compute BFS layers
1026
- const layers: Record<number, Node[]> = {};
1027
- const visited = new Set<Node>();
1028
- let currentLayer = 0;
1029
-
1030
- // Starting layer
1031
- layers[currentLayer] = [start];
1032
- visited.add(start);
1033
-
1034
- // BFS traversal
1035
- while (Object.values(layers).flat().length < allNodes.length) {
1036
- const nextLayer: Node[] = [];
1037
- const currentNodes = layers[currentLayer];
1038
-
1039
- for (const node of currentNodes) {
1040
- // Get neighbors - this is a simplified approach
1041
- // In a real implementation, we would get neighbors from the graph
1042
- const neighbors = getNeighbors(graph, node);
1043
-
1044
- for (const neighbor of neighbors) {
1045
- if (!visited.has(neighbor)) {
1046
- nextLayer.push(neighbor);
1047
- visited.add(neighbor);
1048
- }
1049
- }
1050
- }
1051
-
1052
- if (nextLayer.length === 0) {
1053
- // No more connected nodes
1054
- const unvisited: Node[] = allNodes.filter((node: Node) => !visited.has(node));
1055
- if (unvisited.length > 0) {
1056
- throw new Error("bfs_layout didn't include all nodes. Graph may be disconnected.");
1057
- }
1058
- break;
1059
- }
1060
-
1061
- currentLayer++;
1062
- layers[currentLayer] = nextLayer;
1063
- }
1064
-
1065
- // Use multipartite_layout to position the layers
1066
- return multipartiteLayout(graph, layers, align, scale, center);
1067
-
1068
- // Helper function to get neighbors
1069
- function getNeighbors(graph: Graph, node: Node): Node[] {
1070
- if (!graph.edges) return [];
1071
-
1072
- return graph.edges()
1073
- .filter((edge: Edge) => edge[0] === node || edge[1] === node)
1074
- .map((edge: Edge): Node => edge[0] === node ? edge[1] : edge[0]);
1075
- }
1076
- }
1077
-
1078
- /**
1079
- * Position nodes without edge intersections (planar layout).
1080
- *
1081
- * @param G - Graph
1082
- * @param scale - Scale factor for positions
1083
- * @param center - Coordinate pair around which to center the layout
1084
- * @param dim - Dimension of layout (must be 2)
1085
- * @returns Positions dictionary keyed by node
1086
- */
1087
- function planarLayout(
1088
- G: Graph,
1089
- scale: number = 1,
1090
- center: number[] | null = null,
1091
- dim: number = 2
1092
- ): PositionMap {
1093
- if (dim !== 2) {
1094
- throw new Error("can only handle 2 dimensions");
1095
- }
1096
-
1097
- const processed = _processParams(G, center || [0, 0], dim);
1098
- const graph = processed.G;
1099
- center = processed.center;
1100
-
1101
- const nodes = getNodesFromGraph(graph);
1102
- const edges = getEdgesFromGraph(graph);
1103
-
1104
- if (nodes.length === 0) {
1105
- return {};
1106
- }
1107
-
1108
- // Check if graph is planar and get embedding
1109
- const { isPlanar, embedding } = checkPlanarity(graph, nodes, edges);
1110
-
1111
- if (!isPlanar) {
1112
- throw new Error("G is not planar.");
1113
- }
1114
-
1115
- if (!embedding) {
1116
- throw new Error("Failed to generate planar embedding.");
1117
- }
1118
-
1119
- // Convert embedding to positions
1120
- let pos = combinatorialEmbeddingToPos(embedding, nodes);
1121
-
1122
- // Rescale the positions
1123
- pos = rescaleLayout(pos, scale, center) as PositionMap;
1124
-
1125
- return pos;
1126
- }
1127
-
1128
- /**
1129
- * Check if graph is planar using a simplified version of Boyer-Myrvold algorithm.
1130
- * Returns planarity and embedding information.
1131
- *
1132
- * @param G - Graph
1133
- * @param nodes - List of nodes
1134
- * @param edges - List of edges
1135
- * @returns Object containing isPlanar flag and embedding
1136
- */
1137
- function checkPlanarity(
1138
- G: Graph,
1139
- nodes: Node[],
1140
- edges: Edge[]
1141
- ): { isPlanar: boolean; embedding: Embedding | null } {
1142
- // For small graphs (n <= 4), all are planar
1143
- if (nodes.length <= 4) {
1144
- return { isPlanar: true, embedding: createTriangulationEmbedding(nodes, edges) };
1145
- }
1146
-
1147
- // For K5 (complete graph with 5 nodes) and K3,3 (complete bipartite with 3,3 nodes)
1148
- // these are not planar by Kuratowski's theorem
1149
- if (isK5(nodes, edges) || isK33(nodes, edges)) {
1150
- return { isPlanar: false, embedding: null };
1151
- }
1152
-
1153
- // For other graphs, use LR algorithm (Left-Right Planarity Test)
1154
- const result = lrPlanarityTest(nodes, edges);
1155
- return result;
1156
- }
1157
-
1158
- /**
1159
- * Check if graph is K5 (complete graph with 5 nodes)
1160
- *
1161
- * @param nodes - List of nodes
1162
- * @param edges - List of edges
1163
- * @returns True if graph is K5
1164
- */
1165
- function isK5(nodes: Node[], edges: Edge[]): boolean {
1166
- if (nodes.length !== 5) return false;
1167
-
1168
- // K5 has exactly 10 edges
1169
- if (edges.length !== 10) return false;
1170
-
1171
- // Check if every pair of distinct nodes is connected
1172
- for (let i = 0; i < nodes.length; i++) {
1173
- for (let j = i + 1; j < nodes.length; j++) {
1174
- const hasEdge = edges.some(
1175
- e => (e[0] === nodes[i] && e[1] === nodes[j]) ||
1176
- (e[0] === nodes[j] && e[1] === nodes[i])
1177
- );
1178
- if (!hasEdge) return false;
1179
- }
1180
- }
1181
-
1182
- return true;
1183
- }
1184
-
1185
- /**
1186
- * Check if graph is K3,3 (complete bipartite with 3,3 nodes)
1187
- *
1188
- * @param nodes - List of nodes
1189
- * @param edges - List of edges
1190
- * @returns True if graph is K3,3
1191
- */
1192
- function isK33(nodes: Node[], edges: Edge[]): boolean {
1193
- if (nodes.length !== 6) return false;
1194
-
1195
- // K3,3 has exactly 9 edges
1196
- if (edges.length !== 9) return false;
1197
-
1198
- // Try to find a bipartite partition
1199
- const nodePartitions = tryFindBipartitePartition(nodes, edges);
1200
- if (!nodePartitions) return false;
1201
-
1202
- const [part1, part2] = nodePartitions;
1203
-
1204
- // Check if both partitions have size 3
1205
- if (part1.length !== 3 || part2.length !== 3) return false;
1206
-
1207
- // Check if every node in part1 is connected to every node in part2
1208
- for (const n1 of part1) {
1209
- for (const n2 of part2) {
1210
- const hasEdge = edges.some(
1211
- e => (e[0] === n1 && e[1] === n2) ||
1212
- (e[0] === n2 && e[1] === n1)
1213
- );
1214
- if (!hasEdge) return false;
1215
- }
1216
- }
1217
-
1218
- return true;
1219
- }
1220
-
1221
- /**
1222
- * Try to find a bipartite partition of the nodes
1223
- *
1224
- * @param nodes - List of nodes
1225
- * @param edges - List of edges
1226
- * @returns Array of two partitions, or null if not bipartite
1227
- */
1228
- function tryFindBipartitePartition(
1229
- nodes: Node[],
1230
- edges: Edge[]
1231
- ): [Node[], Node[]] | null {
1232
- const colorMap: Record<Node, number> = {};
1233
- const adjList: Record<Node, Node[]> = {};
1234
-
1235
- // Create adjacency list
1236
- for (const node of nodes) {
1237
- adjList[node] = [];
1238
- }
1239
-
1240
- for (const [u, v] of edges) {
1241
- adjList[u].push(v);
1242
- adjList[v].push(u);
1243
- }
1244
-
1245
- // BFS to color nodes
1246
- const queue: Node[] = [nodes[0]];
1247
- colorMap[nodes[0]] = 0;
1248
-
1249
- while (queue.length > 0) {
1250
- const node = queue.shift()!;
1251
- const nodeColor = colorMap[node];
1252
-
1253
- for (const neighbor of adjList[node]) {
1254
- if (colorMap[neighbor] === undefined) {
1255
- colorMap[neighbor] = 1 - nodeColor; // Toggle color (0/1)
1256
- queue.push(neighbor);
1257
- } else if (colorMap[neighbor] === nodeColor) {
1258
- // Conflict: not bipartite
1259
- return null;
1260
- }
1261
- }
1262
- }
1263
-
1264
- // Create partitions
1265
- const part0: Node[] = [];
1266
- const part1: Node[] = [];
1267
-
1268
- for (const node of nodes) {
1269
- if (colorMap[node] === 0) {
1270
- part0.push(node);
1271
- } else {
1272
- part1.push(node);
1273
- }
1274
- }
1275
-
1276
- return [part0, part1];
1277
- }
1278
-
1279
- /**
1280
- * Left-Right Planarity Test for general graphs
1281
- *
1282
- * @param nodes - List of nodes
1283
- * @param edges - List of edges
1284
- * @returns Object containing isPlanar flag and embedding
1285
- */
1286
- function lrPlanarityTest(
1287
- nodes: Node[],
1288
- edges: Edge[]
1289
- ): { isPlanar: boolean; embedding: Embedding | null } {
1290
- // Create adjacency list for the graph
1291
- const adjList: Record<Node, Node[]> = {};
1292
- for (const node of nodes) {
1293
- adjList[node] = [];
1294
- }
1295
-
1296
- for (const [u, v] of edges) {
1297
- adjList[u].push(v);
1298
- adjList[v].push(u);
1299
- }
1300
-
1301
- // Step 1: Perform DFS to get an st-numbering (ordering of nodes)
1302
- const visited = new Set<Node>();
1303
- const ordering: Node[] = [];
1304
-
1305
- function dfs(node: Node): void {
1306
- visited.add(node);
1307
- ordering.push(node);
1308
-
1309
- for (const neighbor of adjList[node]) {
1310
- if (!visited.has(neighbor)) {
1311
- dfs(neighbor);
1312
- }
1313
- }
1314
- }
1315
-
1316
- // Start DFS from first node
1317
- dfs(nodes[0]);
1318
-
1319
- // If the graph is disconnected, it's still planar but we need to handle each component
1320
- if (ordering.length < nodes.length) {
1321
- // Create a simple triangulation embedding for disconnected graphs
1322
- return { isPlanar: true, embedding: createTriangulationEmbedding(nodes, edges) };
1323
- }
1324
-
1325
- // Step 2: For a general implementation, we'll use a simplified approach
1326
- // since this would normally require implementing the entire LR algorithm
1327
-
1328
- // For this implementation, since we can't fully implement Boyer-Myrvold,
1329
- // we'll create a reasonable planar embedding for most planar graphs
1330
-
1331
- // We assume the graph is planar if it's sparse enough (|E| <= 3|V| - 6)
1332
- // This is a necessary but not sufficient condition for planar graphs
1333
- if (edges.length > 3 * nodes.length - 6) {
1334
- return { isPlanar: false, embedding: null };
1335
- }
1336
-
1337
- // Create a planar embedding using a triangulation approach
1338
- const embedding = createTriangulationEmbedding(nodes, edges);
1339
-
1340
- return { isPlanar: true, embedding };
1341
- }
1342
-
1343
- /**
1344
- * Create a triangulation-based embedding for a planar graph
1345
- *
1346
- * @param nodes - List of nodes
1347
- * @param edges - List of edges
1348
- * @returns Embedding object
1349
- */
1350
- function createTriangulationEmbedding(
1351
- nodes: Node[],
1352
- edges: Edge[]
1353
- ): Embedding {
1354
- // Create a simple embedding using the incremental approach
1355
- const embedding: Embedding = {
1356
- nodeOrder: [...nodes],
1357
- faceList: [],
1358
- nodePositions: {}
1359
- };
1360
-
1361
- // Create a map of adjacent nodes
1362
- const adjMap: Record<Node, Set<Node>> = {};
1363
- for (const node of nodes) {
1364
- adjMap[node] = new Set<Node>();
1365
- }
1366
-
1367
- for (const [u, v] of edges) {
1368
- adjMap[u].add(v);
1369
- adjMap[v].add(u);
1370
- }
1371
-
1372
- // Create outer face as a cycle (if possible)
1373
- const outerFace = findCycle(nodes, edges, adjMap) || nodes;
1374
- embedding.faceList.push(outerFace);
1375
-
1376
- // Position nodes on a convex polygon (outer face)
1377
- const n = outerFace.length;
1378
- for (let i = 0; i < n; i++) {
1379
- const angle = 2 * Math.PI * i / n;
1380
- embedding.nodePositions[outerFace[i]] = [Math.cos(angle), Math.sin(angle)];
1381
- }
1382
-
1383
- // Position interior nodes using barycentric coordinates
1384
- const interiorNodes = nodes.filter(node => !embedding.nodePositions[node]);
1385
-
1386
- for (const node of interiorNodes) {
1387
- const neighbors = Array.from(adjMap[node]);
1388
-
1389
- if (neighbors.length === 0) {
1390
- // Isolated node, place at center
1391
- embedding.nodePositions[node] = [0, 0];
1392
- } else {
1393
- // Average position of neighbors that have positions
1394
- let xSum = 0, ySum = 0, count = 0;
1395
-
1396
- for (const neighbor of neighbors) {
1397
- if (embedding.nodePositions[neighbor]) {
1398
- xSum += embedding.nodePositions[neighbor][0];
1399
- ySum += embedding.nodePositions[neighbor][1];
1400
- count++;
1401
- }
1402
- }
1403
-
1404
- if (count > 0) {
1405
- // Place slightly away from center to avoid overlaps
1406
- const jitter = 0.1 * Math.random();
1407
- embedding.nodePositions[node] = [
1408
- xSum / count + jitter * (Math.random() - 0.5),
1409
- ySum / count + jitter * (Math.random() - 0.5)
1410
- ];
1411
- } else {
1412
- // No neighbors have positions yet, place randomly inside unit circle
1413
- const r = 0.5 * Math.random();
1414
- const angle = 2 * Math.PI * Math.random();
1415
- embedding.nodePositions[node] = [r * Math.cos(angle), r * Math.sin(angle)];
1416
- }
1417
- }
1418
- }
1419
-
1420
- return embedding;
1421
- }
1422
-
1423
- /**
1424
- * Find a simple cycle in the graph (for outer face)
1425
- *
1426
- * @param nodes - List of nodes
1427
- * @param edges - List of edges
1428
- * @param adjMap - Adjacency map
1429
- * @returns Cycle as array of nodes, or null if none found
1430
- */
1431
- function findCycle(
1432
- nodes: Node[],
1433
- edges: Edge[],
1434
- adjMap: Record<Node, Set<Node>>
1435
- ): Node[] | null {
1436
- if (nodes.length === 0) return null;
1437
- if (nodes.length <= 2) return nodes; // Not a real cycle but handle it
1438
-
1439
- // Try to find a Hamiltonian cycle for simplicity (for small graphs)
1440
- if (nodes.length <= 8) {
1441
- const visited = new Set<Node>();
1442
- const path: Node[] = [];
1443
-
1444
- function hamiltonianCycleDFS(node: Node): boolean {
1445
- path.push(node);
1446
- visited.add(node);
1447
-
1448
- if (path.length === nodes.length) {
1449
- // Check if it's a cycle (last node connects to first)
1450
- if (adjMap[node].has(path[0])) {
1451
- return true;
1452
- }
1453
- // Not a cycle
1454
- visited.delete(node);
1455
- path.pop();
1456
- return false;
1457
- }
1458
-
1459
- for (const neighbor of adjMap[node]) {
1460
- if (!visited.has(neighbor)) {
1461
- if (hamiltonianCycleDFS(neighbor)) {
1462
- return true;
1463
- }
1464
- }
1465
- }
1466
-
1467
- visited.delete(node);
1468
- path.pop();
1469
- return false;
1470
- }
1471
-
1472
- if (hamiltonianCycleDFS(nodes[0])) {
1473
- return path;
1474
- }
1475
- }
1476
-
1477
- // Fallback: try to find any cycle using DFS
1478
- const visited = new Set<Node>();
1479
- const parent: Record<Node, Node | null> = {};
1480
- let cycleFound: Node[] | null = null;
1481
-
1482
- function findCycleDFS(node: Node, parentNode: Node | null): boolean {
1483
- visited.add(node);
1484
-
1485
- for (const neighbor of adjMap[node]) {
1486
- if (neighbor === parentNode) continue;
1487
-
1488
- if (visited.has(neighbor)) {
1489
- // Found a cycle
1490
- cycleFound = constructCycle(node, neighbor, parent);
1491
- return true;
1492
- }
1493
-
1494
- parent[neighbor] = node;
1495
- if (findCycleDFS(neighbor, node)) {
1496
- return true;
1497
- }
1498
- }
1499
-
1500
- return false;
1501
- }
1502
-
1503
- function constructCycle(u: Node, v: Node, parent: Record<Node, Node | null>): Node[] {
1504
- const cycle: Node[] = [v, u];
1505
- let current = u;
1506
-
1507
- while (parent[current] !== undefined && parent[current] !== v) {
1508
- current = parent[current]!;
1509
- cycle.push(current);
1510
- }
1511
-
1512
- return cycle;
1513
- }
1514
-
1515
- // Try to find a cycle
1516
- for (const node of nodes) {
1517
- if (!visited.has(node)) {
1518
- parent[node] = null;
1519
- if (findCycleDFS(node, null)) {
1520
- break;
1521
- }
1522
- }
1523
- }
1524
-
1525
- return cycleFound || nodes; // Fallback to all nodes if no cycle found
1526
- }
1527
-
1528
- /**
1529
- * Convert a combinatorial embedding to node positions
1530
- *
1531
- * @param embedding - The embedding object
1532
- * @param nodes - List of nodes
1533
- * @returns Dictionary mapping nodes to positions
1534
- */
1535
- function combinatorialEmbeddingToPos(
1536
- embedding: Embedding,
1537
- nodes: Node[]
1538
- ): PositionMap {
1539
- const pos: PositionMap = {};
1540
-
1541
- // Use the positions from the embedding
1542
- for (const node of nodes) {
1543
- if (embedding.nodePositions[node]) {
1544
- pos[node] = embedding.nodePositions[node];
1545
- } else {
1546
- // Fallback for any nodes without positions
1547
- pos[node] = [0, 0];
1548
- }
1549
- }
1550
-
1551
- return pos;
1552
- }
1553
-
1554
- // Type definitions for distance structure used in Kamada-Kawai
1555
- type DistanceMap = Record<Node, Record<Node, number>>;
1556
-
1557
- /**
1558
- * Position nodes using Kamada-Kawai path-length cost-function.
1559
- *
1560
- * @param G - NetworkX graph or list of nodes
1561
- * @param dist - A two-level dictionary of optimal distances between nodes
1562
- * @param pos - Initial positions for nodes
1563
- * @param weight - The edge attribute used for edge weights
1564
- * @param scale - Scale factor for positions
1565
- * @param center - Coordinate pair around which to center the layout
1566
- * @param dim - Dimension of layout
1567
- * @returns Positions dictionary keyed by node
1568
- */
1569
- function kamadaKawaiLayout(
1570
- G: Graph,
1571
- dist: DistanceMap | null = null,
1572
- pos: PositionMap | null = null,
1573
- weight: string = 'weight',
1574
- scale: number = 1,
1575
- center: number[] | null = null,
1576
- dim: number = 2
1577
- ): PositionMap {
1578
- const processed = _processParams(G, center, dim);
1579
- const graph = processed.G;
1580
- center = processed.center;
1581
-
1582
- const nodes = getNodesFromGraph(graph);
1583
-
1584
- if (nodes.length === 0) {
1585
- return {};
1586
- }
1587
-
1588
- if (nodes.length === 1) {
1589
- return { [nodes[0]]: center };
1590
- }
1591
-
1592
- // Initialize distance matrix
1593
- if (!dist) {
1594
- dist = _computeShortestPathDistances(graph, weight);
1595
- }
1596
-
1597
- // Convert distances to a matrix
1598
- const nodesArray: Node[] = Array.from(nodes);
1599
- const nNodes = nodesArray.length;
1600
- const distMatrix: number[][] = Array(nNodes).fill(0).map(() => Array(nNodes).fill(1e6));
1601
-
1602
- for (let i = 0; i < nNodes; i++) {
1603
- const nodeI = nodesArray[i];
1604
- distMatrix[i][i] = 0;
1605
-
1606
- if (!dist[nodeI]) continue;
1607
-
1608
- for (let j = 0; j < nNodes; j++) {
1609
- const nodeJ = nodesArray[j];
1610
- if (dist[nodeI][nodeJ] !== undefined) {
1611
- distMatrix[i][j] = dist[nodeI][nodeJ];
1612
- }
1613
- }
1614
- }
1615
-
1616
- // Initialize positions if not provided
1617
- if (!pos) {
1618
- if (dim >= 3) {
1619
- pos = randomLayout(G, null, dim);
1620
- } else if (dim === 2) {
1621
- pos = circularLayout(G, 1, [0, 0], dim);
1622
- } else {
1623
- // For 1D, use a linear layout
1624
- const posArray: PositionMap = {};
1625
- nodesArray.forEach((node, i) => {
1626
- posArray[node] = [i / (nNodes - 1 || 1)];
1627
- });
1628
- pos = posArray;
1629
- }
1630
- }
1631
-
1632
- // Convert positions to array for computation
1633
- const posArray: number[][] = new Array(nNodes);
1634
- for (let i = 0; i < nNodes; i++) {
1635
- const node = nodesArray[i];
1636
- posArray[i] = pos[node] ? [...pos[node]] : Array(dim).fill(0);
1637
-
1638
- // Ensure correct dimensionality
1639
- while (posArray[i].length < dim) {
1640
- posArray[i].push(0);
1641
- }
1642
- }
1643
-
1644
- // Run the Kamada-Kawai algorithm
1645
- const newPositions = _kamadaKawaiSolve(distMatrix, posArray, dim);
1646
-
1647
- // Convert positions array back to dictionary and rescale
1648
- const finalPos: PositionMap = {};
1649
- for (let i = 0; i < nNodes; i++) {
1650
- finalPos[nodesArray[i]] = newPositions[i];
1651
- }
1652
-
1653
- return rescaleLayout(finalPos, scale, center) as PositionMap;
1654
- }
1655
-
1656
- /**
1657
- * Compute all-pairs shortest path distances for the graph
1658
- *
1659
- * @param G - NetworkX graph
1660
- * @param weight - Edge attribute for weight
1661
- * @returns Dictionary of dictionaries of shortest path distances
1662
- */
1663
- function _computeShortestPathDistances(
1664
- G: Graph,
1665
- weight: string
1666
- ): DistanceMap {
1667
- const distances: DistanceMap = {};
1668
- const nodes = G.nodes ? G.nodes() : G as Node[];
1669
- const edges = G.edges ? G.edges() : [] as Edge[];
1670
-
1671
- // Initialize distances with direct edges
1672
- for (const node of nodes) {
1673
- distances[node] = {};
1674
- distances[node][node] = 0;
1675
-
1676
- for (const other of nodes) {
1677
- if (node !== other) {
1678
- distances[node][other] = Infinity;
1679
- }
1680
- }
1681
- }
1682
-
1683
- // Add direct edges
1684
- for (const [source, target] of edges) {
1685
- // In a real implementation, we would get the weight from the graph
1686
- // For now, assume weight = 1 or use weight attribute if available
1687
- let edgeWeight = 1;
1688
- if (G.getEdgeData) {
1689
- edgeWeight = G.getEdgeData(source, target, weight) || 1;
1690
- }
1691
-
1692
- distances[source][target] = edgeWeight;
1693
- distances[target][source] = edgeWeight; // Assuming undirected graph
1694
- }
1695
-
1696
- // Floyd-Warshall algorithm for all-pairs shortest paths
1697
- for (const k of nodes) {
1698
- for (const i of nodes) {
1699
- for (const j of nodes) {
1700
- if (distances[i][k] + distances[k][j] < distances[i][j]) {
1701
- distances[i][j] = distances[i][k] + distances[k][j];
1702
- }
1703
- }
1704
- }
1705
- }
1706
-
1707
- return distances;
1708
- }
1709
-
1710
- /**
1711
- * Solve the Kamada-Kawai layout optimization problem
1712
- *
1713
- * @param distMatrix - Matrix of desired distances between nodes
1714
- * @param positions - Initial node positions
1715
- * @param dim - Dimension of layout
1716
- * @returns Optimized node positions
1717
- */
1718
- function _kamadaKawaiSolve(
1719
- distMatrix: number[][],
1720
- positions: number[][],
1721
- dim: number
1722
- ): number[][] {
1723
- // Implementation of L-BFGS optimization for Kamada-Kawai
1724
- const nNodes = positions.length;
1725
- const meanWeight = 1e-3;
1726
-
1727
- // Convert distances to inverse distances (with protection against division by zero)
1728
- const invDistMatrix = distMatrix.map(row =>
1729
- row.map(d => d === 0 ? 0 : 1 / (d + 1e-3))
1730
- );
1731
-
1732
- // Flatten positions for optimization
1733
- let posVec = positions.flat();
1734
-
1735
- // Optimization parameters
1736
- const maxIter = 500;
1737
- const gtol = 1e-5;
1738
- const m = 10; // L-BFGS memory size
1739
-
1740
- // Implement a simplified L-BFGS-B algorithm
1741
- let alpha = 1.0;
1742
- const oldValues: number[][] = [];
1743
- const oldGrads: number[][] = [];
1744
-
1745
- for (let iter = 0; iter < maxIter; iter++) {
1746
- // Calculate cost and gradient
1747
- const [cost, grad] = _kamadaKawaiCostfn(posVec, invDistMatrix, meanWeight, dim);
1748
-
1749
- // Compute search direction using L-BFGS approximation
1750
- const direction = _lbfgsDirection(grad, oldValues, oldGrads, m);
1751
-
1752
- // Simple line search for step size
1753
- alpha = _backtrackingLineSearch(
1754
- posVec, direction, cost, grad,
1755
- (x: number[]) => _kamadaKawaiCostfn(x, invDistMatrix, meanWeight, dim)[0],
1756
- alpha
1757
- );
1758
-
1759
- // Save current position and gradient for next iteration
1760
- const oldPos = [...posVec];
1761
-
1762
- // Update position
1763
- for (let i = 0; i < posVec.length; i++) {
1764
- posVec[i] += alpha * direction[i];
1765
- }
1766
-
1767
- // Calculate new gradient
1768
- const [, newGrad] = _kamadaKawaiCostfn(posVec, invDistMatrix, meanWeight, dim);
1769
-
1770
- // Update L-BFGS memory
1771
- oldValues.push(posVec.map((val, i) => val - oldPos[i]));
1772
- oldGrads.push(newGrad.map((val, i) => val - grad[i]));
1773
-
1774
- // Keep only m most recent updates
1775
- if (oldValues.length > m) {
1776
- oldValues.shift();
1777
- oldGrads.shift();
1778
- }
1779
-
1780
- // Check convergence
1781
- const gradNorm = Math.sqrt(newGrad.reduce((sum, g) => sum + g * g, 0));
1782
- if (gradNorm < gtol) {
1783
- break;
1784
- }
1785
- }
1786
-
1787
- // Reshape result back into positions array
1788
- const result: number[][] = [];
1789
- for (let i = 0; i < nNodes; i++) {
1790
- result.push(posVec.slice(i * dim, (i + 1) * dim));
1791
- }
1792
-
1793
- return result;
1794
- }
1795
-
1796
- /**
1797
- * Cost function and gradient for Kamada-Kawai layout algorithm
1798
- *
1799
- * @param posVec - Flattened position array
1800
- * @param invDist - Inverse distance matrix
1801
- * @param meanWeight - Weight for centering positions
1802
- * @param dim - Dimension of layout
1803
- * @returns Array with [cost, gradient]
1804
- */
1805
- function _kamadaKawaiCostfn(
1806
- posVec: number[],
1807
- invDist: number[][],
1808
- meanWeight: number,
1809
- dim: number
1810
- ): [number, number[]] {
1811
- const nNodes = invDist.length;
1812
- const positions: number[][] = [];
1813
-
1814
- // Reshape flat vector into positions array
1815
- for (let i = 0; i < nNodes; i++) {
1816
- positions.push(posVec.slice(i * dim, (i + 1) * dim));
1817
- }
1818
-
1819
- // Calculate cost
1820
- let cost = 0;
1821
-
1822
- // Add mean position penalty term
1823
- const sumPos = Array(dim).fill(0);
1824
- for (let i = 0; i < nNodes; i++) {
1825
- for (let d = 0; d < dim; d++) {
1826
- sumPos[d] += positions[i][d];
1827
- }
1828
- }
1829
- cost += 0.5 * meanWeight * sumPos.reduce((sum, val) => sum + val * val, 0);
1830
-
1831
- // Add distance penalty terms
1832
- for (let i = 0; i < nNodes; i++) {
1833
- for (let j = i + 1; j < nNodes; j++) {
1834
- // Calculate actual distance
1835
- const diff = positions[i].map((val, d) => val - positions[j][d]);
1836
- const distance = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0));
1837
-
1838
- // Add penalty for difference between actual and ideal distance
1839
- const idealInvDist = invDist[i][j];
1840
- const offset = distance * idealInvDist - 1.0;
1841
- cost += 0.5 * offset * offset;
1842
- }
1843
- }
1844
-
1845
- // Calculate gradient
1846
- const grad = new Array(posVec.length).fill(0);
1847
-
1848
- // Add gradient of mean position penalty
1849
- for (let i = 0; i < nNodes; i++) {
1850
- for (let d = 0; d < dim; d++) {
1851
- grad[i * dim + d] += meanWeight * sumPos[d];
1852
- }
1853
- }
1854
-
1855
- // Add gradient of distance penalties
1856
- for (let i = 0; i < nNodes; i++) {
1857
- for (let j = i + 1; j < nNodes; j++) {
1858
- // Calculate actual distance and direction
1859
- const diff = positions[i].map((val, d) => val - positions[j][d]);
1860
- const distance = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0)) || 1e-10;
1861
- const direction = diff.map(d => d / distance);
1862
-
1863
- // Calculate contribution to gradient
1864
- const idealInvDist = invDist[i][j];
1865
- const offset = distance * idealInvDist - 1.0;
1866
-
1867
- for (let d = 0; d < dim; d++) {
1868
- const force = idealInvDist * offset * direction[d];
1869
- grad[i * dim + d] += force;
1870
- grad[j * dim + d] -= force;
1871
- }
1872
- }
1873
- }
1874
-
1875
- return [cost, grad];
1876
- }
1877
-
1878
- /**
1879
- * Compute the search direction using L-BFGS approximation
1880
- *
1881
- * @param grad - Current gradient
1882
- * @param sList - List of position differences (s_k)
1883
- * @param yList - List of gradient differences (y_k)
1884
- * @param m - Memory size
1885
- * @returns Direction vector
1886
- */
1887
- function _lbfgsDirection(
1888
- grad: number[],
1889
- sList: number[][],
1890
- yList: number[][],
1891
- m: number
1892
- ): number[] {
1893
- if (sList.length === 0) {
1894
- // First iteration - use negative gradient
1895
- return grad.map(g => -g);
1896
- }
1897
-
1898
- const q = grad.slice();
1899
- const alpha = Array(sList.length).fill(0);
1900
- const rho: number[] = [];
1901
-
1902
- // Compute rho values
1903
- for (let i = 0; i < sList.length; i++) {
1904
- const s = sList[i];
1905
- const y = yList[i];
1906
- rho.push(1 / y.reduce((sum, val, j) => sum + val * s[j], 0));
1907
- }
1908
-
1909
- // Forward pass
1910
- for (let i = sList.length - 1; i >= 0; i--) {
1911
- const s = sList[i];
1912
- alpha[i] = rho[i] * s.reduce((sum, val, j) => sum + val * q[j], 0);
1913
- for (let j = 0; j < q.length; j++) {
1914
- q[j] -= alpha[i] * yList[i][j];
1915
- }
1916
- }
1917
-
1918
- // Scale initial Hessian approximation
1919
- let gamma = 1;
1920
- if (sList.length > 0 && yList.length > 0) {
1921
- const y = yList[yList.length - 1];
1922
- const s = sList[sList.length - 1];
1923
- gamma = s.reduce((sum, val, i) => sum + val * y[i], 0) /
1924
- y.reduce((sum, val) => sum + val * val, 0);
1925
- }
1926
-
1927
- // Initialize direction with scaled negative gradient
1928
- const direction = q.map(val => -gamma * val);
1929
-
1930
- // Backward pass
1931
- for (let i = 0; i < sList.length; i++) {
1932
- const s = sList[i];
1933
- const y = yList[i];
1934
- const beta = rho[i] * y.reduce((sum, val, j) => sum + val * direction[j], 0);
1935
- for (let j = 0; j < direction.length; j++) {
1936
- direction[j] += s[j] * (alpha[i] - beta);
1937
- }
1938
- }
1939
-
1940
- return direction;
1941
- }
1942
-
1943
- /**
1944
- * Backtracking line search to find step size
1945
- *
1946
- * @param x - Current position
1947
- * @param direction - Search direction
1948
- * @param f - Function value at current position
1949
- * @param grad - Gradient at current position
1950
- * @param func - Function to evaluate cost
1951
- * @param alpha0 - Initial step size
1952
- * @returns Optimal step size
1953
- */
1954
- function _backtrackingLineSearch(
1955
- x: number[],
1956
- direction: number[],
1957
- f: number,
1958
- grad: number[],
1959
- func: (x: number[]) => number,
1960
- alpha0: number
1961
- ): number {
1962
- const c1 = 1e-4;
1963
- const c2 = 0.9;
1964
- const initialSlope = grad.reduce((sum, g, i) => sum + g * direction[i], 0);
1965
-
1966
- if (initialSlope >= 0) {
1967
- return 1e-8; // Direction is not a descent direction
1968
- }
1969
-
1970
- let alpha = alpha0;
1971
- const maxIter = 20;
1972
-
1973
- for (let i = 0; i < maxIter; i++) {
1974
- // Try step
1975
- const newX = x.map((val, i) => val + alpha * direction[i]);
1976
- const newF = func(newX);
1977
-
1978
- // Check sufficient decrease condition (Armijo condition)
1979
- if (newF <= f + c1 * alpha * initialSlope) {
1980
- return alpha;
1981
- }
1982
-
1983
- // Reduce step size
1984
- alpha *= c2;
1985
- }
1986
-
1987
- return alpha; // Return last alpha even if not optimal
1988
- }
1989
-
1990
- /**
1991
- * Position nodes using the ForceAtlas2 force-directed algorithm.
1992
- *
1993
- * @param G - Graph
1994
- * @param pos - Initial positions for nodes
1995
- * @param maxIter - Maximum number of iterations
1996
- * @param jitterTolerance - Controls tolerance for node speed adjustments
1997
- * @param scalingRatio - Scaling of attraction and repulsion forces
1998
- * @param gravity - Attraction to center to prevent disconnected components from drifting
1999
- * @param distributedAction - Distributes attraction force among nodes
2000
- * @param strongGravity - Uses a stronger gravity model
2001
- * @param nodeMass - Dictionary mapping nodes to their masses
2002
- * @param nodeSize - Dictionary mapping nodes to their sizes
2003
- * @param weight - Edge attribute for weight
2004
- * @param dissuadeHubs - Whether to prevent hub nodes from clustering
2005
- * @param linlog - Whether to use logarithmic attraction
2006
- * @param seed - Random seed for initial positions
2007
- * @param dim - Dimension of layout
2008
- * @returns Positions dictionary keyed by node
2009
- */
2010
- function forceatlas2Layout(
2011
- G: Graph,
2012
- pos: PositionMap | null = null,
2013
- maxIter: number = 100,
2014
- jitterTolerance: number = 1.0,
2015
- scalingRatio: number = 2.0,
2016
- gravity: number = 1.0,
2017
- distributedAction: boolean = false,
2018
- strongGravity: boolean = false,
2019
- nodeMass: Record<Node, number> | null = null,
2020
- nodeSize: Record<Node, number> | null = null,
2021
- weight: string | null = null,
2022
- dissuadeHubs: boolean = false,
2023
- linlog: boolean = false,
2024
- seed: number | null = null,
2025
- dim: number = 2
2026
- ): PositionMap {
2027
- const processed = _processParams(G, null, dim);
2028
- const graph = processed.G;
2029
-
2030
- const nodes = getNodesFromGraph(graph);
2031
-
2032
- if (nodes.length === 0) {
2033
- return {};
2034
- }
2035
-
2036
- // Initialize random number generator
2037
- const rng = new RandomNumberGenerator(seed ?? undefined);
2038
-
2039
- // Initialize positions if not provided
2040
- let posArray: number[][];
2041
- if (pos === null) {
2042
- pos = {};
2043
- posArray = new Array(nodes.length);
2044
- for (let i = 0; i < nodes.length; i++) {
2045
- posArray[i] = Array(dim).fill(0).map(() => rng.rand() as number * 2 - 1);
2046
- pos[nodes[i]] = posArray[i];
2047
- }
2048
- } else if (Object.keys(pos).length === nodes.length) {
2049
- // Use provided positions
2050
- posArray = new Array(nodes.length);
2051
- for (let i = 0; i < nodes.length; i++) {
2052
- posArray[i] = [...pos[nodes[i]]];
2053
- }
2054
- } else {
2055
- // Some nodes don't have positions, initialize within the range of existing positions
2056
- let minPos = Array(dim).fill(Number.POSITIVE_INFINITY);
2057
- let maxPos = Array(dim).fill(Number.NEGATIVE_INFINITY);
2058
-
2059
- // Find min and max of existing positions
2060
- for (const node in pos) {
2061
- for (let d = 0; d < dim; d++) {
2062
- minPos[d] = Math.min(minPos[d], pos[node][d]);
2063
- maxPos[d] = Math.max(maxPos[d], pos[node][d]);
2064
- }
2065
- }
2066
-
2067
- posArray = new Array(nodes.length);
2068
- for (let i = 0; i < nodes.length; i++) {
2069
- const node = nodes[i];
2070
- if (pos[node]) {
2071
- posArray[i] = [...pos[node]];
2072
- } else {
2073
- posArray[i] = Array(dim).fill(0).map((_, d) =>
2074
- minPos[d] + (rng.rand() as number) * (maxPos[d] - minPos[d])
2075
- );
2076
- pos[node] = posArray[i];
2077
- }
2078
- }
2079
- }
2080
-
2081
- // Initialize mass and size arrays
2082
- const mass = new Array(nodes.length).fill(0);
2083
- const size = new Array(nodes.length).fill(0);
2084
-
2085
- // Flag to track whether to adjust for node sizes
2086
- const adjustSizes = nodeSize !== null;
2087
-
2088
- // Set node masses and sizes
2089
- for (let i = 0; i < nodes.length; i++) {
2090
- const node = nodes[i];
2091
- mass[i] = nodeMass && nodeMass[node] ?
2092
- nodeMass[node] :
2093
- (graph.edges ? getNodeDegree(graph, node) + 1 : 1);
2094
-
2095
- size[i] = nodeSize && nodeSize[node] ? nodeSize[node] : 1;
2096
- }
2097
-
2098
- // Create adjacency matrix
2099
- const n = nodes.length;
2100
- const A = Array(n).fill(0).map(() => Array(n).fill(0));
2101
-
2102
- // Populate adjacency matrix with edge weights
2103
- const edges = graph.edges ? graph.edges() : [] as Edge[];
2104
- const nodeIndices: Record<Node, number> = {};
2105
- nodes.forEach((node, i) => { nodeIndices[node] = i; });
2106
-
2107
- for (const [source, target] of edges) {
2108
- const i = nodeIndices[source];
2109
- const j = nodeIndices[target];
2110
-
2111
- // Use edge weight if provided, otherwise default to 1
2112
- let edgeWeight = 1;
2113
- if (weight && graph.getEdgeData) {
2114
- edgeWeight = graph.getEdgeData(source, target, weight) || 1;
2115
- }
2116
-
2117
- A[i][j] = edgeWeight;
2118
- A[j][i] = edgeWeight; // For undirected graphs
2119
- }
2120
-
2121
- // Initialize force arrays
2122
- const gravities = Array(n).fill(0).map(() => Array(dim).fill(0));
2123
- const attraction = Array(n).fill(0).map(() => Array(dim).fill(0));
2124
- const repulsion = Array(n).fill(0).map(() => Array(dim).fill(0));
2125
-
2126
- // Simulation parameters
2127
- let speed = 1;
2128
- let speedEfficiency = 1;
2129
- let swing = 1;
2130
- let traction = 1;
2131
-
2132
- // Helper function to estimate factor for force scaling
2133
- function estimateFactor(
2134
- n: number,
2135
- swing: number,
2136
- traction: number,
2137
- speed: number,
2138
- speedEfficiency: number,
2139
- jitterTolerance: number
2140
- ): [number, number] {
2141
- // Optimal jitter parameters
2142
- const optJitter = 0.05 * Math.sqrt(n);
2143
- const minJitter = Math.sqrt(optJitter);
2144
- const maxJitter = 10;
2145
- const minSpeedEfficiency = 0.05;
2146
-
2147
- // Estimate jitter based on current state
2148
- const other = Math.min(maxJitter, optJitter * traction / (n * n));
2149
- let jitter = jitterTolerance * Math.max(minJitter, other);
2150
-
2151
- // Adjust speed efficiency based on swing/traction ratio
2152
- if (swing / traction > 2.0) {
2153
- if (speedEfficiency > minSpeedEfficiency) {
2154
- speedEfficiency *= 0.5;
2155
- }
2156
- jitter = Math.max(jitter, jitterTolerance);
2157
- }
2158
-
2159
- // Calculate target speed
2160
- let targetSpeed = swing === 0 ?
2161
- Number.POSITIVE_INFINITY :
2162
- jitter * speedEfficiency * traction / swing;
2163
-
2164
- // Further adjust speed efficiency
2165
- if (swing > jitter * traction) {
2166
- if (speedEfficiency > minSpeedEfficiency) {
2167
- speedEfficiency *= 0.7;
2168
- }
2169
- } else if (speed < 1000) {
2170
- speedEfficiency *= 1.3;
2171
- }
2172
-
2173
- // Limit the speed increase
2174
- const maxRise = 0.5;
2175
- speed = speed + Math.min(targetSpeed - speed, maxRise * speed);
2176
-
2177
- return [speed, speedEfficiency];
2178
- }
2179
-
2180
- // Main simulation loop
2181
- for (let iter = 0; iter < maxIter; iter++) {
2182
- // Reset forces for this iteration
2183
- for (let i = 0; i < n; i++) {
2184
- for (let d = 0; d < dim; d++) {
2185
- attraction[i][d] = 0;
2186
- repulsion[i][d] = 0;
2187
- gravities[i][d] = 0;
2188
- }
2189
- }
2190
-
2191
- // Compute pairwise differences and distances
2192
- const diff = Array(n).fill(0).map(() =>
2193
- Array(n).fill(0).map(() => Array(dim).fill(0))
2194
- );
2195
-
2196
- const distance = Array(n).fill(0).map(() => Array(n).fill(0));
2197
-
2198
- for (let i = 0; i < n; i++) {
2199
- for (let j = 0; j < n; j++) {
2200
- if (i === j) continue;
2201
-
2202
- for (let d = 0; d < dim; d++) {
2203
- diff[i][j][d] = posArray[i][d] - posArray[j][d];
2204
- }
2205
-
2206
- distance[i][j] = Math.sqrt(diff[i][j].reduce((sum, d) => sum + d * d, 0));
2207
- // Prevent division by zero
2208
- if (distance[i][j] < 0.01) distance[i][j] = 0.01;
2209
- }
2210
- }
2211
-
2212
- // Calculate attraction forces
2213
- if (linlog) {
2214
- // Logarithmic attraction model
2215
- for (let i = 0; i < n; i++) {
2216
- for (let j = 0; j < n; j++) {
2217
- if (i === j || A[i][j] === 0) continue;
2218
-
2219
- const dist = distance[i][j];
2220
- const factor = -Math.log(1 + dist) / dist * A[i][j];
2221
-
2222
- for (let d = 0; d < dim; d++) {
2223
- const force = factor * diff[i][j][d];
2224
- attraction[i][d] += force;
2225
- }
2226
- }
2227
- }
2228
- } else {
2229
- // Linear attraction model
2230
- for (let i = 0; i < n; i++) {
2231
- for (let j = 0; j < n; j++) {
2232
- if (i === j || A[i][j] === 0) continue;
2233
-
2234
- for (let d = 0; d < dim; d++) {
2235
- const force = -diff[i][j][d] * A[i][j];
2236
- attraction[i][d] += force;
2237
- }
2238
- }
2239
- }
2240
- }
2241
-
2242
- // Apply distributed attraction if enabled
2243
- if (distributedAction) {
2244
- for (let i = 0; i < n; i++) {
2245
- for (let d = 0; d < dim; d++) {
2246
- attraction[i][d] /= mass[i];
2247
- }
2248
- }
2249
- }
2250
-
2251
- // Calculate repulsion forces
2252
- for (let i = 0; i < n; i++) {
2253
- for (let j = 0; j < n; j++) {
2254
- if (i === j) continue;
2255
-
2256
- let dist = distance[i][j];
2257
-
2258
- // Adjust distance for node sizes if needed
2259
- if (adjustSizes) {
2260
- dist -= size[i] - size[j];
2261
- dist = Math.max(dist, 0.01); // Prevent negative or zero distances
2262
- }
2263
-
2264
- const distSquared = dist * dist;
2265
- const massProduct = mass[i] * mass[j];
2266
- const factor = (massProduct / distSquared) * scalingRatio;
2267
-
2268
- for (let d = 0; d < dim; d++) {
2269
- const direction = diff[i][j][d] / dist;
2270
- repulsion[i][d] += direction * factor;
2271
- }
2272
- }
2273
- }
2274
-
2275
- // Calculate gravity forces
2276
- // First find the center of mass
2277
- const centerOfMass = Array(dim).fill(0);
2278
- for (let i = 0; i < n; i++) {
2279
- for (let d = 0; d < dim; d++) {
2280
- centerOfMass[d] += posArray[i][d] / n;
2281
- }
2282
- }
2283
-
2284
- for (let i = 0; i < n; i++) {
2285
- const posCentered = Array(dim);
2286
- for (let d = 0; d < dim; d++) {
2287
- posCentered[d] = posArray[i][d] - centerOfMass[d];
2288
- }
2289
-
2290
- if (strongGravity) {
2291
- // Strong gravity model
2292
- for (let d = 0; d < dim; d++) {
2293
- gravities[i][d] = -gravity * mass[i] * posCentered[d];
2294
- }
2295
- } else {
2296
- // Regular gravity model
2297
- const dist = Math.sqrt(posCentered.reduce((sum, val) => sum + val * val, 0));
2298
-
2299
- if (dist > 0.01) {
2300
- for (let d = 0; d < dim; d++) {
2301
- const direction = posCentered[d] / dist;
2302
- gravities[i][d] = -gravity * mass[i] * direction;
2303
- }
2304
- }
2305
- }
2306
- }
2307
-
2308
- // Calculate total forces and update positions
2309
- const update = Array(n).fill(0).map(() => Array(dim).fill(0));
2310
- let totalSwing = 0;
2311
- let totalTraction = 0;
2312
-
2313
- for (let i = 0; i < n; i++) {
2314
- for (let d = 0; d < dim; d++) {
2315
- update[i][d] = attraction[i][d] + repulsion[i][d] + gravities[i][d];
2316
- }
2317
-
2318
- // Calculate swing and traction for this node
2319
- const oldPos = [...posArray[i]];
2320
- const newPos = oldPos.map((p, d) => p + update[i][d]);
2321
-
2322
- const swingVector = oldPos.map((p, d) => p - newPos[d]);
2323
- const tractionVector = oldPos.map((p, d) => p + newPos[d]);
2324
-
2325
- const swingMagnitude = Math.sqrt(swingVector.reduce((sum, val) => sum + val * val, 0));
2326
- const tractionMagnitude = Math.sqrt(tractionVector.reduce((sum, val) => sum + val * val, 0));
2327
-
2328
- totalSwing += mass[i] * swingMagnitude;
2329
- totalTraction += 0.5 * mass[i] * tractionMagnitude;
2330
- }
2331
-
2332
- // Update speed and efficiency
2333
- [speed, speedEfficiency] = estimateFactor(
2334
- n,
2335
- totalSwing,
2336
- totalTraction,
2337
- speed,
2338
- speedEfficiency,
2339
- jitterTolerance
2340
- );
2341
-
2342
- // Apply forces to update positions
2343
- let totalMovement = 0;
2344
-
2345
- for (let i = 0; i < n; i++) {
2346
- let factor;
2347
-
2348
- if (adjustSizes) {
2349
- // Calculate displacement magnitude
2350
- const df = Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
2351
- const swinging = mass[i] * df;
2352
-
2353
- // Determine scaling factor with size adjustments
2354
- factor = 0.1 * speed / (1 + Math.sqrt(speed * swinging));
2355
- factor = Math.min(factor * df, 10) / df;
2356
- } else {
2357
- // Standard scaling factor
2358
- const swinging = mass[i] * Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
2359
- factor = speed / (1 + Math.sqrt(speed * swinging));
2360
- }
2361
-
2362
- // Apply factor to update position
2363
- for (let d = 0; d < dim; d++) {
2364
- const movement = update[i][d] * factor;
2365
- posArray[i][d] += movement;
2366
- totalMovement += Math.abs(movement);
2367
- }
2368
- }
2369
-
2370
- // Check for convergence
2371
- if (totalMovement < 1e-10) {
2372
- break;
2373
- }
2374
- }
2375
-
2376
- // Create position dictionary
2377
- const positions: PositionMap = {};
2378
- for (let i = 0; i < n; i++) {
2379
- positions[nodes[i]] = posArray[i];
2380
- }
2381
-
2382
- return rescaleLayout(positions) as PositionMap;
2383
-
2384
- // Helper function to get node degree
2385
- function getNodeDegree(graph: Graph, node: Node): number {
2386
- if (!graph.edges) return 0;
2387
-
2388
- return graph.edges().filter((edge: Edge) =>
2389
- edge[0] === node || edge[1] === node
2390
- ).length;
2391
- }
2392
- }
2393
-
2394
- /**
2395
- * Layout algorithm with attractive and repulsive forces (ARF).
2396
- *
2397
- * @param G - Graph
2398
- * @param pos - Initial positions for nodes
2399
- * @param scaling - Scale factor for positions
2400
- * @param a - Strength of springs between connected nodes (should be > 1)
2401
- * @param maxIter - Maximum number of iterations
2402
- * @param seed - Random seed for initial positions
2403
- * @returns Positions dictionary keyed by node
2404
- */
2405
- function arfLayout(
2406
- G: Graph,
2407
- pos: PositionMap | null = null,
2408
- scaling: number = 1,
2409
- a: number = 1.1,
2410
- maxIter: number = 1000,
2411
- seed: number | null = null
2412
- ): PositionMap {
2413
- if (a <= 1) {
2414
- throw new Error("The parameter a should be larger than 1");
2415
- }
2416
-
2417
- const nodes = getNodesFromGraph(G);
2418
- const edges = getEdgesFromGraph(G);
2419
-
2420
- if (nodes.length === 0) {
2421
- return {};
2422
- }
2423
-
2424
- // Initialize positions if not provided
2425
- if (!pos) {
2426
- pos = randomLayout(G, null, 2, seed);
2427
- } else {
2428
- // Make sure all nodes have positions
2429
- const rng = new RandomNumberGenerator(seed ?? undefined);
2430
- const defaultPos: PositionMap = {};
2431
- nodes.forEach((node: Node) => {
2432
- if (!pos![node]) {
2433
- defaultPos[node] = [(rng.rand() as number), (rng.rand() as number)];
2434
- }
2435
- });
2436
- pos = { ...pos, ...defaultPos };
2437
- }
2438
-
2439
- // Create node index mapping
2440
- const nodeIndex: Record<Node, number> = {};
2441
- nodes.forEach((node: Node, i: number) => {
2442
- nodeIndex[node] = i;
2443
- });
2444
-
2445
- // Create positions array
2446
- const positions: number[][] = nodes.map((node: Node) => [...pos![node]]);
2447
-
2448
- // Initialize spring constant matrix
2449
- const N = nodes.length;
2450
- const K = Array(N).fill(0).map(() => Array(N).fill(1));
2451
-
2452
- // Set diagonal to zero (no self-attraction)
2453
- for (let i = 0; i < N; i++) {
2454
- K[i][i] = 0;
2455
- }
2456
-
2457
- // Set stronger attraction between connected nodes
2458
- for (const [source, target] of edges) {
2459
- if (source === target) continue;
2460
-
2461
- const i = nodeIndex[source];
2462
- const j = nodeIndex[target];
2463
- K[i][j] = a;
2464
- K[j][i] = a;
2465
- }
2466
-
2467
- // Calculate rho (scale factor)
2468
- const rho = scaling * Math.sqrt(N);
2469
-
2470
- // Optimization loop
2471
- const dt = 1e-3; // Time step
2472
- const etol = 1e-6; // Error tolerance
2473
- let error = etol + 1;
2474
- let nIter = 0;
2475
-
2476
- while (error > etol && nIter < maxIter) {
2477
- // Calculate changes for each node
2478
- const change = Array(N).fill(0).map(() => [0, 0]);
2479
-
2480
- for (let i = 0; i < N; i++) {
2481
- for (let j = 0; j < N; j++) {
2482
- if (i === j) continue;
2483
-
2484
- // Calculate difference vector
2485
- const diff = positions[i].map((coord, dim) => coord - positions[j][dim]);
2486
-
2487
- // Calculate distance (with minimum to avoid division by zero)
2488
- const dist = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0)) || 0.01;
2489
-
2490
- // Calculate attractive and repulsive forces
2491
- for (let d = 0; d < diff.length; d++) {
2492
- change[i][d] += K[i][j] * diff[d] - (rho / dist) * diff[d];
2493
- }
2494
- }
2495
- }
2496
-
2497
- // Update positions
2498
- for (let i = 0; i < N; i++) {
2499
- for (let d = 0; d < positions[i].length; d++) {
2500
- positions[i][d] += change[i][d] * dt;
2501
- }
2502
- }
2503
-
2504
- // Calculate error (sum of force magnitudes)
2505
- error = change.reduce((sum, c) =>
2506
- sum + Math.sqrt(c.reduce((s, v) => s + v * v, 0)), 0);
2507
-
2508
- nIter++;
2509
- }
2510
-
2511
- // Convert positions array back to object
2512
- const finalPos: PositionMap = {};
2513
- nodes.forEach((node: Node, i: number) => {
2514
- finalPos[node] = positions[i];
2515
- });
2516
-
2517
- return finalPos;
2518
- }
2519
-
2520
- /**
2521
- * Return a dictionary of scaled positions keyed by node.
2522
- *
2523
- * @param pos - Dictionary of positions keyed by node
2524
- * @param scale - Scale factor for positions
2525
- * @returns Dictionary of scaled positions
2526
- */
2527
- function rescaleLayoutDict(
2528
- pos: PositionMap,
2529
- scale: number = 1
2530
- ): PositionMap {
2531
- if (Object.keys(pos).length === 0) {
2532
- return {};
2533
- }
2534
-
2535
- // Extract positions as array
2536
- const posArray = Object.values(pos);
2537
-
2538
- // Find center of positions
2539
- const center: number[] = [];
2540
- for (let d = 0; d < posArray[0].length; d++) {
2541
- center[d] = posArray.reduce((sum, p) => sum + p[d], 0) / posArray.length;
2542
- }
2543
-
2544
- // Center positions
2545
- const centeredPos: PositionMap = {};
2546
- for (const [node, p] of Object.entries(pos)) {
2547
- centeredPos[node] = p.map((val, d) => val - center[d]);
2548
- }
2549
-
2550
- // Find maximum distance from center
2551
- let maxDist = 0;
2552
- for (const p of Object.values(centeredPos)) {
2553
- const dist = Math.sqrt(p.reduce((sum, val) => sum + val * val, 0));
2554
- maxDist = Math.max(maxDist, dist);
2555
- }
2556
-
2557
- // Scale positions
2558
- const scaledPos: PositionMap = {};
2559
- if (maxDist > 0) {
2560
- for (const [node, p] of Object.entries(centeredPos)) {
2561
- scaledPos[node] = p.map(val => val * scale / maxDist);
2562
- }
2563
- } else {
2564
- // All points at the center
2565
- for (const node of Object.keys(centeredPos)) {
2566
- scaledPos[node] = Array(centeredPos[node].length).fill(0);
2567
- }
2568
- }
2569
-
2570
- return scaledPos;
2571
- }
2572
-
2573
- // Graph generation utilities
2574
- /**
2575
- * Create a complete graph with n nodes
2576
- * @param n - Number of nodes
2577
- * @returns Graph object with all nodes connected to all other nodes
2578
- */
2579
- function completeGraph(n: number): Graph {
2580
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2581
- const edges: Edge[] = [];
2582
-
2583
- for (let i = 0; i < n; i++) {
2584
- for (let j = i + 1; j < n; j++) {
2585
- edges.push([i, j]);
2586
- }
2587
- }
2588
-
2589
- return {
2590
- nodes: () => nodes,
2591
- edges: () => edges
2592
- };
2593
- }
2594
-
2595
- /**
2596
- * Create a cycle graph with n nodes
2597
- * @param n - Number of nodes
2598
- * @returns Graph object with nodes connected in a cycle
2599
- */
2600
- function cycleGraph(n: number): Graph {
2601
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2602
- const edges: Edge[] = [];
2603
-
2604
- for (let i = 0; i < n; i++) {
2605
- edges.push([i, (i + 1) % n]);
2606
- }
2607
-
2608
- return {
2609
- nodes: () => nodes,
2610
- edges: () => edges
2611
- };
2612
- }
2613
-
2614
- /**
2615
- * Create a star graph with n nodes (1 center + n-1 leaves)
2616
- * @param n - Total number of nodes
2617
- * @returns Graph object with star topology
2618
- */
2619
- function starGraph(n: number): Graph {
2620
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2621
- const edges: Edge[] = [];
2622
-
2623
- // Connect all nodes to node 0 (center)
2624
- for (let i = 1; i < n; i++) {
2625
- edges.push([0, i]);
2626
- }
2627
-
2628
- return {
2629
- nodes: () => nodes,
2630
- edges: () => edges
2631
- };
2632
- }
2633
-
2634
- /**
2635
- * Create a wheel graph with n nodes (1 center + n-1 rim nodes)
2636
- * @param n - Total number of nodes
2637
- * @returns Graph object with wheel topology
2638
- */
2639
- function wheelGraph(n: number): Graph {
2640
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2641
- const edges: Edge[] = [];
2642
-
2643
- // Connect all rim nodes to center (node 0)
2644
- for (let i = 1; i < n; i++) {
2645
- edges.push([0, i]);
2646
- }
2647
-
2648
- // Connect rim nodes in a cycle
2649
- for (let i = 1; i < n - 1; i++) {
2650
- edges.push([i, i + 1]);
2651
- }
2652
- if (n > 2) {
2653
- edges.push([n - 1, 1]);
2654
- }
2655
-
2656
- return {
2657
- nodes: () => nodes,
2658
- edges: () => edges
2659
- };
2660
- }
2661
-
2662
- /**
2663
- * Create a grid graph with rows x cols nodes
2664
- * @param rows - Number of rows
2665
- * @param cols - Number of columns
2666
- * @returns Graph object with grid topology
2667
- */
2668
- function gridGraph(rows: number, cols: number): Graph {
2669
- const nodes: Node[] = [];
2670
- const edges: Edge[] = [];
2671
-
2672
- // Create nodes
2673
- for (let i = 0; i < rows; i++) {
2674
- for (let j = 0; j < cols; j++) {
2675
- nodes.push(`${i},${j}`);
2676
- }
2677
- }
2678
-
2679
- // Create edges
2680
- for (let i = 0; i < rows; i++) {
2681
- for (let j = 0; j < cols; j++) {
2682
- // Connect to right neighbor
2683
- if (j < cols - 1) {
2684
- edges.push([`${i},${j}`, `${i},${j + 1}`]);
2685
- }
2686
- // Connect to bottom neighbor
2687
- if (i < rows - 1) {
2688
- edges.push([`${i},${j}`, `${i + 1},${j}`]);
2689
- }
2690
- }
2691
- }
2692
-
2693
- return {
2694
- nodes: () => nodes,
2695
- edges: () => edges
2696
- };
2697
- }
2698
-
2699
- /**
2700
- * Create a random graph with n nodes and given edge probability
2701
- * @param n - Number of nodes
2702
- * @param p - Probability of edge between any two nodes (0-1)
2703
- * @param seed - Random seed for reproducibility
2704
- * @returns Graph object with random edges
2705
- */
2706
- function randomGraph(n: number, p: number, seed?: number): Graph {
2707
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2708
- const edges: Edge[] = [];
2709
-
2710
- // Simple deterministic pseudo-random if seed provided
2711
- let currentSeed = seed;
2712
- let random = seed !== undefined
2713
- ? () => {
2714
- currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2715
- return currentSeed / 233280;
2716
- }
2717
- : Math.random;
2718
-
2719
- for (let i = 0; i < n; i++) {
2720
- for (let j = i + 1; j < n; j++) {
2721
- if (random() < p) {
2722
- edges.push([i, j]);
2723
- }
2724
- }
2725
- }
2726
-
2727
- return {
2728
- nodes: () => nodes,
2729
- edges: () => edges
2730
- };
2731
- }
2732
-
2733
- /**
2734
- * Create a bipartite graph with two sets of nodes
2735
- * @param n1 - Number of nodes in first set
2736
- * @param n2 - Number of nodes in second set
2737
- * @param p - Probability of edge between nodes in different sets
2738
- * @param seed - Random seed for reproducibility
2739
- * @returns Graph object with bipartite structure and setA/setB properties
2740
- */
2741
- function bipartiteGraph(n1: number, n2: number, p: number, seed?: number): Graph & { setA: Node[], setB: Node[] } {
2742
- const setA: Node[] = Array.from({ length: n1 }, (_, i) => `A${i}`);
2743
- const setB: Node[] = Array.from({ length: n2 }, (_, i) => `B${i}`);
2744
- const nodes = [...setA, ...setB];
2745
- const edges: Edge[] = [];
2746
-
2747
- // Simple deterministic pseudo-random if seed provided
2748
- let currentSeed = seed;
2749
- let random = seed !== undefined
2750
- ? () => {
2751
- currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2752
- return currentSeed / 233280;
2753
- }
2754
- : Math.random;
2755
-
2756
- // Only connect nodes between sets
2757
- for (const a of setA) {
2758
- for (const b of setB) {
2759
- if (random() < p) {
2760
- edges.push([a, b]);
2761
- }
2762
- }
2763
- }
2764
-
2765
- return {
2766
- nodes: () => nodes,
2767
- edges: () => edges,
2768
- setA,
2769
- setB
2770
- };
2771
- }
2772
-
2773
- /**
2774
- * Create a scale-free graph using Barabási-Albert model
2775
- * @param n - Total number of nodes
2776
- * @param m - Number of edges to attach from new node
2777
- * @param seed - Random seed for reproducibility
2778
- * @returns Graph object with scale-free properties
2779
- */
2780
- function scaleFreeGraph(n: number, m: number, seed?: number): Graph {
2781
- if (m >= n) {
2782
- throw new Error('m must be less than n');
2783
- }
2784
-
2785
- const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2786
- const edges: Edge[] = [];
2787
- const degrees = new Array(n).fill(0);
2788
-
2789
- // Simple deterministic pseudo-random if seed provided
2790
- let currentSeed = seed;
2791
- let random = seed !== undefined
2792
- ? () => {
2793
- currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2794
- return currentSeed / 233280;
2795
- }
2796
- : Math.random;
2797
-
2798
- // Start with complete graph of m+1 nodes
2799
- for (let i = 0; i <= m; i++) {
2800
- for (let j = i + 1; j <= m; j++) {
2801
- edges.push([i, j]);
2802
- degrees[i]++;
2803
- degrees[j]++;
2804
- }
2805
- }
2806
-
2807
- // Add remaining nodes
2808
- for (let i = m + 1; i < n; i++) {
2809
- const targets = new Set<number>();
2810
- const totalDegree = degrees.reduce((sum, d) => sum + d, 0);
2811
-
2812
- // Choose m targets based on preferential attachment
2813
- while (targets.size < m) {
2814
- let r = random() * totalDegree;
2815
- let cumSum = 0;
2816
-
2817
- for (let j = 0; j < i; j++) {
2818
- cumSum += degrees[j];
2819
- if (r <= cumSum && !targets.has(j)) {
2820
- targets.add(j);
2821
- break;
2822
- }
2823
- }
2824
- }
2825
-
2826
- // Add edges to targets
2827
- for (const target of targets) {
2828
- edges.push([i, target]);
2829
- degrees[i]++;
2830
- degrees[target]++;
2831
- }
2832
- }
2833
-
2834
- return {
2835
- nodes: () => nodes,
2836
- edges: () => edges
2837
- };
2838
- }
2839
-
2840
- // Export the layout functions
2841
- export {
2842
- randomLayout,
2843
- circularLayout,
2844
- shellLayout,
2845
- springLayout,
2846
- fruchtermanReingoldLayout,
2847
- spectralLayout,
2848
- spiralLayout,
2849
- bipartiteLayout,
2850
- multipartiteLayout,
2851
- bfsLayout,
2852
- planarLayout,
2853
- kamadaKawaiLayout,
2854
- forceatlas2Layout,
2855
- arfLayout,
2856
- rescaleLayout,
2857
- rescaleLayoutDict,
2858
- // Graph generation utilities
2859
- completeGraph,
2860
- cycleGraph,
2861
- starGraph,
2862
- wheelGraph,
2863
- gridGraph,
2864
- randomGraph,
2865
- bipartiteGraph,
2866
- scaleFreeGraph
2867
- };