@graphty/layout 1.1.0 → 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.
Files changed (57) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +89 -14
  3. package/.releaserc.json +22 -0
  4. package/CHANGELOG.md +31 -0
  5. package/CLAUDE.md +104 -0
  6. package/CONTRIBUTING.md +1 -0
  7. package/DEPLOYMENT.md +59 -0
  8. package/README.md +662 -93
  9. package/dist/layout-helpers.d.ts +123 -0
  10. package/dist/layout-helpers.js +458 -0
  11. package/dist/layout-helpers.js.map +1 -0
  12. package/dist/layout.d.ts +275 -0
  13. package/dist/layout.js +2304 -0
  14. package/dist/layout.js.map +1 -0
  15. package/dist/vitest.config.d.ts +2 -0
  16. package/dist/vitest.config.js +30 -0
  17. package/dist/vitest.config.js.map +1 -0
  18. package/examples/3d-force-directed.html +611 -0
  19. package/examples/3d-kamada-kawai.html +394 -0
  20. package/examples/3d-layout-comparison.html +448 -0
  21. package/examples/3d-spherical-layout.html +319 -0
  22. package/examples/arf-layout.html +13 -1
  23. package/examples/bfs-layout.html +49 -39
  24. package/examples/bipartite-layout.html +89 -69
  25. package/examples/circular-layout.html +26 -35
  26. package/examples/forceatlas2-layout.html +134 -28
  27. package/examples/index.html +75 -0
  28. package/examples/kamada-kawai-layout.html +13 -1
  29. package/examples/multipartite-layout.html +73 -88
  30. package/examples/planar-layout.html +13 -1
  31. package/examples/random-layout.html +13 -1
  32. package/examples/shell-layout.html +65 -34
  33. package/examples/spectral-layout.html +13 -1
  34. package/examples/spiral-layout.html +13 -1
  35. package/examples/spring-layout.html +25 -3
  36. package/layout-helpers.ts +560 -0
  37. package/layout.ts +316 -14
  38. package/package.json +20 -6
  39. package/test/arf-layout.test.ts +443 -0
  40. package/test/bfs-layout.test.ts +427 -0
  41. package/test/bipartite-layout.test.ts +344 -0
  42. package/test/circular-layout.test.ts +442 -0
  43. package/test/forceatlas2-layout.test.ts +405 -0
  44. package/test/fruchterman-reingold-layout.test.ts +477 -0
  45. package/test/graph-generators.test.ts +450 -0
  46. package/test/kamada-kawai-layout.test.ts +623 -0
  47. package/test/multipartite-layout.test.ts +404 -0
  48. package/test/planar-layout.test.ts +266 -0
  49. package/test/random-layout.test.ts +254 -0
  50. package/test/rescale-layout.test.ts +373 -0
  51. package/test/shell-layout.test.ts +347 -0
  52. package/test/spectral-layout.test.ts +378 -0
  53. package/test/spiral-layout.test.ts +338 -0
  54. package/test/spring-layout.test.ts +241 -0
  55. package/vite.config.js +36 -0
  56. package/vitest.config.ts +30 -0
  57. package/.releaserc +0 -3
