@graphty/layout 1.0.1 → 1.1.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.
Files changed (51) hide show
  1. package/.github/workflows/ci.yml +105 -0
  2. package/.releaserc.json +22 -0
  3. package/CHANGELOG.md +24 -0
  4. package/CLAUDE.md +104 -0
  5. package/CONTRIBUTING.md +1 -0
  6. package/README.md +893 -34
  7. package/dist/layout-helpers.d.ts +123 -0
  8. package/dist/layout-helpers.js +457 -0
  9. package/dist/layout-helpers.js.map +1 -0
  10. package/dist/layout.d.ts +275 -0
  11. package/dist/layout.js +2280 -0
  12. package/dist/layout.js.map +1 -0
  13. package/dist/vitest.config.d.ts +2 -0
  14. package/dist/vitest.config.js +30 -0
  15. package/dist/vitest.config.js.map +1 -0
  16. package/examples/arf-layout.html +1 -1
  17. package/examples/bfs-layout.html +37 -39
  18. package/examples/bipartite-layout.html +77 -69
  19. package/examples/circular-layout.html +13 -34
  20. package/examples/forceatlas2-layout.html +122 -28
  21. package/examples/kamada-kawai-layout.html +1 -1
  22. package/examples/multipartite-layout.html +64 -51
  23. package/examples/planar-layout.html +1 -1
  24. package/examples/random-layout.html +1 -1
  25. package/examples/shell-layout.html +53 -34
  26. package/examples/spectral-layout.html +1 -1
  27. package/examples/spiral-layout.html +1 -1
  28. package/examples/spring-layout.html +12 -2
  29. package/layout-helpers.ts +559 -0
  30. package/{layout.js → layout.ts} +1261 -771
  31. package/package.json +22 -6
  32. package/test/arf-layout.test.ts +443 -0
  33. package/test/bfs-layout.test.ts +427 -0
  34. package/test/bipartite-layout.test.ts +344 -0
  35. package/test/circular-layout.test.ts +300 -0
  36. package/test/forceatlas2-layout.test.ts +405 -0
  37. package/test/fruchterman-reingold-layout.test.ts +477 -0
  38. package/test/graph-generators.test.ts +450 -0
  39. package/test/kamada-kawai-layout.test.ts +351 -0
  40. package/test/multipartite-layout.test.ts +404 -0
  41. package/test/planar-layout.test.ts +266 -0
  42. package/test/random-layout.test.ts +254 -0
  43. package/test/rescale-layout.test.ts +373 -0
  44. package/test/shell-layout.test.ts +347 -0
  45. package/test/spectral-layout.test.ts +378 -0
  46. package/test/spiral-layout.test.ts +338 -0
  47. package/test/spring-layout.test.ts +241 -0
  48. package/tsconfig.json +16 -0
  49. package/vitest.config.ts +30 -0
  50. package/.husky/commit-msg +0 -1
  51. package/.husky/prepare-commit-msg +0 -1
