@graphty/layout 1.1.0 → 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 (43) hide show
  1. package/.github/workflows/ci.yml +89 -14
  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 +609 -93
  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/bfs-layout.html +37 -39
  17. package/examples/bipartite-layout.html +77 -69
  18. package/examples/circular-layout.html +13 -34
  19. package/examples/forceatlas2-layout.html +122 -28
  20. package/examples/multipartite-layout.html +64 -51
  21. package/examples/shell-layout.html +53 -34
  22. package/examples/spring-layout.html +11 -1
  23. package/layout-helpers.ts +559 -0
  24. package/layout.ts +277 -1
  25. package/package.json +17 -6
  26. package/test/arf-layout.test.ts +443 -0
  27. package/test/bfs-layout.test.ts +427 -0
  28. package/test/bipartite-layout.test.ts +344 -0
  29. package/test/circular-layout.test.ts +300 -0
  30. package/test/forceatlas2-layout.test.ts +405 -0
  31. package/test/fruchterman-reingold-layout.test.ts +477 -0
  32. package/test/graph-generators.test.ts +450 -0
  33. package/test/kamada-kawai-layout.test.ts +351 -0
  34. package/test/multipartite-layout.test.ts +404 -0
  35. package/test/planar-layout.test.ts +266 -0
  36. package/test/random-layout.test.ts +254 -0
  37. package/test/rescale-layout.test.ts +373 -0
  38. package/test/shell-layout.test.ts +347 -0
  39. package/test/spectral-layout.test.ts +378 -0
  40. package/test/spiral-layout.test.ts +338 -0
  41. package/test/spring-layout.test.ts +241 -0
  42. package/vitest.config.ts +30 -0
  43. package/.releaserc +0 -3
