@graphty/layout 1.2.0 → 1.2.2

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