@graphty/layout 1.1.1 → 1.2.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 (100) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +3 -7
  3. package/CHANGELOG.md +14 -0
  4. package/DEPLOYMENT.md +59 -0
  5. package/README.md +54 -1
  6. package/dist/vitest.config.js +2 -2
  7. package/dist/vitest.config.js.map +1 -1
  8. package/examples/3d-force-directed.html +611 -0
  9. package/examples/3d-kamada-kawai.html +379 -0
  10. package/examples/3d-layout-comparison.html +448 -0
  11. package/examples/3d-spherical-layout.html +319 -0
  12. package/examples/arf-layout.html +13 -1
  13. package/examples/bfs-layout.html +38 -32
  14. package/examples/bipartite-layout.html +16 -4
  15. package/examples/circular-layout.html +14 -2
  16. package/examples/forceatlas2-layout.html +118 -151
  17. package/examples/index.html +75 -0
  18. package/examples/kamada-kawai-layout.html +13 -1
  19. package/{dist → examples}/layout-helpers.js +58 -29
  20. package/examples/multipartite-layout.html +44 -54
  21. package/examples/planar-layout.html +13 -1
  22. package/examples/random-layout.html +13 -1
  23. package/examples/shell-layout.html +16 -2
  24. package/examples/spectral-layout.html +13 -1
  25. package/examples/spiral-layout.html +13 -1
  26. package/examples/spring-layout.html +15 -13
  27. package/package.json +6 -3
  28. package/src/algorithms/index.ts +6 -0
  29. package/src/algorithms/optimization/index.ts +12 -0
  30. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  31. package/src/algorithms/optimization/lbfgs.ts +68 -0
  32. package/src/algorithms/optimization/line-search.ts +50 -0
  33. package/src/algorithms/optimization/types.ts +8 -0
  34. package/src/algorithms/planarity/check.ts +38 -0
  35. package/src/algorithms/planarity/embedding.ts +216 -0
  36. package/src/algorithms/planarity/index.ts +12 -0
  37. package/src/algorithms/planarity/lr-test.ts +70 -0
  38. package/src/algorithms/planarity/special-graphs.ts +126 -0
  39. package/src/generators/basic.ts +93 -0
  40. package/src/generators/bipartite.ts +45 -0
  41. package/src/generators/grid.ts +42 -0
  42. package/src/generators/index.ts +15 -0
  43. package/src/generators/random.ts +39 -0
  44. package/src/generators/scale-free.ts +72 -0
  45. package/src/index.ts +18 -0
  46. package/src/layouts/basic/index.ts +5 -0
  47. package/src/layouts/basic/random.ts +32 -0
  48. package/src/layouts/force-directed/arf.ts +130 -0
  49. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  50. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  51. package/src/layouts/force-directed/index.ts +9 -0
  52. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  53. package/src/layouts/force-directed/spring.ts +35 -0
  54. package/src/layouts/geometric/circular.ts +77 -0
  55. package/src/layouts/geometric/index.ts +7 -0
  56. package/src/layouts/geometric/shell.ts +80 -0
  57. package/src/layouts/geometric/spiral.ts +93 -0
  58. package/src/layouts/hierarchical/bfs.ts +81 -0
  59. package/src/layouts/hierarchical/bipartite.ts +94 -0
  60. package/src/layouts/hierarchical/index.ts +7 -0
  61. package/src/layouts/hierarchical/multipartite.ts +88 -0
  62. package/src/layouts/index.ts +9 -0
  63. package/src/layouts/specialized/index.ts +6 -0
  64. package/src/layouts/specialized/planar.ts +65 -0
  65. package/src/layouts/specialized/spectral.ts +128 -0
  66. package/src/types/embedding.ts +11 -0
  67. package/src/types/graph.ts +13 -0
  68. package/src/types/index.ts +7 -0
  69. package/src/types/layout.ts +9 -0
  70. package/src/utils/graph.ts +77 -0
  71. package/src/utils/index.ts +9 -0
  72. package/src/utils/numpy.ts +108 -0
  73. package/src/utils/params.ts +26 -0
  74. package/src/utils/random.ts +53 -0
  75. package/src/utils/rescale.ts +137 -0
  76. package/test/arf-layout.test.ts +1 -1
  77. package/test/bfs-layout.test.ts +1 -1
  78. package/test/bipartite-layout.test.ts +1 -1
  79. package/test/circular-layout.test.ts +145 -3
  80. package/test/forceatlas2-layout.test.ts +1 -1
  81. package/test/fruchterman-reingold-layout.test.ts +1 -1
  82. package/test/graph-generators.test.ts +1 -1
  83. package/test/kamada-kawai-layout.test.ts +273 -1
  84. package/test/multipartite-layout.test.ts +1 -1
  85. package/test/planar-layout.test.ts +1 -1
  86. package/test/random-layout.test.ts +1 -1
  87. package/test/rescale-layout.test.ts +1 -1
  88. package/test/shell-layout.test.ts +1 -1
  89. package/test/spectral-layout.test.ts +1 -1
  90. package/test/spiral-layout.test.ts +1 -1
  91. package/test/spring-layout.test.ts +1 -1
  92. package/vite.config.js +36 -0
  93. package/vitest.config.ts +2 -2
  94. package/dist/layout-helpers.d.ts +0 -123
  95. package/dist/layout-helpers.js.map +0 -1
  96. package/dist/layout.d.ts +0 -275
  97. package/dist/layout.js +0 -2280
  98. package/dist/layout.js.map +0 -1
  99. package/layout-helpers.ts +0 -559
  100. package/layout.ts +0 -2867
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>ForceAtlas2 Layout Test</title>
@@ -191,16 +203,117 @@
191
203
 