@@ -0,0 +1,344 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ bipartiteLayout,
4
+ bipartiteGraph
5
+ } from '../layout.ts';
6
+
7
+ describe('Bipartite Layout', () => {
8
+ describe('Basic functionality', () => {
9
+ it('should position all nodes', () => {
10
+ const graph = bipartiteGraph(3, 4, 0.5, 42);
11
+ const positions = bipartiteLayout(graph, graph.setA);
12
+
13
+ assert.equal(Object.keys(positions).length, 7);
14
+ graph.nodes().forEach(node => {
15
+ assert.isDefined(positions[node]);
16
+ assert.equal(positions[node].length, 2);
17
+ assert.isNumber(positions[node][0]);
18
+ assert.isNumber(positions[node][1]);
19
+ });
20
+ });
21
+
22
+ it('should handle empty graph', () => {
23
+ const emptyGraph = { nodes: () => [], edges: () => [] };
24
+ const positions = bipartiteLayout(emptyGraph, []);
25
+
26
+ assert.equal(Object.keys(positions).length, 0);
27
+ });
28
+
29
+ it('should handle single node', () => {
30
+ const singleNode = { nodes: () => ['A'], edges: () => [] };
31
+ const positions = bipartiteLayout(singleNode, ['A']);
32
+
33
+ assert.equal(Object.keys(positions).length, 1);
34
+ assert.deepEqual(positions['A'], [0, 0]);
35
+ });
36
+
37
+ it('should handle disconnected bipartite graph', () => {
38
+ const graph = {
39
+ nodes: () => ['A0', 'A1', 'B0', 'B1', 'B2'],
40
+ edges: () => [['A0', 'B0'], ['A1', 'B2']] // B1 is disconnected
41
+ };
42
+ const setA = ['A0', 'A1'];
43
+
44
+ const positions = bipartiteLayout(graph, setA);
45
+
46
+ assert.equal(Object.keys(positions).length, 5);
47
+ graph.nodes().forEach(node => {
48
+ assert.isDefined(positions[node]);
49
+ assert.equal(positions[node].length, 2);
50
+ });
51
+ });
52
+ });
53
+
54
+ describe('Bipartite arrangement', () => {
55
+ it('should separate sets on opposite sides for vertical alignment', () => {
56
+ const graph = bipartiteGraph(4, 5, 0.7, 123);
57
+ const positions = bipartiteLayout(graph, graph.setA, 'vertical');
58
+
59
+ // Get x-coordinates for each set
60
+ const setAX = graph.setA.map(node => positions[node][0]);
61
+ const setBX = graph.setB.map(node => positions[node][0]);
62
+
63
+ const avgAX = setAX.reduce((a, b) => a + b) / setAX.length;
64
+ const avgBX = setBX.reduce((a, b) => a + b) / setBX.length;
65
+
66
+ // Sets should be on opposite sides (significant x difference)
67
+ assert.isAbove(Math.abs(avgAX - avgBX), 1);
68
+
69
+ // All nodes in a set should have same x-coordinate
70
+ setAX.forEach(x => assert.approximately(x, setAX[0], 1e-10));
71
+ setBX.forEach(x => assert.approximately(x, setBX[0], 1e-10));
72
+ });
73
+
74
+ it('should separate sets on opposite sides for horizontal alignment', () => {
75
+ const graph = bipartiteGraph(3, 4, 0.8, 456);
76
+ const positions = bipartiteLayout(graph, graph.setA, 'horizontal');
77
+
78
+ // Get y-coordinates for each set
79
+ const setAY = graph.setA.map(node => positions[node][1]);
80
+ const setBY = graph.setB.map(node => positions[node][1]);
81
+
82
+ const avgAY = setAY.reduce((a, b) => a + b) / setAY.length;
83
+ const avgBY = setBY.reduce((a, b) => a + b) / setBY.length;
84
+
85
+ // Sets should be on opposite sides (significant y difference)
86
+ assert.isAbove(Math.abs(avgAY - avgBY), 1);
87
+
88
+ // All nodes in a set should have same y-coordinate
89
+ setAY.forEach(y => assert.approximately(y, setAY[0], 1e-10));
90
+ setBY.forEach(y => assert.approximately(y, setBY[0], 1e-10));
91
+ });
92
+
93
+ it('should evenly space nodes within each set', () => {
94
+ const graph = {
95
+ nodes: () => ['A0', 'A1', 'A2', 'A3', 'B0', 'B1', 'B2'],
96
+ edges: () => [['A0', 'B0'], ['A1', 'B1'], ['A2', 'B2'], ['A3', 'B0']]
97
+ };
98
+ const setA = ['A0', 'A1', 'A2', 'A3'];
99
+
100
+ const positions = bipartiteLayout(graph, setA, 'vertical');
101
+
102
+ // Check spacing in set A
103
+ const aPositions = setA.map(node => positions[node][1]).sort((a, b) => a - b);
104
+ const aSpacings = [];
105
+ for (let i = 1; i < aPositions.length; i++) {
106
+ aSpacings.push(aPositions[i] - aPositions[i-1]);
107
+ }
108
+
109
+ // All spacings should be equal
110
+ const avgSpacing = aSpacings.reduce((a, b) => a + b) / aSpacings.length;
111
+ aSpacings.forEach(s => assert.approximately(s, avgSpacing, 1e-10));
112
+ });
113
+
114
+ it('should handle sets of different sizes', () => {
115
+ const graph = {
116
+ nodes: () => ['A0', 'B0', 'B1', 'B2', 'B3', 'B4'],
117
+ edges: () => [['A0', 'B0'], ['A0', 'B1'], ['A0', 'B2']]
118
+ };
119
+ const setA = ['A0'];
120
+
121
+ const positions = bipartiteLayout(graph, setA);
122
+
123
+ // Single node in set A should be positioned
124
+ assert.isDefined(positions['A0']);
125
+ assert.equal(positions['A0'].length, 2);
126
+
127
+ // Set B nodes should be evenly distributed
128
+ const bNodes = ['B0', 'B1', 'B2', 'B3', 'B4'];
129
+ const bPositions = bNodes.map(node => positions[node][1]).sort((a, b) => a - b);
130
+
131
+ // Check that they span a reasonable range
132
+ const span = bPositions[bPositions.length - 1] - bPositions[0];
133
+ assert.isAbove(span, 0.5); // Allow smaller span
134
+ });
135
+ });
136
+
137
+ describe('Parameter variations', () => {
138
+ it('should respect scale parameter', () => {
139
+ const graph = bipartiteGraph(3, 3, 1.0);
140
+ const positions1 = bipartiteLayout(graph, graph.setA, 'vertical', 1);
141
+ const positions2 = bipartiteLayout(graph, graph.setA, 'vertical', 2);
142
+
143
+ // Calculate spans for each layout
144
+ const span1 = getLayoutSpan(positions1);
145
+ const span2 = getLayoutSpan(positions2);
146
+
147
+ assert.approximately(span2.width / span1.width, 2, 0.1);
148
+ assert.approximately(span2.height / span1.height, 2, 0.1);
149
+ });
150
+
151
+ it('should respect center parameter', () => {
152
+ const graph = bipartiteGraph(2, 3, 0.6);
153
+ const center = [5, -3];
154
+
155
+ const positions = bipartiteLayout(graph, graph.setA, 'vertical', 1, center);
156
+
157
+ // Calculate center of mass
158
+ const com = getCenterOfMass(positions);
159
+
160
+ assert.approximately(com[0], center[0], 0.5);
161
+ assert.approximately(com[1], center[1], 0.5);
162
+ });
163
+
164
+ it('should respect aspect ratio parameter', () => {
165
+ const graph = bipartiteGraph(4, 4, 0.5);
166
+
167
+ const positions1 = bipartiteLayout(graph, graph.setA, 'vertical', 1, [0, 0], 0.5);
168
+ const positions2 = bipartiteLayout(graph, graph.setA, 'vertical', 1, [0, 0], 2.0);
169
+
170
+ const span1 = getLayoutSpan(positions1);
171
+ const span2 = getLayoutSpan(positions2);
172
+
173
+ const ratio1 = span1.height / span1.width;
174
+ const ratio2 = span2.height / span2.width;
175
+
176
+ // Different aspect ratios should produce different layouts
177
+ assert.notEqual(ratio1, ratio2);
178
+ // Just check they are different, don't assume order
179
+ });
180
+
181
+ it('should handle alignment parameter correctly', () => {
182
+ const graph = bipartiteGraph(3, 4, 0.7);
183
+
184
+ const verticalPos = bipartiteLayout(graph, graph.setA, 'vertical');
185
+ const horizontalPos = bipartiteLayout(graph, graph.setA, 'horizontal');
186
+
187
+ // In vertical, sets differ in x; in horizontal, sets differ in y
188
+ const vDiffX = Math.abs(verticalPos[graph.setA[0]][0] - verticalPos[graph.setB[0]][0]);
189
+ const vDiffY = Math.abs(verticalPos[graph.setA[0]][1] - verticalPos[graph.setB[0]][1]);
190
+
191
+ const hDiffX = Math.abs(horizontalPos[graph.setA[0]][0] - horizontalPos[graph.setB[0]][0]);
192
+ const hDiffY = Math.abs(horizontalPos[graph.setA[0]][1] - horizontalPos[graph.setB[0]][1]);
193
+
194
+ // Vertical: large X difference, small Y difference
195
+ assert.isAbove(vDiffX, 1);
196
+ assert.isBelow(vDiffY, 1);
197
+
198
+ // Horizontal: small X difference, large Y difference
199
+ assert.isBelow(hDiffX, 1);
200
+ assert.isAbove(hDiffY, 1);
201
+ });
202
+ });
203
+
204
+ describe('Special cases and edge cases', () => {
205
+ it('should handle string node IDs', () => {
206
+ const graph = {
207
+ nodes: () => ['user1', 'user2', 'user3', 'item1', 'item2'],
208
+ edges: () => [['user1', 'item1'], ['user2', 'item1'], ['user2', 'item2'], ['user3', 'item2']]
209
+ };
210
+ const users = ['user1', 'user2', 'user3'];
211
+
212
+ const positions = bipartiteLayout(graph, users);
213
+
214
+ assert.equal(Object.keys(positions).length, 5);
215
+ graph.nodes().forEach(node => {
216
+ assert.isDefined(positions[node]);
217
+ assert.equal(positions[node].length, 2);
218
+ });
219
+ });
220
+
221
+ it('should handle nodes not in specified set', () => {
222
+ const graph = {
223
+ nodes: () => [0, 1, 2, 3, 4],
224
+ edges: () => [[0, 2], [0, 3], [1, 3], [1, 4]]
225
+ };
226
+ const setA = [0, 1]; // 2, 3, 4 are implicitly in set B
227
+
228
+ const positions = bipartiteLayout(graph, setA);
229
+
230
+ // All nodes should be positioned
231
+ assert.equal(Object.keys(positions).length, 5);
232
+
233
+ // Check that sets are separated
234
+ const aX = positions[0][0];
235
+ const bX = positions[2][0];
236
+ assert.notEqual(aX, bX);
237
+ });
238
+
239
+ it('should produce consistent results', () => {
240
+ const graph = bipartiteGraph(5, 6, 0.4, 789);
241
+
242
+ const positions1 = bipartiteLayout(graph, graph.setA);
243
+ const positions2 = bipartiteLayout(graph, graph.setA);
244
+
245
+ // Bipartite layout is deterministic
246
+ graph.nodes().forEach(node => {
247
+ assert.deepEqual(positions1[node], positions2[node]);
248
+ });
249
+ });
250
+
251
+ it('should handle complete bipartite graphs', () => {
252
+ // Complete bipartite graph K(3,3)
253
+ const graph = {
254
+ nodes: () => ['A0', 'A1', 'A2', 'B0', 'B1', 'B2'],
255
+ edges: () => [
256
+ ['A0', 'B0'], ['A0', 'B1'], ['A0', 'B2'],
257
+ ['A1', 'B0'], ['A1', 'B1'], ['A1', 'B2'],
258
+ ['A2', 'B0'], ['A2', 'B1'], ['A2', 'B2']
259
+ ]
260
+ };
261
+ const setA = ['A0', 'A1', 'A2'];
262
+
263
+ const positions = bipartiteLayout(graph, setA);
264
+
265
+ // Should produce clean bipartite visualization
266
+ assert.equal(Object.keys(positions).length, 6);
267
+
268
+ // All nodes in each set should align
269
+ const aXs = setA.map(n => positions[n][0]);
270
+ assert.isTrue(aXs.every(x => Math.abs(x - aXs[0]) < 1e-10));
271
+ });
272
+
273
+ it('should handle large bipartite graphs efficiently', () => {
274
+ const graph = bipartiteGraph(50, 50, 0.1, 12345);
275
+
276
+ const startTime = performance.now();
277
+ const positions = bipartiteLayout(graph, graph.setA);
278
+ const endTime = performance.now();
279
+
280
+ assert.equal(Object.keys(positions).length, 100);
281
+ assert.isBelow(endTime - startTime, 100); // Should be very fast
282
+ });
283
+ });
284
+
285
+ describe('Layout quality', () => {
286
+ it('should minimize edge crossings', () => {
287
+ // Create a graph where ordering matters
288
+ const graph = {
289
+ nodes: () => ['A0', 'A1', 'A2', 'B0', 'B1', 'B2'],
290
+ edges: () => [
291
+ ['A0', 'B0'], ['A1', 'B1'], ['A2', 'B2'] // No crossings if ordered properly
292
+ ]
293
+ };
294
+ const setA = ['A0', 'A1', 'A2'];
295
+
296
+ const positions = bipartiteLayout(graph, setA);
297
+
298
+ // Check that connected nodes have similar y-coordinates
299
+ // This suggests minimal edge crossings
300
+ const edgeLengths = graph.edges().map(([u, v]) => {
301
+ return Math.abs(positions[u][1] - positions[v][1]);
302
+ });
303
+
304
+ // All edges should have similar vertical spans (suggesting no crossings)
305
+ const avgLength = edgeLengths.reduce((a, b) => a + b) / edgeLengths.length;
306
+ edgeLengths.forEach(len => {
307
+ assert.isBelow(Math.abs(len - avgLength), 0.5);
308
+ });
309
+ });
310
+ });
311
+ });
312
+
313
+ // Helper functions
314
+ function getLayoutSpan(positions) {
315
+ const nodes = Object.keys(positions);
316
+ let minX = Infinity, maxX = -Infinity;
317
+ let minY = Infinity, maxY = -Infinity;
318
+
319
+ nodes.forEach(node => {
320
+ minX = Math.min(minX, positions[node][0]);
321
+ maxX = Math.max(maxX, positions[node][0]);
322
+ minY = Math.min(minY, positions[node][1]);
323
+ maxY = Math.max(maxY, positions[node][1]);
324
+ });
325
+
326
+ return {
327
+ width: maxX - minX,
328
+ height: maxY - minY
329
+ };
330
+ }
331
+
332
+ function getCenterOfMass(positions) {
333
+ const nodes = Object.keys(positions);
334
+ const com = [0, 0];
335
+
336
+ nodes.forEach(node => {
337
+ com[0] += positions[node][0];
338
+ com[1] += positions[node][1];
339
+ });
340
+
341
+ com[0] /= nodes.length;
342
+ com[1] /= nodes.length;
343
+ return com;
344
+ }
@@ -0,0 +1,300 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ circularLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ wheelGraph,
8
+ gridGraph,
9
+ randomGraph
10
+ } from '../layout.ts';
11
+
12
+ describe('Circular Layout', () => {
13
+ describe('Basic functionality', () => {
14
+ it('should position all nodes', () => {
15
+ const graph = completeGraph(6);
16
+ const positions = circularLayout(graph);
17
+
18
+ assert.equal(Object.keys(positions).length, 6);
19
+ graph.nodes().forEach(node => {
20
+ assert.isDefined(positions[node]);
21
+ assert.equal(positions[node].length, 2);
22
+ assert.isNumber(positions[node][0]);
23
+ assert.isNumber(positions[node][1]);
24
+ });
25
+ });
26
+
27
+ it('should handle empty graph', () => {
28
+ const emptyGraph = { nodes: () => [], edges: () => [] };
29
+ const positions = circularLayout(emptyGraph);
30
+
31
+ assert.equal(Object.keys(positions).length, 0);
32
+ });
33
+
34
+ it('should handle single node', () => {
35
+ const singleNode = { nodes: () => ['A'], edges: () => [] };
36
+ const positions = circularLayout(singleNode);
37
+
38
+ assert.equal(Object.keys(positions).length, 1);
39
+ assert.deepEqual(positions['A'], [0, 0]);
40
+ });
41
+
42
+ it('should handle disconnected components', () => {
43
+ const disconnected = {
44
+ nodes: () => ['A', 'B', 'C', 'D'],
45
+ edges: () => [['A', 'B'], ['C', 'D']]
46
+ };
47
+ const positions = circularLayout(disconnected);
48
+
49
+ assert.equal(Object.keys(positions).length, 4);
50
+ ['A', 'B', 'C', 'D'].forEach(node => {
51
+ assert.isDefined(positions[node]);
52
+ assert.equal(positions[node].length, 2);
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('Circular arrangement properties', () => {
58
+ it('should arrange nodes in a circle with equal distances from center', () => {
59
+ const n = 8;
60
+ const graph = cycleGraph(n);
61
+ const positions = circularLayout(graph);
62
+
63
+ // Calculate distances from origin (default center)
64
+ const distances = graph.nodes().map(node => {
65
+ const [x, y] = positions[node];
66
+ return Math.sqrt(x * x + y * y);
67
+ });
68
+
69
+ // All distances should be equal
70
+ const firstDistance = distances[0];
71
+ distances.forEach(d => {
72
+ assert.approximately(d, firstDistance, 1e-10);
73
+ });
74
+
75
+ // Distance should be 1 (default scale)
76
+ assert.approximately(firstDistance, 1, 1e-10);
77
+ });
78
+
79
+ it('should space nodes evenly around the circle', () => {
80
+ const n = 6;
81
+ const graph = completeGraph(n);
82
+ const positions = circularLayout(graph);
83
+
84
+ // Calculate angles from center
85
+ const angles = graph.nodes().map(node => {
86
+ const [x, y] = positions[node];
87
+ return Math.atan2(y, x);
88
+ }).sort((a, b) => a - b);
89
+
90
+ // Calculate angle differences
91
+ const angleDiffs: number[] = [];
92
+ for (let i = 0; i < n; i++) {
93
+ const diff = i < n - 1
94
+ ? angles[i + 1] - angles[i]
95
+ : angles[0] + 2 * Math.PI - angles[i];
96
+ angleDiffs.push(diff);
97
+ }
98
+
99
+ // All angle differences should be equal (2π/n)
100
+ const expectedAngle = 2 * Math.PI / n;
101
+ angleDiffs.forEach(diff => {
102
+ assert.approximately(diff, expectedAngle, 1e-5);
103
+ });
104
+ });
105
+
106
+ it('should maintain circular arrangement for different graph types', () => {
107
+ const graphs = [
108
+ completeGraph(5),
109
+ cycleGraph(5),
110
+ starGraph(5),
111
+ wheelGraph(5)
112
+ ];
113
+
114
+ graphs.forEach(graph => {
115
+ const positions = circularLayout(graph);
116
+
117
+ // Check all nodes are on a circle
118
+ const distances = graph.nodes().map(node => {
119
+ const [x, y] = positions[node];
120
+ return Math.sqrt(x * x + y * y);
121
+ });
122
+
123
+ const avgDistance = distances.reduce((a, b) => a + b) / distances.length;
124
+ distances.forEach(d => {
125
+ assert.isBelow(Math.abs(d - avgDistance), 0.001);
126
+ });
127
+ });
128
+ });
129
+ });
130
+
131
+ describe('Parameter variations', () => {
132
+ it('should respect scale parameter', () => {
133
+ const graph = cycleGraph(4);
134
+ const scale1 = 1;
135
+ const scale2 = 2.5;
136
+
137
+ const positions1 = circularLayout(graph, scale1);
138
+ const positions2 = circularLayout(graph, scale2);
139
+
140
+ // Calculate radii
141
+ const radius1 = Math.sqrt(positions1[0][0] ** 2 + positions1[0][1] ** 2);
142
+ const radius2 = Math.sqrt(positions2[0][0] ** 2 + positions2[0][1] ** 2);
143
+
144
+ assert.approximately(radius2 / radius1, scale2 / scale1, 1e-5);
145
+ });
146
+
147
+ it('should respect center parameter', () => {
148
+ const graph = starGraph(5);
149
+ const center1 = [0, 0];
150
+ const center2 = [3, 4];
151
+
152
+ const positions1 = circularLayout(graph, 1, center1);
153
+ const positions2 = circularLayout(graph, 1, center2);
154
+
155
+ // Check that all positions are shifted by the center difference
156
+ graph.nodes().forEach(node => {
157
+ assert.approximately(positions2[node][0] - positions1[node][0], 3, 1e-10);
158
+ assert.approximately(positions2[node][1] - positions1[node][1], 4, 1e-10);
159
+ });
160
+ });
161
+
162
+ it('should handle different dimensions', () => {
163
+ const graph = completeGraph(4);
164
+
165
+ // 2D layout (default)
166
+ const positions2D = circularLayout(graph, 1, [0, 0], 2);
167
+ graph.nodes().forEach(node => {
168
+ assert.equal(positions2D[node].length, 2);
169
+ });
170
+
171
+ // 3D layout
172
+ const positions3D = circularLayout(graph, 1, [0, 0, 0], 3);
173
+ graph.nodes().forEach(node => {
174
+ assert.equal(positions3D[node].length, 3);
175
+ // In 3D circular layout, all z-coordinates should be 0
176
+ assert.equal(positions3D[node][2], 0);
177
+ });
178
+ });
179
+
180
+ it('should produce same layout for same inputs', () => {
181
+ const graph = randomGraph(10, 0.3, 12345);
182
+
183
+ const positions1 = circularLayout(graph, 1.5, [2, 3]);
184
+ const positions2 = circularLayout(graph, 1.5, [2, 3]);
185
+
186
+ // Circular layout is deterministic
187
+ graph.nodes().forEach(node => {
188
+ assert.deepEqual(positions1[node], positions2[node]);
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('Special cases and edge cases', () => {
194
+ it('should handle graphs with string node IDs', () => {
195
+ const graph = {
196
+ nodes: () => ['alice', 'bob', 'charlie', 'david'],
197
+ edges: () => [['alice', 'bob'], ['bob', 'charlie'], ['charlie', 'david'], ['david', 'alice']]
198
+ };
199
+
200
+ const positions = circularLayout(graph);
201
+
202
+ assert.equal(Object.keys(positions).length, 4);
203
+ ['alice', 'bob', 'charlie', 'david'].forEach(node => {
204
+ assert.isDefined(positions[node]);
205
+ const [x, y] = positions[node];
206
+ const radius = Math.sqrt(x * x + y * y);
207
+ assert.approximately(radius, 1, 1e-10);
208
+ });
209
+ });
210
+
211
+ it('should handle large graphs efficiently', () => {
212
+ const n = 100;
213
+ const graph = cycleGraph(n);
214
+
215
+ const startTime = performance.now();
216
+ const positions = circularLayout(graph);
217
+ const endTime = performance.now();
218
+
219
+ assert.equal(Object.keys(positions).length, n);
220
+ assert.isBelow(endTime - startTime, 100); // Should complete quickly
221
+ });
222
+
223
+ it('should produce visually pleasing layout for grid graphs', () => {
224
+ const graph = gridGraph(3, 3);
225
+ const positions = circularLayout(graph);
226
+
227
+ // All 9 nodes should be on a circle
228
+ assert.equal(Object.keys(positions).length, 9);
229
+
230
+ const distances = graph.nodes().map(node => {
231
+ const [x, y] = positions[node];
232
+ return Math.sqrt(x * x + y * y);
233
+ });
234
+
235
+ // All should be at same distance
236
+ const avgDistance = distances.reduce((a, b) => a + b) / distances.length;
237
+ distances.forEach(d => {
238
+ assert.isBelow(Math.abs(d - avgDistance), 0.001);
239
+ });
240
+ });
241
+
242
+ it('should handle negative scale gracefully', () => {
243
+ const graph = completeGraph(4);
244
+ const positions = circularLayout(graph, -1);
245
+
246
+ // Should still produce valid layout with absolute value of scale
247
+ graph.nodes().forEach(node => {
248
+ const [x, y] = positions[node];
249
+ const radius = Math.sqrt(x * x + y * y);
250
+ assert.approximately(radius, 1, 1e-10);
251
+ });
252
+ });
253
+ });
254
+
255
+ describe('Comparison with other layouts', () => {
256
+ it('should produce perfect circle unlike force-directed layouts', () => {
257
+ const graph = cycleGraph(8);
258
+ const positions = circularLayout(graph);
259
+
260
+ // Calculate variance in radii
261
+ const radii = graph.nodes().map(node => {
262
+ const [x, y] = positions[node];
263
+ return Math.sqrt(x * x + y * y);
264
+ });
265
+
266
+ const avgRadius = radii.reduce((a, b) => a + b) / radii.length;
267
+ const variance = radii.reduce((sum, r) => sum + Math.pow(r - avgRadius, 2), 0) / radii.length;
268
+
269
+ // Variance should be essentially zero for perfect circle
270
+ assert.isBelow(variance, 1e-20);
271
+ });
272
+
273
+ it('should maintain node order in layout', () => {
274
+ const nodes = [0, 1, 2, 3, 4, 5];
275
+ const graph = {
276
+ nodes: () => nodes,
277
+ edges: () => [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 0]]
278
+ };
279
+
280
+ const positions = circularLayout(graph);
281
+
282
+ // Calculate angles and check they increase monotonically
283
+ const angles = nodes.map(node => {
284
+ const [x, y] = positions[node];
285
+ let angle = Math.atan2(y, x);
286
+ if (angle < 0) angle += 2 * Math.PI;
287
+ return angle;
288
+ });
289
+
290
+ // First node should be at angle 0 (positioned at (1, 0))
291
+ assert.approximately(positions[0][0], 1, 1e-10);
292
+ assert.approximately(positions[0][1], 0, 1e-10);
293
+
294
+ // Angles should increase in order
295
+ for (let i = 1; i < angles.length; i++) {
296
+ assert.isAbove(angles[i], angles[i - 1]);
297
+ }
298
+ });
299
+ });
300
+ });