@graphty/layout 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +3 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/vitest.config.js +2 -2
- package/dist/vitest.config.js.map +1 -1
- package/examples/3d-kamada-kawai.html +4 -19
- package/examples/bfs-layout.html +26 -32
- package/examples/bipartite-layout.html +3 -3
- package/examples/forceatlas2-layout.html +106 -151
- package/{dist → examples}/layout-helpers.js +58 -30
- package/examples/multipartite-layout.html +32 -14
- package/examples/shell-layout.html +4 -2
- package/examples/spring-layout.html +1 -11
- package/package.json +3 -3
- package/src/algorithms/index.ts +6 -0
- package/src/algorithms/optimization/index.ts +12 -0
- package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
- package/src/algorithms/optimization/lbfgs.ts +68 -0
- package/src/algorithms/optimization/line-search.ts +50 -0
- package/src/algorithms/optimization/types.ts +8 -0
- package/src/algorithms/planarity/check.ts +38 -0
- package/src/algorithms/planarity/embedding.ts +216 -0
- package/src/algorithms/planarity/index.ts +12 -0
- package/src/algorithms/planarity/lr-test.ts +70 -0
- package/src/algorithms/planarity/special-graphs.ts +126 -0
- package/src/generators/basic.ts +93 -0
- package/src/generators/bipartite.ts +45 -0
- package/src/generators/grid.ts +42 -0
- package/src/generators/index.ts +15 -0
- package/src/generators/random.ts +39 -0
- package/src/generators/scale-free.ts +72 -0
- package/src/index.ts +18 -0
- package/src/layouts/basic/index.ts +5 -0
- package/src/layouts/basic/random.ts +32 -0
- package/src/layouts/force-directed/arf.ts +130 -0
- package/src/layouts/force-directed/forceatlas2.ts +407 -0
- package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
- package/src/layouts/force-directed/index.ts +9 -0
- package/src/layouts/force-directed/kamada-kawai.ts +112 -0
- package/src/layouts/force-directed/spring.ts +35 -0
- package/src/layouts/geometric/circular.ts +77 -0
- package/src/layouts/geometric/index.ts +7 -0
- package/src/layouts/geometric/shell.ts +80 -0
- package/src/layouts/geometric/spiral.ts +93 -0
- package/src/layouts/hierarchical/bfs.ts +81 -0
- package/src/layouts/hierarchical/bipartite.ts +94 -0
- package/src/layouts/hierarchical/index.ts +7 -0
- package/src/layouts/hierarchical/multipartite.ts +88 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/specialized/index.ts +6 -0
- package/src/layouts/specialized/planar.ts +65 -0
- package/src/layouts/specialized/spectral.ts +128 -0
- package/src/types/embedding.ts +11 -0
- package/src/types/graph.ts +13 -0
- package/src/types/index.ts +7 -0
- package/src/types/layout.ts +9 -0
- package/src/utils/graph.ts +77 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/numpy.ts +111 -0
- package/src/utils/params.ts +26 -0
- package/src/utils/random.ts +53 -0
- package/src/utils/rescale.ts +137 -0
- package/test/arf-layout.test.ts +1 -1
- package/test/bfs-layout.test.ts +1 -1
- package/test/bipartite-layout.test.ts +1 -1
- package/test/circular-layout.test.ts +1 -1
- package/test/forceatlas2-layout.test.ts +1 -1
- package/test/fruchterman-reingold-layout.test.ts +1 -1
- package/test/graph-generators.test.ts +1 -1
- package/test/kamada-kawai-layout.test.ts +1 -1
- package/test/multipartite-layout.test.ts +1 -1
- package/test/planar-layout.test.ts +1 -1
- package/test/random-layout.test.ts +1 -1
- package/test/rescale-layout.test.ts +1 -1
- package/test/shell-layout.test.ts +1 -1
- package/test/spectral-layout.test.ts +1 -1
- package/test/spiral-layout.test.ts +1 -1
- package/test/spring-layout.test.ts +1 -1
- package/test/test-utils.ts +34 -0
- package/test/utils-graph.test.ts +155 -0
- package/test/utils-index.test.ts +77 -0
- package/test/utils-numpy.test.ts +272 -0
- package/test/utils-params.test.ts +99 -0
- package/test/utils-random.test.ts +229 -0
- package/vitest.config.ts +2 -2
- package/dist/layout-helpers.d.ts +0 -123
- package/dist/layout-helpers.js.map +0 -1
- package/dist/layout.d.ts +0 -275
- package/dist/layout.js +0 -2304
- package/dist/layout.js.map +0 -1
- package/layout-helpers.ts +0 -560
- 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,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
|
+
}
|