192
204
  <script type="module">
193
205
  import {
194
- forceatlas2Layout,
195
- randomGraph,
196
- scaleFreeGraph,
197
- gridGraph,
206
+ forceatlas2Layout
207
+ } from "./layout.js";
208
+
209
+ import {
198
210
  autoConfigureForce
199
- } from '../dist/layout.js';
211
+ } from "./layout-helpers.js";
200
212
 
201
213
  let currentGraph = null;
202
214
  let currentConfig = null;
203
215
 
216
+ // Graph generation utilities
217
+ function randomGraph(n, p, seed) {
218
+ const nodes = Array.from({length: n}, (_, i) => i);
219
+ const edges = [];
220
+
221
+ // Simple random graph with probability p for each edge
222
+ for (let i = 0; i < n; i++) {
223
+ for (let j = i + 1; j < n; j++) {
224
+ if (Math.random() < p) {
225
+ edges.push([i, j]);
226
+ }
227
+ }
228
+ }
229
+
230
+ return {
231
+ nodes: () => nodes,
232
+ edges: () => edges
233
+ };
234
+ }
235
+
236
+ function scaleFreeGraph(n, m, seed) {
237
+ const nodes = Array.from({length: n}, (_, i) => i);
238
+ const edges = [];
239
+
240
+ // Start with a complete graph of m+1 nodes
241
+ for (let i = 0; i <= m; i++) {
242
+ for (let j = i + 1; j <= m; j++) {
243
+ edges.push([i, j]);
244
+ }
245
+ }
246
+
247
+ // Add remaining nodes with preferential attachment
248
+ for (let i = m + 1; i < n; i++) {
249
+ const degrees = new Map();
250
+ for (const [u, v] of edges) {
251
+ degrees.set(u, (degrees.get(u) || 0) + 1);
252
+ degrees.set(v, (degrees.get(v) || 0) + 1);
253
+ }
254
+
255
+ const totalDegree = Array.from(degrees.values()).reduce((a, b) => a + b, 0);
256
+ const targets = new Set();
257
+
258
+ while (targets.size < Math.min(m, i)) {
259
+ let r = Math.random() * totalDegree;
260
+ let node = 0;
261
+
262
+ for (const [n, d] of degrees) {
263
+ r -= d;
264
+ if (r <= 0) {
265
+ node = n;
266
+ break;
267
+ }
268
+ }
269
+
270
+ if (!targets.has(node)) {
271
+ targets.add(node);
272
+ edges.push([i, node]);
273
+ }
274
+ }
275
+ }
276
+
277
+ return {
278
+ nodes: () => nodes,
279
+ edges: () => edges
280
+ };
281
+ }
282
+
283
+ function gridGraph(rows, cols) {
284
+ const nodes = [];
285
+ const edges = [];
286
+
287
+ // Create nodes
288
+ for (let r = 0; r < rows; r++) {
289
+ for (let c = 0; c < cols; c++) {
290
+ nodes.push(r * cols + c);
291
+ }
292
+ }
293
+
294
+ // Create edges
295
+ for (let r = 0; r < rows; r++) {
296
+ for (let c = 0; c < cols; c++) {
297
+ const node = r * cols + c;
298
+
299
+ // Right edge
300
+ if (c < cols - 1) {
301
+ edges.push([node, node + 1]);
302
+ }
303
+
304
+ // Down edge
305
+ if (r < rows - 1) {
306
+ edges.push([node, node + cols]);
307
+ }
308
+ }
309
+ }
310
+
311
+ return {
312
+ nodes: () => nodes,
313
+ edges: () => edges
314
+ };
315
+ }
316
+
204
317
  function generateGraph(type, numNodes) {
205
318
  switch(type) {
206
319
  case 'scale-free':
@@ -251,150 +364,7 @@
251
364
  case 'random':
252
365
  default:
253
366
  return randomGraph(numNodes, 0.1, Date.now());
254
- for(let i = 1; i < Math.min(5, numNodes); i++) {
255
- edges.push([0, i]);
256
- }
257
-
258
- // Add nodes with preferential attachment
259
- for(let i = 5; i < numNodes; i++) {
260
- // Number of connections for new node
261
- const newEdges = Math.min(3, i);
262
-
263
- // Track edge count per node for preferential attachment
264
- const nodeDegrees = new Array(i).fill(0);
265
- for(const edge of edges) {
266
- nodeDegrees[edge[0]]++;
267
- nodeDegrees[edge[1]]++;
268
- }
269
-
270
- // Calculate total degree for probability
271
- const totalDegree = nodeDegrees.reduce((sum, deg) => sum + deg, 0);
272
-
273
- // Add edges based on preferential attachment
274
- const added = new Set();
275
- for(let j = 0; j < newEdges; j++) {
276
- let targetNode;
277
- do {
278
- // Choose node with probability proportional to its degree
279
- let rand = Math.random() * totalDegree;
280
- targetNode = 0;
281
- while(targetNode < i && rand > 0) {
282
- rand -= nodeDegrees[targetNode];
283
- if(rand > 0) targetNode++;
284
- }
285
- if(targetNode >= i) targetNode = Math.floor(Math.random() * i);
286
- } while(added.has(targetNode));
287
-
288
- edges.push([i, targetNode]);
289
- added.add(targetNode);
290
- }
291
- }
292
- break;
293
-
294
- case 'community':
295
- // Create community structure with 3-4 communities
296
- const numCommunities = Math.min(4, Math.ceil(numNodes / 10));
297
- const communitySizes = [];
298
- let remainingNodes = numNodes;
299
-
300
- for(let i = 0; i < numCommunities - 1; i++) {
301
- const size = Math.ceil(numNodes / numCommunities);
302
- communitySizes.push(size);
303
- remainingNodes -= size;
304
- }
305
- communitySizes.push(remainingNodes);
306
-
307
- let nodeIndex = 0;
308
- for(let c = 0; c < numCommunities; c++) {
309
- const communityStart = nodeIndex;
310
- const communityEnd = communityStart + communitySizes[c];
311
-
312
- // Connect nodes within community (dense)
313
- for(let i = communityStart; i < communityEnd; i++) {
314
- for(let j = i + 1; j < communityEnd; j++) {
315
- if(Math.random() < 0.7) { // High probability within community
316
- edges.push([i, j]);
317
- }
318
- }
319
- }
320
-
321
- // Connect to other communities (sparse)
322
- if(c > 0) {
323
- const prevCommunityStart = nodeIndex - communitySizes[c-1];
324
- const bridgeEdges = Math.max(1, Math.floor(communitySizes[c] * 0.1));
325
- for(let e = 0; e < bridgeEdges; e++) {
326
- const source = communityStart + Math.floor(Math.random() * communitySizes[c]);
327
- const target = prevCommunityStart + Math.floor(Math.random() * communitySizes[c-1]);
328
- edges.push([source, target]);
329
- }
330
- }
331
-
332
- nodeIndex = communityEnd;
333
- }
334
- break;
335
-
336
- case 'grid':
337
- // Create a grid-like structure
338
- const gridSize = Math.ceil(Math.sqrt(numNodes));
339
- for(let i = 0; i < numNodes; i++) {
340
- const row = Math.floor(i / gridSize);
341
- const col = i % gridSize;
342
-
343
- // Connect right
344
- if(col < gridSize - 1 && i + 1 < numNodes) {
345
- edges.push([i, i + 1]);
346
- }
347
-
348
- // Connect down
349
- if(row < gridSize - 1 && i + gridSize < numNodes) {
350
- edges.push([i, i + gridSize]);
351
- }
352
- }
353
- break;
354
-
355
- case 'random':
356
- default:
357
- // Random graph with controlled density
358
- const density = 0.1; // 10% of possible edges
359
- const possibleEdges = (numNodes * (numNodes - 1)) / 2;
360
- const targetEdges = Math.ceil(possibleEdges * density);
361
-
362
- // Ensure connected graph with spanning tree
363
- for(let i = 1; i < numNodes; i++) {
364
- const parent = Math.floor(Math.random() * i);
365
- edges.push([parent, i]);
366
- }
367
-
368
- // Add random edges up to target density
369
- let additionalEdges = targetEdges - (numNodes - 1);
370
- let attempts = 0;
371
-
372
- while(additionalEdges > 0 && attempts < 1000) {
373
- const a = Math.floor(Math.random() * numNodes);
374
- const b = Math.floor(Math.random() * numNodes);
375
-
376
- if(a !== b && !edges.some(e => (e[0] === a && e[1] === b) || (e[0] === b && e[1] === a))) {
377
- edges.push([a, b]);
378
- additionalEdges--;
379
- }
380
-
381
- attempts++;
382
- }
383
- break;
384
367
  }
385
-
386
- // Create node masses based on degree
387
- const masses = {};
388
- for(const edge of edges) {
389
- masses[edge[0]] = (masses[edge[0]] || 1) + 0.1;
390
- masses[edge[1]] = (masses[edge[1]] || 1) + 0.1;
391
- }
392
-
393
- return {
394
- nodes: () => nodes,
395
- edges: () => edges,
396
- masses: masses
397
- };
398
368
  }
