@graphty/layout 1.1.1 → 1.2.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/.env.example +11 -0
- package/CHANGELOG.md +7 -0
- package/DEPLOYMENT.md +59 -0
- package/README.md +53 -0
- package/dist/layout-helpers.js +2 -1
- package/dist/layout-helpers.js.map +1 -1
- package/dist/layout.d.ts +2 -2
- package/dist/layout.js +38 -14
- package/dist/layout.js.map +1 -1
- package/examples/3d-force-directed.html +611 -0
- package/examples/3d-kamada-kawai.html +394 -0
- package/examples/3d-layout-comparison.html +448 -0
- package/examples/3d-spherical-layout.html +319 -0
- package/examples/arf-layout.html +13 -1
- package/examples/bfs-layout.html +13 -1
- package/examples/bipartite-layout.html +13 -1
- package/examples/circular-layout.html +14 -2
- package/examples/forceatlas2-layout.html +13 -1
- package/examples/index.html +75 -0
- package/examples/kamada-kawai-layout.html +13 -1
- package/examples/multipartite-layout.html +13 -41
- package/examples/planar-layout.html +13 -1
- package/examples/random-layout.html +13 -1
- package/examples/shell-layout.html +13 -1
- package/examples/spectral-layout.html +13 -1
- package/examples/spiral-layout.html +13 -1
- package/examples/spring-layout.html +14 -2
- package/layout-helpers.ts +2 -1
- package/layout.ts +39 -13
- package/package.json +5 -2
- package/test/circular-layout.test.ts +145 -3
- package/test/kamada-kawai-layout.test.ts +273 -1
- package/vite.config.js +36 -0
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Planar Layout Test</title>
|
|
@@ -161,7 +173,7 @@
|
|
|
161
173
|
</div>
|
|
162
174
|
|
|
163
175
|
<script type="module">
|
|
164
|
-
import { planarLayout } from
|
|
176
|
+
import { planarLayout } from "./layout.js";
|
|
165
177
|
|
|
166
178
|
let currentGraph = null;
|
|
167
179
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Random Layout Test</title>
|
|
@@ -142,7 +154,7 @@
|
|
|
142
154
|
</div>
|
|
143
155
|
|
|
144
156
|
<script type="module">
|
|
145
|
-
import { randomLayout } from
|
|
157
|
+
import { randomLayout } from "./layout.js";
|
|
146
158
|
|
|
147
159
|
let currentGraph = null;
|
|
148
160
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Shell Layout Test</title>
|
|
@@ -137,7 +149,7 @@
|
|
|
137
149
|
randomGraph,
|
|
138
150
|
groupNodes,
|
|
139
151
|
findBestRoot
|
|
140
|
-
} from
|
|
152
|
+
} from "./layout.js";
|
|
141
153
|
|
|
142
154
|
let currentGraph = null;
|
|
143
155
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Spectral Layout Test</title>
|
|
@@ -135,7 +147,7 @@
|
|
|
135
147
|
</div>
|
|
136
148
|
|
|
137
149
|
<script type="module">
|
|
138
|
-
import { spectralLayout } from
|
|
150
|
+
import { spectralLayout } from "./layout.js";
|
|
139
151
|
|
|
140
152
|
let currentGraph = null;
|
|
141
153
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Spiral Layout Test</title>
|
|
@@ -171,7 +183,7 @@
|
|
|
171
183
|
</div>
|
|
172
184
|
|
|
173
185
|
<script type="module">
|
|
174
|
-
import { spiralLayout } from
|
|
186
|
+
import { spiralLayout } from "./layout.js";
|
|
175
187
|
|
|
176
188
|
let currentGraph = null;
|
|
177
189
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<!-- Eruda Mobile Console -->
|
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Initialize Eruda console for mobile debugging
|
|
8
|
+
if (typeof eruda !== "undefined") {
|
|
9
|
+
eruda.init();
|
|
10
|
+
// Auto-show on mobile devices
|
|
11
|
+
if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
|
|
12
|
+
eruda.show();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
4
16
|
<meta charset="UTF-8">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Spring Layout Test</title>
|
|
@@ -141,7 +153,7 @@
|
|
|
141
153
|
</div>
|
|
142
154
|
|
|
143
155
|
<script type="module">
|
|
144
|
-
import { springLayout } from
|
|
156
|
+
import { springLayout } from "./layout.js";
|
|
145
157
|
|
|
146
158
|
let currentGraph = null;
|
|
147
159
|
|
|
@@ -308,4 +320,4 @@
|
|
|
308
320
|
newGraph();
|
|
309
321
|
</script>
|
|
310
322
|
</body>
|
|
311
|
-
</html>
|
|
323
|
+
</html>
|
package/layout-helpers.ts
CHANGED
|
@@ -545,7 +545,8 @@ function groupByCommunity(nodes: Node[], edges: Edge[], targetGroups: number): N
|
|
|
545
545
|
if (!improved) break;
|
|
546
546
|
|
|
547
547
|
// Count communities
|
|
548
|
-
|
|
548
|
+
const currentNumCommunities = new Set(communities.values()).size;
|
|
549
|
+
numCommunities = currentNumCommunities;
|
|
549
550
|
}
|
|
550
551
|
|
|
551
552
|
// Group nodes by community
|
package/layout.ts
CHANGED
|
@@ -245,12 +245,12 @@ function randomLayout(G: Graph, center: number[] | null = null, dim: number = 2,
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* Position nodes on a circle.
|
|
248
|
+
* Position nodes on a circle (2D) or sphere (3D).
|
|
249
249
|
*
|
|
250
250
|
* @param G - Graph or list of nodes
|
|
251
251
|
* @param scale - Scale factor for positions
|
|
252
252
|
* @param center - Coordinate pair around which to center the layout
|
|
253
|
-
* @param dim - Dimension of layout (
|
|
253
|
+
* @param dim - Dimension of layout (supports 2D circle or 3D sphere)
|
|
254
254
|
* @returns Positions dictionary keyed by node
|
|
255
255
|
*/
|
|
256
256
|
function circularLayout(G: Graph, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
|
|
@@ -273,14 +273,41 @@ function circularLayout(G: Graph, scale: number = 1, center: number[] | null = n
|
|
|
273
273
|
return pos;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
if (dim === 2) {
|
|
277
|
+
// 2D circle layout
|
|
278
|
+
const theta = np.linspace(0, 2 * Math.PI, nodes.length + 1).slice(0, -1);
|
|
278
279
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
nodes.forEach((node: Node, i: number) => {
|
|
281
|
+
const x: number = Math.cos(theta[i]) * scale + center[0];
|
|
282
|
+
const y: number = Math.sin(theta[i]) * scale + center[1];
|
|
283
|
+
pos[node] = [x, y];
|
|
284
|
+
});
|
|
285
|
+
} else if (dim === 3) {
|
|
286
|
+
// 3D sphere layout using Fibonacci spiral
|
|
287
|
+
const n = nodes.length;
|
|
288
|
+
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
|
289
|
+
|
|
290
|
+
nodes.forEach((node: Node, i: number) => {
|
|
291
|
+
// Use Fibonacci spiral for even distribution on sphere
|
|
292
|
+
const theta = 2 * Math.PI * i / goldenRatio;
|
|
293
|
+
const phi = Math.acos(1 - 2 * (i + 0.5) / n);
|
|
294
|
+
|
|
295
|
+
const x = Math.sin(phi) * Math.cos(theta) * scale + center[0];
|
|
296
|
+
const y = Math.sin(phi) * Math.sin(theta) * scale + center[1];
|
|
297
|
+
const z = Math.cos(phi) * scale + center[2];
|
|
298
|
+
|
|
299
|
+
pos[node] = [x, y, z];
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
// For higher dimensions, fall back to random on hypersphere
|
|
303
|
+
const rng = new RandomNumberGenerator();
|
|
304
|
+
nodes.forEach((node: Node) => {
|
|
305
|
+
// Generate random point on unit hypersphere
|
|
306
|
+
const coords = Array(dim).fill(0).map(() => rng.rand() as number * 2 - 1);
|
|
307
|
+
const norm = Math.sqrt(coords.reduce((sum, c) => sum + c * c, 0));
|
|
308
|
+
pos[node] = coords.map((c, j) => c / norm * scale + center[j]);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
284
311
|
|
|
285
312
|
return pos;
|
|
286
313
|
}
|
|
@@ -1615,10 +1642,9 @@ function kamadaKawaiLayout(
|
|
|
1615
1642
|
|
|
1616
1643
|
// Initialize positions if not provided
|
|
1617
1644
|
if (!pos) {
|
|
1618
|
-
if (dim >=
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
pos = circularLayout(G, 1, [0, 0], dim);
|
|
1645
|
+
if (dim >= 2) {
|
|
1646
|
+
// Use circular/spherical layout for 2D and 3D
|
|
1647
|
+
pos = circularLayout(G, 1, center, dim);
|
|
1622
1648
|
} else {
|
|
1623
1649
|
// For 1D, use a linear layout
|
|
1624
1650
|
const posArray: PositionMap = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/layout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "graph layout algorithms based on networkx",
|
|
5
5
|
"main": "dist/layout.js",
|
|
6
6
|
"type": "module",
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
"test:coverage": "vitest run --coverage",
|
|
15
15
|
"prepare": "husky",
|
|
16
16
|
"build": "tsc",
|
|
17
|
+
"build:examples": "npm run build && cp dist/layout.js examples/layout.js",
|
|
17
18
|
"watch": "tsc --watch",
|
|
18
19
|
"dev": "tsc --watch",
|
|
19
|
-
"commit": "cz"
|
|
20
|
+
"commit": "cz",
|
|
21
|
+
"serve": "npm run build:examples && vite",
|
|
22
|
+
"examples": "npm run build:examples && vite"
|
|
20
23
|
},
|
|
21
24
|
"repository": {
|
|
22
25
|
"type": "git",
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
wheelGraph,
|
|
8
8
|
gridGraph,
|
|
9
9
|
randomGraph
|
|
10
|
-
} from '../layout.
|
|
10
|
+
} from '../layout.js';
|
|
11
11
|
|
|
12
12
|
describe('Circular Layout', () => {
|
|
13
13
|
describe('Basic functionality', () => {
|
|
@@ -172,8 +172,8 @@ describe('Circular Layout', () => {
|
|
|
172
172
|
const positions3D = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
173
173
|
graph.nodes().forEach(node => {
|
|
174
174
|
assert.equal(positions3D[node].length, 3);
|
|
175
|
-
// In 3D
|
|
176
|
-
assert.
|
|
175
|
+
// In 3D spherical layout, z-coordinates should vary
|
|
176
|
+
assert.isNumber(positions3D[node][2]);
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
179
|
|
|
@@ -297,4 +297,146 @@ describe('Circular Layout', () => {
|
|
|
297
297
|
}
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
|
+
|
|
301
|
+
describe('3D spherical layout', () => {
|
|
302
|
+
it('should create spherical layout for dim=3', () => {
|
|
303
|
+
const graph = completeGraph(8);
|
|
304
|
+
const positions = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
305
|
+
|
|
306
|
+
assert.equal(Object.keys(positions).length, 8);
|
|
307
|
+
graph.nodes().forEach(node => {
|
|
308
|
+
assert.isDefined(positions[node]);
|
|
309
|
+
assert.equal(positions[node].length, 3);
|
|
310
|
+
assert.isNumber(positions[node][0]);
|
|
311
|
+
assert.isNumber(positions[node][1]);
|
|
312
|
+
assert.isNumber(positions[node][2]);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should place all nodes on unit sphere', () => {
|
|
317
|
+
const graph = cycleGraph(12);
|
|
318
|
+
const positions = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
319
|
+
|
|
320
|
+
graph.nodes().forEach(node => {
|
|
321
|
+
const [x, y, z] = positions[node];
|
|
322
|
+
const radius = Math.sqrt(x * x + y * y + z * z);
|
|
323
|
+
assert.approximately(radius, 1, 1e-10, `Node ${node} should be on unit sphere`);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should respect scale in 3D', () => {
|
|
328
|
+
const graph = starGraph(6);
|
|
329
|
+
const scale = 2.5;
|
|
330
|
+
const positions = circularLayout(graph, scale, [0, 0, 0], 3);
|
|
331
|
+
|
|
332
|
+
graph.nodes().forEach(node => {
|
|
333
|
+
const [x, y, z] = positions[node];
|
|
334
|
+
const radius = Math.sqrt(x * x + y * y + z * z);
|
|
335
|
+
assert.approximately(radius, scale, 1e-10);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should respect center in 3D', () => {
|
|
340
|
+
const graph = completeGraph(4);
|
|
341
|
+
const center = [10, -5, 7];
|
|
342
|
+
const positions = circularLayout(graph, 1, center, 3);
|
|
343
|
+
|
|
344
|
+
graph.nodes().forEach(node => {
|
|
345
|
+
const [x, y, z] = positions[node];
|
|
346
|
+
const dx = x - center[0];
|
|
347
|
+
const dy = y - center[1];
|
|
348
|
+
const dz = z - center[2];
|
|
349
|
+
const radius = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
350
|
+
assert.approximately(radius, 1, 1e-10);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should distribute nodes evenly on sphere using Fibonacci spiral', () => {
|
|
355
|
+
const graph = completeGraph(20);
|
|
356
|
+
const positions = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
357
|
+
|
|
358
|
+
// Check that no two nodes are too close together
|
|
359
|
+
const nodes = graph.nodes();
|
|
360
|
+
const minDistance = 0.3; // Reasonable minimum distance for 20 nodes on unit sphere
|
|
361
|
+
|
|
362
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
363
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
364
|
+
const pos1 = positions[nodes[i]];
|
|
365
|
+
const pos2 = positions[nodes[j]];
|
|
366
|
+
const dx = pos1[0] - pos2[0];
|
|
367
|
+
const dy = pos1[1] - pos2[1];
|
|
368
|
+
const dz = pos1[2] - pos2[2];
|
|
369
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
370
|
+
assert.isAbove(distance, minDistance, `Nodes ${i} and ${j} are too close`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should handle single node in 3D', () => {
|
|
376
|
+
const singleNode = { nodes: () => ['A'], edges: () => [] };
|
|
377
|
+
const center = [1, 2, 3];
|
|
378
|
+
const positions = circularLayout(singleNode, 1, center, 3);
|
|
379
|
+
|
|
380
|
+
assert.equal(Object.keys(positions).length, 1);
|
|
381
|
+
assert.deepEqual(positions['A'], center);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should produce deterministic results in 3D', () => {
|
|
385
|
+
const graph = cycleGraph(10);
|
|
386
|
+
|
|
387
|
+
const positions1 = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
388
|
+
const positions2 = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
389
|
+
|
|
390
|
+
graph.nodes().forEach(node => {
|
|
391
|
+
assert.deepEqual(positions1[node], positions2[node]);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should handle higher dimensions gracefully', () => {
|
|
396
|
+
const graph = completeGraph(5);
|
|
397
|
+
const positions = circularLayout(graph, 1, [0, 0, 0, 0], 4);
|
|
398
|
+
|
|
399
|
+
assert.equal(Object.keys(positions).length, 5);
|
|
400
|
+
graph.nodes().forEach(node => {
|
|
401
|
+
assert.equal(positions[node].length, 4);
|
|
402
|
+
// Should be on unit hypersphere
|
|
403
|
+
const radius = Math.sqrt(positions[node].reduce((sum, coord) => sum + coord * coord, 0));
|
|
404
|
+
assert.approximately(radius, 1, 0.1); // Allow some tolerance for random hypersphere
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should use all three dimensions meaningfully', () => {
|
|
409
|
+
const graph = completeGraph(30);
|
|
410
|
+
const positions = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
411
|
+
|
|
412
|
+
// Extract all z-coordinates
|
|
413
|
+
const zCoords = graph.nodes().map(node => positions[node][2]);
|
|
414
|
+
|
|
415
|
+
// Check that z-coordinates have meaningful variation
|
|
416
|
+
const minZ = Math.min(...zCoords);
|
|
417
|
+
const maxZ = Math.max(...zCoords);
|
|
418
|
+
const zRange = maxZ - minZ;
|
|
419
|
+
|
|
420
|
+
assert.isAbove(zRange, 1.5, 'Z-coordinates should have significant variation');
|
|
421
|
+
assert.approximately(maxZ, 1, 0.1, 'Max Z should be near 1');
|
|
422
|
+
assert.approximately(minZ, -1, 0.1, 'Min Z should be near -1');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should produce valid 3D layout for string node IDs', () => {
|
|
426
|
+
const graph = {
|
|
427
|
+
nodes: () => ['alice', 'bob', 'charlie', 'david', 'eve'],
|
|
428
|
+
edges: () => [['alice', 'bob'], ['bob', 'charlie'], ['charlie', 'david'],
|
|
429
|
+
['david', 'eve'], ['eve', 'alice']]
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const positions = circularLayout(graph, 1, [0, 0, 0], 3);
|
|
433
|
+
|
|
434
|
+
assert.equal(Object.keys(positions).length, 5);
|
|
435
|
+
graph.nodes().forEach(node => {
|
|
436
|
+
const [x, y, z] = positions[node];
|
|
437
|
+
const radius = Math.sqrt(x * x + y * y + z * z);
|
|
438
|
+
assert.approximately(radius, 1, 1e-10);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
});
|
|
300
442
|
});
|