@@ -0,0 +1,338 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ spiralLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ gridGraph,
8
+ randomGraph
9
+ } from '../layout.ts';
10
+
11
+ describe('Spiral Layout', () => {
12
+ describe('Basic functionality', () => {
13
+ it('should position all nodes', () => {
14
+ const graph = completeGraph(10);
15
+ const positions = spiralLayout(graph);
16
+
17
+ assert.equal(Object.keys(positions).length, 10);
18
+ graph.nodes().forEach(node => {
19
+ assert.isDefined(positions[node]);
20
+ assert.equal(positions[node].length, 2);
21
+ assert.isNumber(positions[node][0]);
22
+ assert.isNumber(positions[node][1]);
23
+ });
24
+ });
25
+
26
+ it('should handle empty graph', () => {
27
+ const emptyGraph = { nodes: () => [], edges: () => [] };
28
+ const positions = spiralLayout(emptyGraph);
29
+
30
+ assert.equal(Object.keys(positions).length, 0);
31
+ });
32
+
33
+ it('should handle single node', () => {
34
+ const singleNode = { nodes: () => ['A'], edges: () => [] };
35
+ const positions = spiralLayout(singleNode);
36
+
37
+ assert.equal(Object.keys(positions).length, 1);
38
+ assert.isDefined(positions['A']);
39
+ assert.equal(positions['A'].length, 2);
40
+ // Single node should be at center
41
+ assert.isBelow(Math.abs(positions['A'][0]), 0.1);
42
+ assert.isBelow(Math.abs(positions['A'][1]), 0.1);
43
+ });
44
+
45
+ it('should handle disconnected components', () => {
46
+ const disconnected = {
47
+ nodes: () => [1, 2, 3, 4, 5, 6],
48
+ edges: () => [[1, 2], [2, 3], [4, 5], [5, 6]]
49
+ };
50
+ const positions = spiralLayout(disconnected);
51
+
52
+ assert.equal(Object.keys(positions).length, 6);
53
+ [1, 2, 3, 4, 5, 6].forEach(node => {
54
+ assert.isDefined(positions[node]);
55
+ assert.equal(positions[node].length, 2);
56
+ });
57
+ });
58
+ });
59
+
60
+ describe('Spiral properties', () => {
61
+ it('should arrange nodes in spiral pattern with increasing radius', () => {
62
+ const graph = cycleGraph(20);
63
+ const positions = spiralLayout(graph);
64
+ const nodes = graph.nodes();
65
+
66
+ // Calculate distances from center for sequential nodes
67
+ const distances = nodes.map(node => {
68
+ const [x, y] = positions[node];
69
+ return Math.sqrt(x * x + y * y);
70
+ });
71
+
72
+ // Generally, later nodes should be further from center
73
+ let increasingTrend = 0;
74
+ for (let i = 1; i < distances.length; i++) {
75
+ if (distances[i] >= distances[i - 1]) {
76
+ increasingTrend++;
77
+ }
78
+ }
79
+
80
+ // Most distances should increase (allowing for some spiral curvature)
81
+ assert.isAbove(increasingTrend / (distances.length - 1), 0.7);
82
+ });
83
+
84
+ it('should create smooth spiral curve', () => {
85
+ const graph = cycleGraph(30);
86
+ const positions = spiralLayout(graph);
87
+ const nodes = graph.nodes();
88
+
89
+ // Calculate angles for sequential nodes
90
+ const angles: number[] = [];
91
+ for (let i = 0; i < nodes.length; i++) {
92
+ const [x, y] = positions[nodes[i]];
93
+ let angle = Math.atan2(y, x);
94
+ // Normalize to [0, 2π]
95
+ if (angle < 0) angle += 2 * Math.PI;
96
+ angles.push(angle);
97
+ }
98
+
99
+ // Count angle wraparounds (full rotations)
100
+ let rotations = 0;
101
+ for (let i = 1; i < angles.length; i++) {
102
+ if (angles[i] < angles[i - 1] - Math.PI) {
103
+ rotations++;
104
+ }
105
+ }
106
+
107
+ // Should have at least one full rotation for 30 nodes
108
+ assert.isAtLeast(rotations, 1);
109
+ });
110
+
111
+ it('should handle equidistant mode', () => {
112
+ const graph = starGraph(15);
113
+ const positions = spiralLayout(graph, 1, [0, 0], 2, true);
114
+
115
+ // Calculate distances between consecutive nodes
116
+ const nodes = graph.nodes();
117
+ const distances: number[] = [];
118
+
119
+ for (let i = 1; i < nodes.length; i++) {
120
+ const dx = positions[nodes[i]][0] - positions[nodes[i-1]][0];
121
+ const dy = positions[nodes[i]][1] - positions[nodes[i-1]][1];
122
+ distances.push(Math.sqrt(dx * dx + dy * dy));
123
+ }
124
+
125
+ // In equidistant mode, distances vary but should have some consistency
126
+ const avgDistance = distances.reduce((a, b) => a + b) / distances.length;
127
+
128
+ // Check that we have reasonable spacing
129
+ assert.isAbove(avgDistance, 0);
130
+ assert.isBelow(avgDistance, 1);
131
+ });
132
+
133
+ it('should handle non-equidistant mode', () => {
134
+ const graph = completeGraph(12);
135
+ const positions = spiralLayout(graph, 1, [0, 0], 2, 0.35, false);
136
+
137
+ // In non-equidistant mode, nodes spread with equal angle
138
+ const nodes = graph.nodes();
139
+ const angles = nodes.map(node => {
140
+ const [x, y] = positions[node];
141
+ return Math.atan2(y, x);
142
+ });
143
+
144
+ // Check angle differences (accounting for spiral growth)
145
+ assert.equal(angles.length, 12);
146
+ // Angles should show regular progression
147
+ });
148
+ });
149
+
150
+ describe('Parameter variations', () => {
151
+ it('should respect scale parameter', () => {
152
+ const graph = cycleGraph(8);
153
+
154
+ const positions1 = spiralLayout(graph, 0.5);
155
+ const positions2 = spiralLayout(graph, 2.0);
156
+
157
+ // Calculate max distances
158
+ const maxDist1 = Math.max(...graph.nodes().map(node => {
159
+ const [x, y] = positions1[node];
160
+ return Math.sqrt(x * x + y * y);
161
+ }));
162
+
163
+ const maxDist2 = Math.max(...graph.nodes().map(node => {
164
+ const [x, y] = positions2[node];
165
+ return Math.sqrt(x * x + y * y);
166
+ }));
167
+
168
+ // Scale 2 should produce larger spiral
169
+ assert.approximately(maxDist2 / maxDist1, 4, 0.1); // Scale factor squared
170
+ });
171
+
172
+ it('should respect center parameter', () => {
173
+ const graph = starGraph(7);
174
+ const center = [3, -2];
175
+
176
+ const positions = spiralLayout(graph, 1, center);
177
+
178
+ // Check that positions are shifted by center
179
+ const defaultPositions = spiralLayout(graph, 1, [0, 0]);
180
+
181
+ // Check center of mass is shifted
182
+ const centerX = graph.nodes().reduce((sum, node) => sum + positions[node][0], 0) / graph.nodes().length;
183
+ const centerY = graph.nodes().reduce((sum, node) => sum + positions[node][1], 0) / graph.nodes().length;
184
+ const defaultCenterX = graph.nodes().reduce((sum, node) => sum + defaultPositions[node][0], 0) / graph.nodes().length;
185
+ const defaultCenterY = graph.nodes().reduce((sum, node) => sum + defaultPositions[node][1], 0) / graph.nodes().length;
186
+
187
+ // Centers should be shifted by approximately the center parameter
188
+ assert.approximately(centerX - defaultCenterX, center[0], 0.5);
189
+ assert.approximately(centerY - defaultCenterY, center[1], 0.5);
190
+ });
191
+
192
+ it('should only support 2D layouts', () => {
193
+ const graph = completeGraph(5);
194
+
195
+ // 2D layout works
196
+ const positions2D = spiralLayout(graph, 1, [0, 0], 2);
197
+ graph.nodes().forEach(node => {
198
+ assert.equal(positions2D[node].length, 2);
199
+ });
200
+
201
+ // 3D layout should throw error
202
+ assert.throws(() => {
203
+ spiralLayout(graph, 1, [0, 0, 0], 3);
204
+ }, 'can only handle 2 dimensions');
205
+ });
206
+
207
+ it('should respect resolution parameter', () => {
208
+ const graph = gridGraph(3, 3);
209
+
210
+ const positions1 = spiralLayout(graph, 1, [0, 0], 2, 0.1, true);
211
+ const positions2 = spiralLayout(graph, 1, [0, 0], 2, 1.0, true);
212
+
213
+ // Different resolutions should produce different spirals
214
+ let different = false;
215
+ graph.nodes().forEach(node => {
216
+ if (Math.abs(positions1[node][0] - positions2[node][0]) > 0.01 ||
217
+ Math.abs(positions1[node][1] - positions2[node][1]) > 0.01) {
218
+ different = true;
219
+ }
220
+ });
221
+ assert.isTrue(different);
222
+ });
223
+ });
224
+
225
+ describe('Special cases and edge cases', () => {
226
+ it('should handle string node IDs', () => {
227
+ const graph = {
228
+ nodes: () => ['first', 'second', 'third', 'fourth', 'fifth'],
229
+ edges: () => [['first', 'second'], ['second', 'third'], ['third', 'fourth'], ['fourth', 'fifth']]
230
+ };
231
+
232
+ const positions = spiralLayout(graph);
233
+
234
+ assert.equal(Object.keys(positions).length, 5);
235
+ ['first', 'second', 'third', 'fourth', 'fifth'].forEach(node => {
236
+ assert.isDefined(positions[node]);
237
+ assert.equal(positions[node].length, 2);
238
+ });
239
+ });
240
+
241
+ it('should produce consistent results', () => {
242
+ const graph = randomGraph(15, 0.2, 12345);
243
+
244
+ const positions1 = spiralLayout(graph, 1, [0, 0], 2, true, 0.5);
245
+ const positions2 = spiralLayout(graph, 1, [0, 0], 2, true, 0.5);
246
+
247
+ // Spiral layout is deterministic
248
+ graph.nodes().forEach(node => {
249
+ assert.deepEqual(positions1[node], positions2[node]);
250
+ });
251
+ });
252
+
253
+ it('should handle large graphs efficiently', () => {
254
+ const graph = gridGraph(20, 20); // 400 nodes
255
+
256
+ const startTime = performance.now();
257
+ const positions = spiralLayout(graph);
258
+ const endTime = performance.now();
259
+
260
+ assert.equal(Object.keys(positions).length, 400);
261
+ assert.isBelow(endTime - startTime, 100);
262
+ });
263
+
264
+ it('should create visually distinct layout from circular', () => {
265
+ const graph = cycleGraph(20);
266
+ const spiralPos = spiralLayout(graph);
267
+
268
+ // In spiral, consecutive nodes should have varying distances from center
269
+ const distances = graph.nodes().map(node => {
270
+ const [x, y] = spiralPos[node];
271
+ return Math.sqrt(x * x + y * y);
272
+ });
273
+
274
+ // Calculate variance in distances
275
+ const avgDist = distances.reduce((a, b) => a + b) / distances.length;
276
+ const variance = distances.reduce((sum, d) => sum + Math.pow(d - avgDist, 2), 0) / distances.length;
277
+
278
+ // Spiral should have some variance (unlike perfect circle)
279
+ assert.isAbove(variance, 0.001);
280
+ });
281
+ });
282
+
283
+ describe('Layout patterns', () => {
284
+ it('should create tight spiral for dense graphs', () => {
285
+ const graph = completeGraph(25);
286
+ const positions = spiralLayout(graph, 1, [0, 0], 2, 0.35, true);
287
+
288
+ // Check that nodes fill space efficiently
289
+ const boundingBox = {
290
+ minX: Math.min(...graph.nodes().map(n => positions[n][0])),
291
+ maxX: Math.max(...graph.nodes().map(n => positions[n][0])),
292
+ minY: Math.min(...graph.nodes().map(n => positions[n][1])),
293
+ maxY: Math.max(...graph.nodes().map(n => positions[n][1]))
294
+ };
295
+
296
+ const width = boundingBox.maxX - boundingBox.minX;
297
+ const height = boundingBox.maxY - boundingBox.minY;
298
+
299
+ // Spiral should be roughly square (not too elongated)
300
+ const aspectRatio = Math.max(width, height) / Math.min(width, height);
301
+ assert.isBelow(aspectRatio, 2);
302
+ });
303
+
304
+ it('should create logarithmic spiral pattern', () => {
305
+ const n = 50;
306
+ const graph = cycleGraph(n);
307
+ const positions = spiralLayout(graph, 1, [0, 0], 2, 0.35, false);
308
+
309
+ // For non-equidistant spiral, radius should grow exponentially
310
+ const radii = graph.nodes().map(node => {
311
+ const [x, y] = positions[node];
312
+ return Math.sqrt(x * x + y * y);
313
+ });
314
+
315
+ // Filter out near-zero radii
316
+ const nonZeroRadii = radii.filter(r => r > 0.01);
317
+
318
+ // Check if growth is approximately exponential
319
+ // by checking if log(radii) grows linearly
320
+ if (nonZeroRadii.length > 10) {
321
+ const logRadii = nonZeroRadii.map(r => Math.log(r));
322
+
323
+ // Simple linear regression
324
+ const n = logRadii.length;
325
+ const indices = Array.from({length: n}, (_, i) => i);
326
+ const sumX = indices.reduce((a, b) => a + b);
327
+ const sumY = logRadii.reduce((a, b) => a + b);
328
+ const sumXY = indices.reduce((sum, x, i) => sum + x * logRadii[i], 0);
329
+ const sumX2 = indices.reduce((sum, x) => sum + x * x, 0);
330
+
331
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
332
+
333
+ // Positive slope indicates exponential growth
334
+ assert.isAbove(slope, 0);
335
+ }
336
+ });
337
+ });
338
+ });
@@ -0,0 +1,241 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ springLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ randomGraph
8
+ } from '../layout.ts';
9
+
10
+ describe('Spring Layout', () => {
11
+ describe('Basic functionality', () => {
12
+ it('should position all nodes', () => {
13
+ const graph = completeGraph(5);
14
+ const positions = springLayout(graph);
15
+
16
+ assert.equal(Object.keys(positions).length, 5);
17
+ graph.nodes().forEach(node => {
18
+ assert.isDefined(positions[node]);
19
+ assert.equal(positions[node].length, 2);
20
+ assert.isNumber(positions[node][0]);
21
+ assert.isNumber(positions[node][1]);
22
+ });
23
+ });
24
+
25
+ it('should handle empty graph', () => {
26
+ const emptyGraph = { nodes: () => [], edges: () => [] };
27
+ const positions = springLayout(emptyGraph);
28
+
29
+ assert.equal(Object.keys(positions).length, 0);
30
+ });
31
+
32
+ it('should handle single node', () => {
33
+ const singleNode = { nodes: () => [0], edges: () => [] };
34
+ const positions = springLayout(singleNode);
35
+
36
+ assert.equal(Object.keys(positions).length, 1);
37
+ assert.deepEqual(positions[0], [0, 0]);
38
+ });
39
+
40
+ it('should handle disconnected components', () => {
41
+ const disconnected = {
42
+ nodes: () => [0, 1, 2, 3],
43
+ edges: () => [[0, 1], [2, 3]] // Two separate components
44
+ };
45
+ const positions = springLayout(disconnected);
46
+
47
+ assert.equal(Object.keys(positions).length, 4);
48
+ // All nodes should have positions
49
+ [0, 1, 2, 3].forEach(node => {
50
+ assert.isDefined(positions[node]);
51
+ assert.equal(positions[node].length, 2);
52
+ });
53
+ });
54
+ });
55
+
56
+ describe('Parameter variations', () => {
57
+ it('should handle different k values', () => {
58
+ const graph = completeGraph(6);
59
+ const positions1 = springLayout(graph, 0.5);
60
+ const positions2 = springLayout(graph, 2.0);
61
+
62
+ // Different k values should produce different layouts
63
+ // Check that at least some positions differ
64
+ let different = false;
65
+ graph.nodes().forEach(node => {
66
+ if (positions1[node][0] !== positions2[node][0] ||
67
+ positions1[node][1] !== positions2[node][1]) {
68
+ different = true;
69
+ }
70
+ });
71
+ assert.isTrue(different);
72
+ });
73
+
74
+ it('should respect fixed positions', () => {
75
+ const graph = starGraph(5);
76
+ const initialPositions = {
77
+ 0: [0, 0],
78
+ 1: [1, 0],
79
+ 2: [0.5, 0.5],
80
+ 3: [-0.5, 0.5],
81
+ 4: [0, -1]
82
+ };
83
+ const fixedNodes = [0, 1]; // Fix center and one leaf
84
+
85
+ const positions = springLayout(graph, null, initialPositions, fixedNodes);
86
+
87
+ // Fixed nodes should remain at their initial positions
88
+ assert.deepEqual(positions[0], [0, 0]);
89
+ assert.deepEqual(positions[1], [1, 0]);
90
+
91
+ // Other nodes should have moved from initial positions
92
+ [2, 3, 4].forEach(node => {
93
+ assert.isDefined(positions[node]);
94
+ assert.equal(positions[node].length, 2);
95
+ // They should have moved from initial positions
96
+ assert.notEqual(positions[node][0], initialPositions[node][0]);
97
+ });
98
+ });
99
+
100
+ it('should converge with more iterations', () => {
101
+ const graph = randomGraph(10, 0.3, 42);
102
+
103
+ // Run with different iteration counts
104
+ const positions10 = springLayout(graph, null, null, null, 10);
105
+ const positions100 = springLayout(graph, null, null, null, 100);
106
+
107
+ // Calculate total movement between iterations
108
+ const movement10to100 = graph.nodes().reduce((sum, node) => {
109
+ const dx = positions100[node][0] - positions10[node][0];
110
+ const dy = positions100[node][1] - positions10[node][1];
111
+ return sum + Math.sqrt(dx * dx + dy * dy);
112
+ }, 0);
113
+
114
+ // More iterations should lead to more stable layout
115
+ // (though initial random positions may vary)
116
+ assert.isAbove(movement10to100, 0);
117
+ });
118
+
119
+ it('should handle different center positions', () => {
120
+ const graph = cycleGraph(4);
121
+ const center1 = [0, 0];
122
+ const center2 = [10, 10];
123
+
124
+ const positions1 = springLayout(graph, null, null, null, 50, 0.01, center1);
125
+ const positions2 = springLayout(graph, null, null, null, 50, 0.01, center2);
126
+
127
+ // Calculate center of mass for each layout
128
+ const com1 = graph.nodes().reduce((acc, node) => {
129
+ return [acc[0] + positions1[node][0], acc[1] + positions1[node][1]];
130
+ }, [0, 0]).map(v => v / 4);
131
+
132
+ const com2 = graph.nodes().reduce((acc, node) => {
133
+ return [acc[0] + positions2[node][0], acc[1] + positions2[node][1]];
134
+ }, [0, 0]).map(v => v / 4);
135
+
136
+ // Centers of mass should be different
137
+ assert.isAbove(Math.abs(com2[0] - com1[0]), 5);
138
+ assert.isAbove(Math.abs(com2[1] - com1[1]), 5);
139
+ });
140
+ });
141
+
142
+ describe('Graph-specific behaviors', () => {
143
+ it('should arrange complete graph symmetrically', () => {
144
+ const graph = completeGraph(4);
145
+ const positions = springLayout(graph, 1, null, null, 200);
146
+
147
+ // In a complete graph, all nodes should be roughly equidistant
148
+ const distances: number[] = [];
149
+ const nodes = graph.nodes();
150
+
151
+ for (let i = 0; i < nodes.length; i++) {
152
+ for (let j = i + 1; j < nodes.length; j++) {
153
+ const dx = positions[nodes[i]][0] - positions[nodes[j]][0];
154
+ const dy = positions[nodes[i]][1] - positions[nodes[j]][1];
155
+ distances.push(Math.sqrt(dx * dx + dy * dy));
156
+ }
157
+ }
158
+
159
+ // Calculate variance in distances
160
+ const avgDistance = distances.reduce((a, b) => a + b) / distances.length;
161
+ const variance = distances.reduce((sum, d) => sum + Math.pow(d - avgDistance, 2), 0) / distances.length;
162
+
163
+ // Variance should be small for symmetric layout
164
+ assert.isBelow(variance / (avgDistance * avgDistance), 0.1);
165
+ });
166
+
167
+ it('should stretch cycle graph into circle-like shape', () => {
168
+ const n = 8;
169
+ const graph = cycleGraph(n);
170
+ const positions = springLayout(graph, 1, null, null, 200);
171
+
172
+ // Calculate center of mass
173
+ const center = graph.nodes().reduce((acc, node) => {
174
+ return [acc[0] + positions[node][0], acc[1] + positions[node][1]];
175
+ }, [0, 0]).map(v => v / n);
176
+
177
+ // Calculate distances from center
178
+ const distances = graph.nodes().map(node => {
179
+ const dx = positions[node][0] - center[0];
180
+ const dy = positions[node][1] - center[1];
181
+ return Math.sqrt(dx * dx + dy * dy);
182
+ });
183
+
184
+ // All nodes should be roughly equidistant from center
185
+ const avgDistance = distances.reduce((a, b) => a + b) / distances.length;
186
+ distances.forEach(d => {
187
+ assert.isBelow(Math.abs(d - avgDistance) / avgDistance, 0.6);
188
+ });
189
+ });
190
+
191
+ it('should position star graph with center near origin', () => {
192
+ const graph = starGraph(7);
193
+ const positions = springLayout(graph, 1, null, null, 100);
194
+
195
+ // Center node (0) should be near the center
196
+ const centerDist = Math.sqrt(positions[0][0] ** 2 + positions[0][1] ** 2);
197
+ assert.isBelow(centerDist, 0.2);
198
+
199
+ // Leaf nodes should be further from origin
200
+ for (let i = 1; i < 7; i++) {
201
+ const leafDist = Math.sqrt(positions[i][0] ** 2 + positions[i][1] ** 2);
202
+ assert.isAbove(leafDist, 0.5);
203
+ }
204
+ });
205
+ });
206
+
207
+ describe('Determinism and reproducibility', () => {
208
+ it('should produce consistent results with same seed', () => {
209
+ const graph = randomGraph(8, 0.4, 12345);
210
+ const seed = 42;
211
+
212
+ // Run layout twice with same parameters and seed
213
+ const positions1 = springLayout(graph, 1, null, null, 50, 1, [0, 0], 2, seed);
214
+ const positions2 = springLayout(graph, 1, null, null, 50, 1, [0, 0], 2, seed);
215
+
216
+ // Results should be identical with same seed
217
+ graph.nodes().forEach(node => {
218
+ assert.equal(positions1[node][0], positions2[node][0]);
219
+ assert.equal(positions1[node][1], positions2[node][1]);
220
+ });
221
+ });
222
+
223
+ it('should produce different results with different seeds', () => {
224
+ const graph = randomGraph(8, 0.4, 12345);
225
+
226
+ // Run layout with different seeds
227
+ const positions1 = springLayout(graph, 1, null, null, 50, 1, [0, 0], 2, 42);
228
+ const positions2 = springLayout(graph, 1, null, null, 50, 1, [0, 0], 2, 123);
229
+
230
+ // Results should be different
231
+ let different = false;
232
+ graph.nodes().forEach(node => {
233
+ if (positions1[node][0] !== positions2[node][0] ||
234
+ positions1[node][1] !== positions2[node][1]) {
235
+ different = true;
236
+ }
237
+ });
238
+ assert.isTrue(different);
239
+ });
240
+ });
241
+ });
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
+ });
@@ -0,0 +1,30 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'happy-dom',
7
+ pool: 'forks',
8
+ testTimeout: 30000,
9
+ coverage: {
10
+ provider: 'v8',
11
+ reporter: ['text', 'json', 'html'],
12
+ include: ['layout.ts'],
13
+ exclude: ['**/*.d.ts', '**/*.test.ts'],
14
+ all: true,
15
+ thresholds: {
16
+ lines: 65,
17
+ functions: 60,
18
+ branches: 85,
19
+ statements: 65
20
+ }
21
+ },
22
+ include: ['test/**/*.test.ts'],
23
+ reporters: ['verbose']
24
+ },
25
+ resolve: {
26
+ alias: {
27
+ '@': '/src'
28
+ }
29
+ }
30
+ });
package/.releaserc DELETED
@@ -1,3 +0,0 @@
1
- {
2
- "branches": ["master", "next"]
3
- }