399
369
 
400
370
  function visualizeGraph(graph, positions) {
@@ -502,9 +472,6 @@
502
472
 
503
473
  // Apply layout immediately
504
474
  applyLayout();
505
- });
506
-
507
- visualizeGraph(currentGraph, positions);
508
475
 
509
476
  } catch (error) {
510
477
  console.error('Error in graph generation:', error);
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>NetworkX Layout Tests</title>
@@ -71,6 +83,38 @@
71
83
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
72
84
  padding: 20px;
73
85
  }
86
+ .section-header {
87
+ text-align: center;
88
+ margin: 40px 0 20px 0;
89
+ color: #333;
90
+ }
91
+ .section-header h2 {
92
+ color: #2c5aa0;
93
+ margin-bottom: 10px;
94
+ }
95
+ .section-header p {
96
+ color: #666;
97
+ margin: 0;
98
+ }
99
+ .layout-card.threed {
100
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
101
+ color: white;
102
+ }
103
+ .layout-card.threed .layout-title {
104
+ color: #fff;
105
+ }
106
+ .layout-card.threed .layout-description {
107
+ color: #f0f0f0;
108
+ }
109
+ .layout-card.threed .test-button {
110
+ background: rgba(255, 255, 255, 0.2);
111
+ color: white;
112
+ border: 2px solid rgba(255, 255, 255, 0.3);
113
+ }
114
+ .layout-card.threed .test-button:hover {
115
+ background: rgba(255, 255, 255, 0.3);
116
+ border-color: rgba(255, 255, 255, 0.5);
117
+ }
74
118
  </style>
