@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,351 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ kamadaKawaiLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ gridGraph,
8
+ randomGraph
9
+ } from '../layout.ts';
10
+
11
+ describe('Kamada-Kawai Layout', () => {
12
+ describe('Basic functionality', () => {
13
+ it('should position all nodes', () => {
14
+ const graph = completeGraph(6);
15
+ const positions = kamadaKawaiLayout(graph);
16
+
17
+ assert.equal(Object.keys(positions).length, 6);
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 = kamadaKawaiLayout(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 = kamadaKawaiLayout(singleNode);
36
+
37
+ assert.equal(Object.keys(positions).length, 1);
38
+ assert.isDefined(positions['A']);
39
+ assert.equal(positions['A'].length, 2);
40
+ });
41
+
42
+ it('should handle disconnected components', () => {
43
+ const graph = {
44
+ nodes: () => [0, 1, 2, 3, 4, 5],
45
+ edges: () => [[0, 1], [1, 2], [3, 4], [4, 5]]
46
+ };
47
+ const positions = kamadaKawaiLayout(graph);
48
+
49
+ assert.equal(Object.keys(positions).length, 6);
50
+ graph.nodes().forEach(node => {
51
+ assert.isDefined(positions[node]);
52
+ });
53
+ });
54
+ });
55
+
56
+ describe('Distance matrix and weights', () => {
57
+ it('should accept custom distance matrix', () => {
58
+ const graph = completeGraph(4);
59
+
60
+ // Create custom distance matrix
61
+ const dist = {};
62
+ graph.nodes().forEach(u => {
63
+ dist[u] = {};
64
+ graph.nodes().forEach(v => {
65
+ dist[u][v] = u === v ? 0 : 1; // All pairs at distance 1
66
+ });
67
+ });
68
+
69
+ const positions = kamadaKawaiLayout(graph, dist);
70
+
71
+ assert.equal(Object.keys(positions).length, 4);
72
+ });
73
+
74
+ it('should handle weighted graphs', () => {
75
+ const graph = {
76
+ nodes: () => [0, 1, 2, 3],
77
+ edges: () => [[0, 1], [1, 2], [2, 3], [3, 0]],
78
+ // Mock edge weight access
79
+ get_edge_data: (u, v) => ({ weight: 1 })
80
+ };
81
+
82
+ const positions = kamadaKawaiLayout(graph, null, null, 'weight');
83
+
84
+ assert.equal(Object.keys(positions).length, 4);
85
+ });
86
+
87
+ it('should use initial positions if provided', () => {
88
+ const graph = cycleGraph(5);
89
+ const initialPos = {};
90
+ graph.nodes().forEach((node, i) => {
91
+ initialPos[node] = [i, 0]; // Line layout
92
+ });
93
+
94
+ const positions = kamadaKawaiLayout(graph, null, initialPos);
95
+
96
+ assert.equal(Object.keys(positions).length, 5);
97
+ // Should produce different layout than initial
98
+ let different = false;
99
+ graph.nodes().forEach(node => {
100
+ if (positions[node][0] !== initialPos[node][0] ||
101
+ positions[node][1] !== initialPos[node][1]) {
102
+ different = true;
103
+ }
104
+ });
105
+ assert.isTrue(different);
106
+ });
107
+ });
108
+
109
+ describe('Parameter variations', () => {
110
+ it('should respect scale parameter', () => {
111
+ const graph = starGraph(6);
112
+
113
+ const positions1 = kamadaKawaiLayout(graph, null, null, 'weight', 1);
114
+ const positions2 = kamadaKawaiLayout(graph, null, null, 'weight', 2);
115
+
116
+ // Calculate spans
117
+ const span1 = getLayoutSpan(positions1);
118
+ const span2 = getLayoutSpan(positions2);
119
+
120
+ assert.approximately(span2.width / span1.width, 2, 0.1);
121
+ assert.approximately(span2.height / span1.height, 2, 0.1);
122
+ });
123
+
124
+ it('should respect center parameter', () => {
125
+ const graph = cycleGraph(6);
126
+ const center = [10, -5];
127
+
128
+ const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, center);
129
+
130
+ // Calculate center of mass
131
+ const com = getCenterOfMass(positions);
132
+
133
+ assert.approximately(com[0], center[0], 1);
134
+ assert.approximately(com[1], center[1], 1);
135
+ });
136
+
137
+ it('should handle different dimensions', () => {
138
+ const graph = completeGraph(4);
139
+
140
+ // 2D layout
141
+ const positions2D = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0], 2);
142
+ graph.nodes().forEach(node => {
143
+ assert.equal(positions2D[node].length, 2);
144
+ });
145
+
146
+ // 3D layout
147
+ const positions3D = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0, 0], 3);
148
+ graph.nodes().forEach(node => {
149
+ assert.equal(positions3D[node].length, 3);
150
+ assert.isNumber(positions3D[node][2]);
151
+ });
152
+ });
153
+ });
154
+
155
+ describe('Layout properties', () => {
156
+ it('should minimize stress for regular graphs', () => {
157
+ const graph = cycleGraph(8);
158
+ const positions = kamadaKawaiLayout(graph);
159
+
160
+ // In a cycle, adjacent nodes should be closer than non-adjacent
161
+ const distances = [];
162
+ graph.nodes().forEach((node, i) => {
163
+ const nextNode = (i + 1) % 8;
164
+ const dx = positions[node][0] - positions[nextNode][0];
165
+ const dy = positions[node][1] - positions[nextNode][1];
166
+ distances.push(Math.sqrt(dx * dx + dy * dy));
167
+ });
168
+
169
+ // All edge lengths should be similar
170
+ const avgDist = distances.reduce((a, b) => a + b) / distances.length;
171
+ distances.forEach(d => {
172
+ assert.approximately(d, avgDist, avgDist * 0.3);
173
+ });
174
+ });
175
+
176
+ it('should create symmetric layouts for symmetric graphs', () => {
177
+ const graph = completeGraph(5);
178
+ const positions = kamadaKawaiLayout(graph);
179
+
180
+ // Calculate all pairwise distances
181
+ const distances = [];
182
+ const nodes = graph.nodes();
183
+ for (let i = 0; i < nodes.length; i++) {
184
+ for (let j = i + 1; j < nodes.length; j++) {
185
+ const dx = positions[nodes[i]][0] - positions[nodes[j]][0];
186
+ const dy = positions[nodes[i]][1] - positions[nodes[j]][1];
187
+ distances.push(Math.sqrt(dx * dx + dy * dy));
188
+ }
189
+ }
190
+
191
+ // In complete graph, all distances should be similar
192
+ const avgDist = distances.reduce((a, b) => a + b) / distances.length;
193
+ let maxDeviation = 0;
194
+ distances.forEach(d => {
195
+ maxDeviation = Math.max(maxDeviation, Math.abs(d - avgDist) / avgDist);
196
+ });
197
+ assert.isBelow(maxDeviation, 0.5);
198
+ });
199
+ });
200
+
201
+ describe('Special cases and edge cases', () => {
202
+ it('should handle string node IDs', () => {
203
+ const graph = {
204
+ nodes: () => ['alice', 'bob', 'charlie', 'david'],
205
+ edges: () => [['alice', 'bob'], ['bob', 'charlie'], ['charlie', 'david'], ['david', 'alice']]
206
+ };
207
+
208
+ const positions = kamadaKawaiLayout(graph);
209
+
210
+ assert.equal(Object.keys(positions).length, 4);
211
+ ['alice', 'bob', 'charlie', 'david'].forEach(node => {
212
+ assert.isDefined(positions[node]);
213
+ assert.equal(positions[node].length, 2);
214
+ });
215
+ });
216
+
217
+ it('should handle large graphs reasonably', () => {
218
+ const graph = gridGraph(8, 8); // 64 nodes
219
+
220
+ const startTime = performance.now();
221
+ const positions = kamadaKawaiLayout(graph, null, null, 'weight', 1, [0, 0], 2);
222
+ const endTime = performance.now();
223
+
224
+ assert.equal(Object.keys(positions).length, 64);
225
+ assert.isBelow(endTime - startTime, 2000); // Should complete in reasonable time
226
+ });
227
+
228
+ it('should produce different results with different initial positions', () => {
229
+ const graph = randomGraph(10, 0.3, 42);
230
+
231
+ // First with default initial positions
232
+ const positions1 = kamadaKawaiLayout(graph);
233
+
234
+ // Then with custom initial positions
235
+ const customPos = {};
236
+ graph.nodes().forEach((node, i) => {
237
+ customPos[node] = [Math.cos(2 * Math.PI * i / 10), Math.sin(2 * Math.PI * i / 10)];
238
+ });
239
+ const positions2 = kamadaKawaiLayout(graph, null, customPos);
240
+
241
+ // Results might be different (though both should be valid)
242
+ let different = false;
243
+ graph.nodes().forEach(node => {
244
+ if (Math.abs(positions1[node][0] - positions2[node][0]) > 0.1 ||
245
+ Math.abs(positions1[node][1] - positions2[node][1]) > 0.1) {
246
+ different = true;
247
+ }
248
+ });
249
+ // Note: They might converge to same solution, so we don't assert different
250
+ assert.equal(Object.keys(positions2).length, 10);
251
+ });
252
+
253
+ it('should handle star graph well', () => {
254
+ const graph = starGraph(10);
255
+ const positions = kamadaKawaiLayout(graph);
256
+
257
+ // Center should be distinguishable from leaves
258
+ const centerPos = positions[0];
259
+ const leafDistances = [];
260
+
261
+ for (let i = 1; i < 10; i++) {
262
+ const dx = positions[i][0] - centerPos[0];
263
+ const dy = positions[i][1] - centerPos[1];
264
+ leafDistances.push(Math.sqrt(dx * dx + dy * dy));
265
+ }
266
+
267
+ // All leaves should be positioned at reasonable distances
268
+ const avgDist = leafDistances.reduce((a, b) => a + b) / leafDistances.length;
269
+ leafDistances.forEach(d => {
270
+ assert.isAbove(d, 0); // All at positive distance
271
+ assert.isBelow(d, avgDist * 2); // Not too far
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('Layout quality', () => {
277
+ it('should preserve graph distances', () => {
278
+ // For a path graph, layout distances should reflect graph distances
279
+ const path = {
280
+ nodes: () => [0, 1, 2, 3, 4],
281
+ edges: () => [[0, 1], [1, 2], [2, 3], [3, 4]]
282
+ };
283
+
284
+ const positions = kamadaKawaiLayout(path);
285
+
286
+ // Distance 0-2 should be roughly twice distance 0-1
287
+ const d01 = getDistance(positions, 0, 1);
288
+ const d02 = getDistance(positions, 0, 2);
289
+ const d03 = getDistance(positions, 0, 3);
290
+
291
+ assert.isAbove(d02, d01);
292
+ assert.isAbove(d03, d02);
293
+ });
294
+
295
+ it('should create stable layouts', () => {
296
+ const graph = randomGraph(15, 0.3, 12345);
297
+
298
+ const positions1 = kamadaKawaiLayout(graph);
299
+ const positions2 = kamadaKawaiLayout(graph);
300
+
301
+ // Should produce similar results (allowing for some numerical differences)
302
+ let totalDiff = 0;
303
+ graph.nodes().forEach(node => {
304
+ const dx = positions1[node][0] - positions2[node][0];
305
+ const dy = positions1[node][1] - positions2[node][1];
306
+ totalDiff += Math.sqrt(dx * dx + dy * dy);
307
+ });
308
+
309
+ assert.isBelow(totalDiff / graph.nodes().length, 0.1);
310
+ });
311
+ });
312
+ });
313
+
314
+ // Helper functions
315
+ function getLayoutSpan(positions) {
316
+ const nodes = Object.keys(positions);
317
+ let minX = Infinity, maxX = -Infinity;
318
+ let minY = Infinity, maxY = -Infinity;
319
+
320
+ nodes.forEach(node => {
321
+ minX = Math.min(minX, positions[node][0]);
322
+ maxX = Math.max(maxX, positions[node][0]);
323
+ minY = Math.min(minY, positions[node][1]);
324
+ maxY = Math.max(maxY, positions[node][1]);
325
+ });
326
+
327
+ return {
328
+ width: maxX - minX,
329
+ height: maxY - minY
330
+ };
331
+ }
332
+
333
+ function getCenterOfMass(positions) {
334
+ const nodes = Object.keys(positions);
335
+ const com = [0, 0];
336
+
337
+ nodes.forEach(node => {
338
+ com[0] += positions[node][0];
339
+ com[1] += positions[node][1];
340
+ });
341
+
342
+ com[0] /= nodes.length;
343
+ com[1] /= nodes.length;
344
+ return com;
345
+ }
346
+
347
+ function getDistance(positions, node1, node2) {
348
+ const dx = positions[node1][0] - positions[node2][0];
349
+ const dy = positions[node1][1] - positions[node2][1];
350
+ return Math.sqrt(dx * dx + dy * dy);
351
+ }