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