@graphty/layout 1.0.1 → 1.1.1

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