@graphty/layout 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +3 -7
  3. package/CHANGELOG.md +14 -0
  4. package/DEPLOYMENT.md +59 -0
  5. package/README.md +54 -1
  6. package/dist/vitest.config.js +2 -2
  7. package/dist/vitest.config.js.map +1 -1
  8. package/examples/3d-force-directed.html +611 -0
  9. package/examples/3d-kamada-kawai.html +379 -0
  10. package/examples/3d-layout-comparison.html +448 -0
  11. package/examples/3d-spherical-layout.html +319 -0
  12. package/examples/arf-layout.html +13 -1
  13. package/examples/bfs-layout.html +38 -32
  14. package/examples/bipartite-layout.html +16 -4
  15. package/examples/circular-layout.html +14 -2
  16. package/examples/forceatlas2-layout.html +118 -151
  17. package/examples/index.html +75 -0
  18. package/examples/kamada-kawai-layout.html +13 -1
  19. package/{dist → examples}/layout-helpers.js +58 -29
  20. package/examples/multipartite-layout.html +44 -54
  21. package/examples/planar-layout.html +13 -1
  22. package/examples/random-layout.html +13 -1
  23. package/examples/shell-layout.html +16 -2
  24. package/examples/spectral-layout.html +13 -1
  25. package/examples/spiral-layout.html +13 -1
  26. package/examples/spring-layout.html +15 -13
  27. package/package.json +6 -3
  28. package/src/algorithms/index.ts +6 -0
  29. package/src/algorithms/optimization/index.ts +12 -0
  30. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  31. package/src/algorithms/optimization/lbfgs.ts +68 -0
  32. package/src/algorithms/optimization/line-search.ts +50 -0
  33. package/src/algorithms/optimization/types.ts +8 -0
  34. package/src/algorithms/planarity/check.ts +38 -0
  35. package/src/algorithms/planarity/embedding.ts +216 -0
  36. package/src/algorithms/planarity/index.ts +12 -0
  37. package/src/algorithms/planarity/lr-test.ts +70 -0
  38. package/src/algorithms/planarity/special-graphs.ts +126 -0
  39. package/src/generators/basic.ts +93 -0
  40. package/src/generators/bipartite.ts +45 -0
  41. package/src/generators/grid.ts +42 -0
  42. package/src/generators/index.ts +15 -0
  43. package/src/generators/random.ts +39 -0
  44. package/src/generators/scale-free.ts +72 -0
  45. package/src/index.ts +18 -0
  46. package/src/layouts/basic/index.ts +5 -0
  47. package/src/layouts/basic/random.ts +32 -0
  48. package/src/layouts/force-directed/arf.ts +130 -0
  49. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  50. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  51. package/src/layouts/force-directed/index.ts +9 -0
  52. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  53. package/src/layouts/force-directed/spring.ts +35 -0
  54. package/src/layouts/geometric/circular.ts +77 -0
  55. package/src/layouts/geometric/index.ts +7 -0
  56. package/src/layouts/geometric/shell.ts +80 -0
  57. package/src/layouts/geometric/spiral.ts +93 -0
  58. package/src/layouts/hierarchical/bfs.ts +81 -0
  59. package/src/layouts/hierarchical/bipartite.ts +94 -0
  60. package/src/layouts/hierarchical/index.ts +7 -0
  61. package/src/layouts/hierarchical/multipartite.ts +88 -0
  62. package/src/layouts/index.ts +9 -0
  63. package/src/layouts/specialized/index.ts +6 -0
  64. package/src/layouts/specialized/planar.ts +65 -0
  65. package/src/layouts/specialized/spectral.ts +128 -0
  66. package/src/types/embedding.ts +11 -0
  67. package/src/types/graph.ts +13 -0
  68. package/src/types/index.ts +7 -0
  69. package/src/types/layout.ts +9 -0
  70. package/src/utils/graph.ts +77 -0
  71. package/src/utils/index.ts +9 -0
  72. package/src/utils/numpy.ts +108 -0
  73. package/src/utils/params.ts +26 -0
  74. package/src/utils/random.ts +53 -0
  75. package/src/utils/rescale.ts +137 -0
  76. package/test/arf-layout.test.ts +1 -1
  77. package/test/bfs-layout.test.ts +1 -1
  78. package/test/bipartite-layout.test.ts +1 -1
  79. package/test/circular-layout.test.ts +145 -3
  80. package/test/forceatlas2-layout.test.ts +1 -1
  81. package/test/fruchterman-reingold-layout.test.ts +1 -1
  82. package/test/graph-generators.test.ts +1 -1
  83. package/test/kamada-kawai-layout.test.ts +273 -1
  84. package/test/multipartite-layout.test.ts +1 -1
  85. package/test/planar-layout.test.ts +1 -1
  86. package/test/random-layout.test.ts +1 -1
  87. package/test/rescale-layout.test.ts +1 -1
  88. package/test/shell-layout.test.ts +1 -1
  89. package/test/spectral-layout.test.ts +1 -1
  90. package/test/spiral-layout.test.ts +1 -1
  91. package/test/spring-layout.test.ts +1 -1
  92. package/vite.config.js +36 -0
  93. package/vitest.config.ts +2 -2
  94. package/dist/layout-helpers.d.ts +0 -123
  95. package/dist/layout-helpers.js.map +0 -1
  96. package/dist/layout.d.ts +0 -275
  97. package/dist/layout.js +0 -2280
  98. package/dist/layout.js.map +0 -1
  99. package/layout-helpers.ts +0 -559
  100. package/layout.ts +0 -2867
