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