@graphty/layout 1.2.0 → 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 (86) hide show
  1. package/.github/workflows/ci.yml +3 -7
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +1 -1
  4. package/dist/vitest.config.js +2 -2
  5. package/dist/vitest.config.js.map +1 -1
  6. package/examples/3d-kamada-kawai.html +4 -19
  7. package/examples/bfs-layout.html +26 -32
  8. package/examples/bipartite-layout.html +3 -3
  9. package/examples/forceatlas2-layout.html +106 -151
  10. package/{dist → examples}/layout-helpers.js +58 -30
  11. package/examples/multipartite-layout.html +32 -14
  12. package/examples/shell-layout.html +4 -2
  13. package/examples/spring-layout.html +1 -11
  14. package/package.json +3 -3
  15. package/src/algorithms/index.ts +6 -0
  16. package/src/algorithms/optimization/index.ts +12 -0
  17. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  18. package/src/algorithms/optimization/lbfgs.ts +68 -0
  19. package/src/algorithms/optimization/line-search.ts +50 -0
  20. package/src/algorithms/optimization/types.ts +8 -0
  21. package/src/algorithms/planarity/check.ts +38 -0
  22. package/src/algorithms/planarity/embedding.ts +216 -0
  23. package/src/algorithms/planarity/index.ts +12 -0
  24. package/src/algorithms/planarity/lr-test.ts +70 -0
  25. package/src/algorithms/planarity/special-graphs.ts +126 -0
  26. package/src/generators/basic.ts +93 -0
  27. package/src/generators/bipartite.ts +45 -0
  28. package/src/generators/grid.ts +42 -0
  29. package/src/generators/index.ts +15 -0
  30. package/src/generators/random.ts +39 -0
  31. package/src/generators/scale-free.ts +72 -0
  32. package/src/index.ts +18 -0
  33. package/src/layouts/basic/index.ts +5 -0
  34. package/src/layouts/basic/random.ts +32 -0
  35. package/src/layouts/force-directed/arf.ts +130 -0
  36. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  37. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  38. package/src/layouts/force-directed/index.ts +9 -0
  39. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  40. package/src/layouts/force-directed/spring.ts +35 -0
  41. package/src/layouts/geometric/circular.ts +77 -0
  42. package/src/layouts/geometric/index.ts +7 -0
  43. package/src/layouts/geometric/shell.ts +80 -0
  44. package/src/layouts/geometric/spiral.ts +93 -0
  45. package/src/layouts/hierarchical/bfs.ts +81 -0
  46. package/src/layouts/hierarchical/bipartite.ts +94 -0
  47. package/src/layouts/hierarchical/index.ts +7 -0
  48. package/src/layouts/hierarchical/multipartite.ts +88 -0
  49. package/src/layouts/index.ts +9 -0
  50. package/src/layouts/specialized/index.ts +6 -0
  51. package/src/layouts/specialized/planar.ts +65 -0
  52. package/src/layouts/specialized/spectral.ts +128 -0
  53. package/src/types/embedding.ts +11 -0
  54. package/src/types/graph.ts +13 -0
  55. package/src/types/index.ts +7 -0
  56. package/src/types/layout.ts +9 -0
  57. package/src/utils/graph.ts +77 -0
  58. package/src/utils/index.ts +9 -0
  59. package/src/utils/numpy.ts +108 -0
  60. package/src/utils/params.ts +26 -0
  61. package/src/utils/random.ts +53 -0
  62. package/src/utils/rescale.ts +137 -0
  63. package/test/arf-layout.test.ts +1 -1
  64. package/test/bfs-layout.test.ts +1 -1
  65. package/test/bipartite-layout.test.ts +1 -1
  66. package/test/circular-layout.test.ts +1 -1
  67. package/test/forceatlas2-layout.test.ts +1 -1
  68. package/test/fruchterman-reingold-layout.test.ts +1 -1
  69. package/test/graph-generators.test.ts +1 -1
  70. package/test/kamada-kawai-layout.test.ts +1 -1
  71. package/test/multipartite-layout.test.ts +1 -1
  72. package/test/planar-layout.test.ts +1 -1
  73. package/test/random-layout.test.ts +1 -1
  74. package/test/rescale-layout.test.ts +1 -1
  75. package/test/shell-layout.test.ts +1 -1
  76. package/test/spectral-layout.test.ts +1 -1
  77. package/test/spiral-layout.test.ts +1 -1
  78. package/test/spring-layout.test.ts +1 -1
  79. package/vitest.config.ts +2 -2
  80. package/dist/layout-helpers.d.ts +0 -123
  81. package/dist/layout-helpers.js.map +0 -1
  82. package/dist/layout.d.ts +0 -275
  83. package/dist/layout.js +0 -2304
  84. package/dist/layout.js.map +0 -1
  85. package/layout-helpers.ts +0 -560
  86. package/layout.ts +0 -2893
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Scale-free graph generation function
3
+ */
4
+
5
+ import { Graph, Node, Edge } from '../types';
6
+
7
+ /**
8
+ * Create a scale-free graph using Barabási-Albert model
9
+ * @param n - Total number of nodes
10
+ * @param m - Number of edges to attach from new node
11
+ * @param seed - Random seed for reproducibility
12
+ * @returns Graph object with scale-free properties
13
+ */
14
+ export function scaleFreeGraph(n: number, m: number, seed?: number): Graph {
15
+ if (m >= n) {
16
+ throw new Error('m must be less than n');
17
+ }
18
+
19
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
20
+ const edges: Edge[] = [];
21
+ const degrees = new Array(n).fill(0);
22
+
23
+ // Simple deterministic pseudo-random if seed provided
24
+ let currentSeed = seed;
25
+ let random = seed !== undefined
26
+ ? () => {
27
+ currentSeed = (currentSeed! * 9301 + 49297) % 233280;
28
+ return currentSeed / 233280;
29
+ }
30
+ : Math.random;
31
+
32
+ // Start with complete graph of m+1 nodes
33
+ for (let i = 0; i <= m; i++) {
34
+ for (let j = i + 1; j <= m; j++) {
35
+ edges.push([i, j]);
36
+ degrees[i]++;
37
+ degrees[j]++;
38
+ }
39
+ }
40
+
41
+ // Add remaining nodes
42
+ for (let i = m + 1; i < n; i++) {
43
+ const targets = new Set<number>();
44
+ const totalDegree = degrees.reduce((sum, d) => sum + d, 0);
45
+
46
+ // Choose m targets based on preferential attachment
47
+ while (targets.size < m) {
48
+ let r = random() * totalDegree;
49
+ let cumSum = 0;
50
+
51
+ for (let j = 0; j < i; j++) {
52
+ cumSum += degrees[j];
53
+ if (r <= cumSum && !targets.has(j)) {
54
+ targets.add(j);
55
+ break;
56
+ }
57
+ }
58
+ }
59
+
60
+ // Add edges to targets
61
+ for (const target of targets) {
62
+ edges.push([i, target]);
63
+ degrees[i]++;
64
+ degrees[target]++;
65
+ }
66
+ }
67
+
68
+ return {
69
+ nodes: () => nodes,
70
+ edges: () => edges
71
+ };
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Main entry point for @graphty/layout
3
+ *
4
+ * This file exports the complete public API, maintaining
5
+ * compatibility with the original layout.ts file.
6
+ */
7
+
8
+ // Re-export all types
9
+ export * from './types';
10
+
11
+ // Re-export utilities that are part of the public API
12
+ export { rescaleLayout, rescaleLayoutDict } from './utils/rescale';
13
+
14
+ // Re-export all layout algorithms
15
+ export * from './layouts';
16
+
17
+ // Re-export all graph generation functions
18
+ export * from './generators';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Basic layout algorithms
3
+ */
4
+
5
+ export { randomLayout } from './random';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Random layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph } from '../../utils/graph';
8
+ import { RandomNumberGenerator } from '../../utils/random';
9
+
10
+ /**
11
+ * Position nodes uniformly at random in the unit square.
12
+ *
13
+ * @param G - Graph or list of nodes
14
+ * @param center - Coordinate pair around which to center the layout
15
+ * @param dim - Dimension of layout
16
+ * @param seed - Random seed for reproducible layouts
17
+ * @returns Positions dictionary keyed by node
18
+ */
19
+ export function randomLayout(G: Graph | Node[], center: number[] | null = null, dim: number = 2, seed: number | null = null): PositionMap {
20
+ const processed = _processParams(G, center, dim);
21
+ const nodes = getNodesFromGraph(processed.G);
22
+ center = processed.center;
23
+
24
+ const rng = new RandomNumberGenerator(seed ?? undefined);
25
+ const pos: PositionMap = {};
26
+
27
+ nodes.forEach((node: Node) => {
28
+ pos[node] = (rng.rand(dim) as number[]).map((val: number, i: number) => val + center[i]);
29
+ });
30
+
31
+ return pos;
32
+ }
@@ -0,0 +1,130 @@
1
+ import type { Graph, Node, PositionMap } from '../../types';
2
+ import { getNodesFromGraph, getEdgesFromGraph } from '../../utils/graph';
3
+ import { RandomNumberGenerator } from '../../utils/random';
4
+ import { randomLayout } from '../basic/random';
5
+
6
+ /**
7
+ * Layout algorithm with attractive and repulsive forces (ARF).
8
+ *
9
+ * @param G - Graph
10
+ * @param pos - Initial positions for nodes
11
+ * @param scaling - Scale factor for positions
12
+ * @param a - Strength of springs between connected nodes (should be > 1)
13
+ * @param maxIter - Maximum number of iterations
14
+ * @param seed - Random seed for initial positions
15
+ * @returns Positions dictionary keyed by node
16
+ */
17
+ export function arfLayout(
18
+ G: Graph,
19
+ pos: PositionMap | null = null,
20
+ scaling: number = 1,
21
+ a: number = 1.1,
22
+ maxIter: number = 1000,
23
+ seed: number | null = null
24
+ ): PositionMap {
25
+ if (a <= 1) {
26
+ throw new Error("The parameter a should be larger than 1");
27
+ }
28
+
29
+ const nodes = getNodesFromGraph(G);
30
+ const edges = getEdgesFromGraph(G);
31
+
32
+ if (nodes.length === 0) {
33
+ return {};
34
+ }
35
+
36
+ // Initialize positions if not provided
37
+ if (!pos) {
38
+ pos = randomLayout(G, null, 2, seed);
39
+ } else {
40
+ // Make sure all nodes have positions
41
+ const rng = new RandomNumberGenerator(seed ?? undefined);
42
+ const defaultPos: PositionMap = {};
43
+ nodes.forEach((node: Node) => {
44
+ if (!pos![node]) {
45
+ defaultPos[node] = [(rng.rand() as number), (rng.rand() as number)];
46
+ }
47
+ });
48
+ pos = { ...pos, ...defaultPos };
49
+ }
50
+
51
+ // Create node index mapping
52
+ const nodeIndex: Record<Node, number> = {};
53
+ nodes.forEach((node: Node, i: number) => {
54
+ nodeIndex[node] = i;
55
+ });
56
+
57
+ // Create positions array
58
+ const positions: number[][] = nodes.map((node: Node) => [...pos![node]]);
59
+
60
+ // Initialize spring constant matrix
61
+ const N = nodes.length;
62
+ const K = Array(N).fill(0).map(() => Array(N).fill(1));
63
+
64
+ // Set diagonal to zero (no self-attraction)
65
+ for (let i = 0; i < N; i++) {
66
+ K[i][i] = 0;
67
+ }
68
+
69
+ // Set stronger attraction between connected nodes
70
+ for (const [source, target] of edges) {
71
+ if (source === target) continue;
72
+
73
+ const i = nodeIndex[source];
74
+ const j = nodeIndex[target];
75
+ K[i][j] = a;
76
+ K[j][i] = a;
77
+ }
78
+
79
+ // Calculate rho (scale factor)
80
+ const rho = scaling * Math.sqrt(N);
81
+
82
+ // Optimization loop
83
+ const dt = 1e-3; // Time step
84
+ const etol = 1e-6; // Error tolerance
85
+ let error = etol + 1;
86
+ let nIter = 0;
87
+
88
+ while (error > etol && nIter < maxIter) {
89
+ // Calculate changes for each node
90
+ const change = Array(N).fill(0).map(() => [0, 0]);
91
+
92
+ for (let i = 0; i < N; i++) {
93
+ for (let j = 0; j < N; j++) {
94
+ if (i === j) continue;
95
+
96
+ // Calculate difference vector
97
+ const diff = positions[i].map((coord, dim) => coord - positions[j][dim]);
98
+
99
+ // Calculate distance (with minimum to avoid division by zero)
100
+ const dist = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0)) || 0.01;
101
+
102
+ // Calculate attractive and repulsive forces
103
+ for (let d = 0; d < diff.length; d++) {
104
+ change[i][d] += K[i][j] * diff[d] - (rho / dist) * diff[d];
105
+ }
106
+ }
107
+ }
108
+
109
+ // Update positions
110
+ for (let i = 0; i < N; i++) {
111
+ for (let d = 0; d < positions[i].length; d++) {
112
+ positions[i][d] += change[i][d] * dt;
113
+ }
114
+ }
115
+
116
+ // Calculate error (sum of force magnitudes)
117
+ error = change.reduce((sum, c) =>
118
+ sum + Math.sqrt(c.reduce((s, v) => s + v * v, 0)), 0);
119
+
120
+ nIter++;
121
+ }
122
+
123
+ // Convert positions array back to object
124
+ const finalPos: PositionMap = {};
125
+ nodes.forEach((node: Node, i: number) => {
126
+ finalPos[node] = positions[i];
127
+ });
128
+
129
+ return finalPos;
130
+ }
@@ -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
+ }