@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,254 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ randomLayout,
4
+ completeGraph,
5
+ cycleGraph,
6
+ starGraph,
7
+ gridGraph
8
+ } from '../layout.ts';
9
+
10
+ describe('Random Layout', () => {
11
+ describe('Basic functionality', () => {
12
+ it('should position all nodes', () => {
13
+ const graph = completeGraph(8);
14
+ const positions = randomLayout(graph);
15
+
16
+ assert.equal(Object.keys(positions).length, 8);
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 = randomLayout(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 = randomLayout(singleNode);
35
+
36
+ assert.equal(Object.keys(positions).length, 1);
37
+ assert.isDefined(positions[0]);
38
+ assert.equal(positions[0].length, 2);
39
+ });
40
+
41
+ it('should handle disconnected components', () => {
42
+ const disconnected = {
43
+ nodes: () => [0, 1, 2, 3],
44
+ edges: () => [[0, 1], [2, 3]]
45
+ };
46
+ const positions = randomLayout(disconnected);
47
+
48
+ assert.equal(Object.keys(positions).length, 4);
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('Random distribution properties', () => {
57
+ it('should position nodes within [0, 1] range by default', () => {
58
+ const graph = cycleGraph(20);
59
+ const positions = randomLayout(graph);
60
+
61
+ graph.nodes().forEach(node => {
62
+ const [x, y] = positions[node];
63
+ assert.isAtLeast(x, -1);
64
+ assert.isAtMost(x, 1);
65
+ assert.isAtLeast(y, -1);
66
+ assert.isAtMost(y, 1);
67
+ });
68
+ });
69
+
70
+ it('should distribute nodes across the full range', () => {
71
+ const graph = gridGraph(10, 10); // 100 nodes
72
+ const positions = randomLayout(graph);
73
+
74
+ const xValues = graph.nodes().map(node => positions[node][0]);
75
+ const yValues = graph.nodes().map(node => positions[node][1]);
76
+
77
+ // With 100 nodes, we should have good coverage
78
+ const minX = Math.min(...xValues);
79
+ const maxX = Math.max(...xValues);
80
+ const minY = Math.min(...yValues);
81
+ const maxY = Math.max(...yValues);
82
+
83
+ // Should use a good portion of the [-1, 1] range
84
+ // With random distribution, we can't guarantee 80% coverage
85
+ assert.isAbove(maxX - minX, 0.5);
86
+ assert.isAbove(maxY - minY, 0.5);
87
+ });
88
+
89
+ it('should handle default range', () => {
90
+ const graph = starGraph(10);
91
+ const positions = randomLayout(graph);
92
+
93
+ graph.nodes().forEach(node => {
94
+ const [x, y] = positions[node];
95
+ assert.isAtLeast(x, -1);
96
+ assert.isAtMost(x, 1);
97
+ assert.isAtLeast(y, -1);
98
+ assert.isAtMost(y, 1);
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('Parameter variations', () => {
104
+ it('should produce different random layouts', () => {
105
+ const graph = completeGraph(5);
106
+
107
+ // Get multiple random layouts
108
+ const positions1 = randomLayout(graph);
109
+ const positions2 = randomLayout(graph);
110
+ const positions3 = randomLayout(graph);
111
+
112
+ // At least some positions should differ
113
+ let different = false;
114
+ graph.nodes().forEach(node => {
115
+ if (positions1[node][0] !== positions2[node][0] ||
116
+ positions1[node][1] !== positions2[node][1] ||
117
+ positions2[node][0] !== positions3[node][0] ||
118
+ positions2[node][1] !== positions3[node][1]) {
119
+ different = true;
120
+ }
121
+ });
122
+ assert.isTrue(different);
123
+ });
124
+
125
+ it('should respect center parameter', () => {
126
+ const graph = cycleGraph(6);
127
+ const center = [10, 20];
128
+
129
+ const positions = randomLayout(graph, center);
130
+
131
+ // Positions should be around the center
132
+ graph.nodes().forEach(node => {
133
+ const [x, y] = positions[node];
134
+ assert.isAtLeast(x, center[0] - 1);
135
+ assert.isAtMost(x, center[0] + 1);
136
+ assert.isAtLeast(y, center[1] - 1);
137
+ assert.isAtMost(y, center[1] + 1);
138
+ });
139
+ });
140
+
141
+ it('should handle different dimensions', () => {
142
+ const graph = completeGraph(4);
143
+
144
+ // 2D layout (default)
145
+ const positions2D = randomLayout(graph, [0, 0], 2);
146
+ graph.nodes().forEach(node => {
147
+ assert.equal(positions2D[node].length, 2);
148
+ });
149
+
150
+ // 3D layout
151
+ const positions3D = randomLayout(graph, [0, 0, 0], 3);
152
+ graph.nodes().forEach(node => {
153
+ assert.equal(positions3D[node].length, 3);
154
+ assert.isNumber(positions3D[node][2]);
155
+ assert.isAtLeast(positions3D[node][2], -1);
156
+ assert.isAtMost(positions3D[node][2], 1);
157
+ });
158
+ });
159
+
160
+ it('should produce different layouts with different seeds', () => {
161
+ const graph = starGraph(8);
162
+
163
+ const positions1 = randomLayout(graph, [0, 0], 2, 42);
164
+ const positions2 = randomLayout(graph, [0, 0], 2, 123);
165
+
166
+ // At least some positions should differ
167
+ let different = false;
168
+ graph.nodes().forEach(node => {
169
+ if (positions1[node][0] !== positions2[node][0] ||
170
+ positions1[node][1] !== positions2[node][1]) {
171
+ different = true;
172
+ }
173
+ });
174
+ assert.isTrue(different);
175
+ });
176
+
177
+ it('should produce same layout with same seed', () => {
178
+ const graph = completeGraph(10);
179
+ const seed = 12345;
180
+
181
+ const positions1 = randomLayout(graph, [0, 0], 2, seed);
182
+ const positions2 = randomLayout(graph, [0, 0], 2, seed);
183
+
184
+ // Should be identical
185
+ graph.nodes().forEach(node => {
186
+ assert.deepEqual(positions1[node], positions2[node]);
187
+ });
188
+ });
189
+ });
190
+
191
+ describe('Special cases', () => {
192
+ it('should handle string node IDs', () => {
193
+ const graph = {
194
+ nodes: () => ['A', 'B', 'C', 'D'],
195
+ edges: () => [['A', 'B'], ['B', 'C'], ['C', 'D']]
196
+ };
197
+
198
+ const positions = randomLayout(graph);
199
+
200
+ assert.equal(Object.keys(positions).length, 4);
201
+ ['A', 'B', 'C', 'D'].forEach(node => {
202
+ assert.isDefined(positions[node]);
203
+ assert.isAtLeast(positions[node][0], -1);
204
+ assert.isAtMost(positions[node][0], 1);
205
+ });
206
+ });
207
+
208
+ it('should handle negative center values', () => {
209
+ const graph = cycleGraph(4);
210
+ const positions = randomLayout(graph, [-5, -5]);
211
+
212
+ // Should place nodes around negative center
213
+ graph.nodes().forEach(node => {
214
+ const [x, y] = positions[node];
215
+ assert.isAtLeast(x, -6);
216
+ assert.isAtMost(x, -4);
217
+ assert.isAtLeast(y, -6);
218
+ assert.isAtMost(y, -4);
219
+ });
220
+ });
221
+
222
+ it('should be fast for large graphs', () => {
223
+ const graph = gridGraph(20, 20); // 400 nodes
224
+
225
+ const startTime = performance.now();
226
+ const positions = randomLayout(graph);
227
+ const endTime = performance.now();
228
+
229
+ assert.equal(Object.keys(positions).length, 400);
230
+ assert.isBelow(endTime - startTime, 50); // Should be very fast
231
+ });
232
+
233
+ it('should avoid node overlap statistically', () => {
234
+ const graph = completeGraph(20);
235
+ const positions = randomLayout(graph);
236
+
237
+ // Calculate minimum distance between any two nodes
238
+ let minDistance = Infinity;
239
+ const nodes = graph.nodes();
240
+
241
+ for (let i = 0; i < nodes.length; i++) {
242
+ for (let j = i + 1; j < nodes.length; j++) {
243
+ const dx = positions[nodes[i]][0] - positions[nodes[j]][0];
244
+ const dy = positions[nodes[i]][1] - positions[nodes[j]][1];
245
+ const distance = Math.sqrt(dx * dx + dy * dy);
246
+ minDistance = Math.min(minDistance, distance);
247
+ }
248
+ }
249
+
250
+ // With 20 nodes in 10x10 space, minimum distance should be > 0
251
+ assert.isAbove(minDistance, 0);
252
+ });
253
+ });
254
+ });
@@ -0,0 +1,373 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import {
3
+ rescaleLayout,
4
+ rescaleLayoutDict,
5
+ circularLayout,
6
+ randomLayout,
7
+ completeGraph,
8
+ starGraph
9
+ } from '../layout.ts';
10
+
11
+ describe('Rescale Layout', () => {
12
+ describe('Dictionary format (rescaleLayout)', () => {
13
+ it('should rescale positions dictionary', () => {
14
+ const positions = {
15
+ 0: [1, 1],
16
+ 1: [-1, 1],
17
+ 2: [-1, -1],
18
+ 3: [1, -1]
19
+ };
20
+
21
+ const rescaled = rescaleLayout(positions, 2);
22
+
23
+ // The function scales based on max distance from center
24
+ // Max distance is sqrt(2), so scale factor is 2/sqrt(2) = sqrt(2)
25
+ const expectedScale = Math.sqrt(2);
26
+ assert.approximately(rescaled[0][0], expectedScale, 0.01);
27
+ assert.approximately(rescaled[0][1], expectedScale, 0.01);
28
+ });
29
+
30
+ it('should center positions around specified point', () => {
31
+ const positions = {
32
+ A: [1, 1],
33
+ B: [-1, 1],
34
+ C: [-1, -1],
35
+ D: [1, -1]
36
+ };
37
+
38
+ const center = [10, 5];
39
+ const rescaled = rescaleLayout(positions, 1, center);
40
+
41
+ // The max distance is sqrt(2), scale is 1/sqrt(2)
42
+ const scale = 1 / Math.sqrt(2);
43
+ assert.approximately(rescaled['A'][0], 10 + scale, 0.01);
44
+ assert.approximately(rescaled['A'][1], 5 + scale, 0.01);
45
+ });
46
+
47
+ it('should handle empty positions', () => {
48
+ const positions = {};
49
+ const rescaled = rescaleLayout(positions, 2, [5, 5]);
50
+
51
+ assert.deepEqual(rescaled, {});
52
+ });
53
+
54
+ it('should handle single node', () => {
55
+ const positions = { node1: [3, 4] };
56
+ const rescaled = rescaleLayout(positions, 2, [0, 0]);
57
+
58
+ // Single node should be placed at center
59
+ assert.deepEqual(rescaled['node1'], [0, 0]);
60
+ });
61
+
62
+ it('should scale and center combined', () => {
63
+ const positions = {
64
+ 0: [2, 0],
65
+ 1: [0, 2],
66
+ 2: [-2, 0],
67
+ 3: [0, -2]
68
+ };
69
+
70
+ const rescaled = rescaleLayout(positions, 0.5, [10, 10]);
71
+
72
+ // Max distance is 2, scale factor is 0.5/2 = 0.25
73
+ assert.approximately(rescaled[0][0], 10.5, 0.01);
74
+ assert.approximately(rescaled[0][1], 10, 0.01);
75
+ });
76
+ });
77
+
78
+ describe('Array format (rescaleLayout)', () => {
79
+ it('should rescale positions array', () => {
80
+ const positions = [
81
+ [1, 1],
82
+ [-1, 1],
83
+ [-1, -1],
84
+ [1, -1]
85
+ ];
86
+
87
+ const rescaled = rescaleLayout(positions, 3);
88
+
89
+ // Max distance is sqrt(2), scale factor is 3/sqrt(2)
90
+ const expectedScale = 3 / Math.sqrt(2);
91
+ assert.approximately(rescaled[0][0], expectedScale, 0.01);
92
+ assert.approximately(rescaled[0][1], expectedScale, 0.01);
93
+ });
94
+
95
+ it('should center array positions', () => {
96
+ const positions = [
97
+ [1, 0],
98
+ [0, 1],
99
+ [-1, 0],
100
+ [0, -1]
101
+ ];
102
+
103
+ const rescaled = rescaleLayout(positions, 1, [5, 5]);
104
+
105
+ assert.deepEqual(rescaled[0], [6, 5]);
106
+ assert.deepEqual(rescaled[1], [5, 6]);
107
+ assert.deepEqual(rescaled[2], [4, 5]);
108
+ assert.deepEqual(rescaled[3], [5, 4]);
109
+ });
110
+
111
+ it('should handle empty array', () => {
112
+ const positions: number[][] = [];
113
+ const rescaled = rescaleLayout(positions, 2, [5, 5]);
114
+
115
+ assert.deepEqual(rescaled, []);
116
+ });
117
+
118
+ it('should handle 3D positions', () => {
119
+ const positions = [
120
+ [1, 1, 1],
121
+ [-1, -1, -1]
122
+ ];
123
+
124
+ const rescaled = rescaleLayout(positions, 2, [0, 0, 0]);
125
+
126
+ // Max distance is sqrt(3), scale factor is 2/sqrt(3)
127
+ const scale = 2 / Math.sqrt(3);
128
+ assert.approximately(rescaled[0][0], scale, 0.01);
129
+ assert.approximately(rescaled[0][1], scale, 0.01);
130
+ assert.approximately(rescaled[0][2], scale, 0.01);
131
+ });
132
+ });
133
+
134
+ describe('Integration with layouts', () => {
135
+ it('should rescale circular layout', () => {
136
+ const graph = completeGraph(6);
137
+ const positions = circularLayout(graph, 1); // radius 1
138
+
139
+ const rescaled = rescaleLayout(positions, 5); // scale up to radius 5
140
+
141
+ // Check that all nodes are now at radius ~5
142
+ Object.values(rescaled).forEach(pos => {
143
+ const radius = Math.sqrt(pos[0] ** 2 + pos[1] ** 2);
144
+ assert.approximately(radius, 5, 0.01);
145
+ });
146
+ });
147
+
148
+ it('should recenter random layout', () => {
149
+ const graph = starGraph(5);
150
+ const positions = randomLayout(graph, [0, 0]); // centered at origin
151
+
152
+ const newCenter = [20, 30];
153
+ const rescaled = rescaleLayout(positions, 1, newCenter);
154
+
155
+ // Calculate new center of mass
156
+ const com = [0, 0];
157
+ Object.values(rescaled).forEach(pos => {
158
+ com[0] += pos[0];
159
+ com[1] += pos[1];
160
+ });
161
+ com[0] /= 5;
162
+ com[1] /= 5;
163
+
164
+ assert.approximately(com[0], newCenter[0], 1);
165
+ assert.approximately(com[1], newCenter[1], 1);
166
+ });
167
+
168
+ it('should maintain relative positions', () => {
169
+ const positions = {
170
+ A: [1, 0],
171
+ B: [0, 1],
172
+ C: [-1, 0],
173
+ D: [0, -1]
174
+ };
175
+
176
+ const rescaled = rescaleLayout(positions, 2.5, [10, 10]);
177
+
178
+ // Check that relative positions are maintained
179
+ // A-C should still be horizontal, B-D vertical
180
+ assert.approximately(rescaled['A'][1], rescaled['C'][1], 0.001); // Same Y
181
+ assert.approximately(rescaled['B'][0], rescaled['D'][0], 0.001); // Same X
182
+
183
+ // Check distance is scaled properly
184
+ const originalMaxDist = 1; // from center to any point
185
+ const newMaxDist = Math.sqrt((rescaled['A'][0] - 10) ** 2 + (rescaled['A'][1] - 10) ** 2);
186
+ assert.approximately(newMaxDist, 2.5, 0.01);
187
+ });
188
+ });
189
+
190
+ describe('rescaleLayoutDict specific', () => {
191
+ it('should be an alias for rescaleLayout with dictionary', () => {
192
+ const positions = {
193
+ node1: [1, 1],
194
+ node2: [-1, -1]
195
+ };
196
+
197
+ const result1 = rescaleLayout(positions, 2);
198
+ const result2 = rescaleLayoutDict(positions, 2);
199
+
200
+ // Compare values with tolerance for floating point
201
+ Object.keys(result1).forEach(key => {
202
+ assert.approximately(result1[key][0], result2[key][0], 0.001);
203
+ assert.approximately(result1[key][1], result2[key][1], 0.001);
204
+ });
205
+ });
206
+
207
+ it('should handle string node IDs', () => {
208
+ const positions = {
209
+ 'user:1': [2, 3],
210
+ 'user:2': [4, 5],
211
+ 'item:1': [1, 2]
212
+ };
213
+
214
+ const rescaled = rescaleLayoutDict(positions, 0.5, [0, 0]);
215
+
216
+ assert.isDefined(rescaled['user:1']);
217
+ assert.isDefined(rescaled['user:2']);
218
+ assert.isDefined(rescaled['item:1']);
219
+ });
220
+ });
221
+
222
+ describe('Edge cases', () => {
223
+ it('should handle zero scale', () => {
224
+ const positions = {
225
+ A: [5, 5],
226
+ B: [10, 10],
227
+ C: [-5, -5]
228
+ };
229
+
230
+ const rescaled = rescaleLayout(positions, 0, [7, 7]);
231
+
232
+ // All nodes should collapse to center
233
+ assert.deepEqual(rescaled['A'], [7, 7]);
234
+ assert.deepEqual(rescaled['B'], [7, 7]);
235
+ assert.deepEqual(rescaled['C'], [7, 7]);
236
+ });
237
+
238
+ it('should handle negative scale', () => {
239
+ const positions = {
240
+ 0: [1, 0],
241
+ 1: [0, 1]
242
+ };
243
+
244
+ const rescaled = rescaleLayout(positions, -1);
245
+
246
+ // Center is at (0.5, 0.5)
247
+ // Centered positions: [0.5, -0.5] and [-0.5, 0.5]
248
+ // Max distance = sqrt(0.5² + 0.5²) = 0.707...
249
+ // Scale factor = -1/0.707... = -1.414...
250
+ // Should flip and scale positions
251
+ assert.approximately(rescaled[0][0], -0.707, 0.01);
252
+ assert.approximately(rescaled[0][1], 0.707, 0.01);
253
+ assert.approximately(rescaled[1][0], 0.707, 0.01);
254
+ assert.approximately(rescaled[1][1], -0.707, 0.01);
255
+ });
256
+
257
+ it('should preserve original positions object', () => {
258
+ const positions = {
259
+ A: [1, 2],
260
+ B: [3, 4]
261
+ };
262
+
263
+ const original = JSON.parse(JSON.stringify(positions));
264
+ const rescaled = rescaleLayout(positions, 2);
265
+
266
+ // Original should be unchanged
267
+ assert.deepEqual(positions, original);
268
+
269
+ // Rescaled should be different
270
+ assert.notDeepEqual(rescaled['A'], positions['A']);
271
+ });
272
+
273
+ it('should handle positions with different dimensions', () => {
274
+ const positions = {
275
+ A: [1, 2],
276
+ B: [3, 4, 5], // 3D
277
+ C: [6] // 1D
278
+ };
279
+
280
+ const rescaled = rescaleLayout(positions, 2, [0, 0, 0]);
281
+
282
+ // rescaleLayout uses dimension of first position (A has 2D)
283
+ // But when center has more dimensions, the result extends to match center
284
+ assert.equal(rescaled['A'].length, 3);
285
+ assert.equal(rescaled['A'][2], 0); // Third dimension from center
286
+ assert.equal(rescaled['B'].length, 3);
287
+ assert.equal(rescaled['C'].length, 3);
288
+ });
289
+
290
+ it('should handle very large scale factors', () => {
291
+ const positions = {
292
+ 0: [0.001, 0],
293
+ 1: [-0.001, 0]
294
+ };
295
+
296
+ const rescaled = rescaleLayout(positions, 1000);
297
+
298
+ // Max distance is 0.001, scale factor is 1000/0.001 = 1,000,000
299
+ assert.approximately(rescaled[0][0], 1000, 0.1);
300
+ assert.approximately(rescaled[1][0], -1000, 0.1);
301
+ });
302
+
303
+ it('should handle positions at origin', () => {
304
+ const positions = {
305
+ A: [0, 0],
306
+ B: [1, 0],
307
+ C: [0, 1]
308
+ };
309
+
310
+ const rescaled = rescaleLayout(positions, 3, [10, 10]);
311
+
312
+ // Center is at (1/3, 1/3)
313
+ // After centering: A=[-1/3,-1/3], B=[2/3,-1/3], C=[-1/3,2/3]
314
+ // Max distance = sqrt((2/3)^2 + (2/3)^2) = sqrt(8/9) = 0.9428...
315
+ // Scale factor = 3 / 0.9428 = 3.182...
316
+ // B after scaling = [2/3 * 3.182, -1/3 * 3.182] + [10, 10] = [12.12, 8.94]
317
+ const dist = Math.sqrt((rescaled['B'][0] - 10) ** 2 + (rescaled['B'][1] - 10) ** 2);
318
+ assert.approximately(dist, 3, 0.1);
319
+ });
320
+ });
321
+
322
+ describe('Practical usage patterns', () => {
323
+ it('should normalize layout to unit square', () => {
324
+ const positions = {
325
+ 0: [10, 20],
326
+ 1: [30, 40],
327
+ 2: [20, 10],
328
+ 3: [40, 30]
329
+ };
330
+
331
+ // Scale to fit in unit square centered at (0.5, 0.5)
332
+ const rescaled = rescaleLayout(positions, 0.5, [0.5, 0.5]);
333
+
334
+ // Check that layout fits in reasonable bounds
335
+ const values = Object.values(rescaled);
336
+ const xCoords = values.map(p => p[0]);
337
+ const yCoords = values.map(p => p[1]);
338
+
339
+ assert.isAtLeast(Math.min(...xCoords), 0);
340
+ assert.isAtMost(Math.max(...xCoords), 1);
341
+ assert.isAtLeast(Math.min(...yCoords), 0);
342
+ assert.isAtMost(Math.max(...yCoords), 1);
343
+ });
344
+
345
+ it('should combine multiple layouts with different scales', () => {
346
+ const layout1 = {
347
+ A: [1, 1],
348
+ B: [-1, 1]
349
+ };
350
+
351
+ const layout2 = {
352
+ C: [0.5, 0.5],
353
+ D: [-0.5, 0.5]
354
+ };
355
+
356
+ // Scale second layout and offset it
357
+ const rescaled2 = rescaleLayout(layout2, 2, [5, 0]);
358
+
359
+ // Combine layouts
360
+ const combined = { ...layout1, ...rescaled2 };
361
+
362
+ assert.equal(Object.keys(combined).length, 4);
363
+ assert.deepEqual(combined['A'], [1, 1]);
364
+
365
+ // Check rescaled layout is positioned correctly
366
+ const centerX = (rescaled2['C'][0] + rescaled2['D'][0]) / 2;
367
+ assert.approximately(centerX, 5, 0.1);
368
+ });
369
+ });
370
+ });
371
+
372
+ // Note: rescaleLayoutDict is tested implicitly through rescaleLayout tests
373
+ // since they share the same implementation for dictionary inputs