75
119
  </head>
76
120
  <body>
@@ -79,6 +123,11 @@
79
123
  <p>Interactive tests for graph layout algorithms in JavaScript</p>
80
124
  </div>
81
125
 
126
+ <div class="section-header">
127
+ <h2>2D Layout Algorithms</h2>
128
+ <p>Classic graph layout algorithms for 2D visualization</p>
129
+ </div>
130
+
82
131
  <div class="layout-grid">
83
132
  <div class="layout-card">
84
133
  <div class="layout-title">Random Layout</div>
@@ -159,6 +208,32 @@
159
208
  </div>
160
209
  </div>
161
210
 
211
+ <div class="section-header">
212
+ <h2>3D Layout Algorithms</h2>
213
+ <p>Interactive 3D visualizations with Three.js - drag to rotate, scroll to zoom</p>
214
+ </div>
215
+
216
+ <div class="layout-grid">
217
+ <div class="layout-card threed">
218
+ <div class="layout-title">3D Force-Directed Layouts</div>
219
+ <div class="layout-description">Compare spring, ForceAtlas2, and ARF algorithms in immersive 3D space with interactive controls</div>
220
+ <a href="3d-force-directed.html" class="test-button">Explore 3D</a>
221
+ </div>
222
+
223
+ <div class="layout-card threed">
224
+ <div class="layout-title">3D Kamada-Kawai Layout</div>
225
+ <div class="layout-description">Stress-minimization algorithm in 3D with real-time stress calculation and graph generation</div>
226
+ <a href="3d-kamada-kawai.html" class="test-button">Explore 3D</a>
227
+ </div>
228
+
229
+ <div class="layout-card threed">
230
+ <div class="layout-title">3D Spherical Layout</div>
231
+ <div class="layout-description">Nodes positioned on a 3D sphere surface with auto-rotation and customizable graph types</div>
232
+ <a href="3d-spherical-layout.html" class="test-button">Explore 3D</a>
233
+ </div>
234
+
235
+ </div>
236
+
162
237
  <div class="footer">
