@graphty/layout 1.0.0

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