@graphty/layout 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/.github/workflows/ci.yml +3 -7
- package/CHANGELOG.md +14 -0
- package/DEPLOYMENT.md +59 -0
- package/README.md +54 -1
- package/dist/vitest.config.js +2 -2
- package/dist/vitest.config.js.map +1 -1
- package/examples/3d-force-directed.html +611 -0
- package/examples/3d-kamada-kawai.html +379 -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 +38 -32
- package/examples/bipartite-layout.html +16 -4
- package/examples/circular-layout.html +14 -2
- package/examples/forceatlas2-layout.html +118 -151
- package/examples/index.html +75 -0
- package/examples/kamada-kawai-layout.html +13 -1
- package/{dist → examples}/layout-helpers.js +58 -29
- package/examples/multipartite-layout.html +44 -54
- package/examples/planar-layout.html +13 -1
- package/examples/random-layout.html +13 -1
- package/examples/shell-layout.html +16 -2
- package/examples/spectral-layout.html +13 -1
- package/examples/spiral-layout.html +13 -1
- package/examples/spring-layout.html +15 -13
- package/package.json +6 -3
- package/src/algorithms/index.ts +6 -0
- package/src/algorithms/optimization/index.ts +12 -0
- package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
- package/src/algorithms/optimization/lbfgs.ts +68 -0
- package/src/algorithms/optimization/line-search.ts +50 -0
- package/src/algorithms/optimization/types.ts +8 -0
- package/src/algorithms/planarity/check.ts +38 -0
- package/src/algorithms/planarity/embedding.ts +216 -0
- package/src/algorithms/planarity/index.ts +12 -0
- package/src/algorithms/planarity/lr-test.ts +70 -0
- package/src/algorithms/planarity/special-graphs.ts +126 -0
- package/src/generators/basic.ts +93 -0
- package/src/generators/bipartite.ts +45 -0
- package/src/generators/grid.ts +42 -0
- package/src/generators/index.ts +15 -0
- package/src/generators/random.ts +39 -0
- package/src/generators/scale-free.ts +72 -0
- package/src/index.ts +18 -0
- package/src/layouts/basic/index.ts +5 -0
- package/src/layouts/basic/random.ts +32 -0
- package/src/layouts/force-directed/arf.ts +130 -0
- package/src/layouts/force-directed/forceatlas2.ts +407 -0
- package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
- package/src/layouts/force-directed/index.ts +9 -0
- package/src/layouts/force-directed/kamada-kawai.ts +112 -0
- package/src/layouts/force-directed/spring.ts +35 -0
- package/src/layouts/geometric/circular.ts +77 -0
- package/src/layouts/geometric/index.ts +7 -0
- package/src/layouts/geometric/shell.ts +80 -0
- package/src/layouts/geometric/spiral.ts +93 -0
- package/src/layouts/hierarchical/bfs.ts +81 -0
- package/src/layouts/hierarchical/bipartite.ts +94 -0
- package/src/layouts/hierarchical/index.ts +7 -0
- package/src/layouts/hierarchical/multipartite.ts +88 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/specialized/index.ts +6 -0
- package/src/layouts/specialized/planar.ts +65 -0
- package/src/layouts/specialized/spectral.ts +128 -0
- package/src/types/embedding.ts +11 -0
- package/src/types/graph.ts +13 -0
- package/src/types/index.ts +7 -0
- package/src/types/layout.ts +9 -0
- package/src/utils/graph.ts +77 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/numpy.ts +108 -0
- package/src/utils/params.ts +26 -0
- package/src/utils/random.ts +53 -0
- package/src/utils/rescale.ts +137 -0
- package/test/arf-layout.test.ts +1 -1
- package/test/bfs-layout.test.ts +1 -1
- package/test/bipartite-layout.test.ts +1 -1
- package/test/circular-layout.test.ts +145 -3
- package/test/forceatlas2-layout.test.ts +1 -1
- package/test/fruchterman-reingold-layout.test.ts +1 -1
- package/test/graph-generators.test.ts +1 -1
- package/test/kamada-kawai-layout.test.ts +273 -1
- package/test/multipartite-layout.test.ts +1 -1
- package/test/planar-layout.test.ts +1 -1
- package/test/random-layout.test.ts +1 -1
- package/test/rescale-layout.test.ts +1 -1
- package/test/shell-layout.test.ts +1 -1
- package/test/spectral-layout.test.ts +1 -1
- package/test/spiral-layout.test.ts +1 -1
- package/test/spring-layout.test.ts +1 -1
- package/vite.config.js +36 -0
- package/vitest.config.ts +2 -2
- package/dist/layout-helpers.d.ts +0 -123
- package/dist/layout-helpers.js.map +0 -1
- package/dist/layout.d.ts +0 -275
- package/dist/layout.js +0 -2280
- package/dist/layout.js.map +0 -1
- package/layout-helpers.ts +0 -559
- package/layout.ts +0 -2867
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
wheelGraph,
|
|
8
8
|
gridGraph,
|
|
9
9
|
randomGraph
|
|
10
|
-
} from '../
|
|
10
|
+
} from '../src';
|
|
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
|
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
starGraph,
|
|
7
7
|
gridGraph,
|
|
8
8
|
randomGraph
|
|
9
|
-
} from '../
|
|
9
|
+
} from '../src';
|
|
10
10
|
|
|
11
11
|
describe('Kamada-Kawai Layout', () => {
|
|
12
12
|
describe('Basic functionality', () => {
|
|
@@ -309,6 +309,249 @@ describe('Kamada-Kawai Layout', () => {
|
|
|
309
309
|
assert.isBelow(totalDiff / graph.nodes().length, 0.1);
|
|
310
310
|
});
|
|
311
311
|
});
|
|
312
|
+
|
|
313
|
+
describe('3D Kamada-Kawai layout', () => {
|
|
314
|
+
it('should create 3D layout when dim=3', () => {
|
|
315
|
+
const graph = completeGraph(8);
|
|
316
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
317
|
+
|
|
318
|
+
assert.equal(Object.keys(positions).length, 8);
|
|
319
|
+
graph.nodes().forEach(node => {
|
|
320
|
+
assert.isDefined(positions[node]);
|
|
321
|
+
assert.equal(positions[node].length, 3);
|
|
322
|
+
assert.isNumber(positions[node][0]);
|
|
323
|
+
assert.isNumber(positions[node][1]);
|
|
324
|
+
assert.isNumber(positions[node][2]);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should use spherical initialization for 3D', () => {
|
|
329
|
+
const graph = cycleGraph(6);
|
|
330
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
331
|
+
|
|
332
|
+
// Should produce reasonable edge lengths (not random)
|
|
333
|
+
const edgeLengths = [];
|
|
334
|
+
for (const [source, target] of graph.edges()) {
|
|
335
|
+
const pos1 = positions[source];
|
|
336
|
+
const pos2 = positions[target];
|
|
337
|
+
const dist = Math.sqrt(
|
|
338
|
+
(pos1[0] - pos2[0]) ** 2 +
|
|
339
|
+
(pos1[1] - pos2[1]) ** 2 +
|
|
340
|
+
(pos1[2] - pos2[2]) ** 2
|
|
341
|
+
);
|
|
342
|
+
edgeLengths.push(dist);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Calculate standard deviation
|
|
346
|
+
const mean = edgeLengths.reduce((a, b) => a + b) / edgeLengths.length;
|
|
347
|
+
const variance = edgeLengths.reduce((acc, len) => acc + (len - mean) ** 2, 0) / edgeLengths.length;
|
|
348
|
+
const stdDev = Math.sqrt(variance);
|
|
349
|
+
|
|
350
|
+
// Should have relatively low variance (not random)
|
|
351
|
+
assert.isBelow(stdDev / mean, 0.3, 'Edge lengths should be relatively uniform');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should produce consistent 3D results across runs', () => {
|
|
355
|
+
const graph = starGraph(7);
|
|
356
|
+
|
|
357
|
+
// Run multiple times
|
|
358
|
+
const runs = 3;
|
|
359
|
+
const allPositions = [];
|
|
360
|
+
|
|
361
|
+
for (let i = 0; i < runs; i++) {
|
|
362
|
+
allPositions.push(kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check that results are consistent
|
|
366
|
+
graph.nodes().forEach(node => {
|
|
367
|
+
for (let i = 1; i < runs; i++) {
|
|
368
|
+
const pos1 = allPositions[0][node];
|
|
369
|
+
const pos2 = allPositions[i][node];
|
|
370
|
+
const diff = Math.sqrt(
|
|
371
|
+
(pos1[0] - pos2[0]) ** 2 +
|
|
372
|
+
(pos1[1] - pos2[1]) ** 2 +
|
|
373
|
+
(pos1[2] - pos2[2]) ** 2
|
|
374
|
+
);
|
|
375
|
+
assert.isBelow(diff, 1e-6, `Node ${node} should have consistent position across runs`);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should respect scale in 3D', () => {
|
|
381
|
+
const graph = completeGraph(5);
|
|
382
|
+
|
|
383
|
+
const positions1 = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
384
|
+
const positions2 = kamadaKawaiLayout(graph, null, null, 'weight', 2, [0, 0, 0], 3);
|
|
385
|
+
|
|
386
|
+
// Calculate bounding box for both
|
|
387
|
+
const bbox1 = getBoundingBox3D(positions1);
|
|
388
|
+
const bbox2 = getBoundingBox3D(positions2);
|
|
389
|
+
|
|
390
|
+
// Scale 2 should be approximately twice scale 1
|
|
391
|
+
assert.approximately(bbox2.width / bbox1.width, 2, 0.1);
|
|
392
|
+
assert.approximately(bbox2.height / bbox1.height, 2, 0.1);
|
|
393
|
+
assert.approximately(bbox2.depth / bbox1.depth, 2, 0.1);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should respect center in 3D', () => {
|
|
397
|
+
const graph = cycleGraph(5);
|
|
398
|
+
const center = [10, -5, 7];
|
|
399
|
+
|
|
400
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, center, 3);
|
|
401
|
+
|
|
402
|
+
// Calculate center of mass
|
|
403
|
+
const com = [0, 0, 0];
|
|
404
|
+
const nodes = graph.nodes();
|
|
405
|
+
nodes.forEach(node => {
|
|
406
|
+
com[0] += positions[node][0];
|
|
407
|
+
com[1] += positions[node][1];
|
|
408
|
+
com[2] += positions[node][2];
|
|
409
|
+
});
|
|
410
|
+
com[0] /= nodes.length;
|
|
411
|
+
com[1] /= nodes.length;
|
|
412
|
+
com[2] /= nodes.length;
|
|
413
|
+
|
|
414
|
+
assert.approximately(com[0], center[0], 1);
|
|
415
|
+
assert.approximately(com[1], center[1], 1);
|
|
416
|
+
assert.approximately(com[2], center[2], 1);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should optimize stress in 3D for regular graphs', () => {
|
|
420
|
+
const graph = completeGraph(6);
|
|
421
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
422
|
+
|
|
423
|
+
// In a complete graph, all pairwise distances should be similar
|
|
424
|
+
const distances = [];
|
|
425
|
+
const nodes = graph.nodes();
|
|
426
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
427
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
428
|
+
const pos1 = positions[nodes[i]];
|
|
429
|
+
const pos2 = positions[nodes[j]];
|
|
430
|
+
const dist = Math.sqrt(
|
|
431
|
+
(pos1[0] - pos2[0]) ** 2 +
|
|
432
|
+
(pos1[1] - pos2[1]) ** 2 +
|
|
433
|
+
(pos1[2] - pos2[2]) ** 2
|
|
434
|
+
);
|
|
435
|
+
distances.push(dist);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const avgDist = distances.reduce((a, b) => a + b) / distances.length;
|
|
440
|
+
const maxDeviation = Math.max(...distances.map(d => Math.abs(d - avgDist) / avgDist));
|
|
441
|
+
|
|
442
|
+
assert.isBelow(maxDeviation, 0.5, 'All pairwise distances should be relatively similar');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should handle custom initial 3D positions', () => {
|
|
446
|
+
const graph = completeGraph(4);
|
|
447
|
+
|
|
448
|
+
// Create custom initial positions (tetrahedron vertices)
|
|
449
|
+
const initial3D = {
|
|
450
|
+
0: [1, 1, 1],
|
|
451
|
+
1: [1, -1, -1],
|
|
452
|
+
2: [-1, 1, -1],
|
|
453
|
+
3: [-1, -1, 1]
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const positions = kamadaKawaiLayout(graph, null, initial3D, 'weight', 1, [0, 0, 0], 3);
|
|
457
|
+
|
|
458
|
+
assert.equal(Object.keys(positions).length, 4);
|
|
459
|
+
|
|
460
|
+
// Should optimize from initial positions
|
|
461
|
+
let different = false;
|
|
462
|
+
graph.nodes().forEach(node => {
|
|
463
|
+
if (
|
|
464
|
+
Math.abs(positions[node][0] - initial3D[node][0]) > 0.01 ||
|
|
465
|
+
Math.abs(positions[node][1] - initial3D[node][1]) > 0.01 ||
|
|
466
|
+
Math.abs(positions[node][2] - initial3D[node][2]) > 0.01
|
|
467
|
+
) {
|
|
468
|
+
different = true;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
assert.isTrue(different, 'Should modify initial positions during optimization');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should use all three dimensions effectively', () => {
|
|
475
|
+
const graph = gridGraph(3, 3);
|
|
476
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
477
|
+
|
|
478
|
+
// Extract coordinate ranges
|
|
479
|
+
const coords = { x: [], y: [], z: [] };
|
|
480
|
+
graph.nodes().forEach(node => {
|
|
481
|
+
coords.x.push(positions[node][0]);
|
|
482
|
+
coords.y.push(positions[node][1]);
|
|
483
|
+
coords.z.push(positions[node][2]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const ranges = {
|
|
487
|
+
x: Math.max(...coords.x) - Math.min(...coords.x),
|
|
488
|
+
y: Math.max(...coords.y) - Math.min(...coords.y),
|
|
489
|
+
z: Math.max(...coords.z) - Math.min(...coords.z)
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// All dimensions should be utilized (not flat)
|
|
493
|
+
assert.isAbove(ranges.x, 0.5, 'X dimension should be utilized');
|
|
494
|
+
assert.isAbove(ranges.y, 0.5, 'Y dimension should be utilized');
|
|
495
|
+
assert.isAbove(ranges.z, 0.2, 'Z dimension should be utilized');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should handle disconnected components in 3D', () => {
|
|
499
|
+
const graph = {
|
|
500
|
+
nodes: () => [0, 1, 2, 3, 4, 5],
|
|
501
|
+
edges: () => [[0, 1], [1, 2], [3, 4], [4, 5]] // Two triangles
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
505
|
+
|
|
506
|
+
assert.equal(Object.keys(positions).length, 6);
|
|
507
|
+
|
|
508
|
+
// Each component should be laid out properly
|
|
509
|
+
const component1 = [0, 1, 2].map(n => positions[n]);
|
|
510
|
+
const component2 = [3, 4, 5].map(n => positions[n]);
|
|
511
|
+
|
|
512
|
+
// Check internal distances within each component
|
|
513
|
+
const dist01 = getDistance3D(positions[0], positions[1]);
|
|
514
|
+
const dist12 = getDistance3D(positions[1], positions[2]);
|
|
515
|
+
const dist34 = getDistance3D(positions[3], positions[4]);
|
|
516
|
+
const dist45 = getDistance3D(positions[4], positions[5]);
|
|
517
|
+
|
|
518
|
+
assert.isAbove(dist01, 0);
|
|
519
|
+
assert.isAbove(dist12, 0);
|
|
520
|
+
assert.isAbove(dist34, 0);
|
|
521
|
+
assert.isAbove(dist45, 0);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should produce better results than random initialization', () => {
|
|
525
|
+
const graph = cycleGraph(8);
|
|
526
|
+
|
|
527
|
+
// Force random initialization by using very high dimensions (will use random hypersphere)
|
|
528
|
+
const randomPos = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0, 0, 0], 5);
|
|
529
|
+
|
|
530
|
+
// Normal 3D with spherical initialization
|
|
531
|
+
const sphericalPos = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
|
|
532
|
+
|
|
533
|
+
// Compare edge length uniformity
|
|
534
|
+
function getEdgeVariance(positions, edges) {
|
|
535
|
+
const lengths = edges.map(([u, v]) => {
|
|
536
|
+
const pos1 = positions[u];
|
|
537
|
+
const pos2 = positions[v];
|
|
538
|
+
let sum = 0;
|
|
539
|
+
for (let i = 0; i < Math.min(pos1.length, pos2.length); i++) {
|
|
540
|
+
sum += (pos1[i] - pos2[i]) ** 2;
|
|
541
|
+
}
|
|
542
|
+
return Math.sqrt(sum);
|
|
543
|
+
});
|
|
544
|
+
const mean = lengths.reduce((a, b) => a + b) / lengths.length;
|
|
545
|
+
return lengths.reduce((acc, len) => acc + (len - mean) ** 2, 0) / lengths.length;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const edges = graph.edges();
|
|
549
|
+
const sphericalVariance = getEdgeVariance(sphericalPos, edges);
|
|
550
|
+
|
|
551
|
+
// Spherical initialization should produce lower variance
|
|
552
|
+
assert.isBelow(sphericalVariance, 0.5, 'Spherical initialization should produce uniform edge lengths');
|
|
553
|
+
});
|
|
554
|
+
});
|
|
312
555
|
});
|
|
313
556
|
|
|
314
557
|
// Helper functions
|
|
@@ -348,4 +591,33 @@ function getDistance(positions, node1, node2) {
|
|
|
348
591
|
const dx = positions[node1][0] - positions[node2][0];
|
|
349
592
|
const dy = positions[node1][1] - positions[node2][1];
|
|
350
593
|
return Math.sqrt(dx * dx + dy * dy);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getBoundingBox3D(positions) {
|
|
597
|
+
const nodes = Object.keys(positions);
|
|
598
|
+
let minX = Infinity, maxX = -Infinity;
|
|
599
|
+
let minY = Infinity, maxY = -Infinity;
|
|
600
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
601
|
+
|
|
602
|
+
nodes.forEach(node => {
|
|
603
|
+
minX = Math.min(minX, positions[node][0]);
|
|
604
|
+
maxX = Math.max(maxX, positions[node][0]);
|
|
605
|
+
minY = Math.min(minY, positions[node][1]);
|
|
606
|
+
maxY = Math.max(maxY, positions[node][1]);
|
|
607
|
+
minZ = Math.min(minZ, positions[node][2]);
|
|
608
|
+
maxZ = Math.max(maxZ, positions[node][2]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
width: maxX - minX,
|
|
613
|
+
height: maxY - minY,
|
|
614
|
+
depth: maxZ - minZ
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getDistance3D(pos1, pos2) {
|
|
619
|
+
const dx = pos1[0] - pos2[0];
|
|
620
|
+
const dy = pos1[1] - pos2[1];
|
|
621
|
+
const dz = pos1[2] - pos2[2];
|
|
622
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
351
623
|
}
|
package/vite.config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineConfig, loadEnv } from 'vite';
|
|
2
|
+
|
|
3
|
+
export default defineConfig(({ mode }) => {
|
|
4
|
+
// Load env file based on `mode` in the current working directory.
|
|
5
|
+
// Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
|
|
6
|
+
const env = loadEnv(mode, process.cwd(), '');
|
|
7
|
+
|
|
8
|
+
const config = {
|
|
9
|
+
server: {
|
|
10
|
+
port: 3000,
|
|
11
|
+
open: '/examples/',
|
|
12
|
+
host: true,
|
|
13
|
+
fs: {
|
|
14
|
+
// Allow serving files from the dist directory
|
|
15
|
+
allow: ['..']
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
build: {
|
|
19
|
+
// Ensure the examples can find the built files
|
|
20
|
+
outDir: 'dist'
|
|
21
|
+
},
|
|
22
|
+
publicDir: false // Disable default public directory behavior
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Allow HOST configuration from .env
|
|
26
|
+
if (env.HOST) {
|
|
27
|
+
config.server.host = env.HOST;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Allow PORT configuration from .env
|
|
31
|
+
if (env.PORT) {
|
|
32
|
+
config.server.port = parseInt(env.PORT);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return config;
|
|
36
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -8,8 +8,8 @@ export default defineConfig({
|
|
|
8
8
|
testTimeout: 30000,
|
|
9
9
|
coverage: {
|
|
10
10
|
provider: 'v8',
|
|
11
|
-
reporter: ['text', 'json', 'html'],
|
|
12
|
-
include: ['
|
|
11
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
12
|
+
include: ['src/**/*.ts'],
|
|
13
13
|
exclude: ['**/*.d.ts', '**/*.test.ts'],
|
|
14
14
|
all: true,
|
|
15
15
|
thresholds: {
|