163
238
  <p>Ported from NetworkX Python library - Interactive tests for JavaScript</p>
164
239
  </div>
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Kamada-Kawai Layout Test</title>
@@ -157,7 +169,7 @@
157
169
  </div>
158
170
 
159
171
  <script type="module">
160
- import { kamadaKawaiLayout } from '../dist/layout.js';
172
+ import { kamadaKawaiLayout } from "./layout.js";
161
173
 
162
174
  let currentGraph = null;
163
175
  let distances = null;
@@ -409,49 +409,78 @@ function groupByKCore(nodes, edges) {
409
409
  .map(([, nodeList]) => nodeList);
410
410
  }
411
411
  function groupByCommunity(nodes, edges, targetGroups) {
412
- // Simple community detection using modularity
412
+ // Fast community detection using label propagation
413
413
  const communities = new Map();
414
- nodes.forEach((node, i) => communities.set(node, i));
415
- // Build adjacency
416
414
  const adj = new Map();
417
- nodes.forEach(node => adj.set(node, new Set()));
415
+
416
+ // Initialize each node in its own community
417
+ nodes.forEach((node, i) => {
418
+ communities.set(node, i);
419
+ adj.set(node, new Set());
420
+ });
421
+
422
+ // Build adjacency
418
423
  edges.forEach(([u, v]) => {
419
424
  adj.get(u).add(v);
420
425
  adj.get(v).add(u);
421
426
  });
422
- // Iteratively merge communities
423
- let numCommunities = nodes.length;
424
- while (numCommunities > targetGroups) {
425
- let improved = false;
426
- for (const node of nodes) {
427
- const currentComm = communities.get(node);
428
- const neighborComms = new Set();
429
- adj.get(node).forEach(neighbor => {
430
- neighborComms.add(communities.get(neighbor));
427
+
428
+ // Label propagation algorithm
429
+ const maxIterations = 10; // Prevent infinite loops
430
+ for (let iter = 0; iter < maxIterations; iter++) {
431
+ let changed = false;
432
+
433
+ // Process nodes in random order
434
+ const shuffledNodes = nodes.slice().sort(() => Math.random() - 0.5);
435
+
436
+ for (const node of shuffledNodes) {
437
+ const neighbors = adj.get(node);
438
+ if (neighbors.size === 0) continue;
439
+
440
+ // Count community frequencies among neighbors
441
+ const commCounts = new Map();
442
+ neighbors.forEach(neighbor => {
443
+ const comm = communities.get(neighbor);
444
+ commCounts.set(comm, (commCounts.get(comm) || 0) + 1);
431
445
  });
432
- for (const targetComm of Array.from(neighborComms)) {
433
- if (targetComm !== currentComm) {
434
- // Simple decision: join most common neighbor community
435
- communities.set(node, targetComm);
436
- improved = true;
437
- break;
446
+
447
+ // Find most frequent community
448
+ let maxCount = 0;
449
+ let bestComm = communities.get(node);
450
+ commCounts.forEach((count, comm) => {
451
+ if (count > maxCount || (count === maxCount && comm < bestComm)) {
452
+ maxCount = count;
453
+ bestComm = comm;
438
454
  }
455
+ });
456
+
457
+ if (bestComm !== communities.get(node)) {
458
+ communities.set(node, bestComm);
459
+ changed = true;
439
460
  }
440
- if (improved)
441
- break;
442
461
  }
443
- if (!improved)
444
- break;
445
- // Count communities
446
- numCommunities = new Set(communities.values()).size;
462
+
463
+ if (!changed) break;
447
464
  }
448
- // Group nodes by community
465
+
466
+ // Consolidate communities
449
467
  const groups = new Map();
450
468
  communities.forEach((comm, node) => {
451
- if (!groups.has(comm))
452
- groups.set(comm, []);
469
+ if (!groups.has(comm)) groups.set(comm, []);
453
470
  groups.get(comm).push(node);
454
471
  });
455
- return Array.from(groups.values());
472
+
473
+ let result = Array.from(groups.values());
474
+
475
+ // If we have too many groups, merge the smallest ones
476
+ while (result.length > targetGroups && result.length > 1) {
477
+ // Sort by size
478
+ result.sort((a, b) => a.length - b.length);
479
+ // Merge two smallest groups
480
+ const merged = result[0].concat(result[1]);
481
+ result = [merged, ...result.slice(2)];
482
+ }
483
+
484
+ return result;
456
485
  }
457
486
  //# sourceMappingURL=layout-helpers.js.map