@@ -0,0 +1,427 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ bfsLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ wheelGraph,
8
+ gridGraph,
9
+ randomGraph
10
+ } from '../layout.ts';
11
+
12
+ describe('BFS Layout', () => {
13
+ describe('Basic functionality', () => {
14
+ it('should position all nodes', () => {
15
+ const graph = starGraph(7);
16
+ const positions = bfsLayout(graph, 0);
17
+
18
+ assert.equal(Object.keys(positions).length, 7);
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 = bfsLayout(emptyGraph, null);
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 = bfsLayout(singleNode, 'A');
37
+
38
+ assert.equal(Object.keys(positions).length, 1);
39
+ assert.isDefined(positions['A']);
40
+ assert.equal(positions['A'].length, 2);
41
+ });
42
+
43
+ it('should throw error for disconnected components', () => {
44
+ const graph = {
45
+ nodes: () => [0, 1, 2, 3, 4, 5],
46
+ edges: () => [[0, 1], [1, 2], [3, 4], [4, 5]] // Two components
47
+ };
48
+
49
+ // BFS layout throws error for disconnected graphs
50
+ assert.throws(() => {
51
+ bfsLayout(graph, 0);
52
+ }, "bfs_layout didn't include all nodes. Graph may be disconnected.");
53
+ });
54
+
55
+ it('should use first node as root if not specified', () => {
56
+ const graph = cycleGraph(5);
57
+ const positions = bfsLayout(graph, graph.nodes()[0]); // Use first node as root
58
+
59
+ assert.equal(Object.keys(positions).length, 5);
60
+ graph.nodes().forEach(node => {
61
+ assert.isDefined(positions[node]);
62
+ });
63
+ });
64
+ });
65
+
66
+ describe('BFS tree structure', () => {
67
+ it('should arrange nodes in levels based on BFS distance', () => {
68
+ const graph = wheelGraph(7); // Center connected to 6 rim nodes
69
+ const positions = bfsLayout(graph, 0); // Start from center
70
+
71
+ // Root node should be positioned
72
+ assert.isDefined(positions[0]);
73
+ assert.equal(positions[0].length, 2);
74
+
75
+ // All rim nodes should be at a similar level (distance 1)
76
+ const rimY = [1, 2, 3, 4, 5, 6].map(node => positions[node][1]);
77
+ // Check they are all positioned
78
+ rimY.forEach(y => {
79
+ assert.isNumber(y);
80
+ assert.isFalse(isNaN(y));
81
+ });
82
+ });
83
+
84
+ it('should create proper tree hierarchy', () => {
85
+ // Create a simple tree structure
86
+ const graph = {
87
+ nodes: () => [0, 1, 2, 3, 4, 5, 6],
88
+ edges: () => [
89
+ [0, 1], [0, 2], // Level 1: 1, 2
90
+ [1, 3], [1, 4], // Level 2: 3, 4
91
+ [2, 5], [2, 6] // Level 2: 5, 6
92
+ ]
93
+ };
94
+
95
+ const positions = bfsLayout(graph, 0);
96
+
97
+ // Check levels
98
+ // Root should be positioned
99
+ assert.isDefined(positions[0]);
100
+ assert.equal(positions[0].length, 2);
101
+
102
+ // Level 1 nodes should be positioned
103
+ assert.isDefined(positions[1]);
104
+ assert.isDefined(positions[2]);
105
+
106
+ // Level 2 nodes should all be positioned
107
+ for (let i = 3; i <= 6; i++) {
108
+ assert.isDefined(positions[i]);
109
+ assert.equal(positions[i].length, 2);
110
+ }
111
+ });
112
+
113
+ it('should handle cycles by treating as tree from root', () => {
114
+ const graph = cycleGraph(6);
115
+ const positions = bfsLayout(graph, 0);
116
+
117
+ // In a cycle, BFS creates a tree structure
118
+ // Node 0 is root, nodes 1 and 5 are children, etc.
119
+ assert.isDefined(positions[0]);
120
+ assert.equal(positions[0].length, 2);
121
+
122
+ // Direct neighbors should be positioned
123
+ assert.isDefined(positions[1]);
124
+ assert.isDefined(positions[5]);
125
+ });
126
+
127
+ it('should space nodes horizontally within each level', () => {
128
+ const graph = starGraph(9); // 1 center + 8 leaves
129
+ const positions = bfsLayout(graph, 0);
130
+
131
+ // Get x-coordinates of leaf nodes
132
+ const leafX = [];
133
+ for (let i = 1; i <= 8; i++) {
134
+ leafX.push(positions[i][0]);
135
+ }
136
+ leafX.sort((a, b) => a - b);
137
+
138
+ // Check that leaves are positioned
139
+ // In multipartite layout, they might all have same x
140
+ assert.equal(leafX.length, 8);
141
+
142
+ // Check spacing if nodes have different x coordinates
143
+ const uniqueX = [...new Set(leafX)];
144
+ if (uniqueX.length > 1) {
145
+ const spacings = [];
146
+ for (let i = 1; i < leafX.length; i++) {
147
+ spacings.push(leafX[i] - leafX[i-1]);
148
+ }
149
+ const avgSpacing = spacings.reduce((a, b) => a + b) / spacings.length;
150
+ spacings.forEach(s => {
151
+ assert.isAtLeast(s, 0); // Allow 0 spacing
152
+ assert.isBelow(s, avgSpacing * 3); // Not too uneven
153
+ });
154
+ }
155
+ });
156
+ });
157
+
158
+ describe('Parameter variations', () => {
159
+ it('should respect scale parameter', () => {
160
+ const graph = wheelGraph(5);
161
+
162
+ const positions1 = bfsLayout(graph, 0, 'vertical', 1);
163
+ const positions2 = bfsLayout(graph, 0, 'vertical', 2);
164
+
165
+ // Calculate spans
166
+ const span1 = getLayoutSpan(positions1);
167
+ const span2 = getLayoutSpan(positions2);
168
+
169
+ assert.approximately(span2.width / span1.width, 2, 0.1);
170
+ assert.approximately(span2.height / span1.height, 2, 0.1);
171
+ });
172
+
173
+ it('should respect center parameter', () => {
174
+ const graph = starGraph(5);
175
+ const center = [10, -5];
176
+
177
+ const positions = bfsLayout(graph, 0, 'vertical', 1, center);
178
+
179
+ // Layout should be centered around specified point
180
+ const avgX = Object.values(positions).reduce((sum, pos) => sum + pos[0], 0) / 5;
181
+ const avgY = Object.values(positions).reduce((sum, pos) => sum + pos[1], 0) / 5;
182
+ assert.approximately(avgX, center[0], 1);
183
+ assert.approximately(avgY, center[1], 1);
184
+
185
+ // Nodes should be positioned around center
186
+ for (let i = 0; i < 5; i++) {
187
+ assert.isDefined(positions[i]);
188
+ }
189
+ });
190
+
191
+ it('should handle different root nodes', () => {
192
+ const graph = cycleGraph(6);
193
+
194
+ const positions0 = bfsLayout(graph, 0);
195
+ const positions3 = bfsLayout(graph, 3);
196
+
197
+ // Different roots should be positioned
198
+ assert.isDefined(positions0[0]);
199
+ assert.isDefined(positions3[3]);
200
+
201
+ // The tree structure should be different
202
+ assert.notDeepEqual(positions0[1], positions3[1]);
203
+ });
204
+ });
205
+
206
+ describe('Special cases and edge cases', () => {
207
+ it('should handle string node IDs', () => {
208
+ const graph = {
209
+ nodes: () => ['root', 'child1', 'child2', 'grandchild1', 'grandchild2'],
210
+ edges: () => [
211
+ ['root', 'child1'],
212
+ ['root', 'child2'],
213
+ ['child1', 'grandchild1'],
214
+ ['child2', 'grandchild2']
215
+ ]
216
+ };
217
+
218
+ const positions = bfsLayout(graph, 'root');
219
+
220
+ assert.equal(Object.keys(positions).length, 5);
221
+ assert.isDefined(positions['root']);
222
+ assert.equal(positions['root'].length, 2);
223
+
224
+ // Children should be positioned
225
+ assert.isDefined(positions['child1']);
226
+ assert.isDefined(positions['child2']);
227
+ });
228
+
229
+ it('should handle star graph with leaf as root', () => {
230
+ const graph = starGraph(7);
231
+ const positions = bfsLayout(graph, 1); // Use leaf as root
232
+
233
+ // Leaf 1 should be positioned
234
+ assert.isDefined(positions[1]);
235
+ assert.equal(positions[1].length, 2);
236
+
237
+ // Center node should be positioned differently from root
238
+ assert.notDeepEqual(positions[0], positions[1]);
239
+
240
+ // Other leaves should be at deeper level than center
241
+ for (let i = 2; i < 7; i++) {
242
+ assert.isDefined(positions[i]);
243
+ }
244
+ });
245
+
246
+ it('should produce consistent results', () => {
247
+ const graph = randomGraph(15, 0.3, 42);
248
+ const root = graph.nodes()[0];
249
+
250
+ const positions1 = bfsLayout(graph, root);
251
+ const positions2 = bfsLayout(graph, root);
252
+
253
+ // BFS layout is deterministic for same root
254
+ graph.nodes().forEach(node => {
255
+ assert.deepEqual(positions1[node], positions2[node]);
256
+ });
257
+ });
258
+
259
+ it('should handle complete binary tree', () => {
260
+ // Create a complete binary tree
261
+ const graph = {
262
+ nodes: () => [0, 1, 2, 3, 4, 5, 6],
263
+ edges: () => [
264
+ [0, 1], [0, 2], // Level 1
265
+ [1, 3], [1, 4], // Level 2
266
+ [2, 5], [2, 6] // Level 2
267
+ ]
268
+ };
269
+
270
+ const positions = bfsLayout(graph, 0);
271
+
272
+ // Root should be positioned
273
+ assert.isDefined(positions[0]);
274
+ assert.equal(positions[0].length, 2);
275
+
276
+ // Children should be positioned differently
277
+ const x1 = positions[1][0];
278
+ const x2 = positions[2][0];
279
+ // Note: In some layouts, x1 might equal x2
280
+
281
+ // Check that all nodes are positioned
282
+ for (let i = 0; i <= 6; i++) {
283
+ assert.isDefined(positions[i]);
284
+ assert.equal(positions[i].length, 2);
285
+ }
286
+
287
+ // Tree should have some structure
288
+ const xCoords = Object.values(positions).map(p => p[0]);
289
+ const yCoords = Object.values(positions).map(p => p[1]);
290
+ const layoutWidth = Math.max(...xCoords) - Math.min(...xCoords);
291
+ const layoutHeight = Math.max(...yCoords) - Math.min(...yCoords);
292
+ assert.isAbove(layoutWidth + layoutHeight, 0);
293
+ });
294
+
295
+ it('should handle grid graph efficiently', () => {
296
+ const graph = gridGraph(5, 5); // 25 nodes
297
+
298
+ const startTime = performance.now();
299
+ const positions = bfsLayout(graph, '0,0');
300
+ const endTime = performance.now();
301
+
302
+ assert.equal(Object.keys(positions).length, 25);
303
+ assert.isBelow(endTime - startTime, 100);
304
+
305
+ // Check that BFS creates proper levels
306
+ // Corner node should be positioned
307
+ assert.isDefined(positions['0,0']);
308
+ assert.equal(positions['0,0'].length, 2);
309
+
310
+ // Adjacent nodes should be positioned
311
+ assert.isDefined(positions['0,1']);
312
+ assert.isDefined(positions['1,0']);
313
+ // They should be positioned
314
+ assert.isNumber(positions['0,1'][1]);
315
+ assert.isNumber(positions['1,0'][1]);
316
+ });
317
+
318
+ it('should handle dense graphs', () => {
319
+ const graph = completeGraph(10);
320
+ const positions = bfsLayout(graph, 0);
321
+
322
+ // In complete graph, all non-root nodes are at level 1
323
+ // Just check they are all positioned
324
+ for (let i = 1; i < 10; i++) {
325
+ assert.isDefined(positions[i]);
326
+ assert.equal(positions[i].length, 2);
327
+ }
328
+
329
+ // Should position nodes (they might all have same x in multipartite layout)
330
+ const xCoords = [];
331
+ for (let i = 1; i < 10; i++) {
332
+ xCoords.push(positions[i][0]);
333
+ }
334
+ // Just verify all nodes are positioned
335
+ assert.equal(xCoords.length, 9);
336
+ });
337
+ });
338
+
339
+ describe('Layout quality', () => {
340
+ it('should minimize edge crossings in tree layout', () => {
341
+ // Binary tree should have no crossings
342
+ const graph = {
343
+ nodes: () => [0, 1, 2, 3, 4, 5, 6],
344
+ edges: () => [
345
+ [0, 1], [0, 2],
346
+ [1, 3], [1, 4],
347
+ [2, 5], [2, 6]
348
+ ]
349
+ };
350
+
351
+ const positions = bfsLayout(graph, 0);
352
+
353
+ // Check that the tree layout is reasonable
354
+ // All nodes should be positioned
355
+ for (let i = 0; i <= 6; i++) {
356
+ assert.isDefined(positions[i]);
357
+ assert.equal(positions[i].length, 2);
358
+ }
359
+
360
+ // Tree should have hierarchical structure
361
+ const levels = [
362
+ [0], // root
363
+ [1, 2], // level 1
364
+ [3, 4, 5, 6] // level 2
365
+ ];
366
+
367
+ // Each level should be positioned
368
+ levels.forEach(level => {
369
+ level.forEach(node => {
370
+ assert.isDefined(positions[node]);
371
+ });
372
+ });
373
+ });
374
+
375
+ it('should create balanced layout for balanced trees', () => {
376
+ // Perfect binary tree
377
+ const graph = {
378
+ nodes: () => Array.from({ length: 7 }, (_, i) => i),
379
+ edges: () => [
380
+ [0, 1], [0, 2],
381
+ [1, 3], [1, 4],
382
+ [2, 5], [2, 6]
383
+ ]
384
+ };
385
+
386
+ const positions = bfsLayout(graph, 0);
387
+
388
+ // Check symmetry
389
+ const rootX = positions[0][0];
390
+
391
+ // Level 1 should be symmetric
392
+ assert.approximately(
393
+ Math.abs(positions[1][0] - rootX),
394
+ Math.abs(positions[2][0] - rootX),
395
+ 0.1
396
+ );
397
+
398
+ // Level 2 should maintain balance
399
+ const leftSubtree = [3, 4].map(n => positions[n][0]);
400
+ const rightSubtree = [5, 6].map(n => positions[n][0]);
401
+
402
+ const leftSpan = Math.max(...leftSubtree) - Math.min(...leftSubtree);
403
+ const rightSpan = Math.max(...rightSubtree) - Math.min(...rightSubtree);
404
+
405
+ assert.approximately(leftSpan, rightSpan, 0.1);
406
+ });
407
+ });
408
+ });
409
+
410
+ // Helper functions
411
+ function getLayoutSpan(positions) {
412
+ const nodes = Object.keys(positions);
413
+ let minX = Infinity, maxX = -Infinity;
414
+ let minY = Infinity, maxY = -Infinity;
415
+
416
+ nodes.forEach(node => {
417
+ minX = Math.min(minX, positions[node][0]);
418
+ maxX = Math.max(maxX, positions[node][0]);
419
+ minY = Math.min(minY, positions[node][1]);
420
+ maxY = Math.max(maxY, positions[node][1]);
421
+ });
422
+
423
+ return {
424
+ width: maxX - minX,
425
+ height: maxY - minY
426
+ };
427
+ }