@@ -0,0 +1,407 @@
1
+ import type { Graph, Node, Edge, PositionMap } from '../../types';
2
+ import { getNodesFromGraph, getEdgesFromGraph } from '../../utils/graph';
3
+ import { _processParams } from '../../utils/params';
4
+ import { rescaleLayout } from '../../utils/rescale';
5
+ import { RandomNumberGenerator } from '../../utils/random';
6
+
7
+ /**
8
+ * Position nodes using the ForceAtlas2 force-directed algorithm.
9
+ *
10
+ * @param G - Graph
11
+ * @param pos - Initial positions for nodes
12
+ * @param maxIter - Maximum number of iterations
13
+ * @param jitterTolerance - Controls tolerance for node speed adjustments
14
+ * @param scalingRatio - Scaling of attraction and repulsion forces
15
+ * @param gravity - Attraction to center to prevent disconnected components from drifting
16
+ * @param distributedAction - Distributes attraction force among nodes
17
+ * @param strongGravity - Uses a stronger gravity model
18
+ * @param nodeMass - Dictionary mapping nodes to their masses
19
+ * @param nodeSize - Dictionary mapping nodes to their sizes
20
+ * @param weight - Edge attribute for weight
21
+ * @param dissuadeHubs - Whether to prevent hub nodes from clustering
22
+ * @param linlog - Whether to use logarithmic attraction
23
+ * @param seed - Random seed for initial positions
24
+ * @param dim - Dimension of layout
25
+ * @returns Positions dictionary keyed by node
26
+ */
27
+ export function forceatlas2Layout(
28
+ G: Graph,
29
+ pos: PositionMap | null = null,
30
+ maxIter: number = 100,
31
+ jitterTolerance: number = 1.0,
32
+ scalingRatio: number = 2.0,
33
+ gravity: number = 1.0,
34
+ distributedAction: boolean = false,
35
+ strongGravity: boolean = false,
36
+ nodeMass: Record<Node, number> | null = null,
37
+ nodeSize: Record<Node, number> | null = null,
38
+ weight: string | null = null,
39
+ dissuadeHubs: boolean = false,
40
+ linlog: boolean = false,
41
+ seed: number | null = null,
42
+ dim: number = 2
43
+ ): PositionMap {
44
+ const processed = _processParams(G, null, dim);
45
+ const graph = processed.G;
46
+
47
+ const nodes = getNodesFromGraph(graph);
48
+
49
+ if (nodes.length === 0) {
50
+ return {};
51
+ }
52
+
53
+ // Initialize random number generator
54
+ const rng = new RandomNumberGenerator(seed ?? undefined);
55
+
56
+ // Initialize positions if not provided
57
+ let posArray: number[][];
58
+ if (pos === null) {
59
+ pos = {};
60
+ posArray = new Array(nodes.length);
61
+ for (let i = 0; i < nodes.length; i++) {
62
+ posArray[i] = Array(dim).fill(0).map(() => rng.rand() as number * 2 - 1);
63
+ pos[nodes[i]] = posArray[i];
64
+ }
65
+ } else if (Object.keys(pos).length === nodes.length) {
66
+ // Use provided positions
67
+ posArray = new Array(nodes.length);
68
+ for (let i = 0; i < nodes.length; i++) {
69
+ posArray[i] = [...pos[nodes[i]]];
70
+ }
71
+ } else {
72
+ // Some nodes don't have positions, initialize within the range of existing positions
73
+ let minPos = Array(dim).fill(Number.POSITIVE_INFINITY);
74
+ let maxPos = Array(dim).fill(Number.NEGATIVE_INFINITY);
75
+
76
+ // Find min and max of existing positions
77
+ for (const node in pos) {
78
+ for (let d = 0; d < dim; d++) {
79
+ minPos[d] = Math.min(minPos[d], pos[node][d]);
80
+ maxPos[d] = Math.max(maxPos[d], pos[node][d]);
81
+ }
82
+ }
83
+
84
+ posArray = new Array(nodes.length);
85
+ for (let i = 0; i < nodes.length; i++) {
86
+ const node = nodes[i];
87
+ if (pos[node]) {
88
+ posArray[i] = [...pos[node]];
89
+ } else {
90
+ posArray[i] = Array(dim).fill(0).map((_, d) =>
91
+ minPos[d] + (rng.rand() as number) * (maxPos[d] - minPos[d])
92
+ );
93
+ pos[node] = posArray[i];
94
+ }
95
+ }
96
+ }
97
+
98
+ // Initialize mass and size arrays
99
+ const mass = new Array(nodes.length).fill(0);
100
+ const size = new Array(nodes.length).fill(0);
101
+
102
+ // Flag to track whether to adjust for node sizes
103
+ const adjustSizes = nodeSize !== null;
104
+
105
+ // Set node masses and sizes
106
+ for (let i = 0; i < nodes.length; i++) {
107
+ const node = nodes[i];
108
+ mass[i] = nodeMass && nodeMass[node] ?
109
+ nodeMass[node] :
110
+ (Array.isArray(graph) ? 1 : getNodeDegree(graph, node) + 1);
111
+
112
+ size[i] = nodeSize && nodeSize[node] ? nodeSize[node] : 1;
113
+ }
114
+
115
+ // Create adjacency matrix
116
+ const n = nodes.length;
117
+ const A = Array(n).fill(0).map(() => Array(n).fill(0));
118
+
119
+ // Populate adjacency matrix with edge weights
120
+ const edges = Array.isArray(graph) ? [] as Edge[] : graph.edges();
121
+ const nodeIndices: Record<Node, number> = {};
122
+ nodes.forEach((node, i) => { nodeIndices[node] = i; });
123
+
124
+ for (const [source, target] of edges) {
125
+ const i = nodeIndices[source];
126
+ const j = nodeIndices[target];
127
+
128
+ // Use edge weight if provided, otherwise default to 1
129
+ let edgeWeight = 1;
130
+ if (weight && !Array.isArray(graph) && graph.getEdgeData) {
131
+ edgeWeight = graph.getEdgeData(source, target, weight) || 1;
132
+ }
133
+
134
+ A[i][j] = edgeWeight;
135
+ A[j][i] = edgeWeight; // For undirected graphs
136
+ }
137
+
138
+ // Initialize force arrays
139
+ const gravities = Array(n).fill(0).map(() => Array(dim).fill(0));
140
+ const attraction = Array(n).fill(0).map(() => Array(dim).fill(0));
141
+ const repulsion = Array(n).fill(0).map(() => Array(dim).fill(0));
142
+
143
+ // Simulation parameters
144
+ let speed = 1;
145
+ let speedEfficiency = 1;
146
+ let swing = 1;
147
+ let traction = 1;
148
+
149
+ // Helper function to estimate factor for force scaling
150
+ function estimateFactor(
151
+ n: number,
152
+ swing: number,
153
+ traction: number,
154
+ speed: number,
155
+ speedEfficiency: number,
156
+ jitterTolerance: number
157
+ ): [number, number] {
158
+ // Optimal jitter parameters
159
+ const optJitter = 0.05 * Math.sqrt(n);
160
+ const minJitter = Math.sqrt(optJitter);
161
+ const maxJitter = 10;
162
+ const minSpeedEfficiency = 0.05;
163
+
164
+ // Estimate jitter based on current state
165
+ const other = Math.min(maxJitter, optJitter * traction / (n * n));
166
+ let jitter = jitterTolerance * Math.max(minJitter, other);
167
+
168
+ // Adjust speed efficiency based on swing/traction ratio
169
+ if (swing / traction > 2.0) {
170
+ if (speedEfficiency > minSpeedEfficiency) {
171
+ speedEfficiency *= 0.5;
172
+ }
173
+ jitter = Math.max(jitter, jitterTolerance);
174
+ }
175
+
176
+ // Calculate target speed
177
+ let targetSpeed = swing === 0 ?
178
+ Number.POSITIVE_INFINITY :
179
+ jitter * speedEfficiency * traction / swing;
180
+
181
+ // Further adjust speed efficiency
182
+ if (swing > jitter * traction) {
183
+ if (speedEfficiency > minSpeedEfficiency) {
184
+ speedEfficiency *= 0.7;
185
+ }
186
+ } else if (speed < 1000) {
187
+ speedEfficiency *= 1.3;
188
+ }
189
+
190
+ // Limit the speed increase
191
+ const maxRise = 0.5;
192
+ speed = speed + Math.min(targetSpeed - speed, maxRise * speed);
193
+
194
+ return [speed, speedEfficiency];
195
+ }
196
+
197
+ // Main simulation loop
198
+ for (let iter = 0; iter < maxIter; iter++) {
199
+ // Reset forces for this iteration
200
+ for (let i = 0; i < n; i++) {
201
+ for (let d = 0; d < dim; d++) {
202
+ attraction[i][d] = 0;
203
+ repulsion[i][d] = 0;
204
+ gravities[i][d] = 0;
205
+ }
206
+ }
207
+
208
+ // Compute pairwise differences and distances
209
+ const diff = Array(n).fill(0).map(() =>
210
+ Array(n).fill(0).map(() => Array(dim).fill(0))
211
+ );
212
+
213
+ const distance = Array(n).fill(0).map(() => Array(n).fill(0));
214
+
215
+ for (let i = 0; i < n; i++) {
216
+ for (let j = 0; j < n; j++) {
217
+ if (i === j) continue;
218
+
219
+ for (let d = 0; d < dim; d++) {
220
+ diff[i][j][d] = posArray[i][d] - posArray[j][d];
221
+ }
222
+
223
+ distance[i][j] = Math.sqrt(diff[i][j].reduce((sum, d) => sum + d * d, 0));
224
+ // Prevent division by zero
225
+ if (distance[i][j] < 0.01) distance[i][j] = 0.01;
226
+ }
227
+ }
228
+
229
+ // Calculate attraction forces
230
+ if (linlog) {
231
+ // Logarithmic attraction model
232
+ for (let i = 0; i < n; i++) {
233
+ for (let j = 0; j < n; j++) {
234
+ if (i === j || A[i][j] === 0) continue;
235
+
236
+ const dist = distance[i][j];
237
+ const factor = -Math.log(1 + dist) / dist * A[i][j];
238
+
239
+ for (let d = 0; d < dim; d++) {
240
+ const force = factor * diff[i][j][d];
241
+ attraction[i][d] += force;
242
+ }
243
+ }
244
+ }
245
+ } else {
246
+ // Linear attraction model
247
+ for (let i = 0; i < n; i++) {
248
+ for (let j = 0; j < n; j++) {
249
+ if (i === j || A[i][j] === 0) continue;
250
+
251
+ for (let d = 0; d < dim; d++) {
252
+ const force = -diff[i][j][d] * A[i][j];
253
+ attraction[i][d] += force;
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ // Apply distributed attraction if enabled
260
+ if (distributedAction) {
261
+ for (let i = 0; i < n; i++) {
262
+ for (let d = 0; d < dim; d++) {
263
+ attraction[i][d] /= mass[i];
264
+ }
265
+ }
266
+ }
267
+
268
+ // Calculate repulsion forces
269
+ for (let i = 0; i < n; i++) {
270
+ for (let j = 0; j < n; j++) {
271
+ if (i === j) continue;
272
+
273
+ let dist = distance[i][j];
274
+
275
+ // Adjust distance for node sizes if needed
276
+ if (adjustSizes) {
277
+ dist -= size[i] - size[j];
278
+ dist = Math.max(dist, 0.01); // Prevent negative or zero distances
279
+ }
280
+
281
+ const distSquared = dist * dist;
282
+ const massProduct = mass[i] * mass[j];
283
+ const factor = (massProduct / distSquared) * scalingRatio;
284
+
285
+ for (let d = 0; d < dim; d++) {
286
+ const direction = diff[i][j][d] / dist;
287
+ repulsion[i][d] += direction * factor;
288
+ }
289
+ }
290
+ }
291
+
292
+ // Calculate gravity forces
293
+ // First find the center of mass
294
+ const centerOfMass = Array(dim).fill(0);
295
+ for (let i = 0; i < n; i++) {
296
+ for (let d = 0; d < dim; d++) {
297
+ centerOfMass[d] += posArray[i][d] / n;
298
+ }
299
+ }
300
+
301
+ for (let i = 0; i < n; i++) {
302
+ const posCentered = Array(dim);
303
+ for (let d = 0; d < dim; d++) {
304
+ posCentered[d] = posArray[i][d] - centerOfMass[d];
305
+ }
306
+
307
+ if (strongGravity) {
308
+ // Strong gravity model
309
+ for (let d = 0; d < dim; d++) {
310
+ gravities[i][d] = -gravity * mass[i] * posCentered[d];
311
+ }
312
+ } else {
313
+ // Regular gravity model
314
+ const dist = Math.sqrt(posCentered.reduce((sum, val) => sum + val * val, 0));
315
+
316
+ if (dist > 0.01) {
317
+ for (let d = 0; d < dim; d++) {
318
+ const direction = posCentered[d] / dist;
319
+ gravities[i][d] = -gravity * mass[i] * direction;
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // Calculate total forces and update positions
326
+ const update = Array(n).fill(0).map(() => Array(dim).fill(0));
327
+ let totalSwing = 0;
328
+ let totalTraction = 0;
329
+
330
+ for (let i = 0; i < n; i++) {
331
+ for (let d = 0; d < dim; d++) {
332
+ update[i][d] = attraction[i][d] + repulsion[i][d] + gravities[i][d];
333
+ }
334
+
335
+ // Calculate swing and traction for this node
336
+ const oldPos = [...posArray[i]];
337
+ const newPos = oldPos.map((p, d) => p + update[i][d]);
338
+
339
+ const swingVector = oldPos.map((p, d) => p - newPos[d]);
340
+ const tractionVector = oldPos.map((p, d) => p + newPos[d]);
341
+
342
+ const swingMagnitude = Math.sqrt(swingVector.reduce((sum, val) => sum + val * val, 0));
343
+ const tractionMagnitude = Math.sqrt(tractionVector.reduce((sum, val) => sum + val * val, 0));
344
+
345
+ totalSwing += mass[i] * swingMagnitude;
346
+ totalTraction += 0.5 * mass[i] * tractionMagnitude;
347
+ }
348
+
349
+ // Update speed and efficiency
350
+ [speed, speedEfficiency] = estimateFactor(
351
+ n,
352
+ totalSwing,
353
+ totalTraction,
354
+ speed,
355
+ speedEfficiency,
356
+ jitterTolerance
357
+ );
358
+
359
+ // Apply forces to update positions
360
+ let totalMovement = 0;
361
+
362
+ for (let i = 0; i < n; i++) {
363
+ let factor;
364
+
365
+ if (adjustSizes) {
366
+ // Calculate displacement magnitude
367
+ const df = Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
368
+ const swinging = mass[i] * df;
369
+
370
+ // Determine scaling factor with size adjustments
371
+ factor = 0.1 * speed / (1 + Math.sqrt(speed * swinging));
372
+ factor = Math.min(factor * df, 10) / df;
373
+ } else {
374
+ // Standard scaling factor
375
+ const swinging = mass[i] * Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
376
+ factor = speed / (1 + Math.sqrt(speed * swinging));
377
+ }
378
+
379
+ // Apply factor to update position
380
+ for (let d = 0; d < dim; d++) {
381
+ const movement = update[i][d] * factor;
382
+ posArray[i][d] += movement;
383
+ totalMovement += Math.abs(movement);
384
+ }
385
+ }
386
+
387
+ // Check for convergence
388
+ if (totalMovement < 1e-10) {
389
+ break;
390
+ }
391
+ }
392
+
393
+ // Create position dictionary
394
+ const positions: PositionMap = {};
395
+ for (let i = 0; i < n; i++) {
396
+ positions[nodes[i]] = posArray[i];
397
+ }
398
+
399
+ return rescaleLayout(positions) as PositionMap;
400
+ }
401
+
402
+ // Helper function to get node degree
403
+ function getNodeDegree(graph: Graph, node: Node): number {
404
+ return graph.edges().filter((edge: Edge) =>
405
+ edge[0] === node || edge[1] === node
406
+ ).length;
407
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Fruchterman-Reingold force-directed layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, Edge, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph, getEdgesFromGraph } from '../../utils/graph';
8
+ import { RandomNumberGenerator } from '../../utils/random';
9
+ import { rescaleLayout } from '../../utils/rescale';
10
+
11
+ /**
12
+ * Position nodes using Fruchterman-Reingold force-directed algorithm.
13
+ *
14
+ * @param {Object} G - Graph or list of nodes
15
+ * @param {number} k - Optimal distance between nodes
16
+ * @param {Object} pos - Initial positions for nodes
17
+ * @param {Array} fixed - Nodes to keep fixed at initial position
18
+ * @param {number} iterations - Maximum number of iterations
19
+ * @param {number} scale - Scale factor for positions
20
+ * @param {Array|null} center - Coordinate pair around which to center the layout
21
+ * @param {number} dim - Dimension of layout
22
+ * @param {number} seed - Random seed for initial positions
23
+ * @returns {Object} Positions dictionary keyed by node
24
+ */
25
+ export function fruchtermanReingoldLayout(
26
+ G: Graph,
27
+ k: number | null = null,
28
+ pos: PositionMap | null = null,
29
+ fixed: Node[] | null = null,
30
+ iterations: number = 50,
31
+ scale: number = 1,
32
+ center: number[] | null = null,
33
+ dim: number = 2,
34
+ seed: number | null = null
35
+ ): PositionMap {
36
+ const processed = _processParams(G, center, dim);
37
+ let graph = processed.G;
38
+ center = processed.center;
39
+
40
+ const nodes = getNodesFromGraph(graph);
41
+ const edges = getEdgesFromGraph(graph);
42
+
43
+ if (nodes.length === 0) {
44
+ return {};
45
+ }
46
+
47
+ if (nodes.length === 1) {
48
+ const singlePos: PositionMap = {};
49
+ singlePos[nodes[0]] = center;
50
+ return singlePos;
51
+ }
52
+
53
+ // Set up initial positions
54
+ let positions: PositionMap = {};
55
+ if (pos) {
56
+ // Use provided positions
57
+ for (const node of nodes) {
58
+ if (pos[node]) {
59
+ positions[node] = [...pos[node]];
60
+ } else {
61
+ const rng = new RandomNumberGenerator(seed ?? undefined);
62
+ positions[node] = rng.rand(dim) as number[];
63
+ }
64
+ }
65
+ } else {
66
+ // Random initial positions
67
+ const rng = new RandomNumberGenerator(seed ?? undefined);
68
+ for (const node of nodes) {
69
+ positions[node] = rng.rand(dim) as number[];
70
+ }
71
+ }
72
+
73
+ // Set up fixed nodes
74
+ const fixedNodes = new Set(fixed || []);
75
+
76
+ // Optimal distance between nodes
77
+ if (!k) {
78
+ k = 1.0 / Math.sqrt(nodes.length);
79
+ }
80
+
81
+ // Initialize temperature
82
+ let t = 0.1;
83
+ // Calculate temperature reduction
84
+ const dt = t / (iterations + 1);
85
+
86
+ // Simple cooling schedule
87
+ for (let i = 0; i < iterations; i++) {
88
+ // Calculate repulsive forces
89
+ const displacement: Record<Node, number[]> = {};
90
+ for (const node of nodes) {
91
+ displacement[node] = Array(dim).fill(0);
92
+ }
93
+
94
+ // Repulsive forces between nodes
95
+ for (let v1i = 0; v1i < nodes.length; v1i++) {
96
+ const v1 = nodes[v1i];
97
+ for (let v2i = v1i + 1; v2i < nodes.length; v2i++) {
98
+ const v2 = nodes[v2i];
99
+
100
+ // Difference vector
101
+ const delta = positions[v1].map((p, i) => p - positions[v2][i]);
102
+
103
+ // Distance
104
+ const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
105
+
106
+ // Force
107
+ const force = (k * k) / distance;
108
+
109
+ // Add force to displacement
110
+ for (let j = 0; j < dim; j++) {
111
+ const direction = delta[j] / distance;
112
+ displacement[v1][j] += direction * force;
113
+ displacement[v2][j] -= direction * force;
114
+ }
115
+ }
116
+ }
117
+
118
+ // Attractive forces between connected nodes
119
+ for (const [source, target] of edges) {
120
+ // Difference vector
121
+ const delta = positions[source].map((p, i) => p - positions[target][i]);
122
+
123
+ // Distance
124
+ const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
125
+
126
+ // Force
127
+ const force = (distance * distance) / k;
128
+
129
+ // Add force to displacement
130
+ for (let j = 0; j < dim; j++) {
131
+ const direction = delta[j] / distance;
132
+ displacement[source][j] -= direction * force;
133
+ displacement[target][j] += direction * force;
134
+ }
135
+ }
136
+
137
+ // Update positions
138
+ for (const node of nodes) {
139
+ if (fixedNodes.has(node)) continue;
140
+
141
+ // Calculate displacement magnitude
142
+ const magnitude = Math.sqrt(displacement[node].reduce((sum, d) => sum + d * d, 0));
143
+
144
+ // Limit maximum displacement by temperature
145
+ const limitedMagnitude = Math.min(magnitude, t);
146
+
147
+ // Update position
148
+ for (let j = 0; j < dim; j++) {
149
+ const direction = magnitude === 0 ? 0 : displacement[node][j] / magnitude;
150
+ positions[node][j] += direction * limitedMagnitude;
151
+ }
152
+ }
153
+
154
+ // Cool temperature
155
+ t -= dt;
156
+ }
157
+
158
+ // Rescale positions
159
+ if (!fixed) {
160
+ positions = rescaleLayout(positions, scale, center) as PositionMap;
161
+ }
162
+
163
+ return positions;
164
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Force-directed layout algorithms
3
+ */
4
+
5
+ export { springLayout } from './spring';
6
+ export { fruchtermanReingoldLayout } from './fruchterman-reingold';
7
+ export { kamadaKawaiLayout } from './kamada-kawai';
8
+ export { forceatlas2Layout } from './forceatlas2';
9
+ export { arfLayout } from './arf';
@@ -0,0 +1,112 @@
1
+ import type { Graph, Node, PositionMap } from '../../types';
2
+ import type { DistanceMap } from '../../algorithms/optimization';
3
+ import { getNodesFromGraph } from '../../utils/graph';
4
+ import { _processParams } from '../../utils/params';
5
+ import { rescaleLayout } from '../../utils/rescale';
6
+ import { circularLayout } from '../geometric/circular';
7
+ import {
8
+ _computeShortestPathDistances,
9
+ _kamadaKawaiSolve
10
+ } from '../../algorithms/optimization';
11
+
12
+ /**
13
+ * Position nodes using Kamada-Kawai path-length cost-function.
14
+ *
15
+ * @param G - NetworkX graph or list of nodes
16
+ * @param dist - A two-level dictionary of optimal distances between nodes
17
+ * @param pos - Initial positions for nodes
18
+ * @param weight - The edge attribute used for edge weights
19
+ * @param scale - Scale factor for positions
20
+ * @param center - Coordinate pair around which to center the layout
21
+ * @param dim - Dimension of layout
22
+ * @returns Positions dictionary keyed by node
23
+ */
24
+ export function kamadaKawaiLayout(
25
+ G: Graph,
26
+ dist: DistanceMap | null = null,
27
+ pos: PositionMap | null = null,
28
+ weight: string = 'weight',
29
+ scale: number = 1,
30
+ center: number[] | null = null,
31
+ dim: number = 2
32
+ ): PositionMap {
33
+ const processed = _processParams(G, center, dim);
34
+ const graph = processed.G;
35
+ center = processed.center;
36
+
37
+ const nodes = getNodesFromGraph(graph);
38
+
39
+ if (nodes.length === 0) {
40
+ return {};
41
+ }
42
+
43
+ if (nodes.length === 1) {
44
+ return { [nodes[0]]: center };
45
+ }
46
+
47
+ // Initialize distance matrix
48
+ if (!dist) {
49
+ // Kamada-Kawai requires a proper Graph, not just a list of nodes
50
+ if (Array.isArray(graph)) {
51
+ throw new Error('Kamada-Kawai layout requires a Graph with edges, not just a list of nodes');
52
+ }
53
+ dist = _computeShortestPathDistances(graph, weight);
54
+ }
55
+
56
+ // Convert distances to a matrix
57
+ const nodesArray: Node[] = Array.from(nodes);
58
+ const nNodes = nodesArray.length;
59
+ const distMatrix: number[][] = Array(nNodes).fill(0).map(() => Array(nNodes).fill(1e6));
60
+
61
+ for (let i = 0; i < nNodes; i++) {
62
+ const nodeI = nodesArray[i];
63
+ distMatrix[i][i] = 0;
64
+
65
+ if (!dist[nodeI]) continue;
66
+
67
+ for (let j = 0; j < nNodes; j++) {
68
+ const nodeJ = nodesArray[j];
69
+ if (dist[nodeI][nodeJ] !== undefined) {
70
+ distMatrix[i][j] = dist[nodeI][nodeJ];
71
+ }
72
+ }
73
+ }
74
+
75
+ // Initialize positions if not provided
76
+ if (!pos) {
77
+ if (dim >= 2) {
78
+ // Use circular/spherical layout for 2D and 3D
79
+ pos = circularLayout(G, 1, center, dim);
80
+ } else {
81
+ // For 1D, use a linear layout
82
+ const posArray: PositionMap = {};
83
+ nodesArray.forEach((node, i) => {
84
+ posArray[node] = [i / (nNodes - 1 || 1)];
85
+ });
86
+ pos = posArray;
87
+ }
88
+ }
89
+
90
+ // Convert positions to array for computation
91
+ const posArray: number[][] = new Array(nNodes);
92
+ for (let i = 0; i < nNodes; i++) {
93
+ const node = nodesArray[i];
94
+ posArray[i] = pos[node] ? [...pos[node]] : Array(dim).fill(0);
95
+
96
+ // Ensure correct dimensionality
97
+ while (posArray[i].length < dim) {
98
+ posArray[i].push(0);
99
+ }
100
+ }
101
+
102
+ // Run the Kamada-Kawai algorithm
103
+ const newPositions = _kamadaKawaiSolve(distMatrix, posArray, dim);
104
+
105
+ // Convert positions array back to dictionary and rescale
106
+ const finalPos: PositionMap = {};
107
+ for (let i = 0; i < nNodes; i++) {
108
+ finalPos[nodesArray[i]] = newPositions[i];
109
+ }
110
+
111
+ return rescaleLayout(finalPos, scale, center) as PositionMap;
112
+ }