@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
@@ -153,12 +153,17 @@
153
153
  </div>
154
154
 
155
155
  <div class="control-group">
156
+ <label for="auto-configure">Auto-configure parameters:</label>
157
+ <input type="checkbox" id="auto-configure" checked>
158
+ </div>
159
+
160
+ <div class="control-group" id="scaling-group">
156
161
  <label for="scaling-ratio">Scaling Ratio:</label>
157
162
  <input type="range" id="scaling-ratio" min="0.5" max="10" step="0.5" value="2">
158
163
  <span id="scaling-ratio-value">2.0</span>
159
164
  </div>
160
165
 
161
- <div class="control-group">
166
+ <div class="control-group" id="gravity-group">
162
167
  <label for="gravity">Gravity:</label>
163
168
  <input type="range" id="gravity" min="0" max="5" step="0.1" value="1">
164
169
  <span id="gravity-value">1.0</span>
@@ -185,19 +190,67 @@
185
190
  </div>
186
191
 
187
192
  <script type="module">
188
- import { forceatlas2Layout } from '../dist/layout.js';
193
+ import {
194
+ forceatlas2Layout,
195
+ randomGraph,
196
+ scaleFreeGraph,
197
+ gridGraph,
198
+ autoConfigureForce
199
+ } from '../dist/layout.js';
189
200
 
190
201
  let currentGraph = null;
191
- let currentSeed = Math.floor(Math.random() * 1000);
202
+ let currentConfig = null;
192
203
 
193
204
  function generateGraph(type, numNodes) {
194
- const nodes = Array.from({length: numNodes}, (_, i) => i);
195
- const edges = [];
196
-
197
205
  switch(type) {
198
206
  case 'scale-free':
199
- // Barabási–Albert model (preferential attachment)
200
- // Start with a small connected graph
207
+ return scaleFreeGraph(numNodes, 2, Date.now());
208
+
209
+ case 'community':
210
+ // Use multiple smaller scale-free graphs
211
+ const numCommunities = Math.ceil(numNodes / 10);
212
+ const nodesPerCommunity = Math.floor(numNodes / numCommunities);
213
+ const nodes = [];
214
+ const edges = [];
215
+
216
+ // Create communities
217
+ for (let c = 0; c < numCommunities; c++) {
218
+ const start = c * nodesPerCommunity;
219
+ const end = Math.min(start + nodesPerCommunity, numNodes);
220
+
221
+ // Dense connections within community
222
+ for (let i = start; i < end; i++) {
223
+ nodes.push(i);
224
+ for (let j = i + 1; j < end; j++) {
225
+ if (Math.random() < 0.6) {
226
+ edges.push([i, j]);
227
+ }
228
+ }
229
+ }
230
+
231
+ // Sparse connections between communities
232
+ if (c > 0) {
233
+ const prevStart = (c - 1) * nodesPerCommunity;
234
+ const prevEnd = Math.min(prevStart + nodesPerCommunity, numNodes);
235
+ for (let i = start; i < end && i < start + 2; i++) {
236
+ for (let j = prevStart; j < prevEnd && j < prevStart + 2; j++) {
237
+ if (Math.random() < 0.1) {
238
+ edges.push([i, j]);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ return { nodes: () => nodes, edges: () => edges };
246
+
247
+ case 'grid':
248
+ const size = Math.ceil(Math.sqrt(numNodes));
249
+ return gridGraph(size, size);
250
+
251
+ case 'random':
252
+ default:
253
+ return randomGraph(numNodes, 0.1, Date.now());
201
254
  for(let i = 1; i < Math.min(5, numNodes); i++) {
202
255
  edges.push([0, i]);
203
256
  }
@@ -394,9 +447,10 @@
394
447
  const x = margin + (pos[0] - minX) / rangeX * scaleX;
395
448
  const y = margin + (pos[1] - minY) / rangeY * scaleY;
396
449
 
397
- // Node size based on degree/mass
398
- const mass = graph.masses[node] || 1;
399
- const radius = 5 + Math.sqrt(mass) * 1.5;
450
+ // Node size based on degree
451
+ const edges = graph.edges();
452
+ const degree = edges.filter(([u, v]) => u === node || v === node).length;
453
+ const radius = 4 + Math.sqrt(degree) * 1.5;
400
454
 
401
455
  // Color based on position/edge distribution
402
456
  const hue = (node * 360 / nodes.length) % 360;
@@ -430,20 +484,24 @@
430
484
  const graphType = document.getElementById('graph-type').value;
431
485
 
432
486
  try {
433
- // Generate new graph and re-seed
487
+ // Generate new graph
434
488
  currentGraph = generateGraph(graphType, numNodes);
435
- currentSeed = Math.floor(Math.random() * 1000);
436
489
 
437
- // Just visualize without layout initially
438
- const positions = {};
439
- currentGraph.nodes().forEach(node => {
440
- // Random positions in a circle
441
- const angle = Math.random() * Math.PI * 2;
442
- const radius = Math.random() * 0.5;
443
- positions[node] = [
444
- Math.cos(angle) * radius,
445
- Math.sin(angle) * radius
446
- ];
490
+ // Auto-configure parameters
491
+ currentConfig = autoConfigureForce(currentGraph);
492
+
493
+ // Update controls with recommended values
494
+ if (document.getElementById('auto-configure').checked) {
495
+ document.getElementById('iterations').value = currentConfig.iterations;
496
+ document.getElementById('iterations-value').textContent = currentConfig.iterations;
497
+ document.getElementById('scaling-ratio').value = currentConfig.scalingRatio;
498
+ document.getElementById('scaling-ratio-value').textContent = currentConfig.scalingRatio.toFixed(1);
499
+ document.getElementById('gravity').value = currentConfig.gravity;
500
+ document.getElementById('gravity-value').textContent = currentConfig.gravity.toFixed(1);
501
+ }
502
+
503
+ // Apply layout immediately
504
+ applyLayout();
447
505
  });
448
506
 
449
507
  visualizeGraph(currentGraph, positions);
@@ -470,9 +528,27 @@
470
528
  };
471
529
 
472
530
  async function runForceAtlas2() {
473
- const iterations = parseInt(document.getElementById('iterations').value);
474
- const scalingRatio = parseFloat(document.getElementById('scaling-ratio').value);
475
- const gravity = parseFloat(document.getElementById('gravity').value);
531
+ const autoConfig = document.getElementById('auto-configure').checked;
532
+
533
+ let iterations, scalingRatio, gravity;
534
+ if (autoConfig && currentConfig) {
535
+ iterations = currentConfig.iterations;
536
+ scalingRatio = currentConfig.scalingRatio;
537
+ gravity = currentConfig.gravity;
538
+
539
+ // Update sliders to show auto values
540
+ document.getElementById('iterations').value = iterations;
541
+ document.getElementById('iterations-value').textContent = iterations;
542
+ document.getElementById('scaling-ratio').value = scalingRatio;
543
+ document.getElementById('scaling-ratio-value').textContent = scalingRatio.toFixed(1);
544
+ document.getElementById('gravity').value = gravity;
545
+ document.getElementById('gravity-value').textContent = gravity.toFixed(1);
546
+ } else {
547
+ iterations = parseInt(document.getElementById('iterations').value);
548
+ scalingRatio = parseFloat(document.getElementById('scaling-ratio').value);
549
+ gravity = parseFloat(document.getElementById('gravity').value);
550
+ }
551
+
476
552
  const strongGravity = document.getElementById('strong-gravity').checked;
477
553
  const dissuadeHubs = document.getElementById('dissuade-hubs').checked;
478
554
  const linlog = document.getElementById('linlog').checked;
@@ -489,12 +565,12 @@
489
565
  gravity,
490
566
  false, // distributedAction
491
567
  strongGravity,
492
- currentGraph.masses, // nodeMass
568
+ null, // nodeMass
493
569
  null, // nodeSize
494
570
  null, // weight
495
571
  dissuadeHubs,
496
572
  linlog,
497
- currentSeed
573
+ Date.now()
498
574
  );
499
575
 
500
576
  visualizeGraph(currentGraph, positions);
@@ -510,6 +586,24 @@
510
586
  };
511
587
  });
512
588
 
589
+ // Auto-config checkbox handler
590
+ document.getElementById('auto-configure').onchange = function() {
591
+ const groups = ['scaling-group', 'gravity-group'];
592
+ groups.forEach(groupId => {
593
+ const group = document.getElementById(groupId);
594
+ group.style.opacity = this.checked ? '0.5' : '1';
595
+ });
596
+
597
+ if (this.checked && currentConfig) {
598
+ document.getElementById('iterations').value = currentConfig.iterations;
599
+ document.getElementById('iterations-value').textContent = currentConfig.iterations;
600
+ document.getElementById('scaling-ratio').value = currentConfig.scalingRatio;
601
+ document.getElementById('scaling-ratio-value').textContent = currentConfig.scalingRatio.toFixed(1);
602
+ document.getElementById('gravity').value = currentConfig.gravity;
603
+ document.getElementById('gravity-value').textContent = currentConfig.gravity.toFixed(1);
604
+ }
605
+ };
606
+
513
607
  // Remove auto-update on change for these controls
514
608
  document.getElementById('graph-type').onchange = null;
515
609
 
@@ -114,12 +114,11 @@
114
114
  </div>
115
115
 
116
116
  <div class="control-group">
117
- <label for="edge-type">Connection type:</label>
118
- <select id="edge-type">
119
- <option value="adjacent">Adjacent layers</option>
120
- <option value="random">Random between layers</option>
121
- <option value="hierarchical">Hierarchical</option>
122
- <option value="complete">Complete</option>
117
+ <label for="graph-type">Graph type:</label>
118
+ <select id="graph-type">
119
+ <option value="scale-free">Scale-Free (automatic layers)</option>
120
+ <option value="tree">Tree-like</option>
121
+ <option value="custom-layers">Custom Layers</option>
123
122
  </select>
124
123
  </div>
125
124
 
@@ -141,63 +140,75 @@
141
140
  </select>
142
141
  </div>
143
142
 
143
+ <div class="control-group">
144
+ <label for="layer-method">Layer assignment:</label>
145
+ <select id="layer-method">
146
+ <option value="bfs">BFS (distance-based)</option>
147
+ <option value="degree">Degree-based</option>
148
+ <option value="community">Community-based</option>
149
+ </select>
150
+ </div>
151
+
144
152
  <button onclick="applyLayout()">Apply Layout</button>
145
153
  <div id="layers-info" class="layers-info"></div>
146
154
  </div>
147
155
  </div>
148
156
 
149
157
  <script type="module">
150
- import { multipartiteLayout } from '../dist/layout.js';
158
+ import {
159
+ multipartiteLayout,
160
+ scaleFreeGraph,
161
+ starGraph,
162
+ randomGraph,
163
+ groupNodes,
164
+ findBestRoot
165
+ } from '../dist/layout.js';
151
166
 
152
167
  let currentGraph = null;
168
+ let currentLayers = null;
153
169
 
154
- function generateMultipartiteGraph(numLayers, nodesPerLayer, edgeType) {
155
- const layers = {};
156
- const allNodes = [];
157
-
158
- // Create layers
159
- for (let layer = 0; layer < numLayers; layer++) {
160
- const layerNodes = [];
161
- for (let node = 0; node < nodesPerLayer; node++) {
162
- const nodeName = `L${layer}N${node}`;
163
- layerNodes.push(nodeName);
164
- allNodes.push(nodeName);
165
- }
166
- layers[layer] = layerNodes;
167
- }
168
-
169
- const edges = [];
170
+ function generateGraph(type, numLayers, nodesPerLayer) {
171
+ const totalNodes = numLayers * nodesPerLayer;
170
172
 
171
- // Generate edges based on type
172
- switch(edgeType) {
173
- case 'adjacent':
174
- // Connect only adjacent layers
173
+ switch(type) {
174
+ case 'scale-free':
175
+ // Generate scale-free network
176
+ return scaleFreeGraph(totalNodes, 2, Date.now());
177
+
178
+ case 'tree':
179
+ // Generate tree-like structure
180
+ return starGraph(totalNodes);
181
+
182
+ case 'custom-layers':
183
+ default:
184
+ // Generate custom layered graph
185
+ const nodes = [];
186
+ const edges = [];
187
+
188
+ // Create nodes
189
+ for (let layer = 0; layer < numLayers; layer++) {
190
+ for (let node = 0; node < nodesPerLayer; node++) {
191
+ nodes.push(`L${layer}N${node}`);
192
+ }
193
+ }
194
+
195
+ // Connect adjacent layers
175
196
  for (let layer = 0; layer < numLayers - 1; layer++) {
176
- const currentLayer = layers[layer];
177
- const nextLayer = layers[layer + 1];
178
-
179
- currentLayer.forEach(node1 => {
180
- nextLayer.forEach(node2 => {
181
- if (Math.random() < 0.4) { // 40% probability
182
- edges.push([node1, node2]);
197
+ for (let n1 = 0; n1 < nodesPerLayer; n1++) {
198
+ for (let n2 = 0; n2 < nodesPerLayer; n2++) {
199
+ if (Math.random() < 0.3) {
200
+ edges.push([
201
+ `L${layer}N${n1}`,
202
+ `L${layer + 1}N${n2}`
203
+ ]);
183
204
  }
184
- });
185
- });
186
- }
187
- break;
188
-
189
- case 'random':
190
- // Random connections between all layers
191
- for (let i = 0; i < allNodes.length * 0.8; i++) {
192
- const node1 = allNodes[Math.floor(Math.random() * allNodes.length)];
193
- const node2 = allNodes[Math.floor(Math.random() * allNodes.length)];
194
-
195
- // Avoid self-loops and duplicates
196
- if (node1 !== node2 && !edges.some(([s, t]) =>
197
- (s === node1 && t === node2) || (s === node2 && t === node1))) {
198
- edges.push([node1, node2]);
205
+ }
199
206
  }
200
207
  }
208
+
209
+ return { nodes: () => nodes, edges: () => edges };
210
+ }
211
+ }
201
212
  break;
202
213
 
203
214
  case 'hierarchical':
@@ -263,7 +274,8 @@
263
274
 
264
275
  // Draw layer guidelines
265
276
  const layerColors = ['#F44336', '#9C27B0', '#3F51B5', '#009688', '#FF9800'];
266
- Object.entries(graph.layers).forEach(([layerIdx, layerNodes]) => {
277
+ if (currentLayers) {
278
+ currentLayers.forEach((layerNodes, layerIdx) => {
267
279
  const layerPositions = layerNodes.map(node => positions[node]);
268
280
 
269
281
  if (layerPositions.length > 1) {
@@ -291,7 +303,8 @@
291
303
  rect.setAttribute('stroke-dasharray', '5,5');
292
304
  svg.appendChild(rect);
293
305
  }
294
- });
306
+ });
307
+ }
295
308
 
296
309
  // Draw edges
297
310
  const edges = graph.edges();
@@ -117,10 +117,12 @@
117
117
  <div class="control-group">
118
118
  <label for="shell-pattern">Shell pattern:</label>
119
119
  <select id="shell-pattern">
120
- <option value="auto">Automatic</option>
121
- <option value="centered">Center + Ring</option>
122
- <option value="triple">Three Rings</option>
123
- <option value="nested">Nested Rings</option>
120
+ <option value="degree">By Degree (hubs in center)</option>
121
+ <option value="k-core">By K-Core (density)</option>
122
+ <option value="bfs">By Distance (BFS)</option>
123
+ <option value="community">By Community</option>
124
+ <option value="manual-center">Manual: Center + Ring</option>
125
+ <option value="manual-triple">Manual: Three Rings</option>
124
126
  </select>
125
127
  </div>
126
128
 
@@ -129,46 +131,64 @@
129
131
  </div>
130
132
 
131
133
  <script type="module">
132
- import { shellLayout } from '../dist/layout.js';
134
+ import {
135
+ shellLayout,
136
+ scaleFreeGraph,
137
+ randomGraph,
138
+ groupNodes,
139
+ findBestRoot
140
+ } from '../dist/layout.js';
133
141
 
134
142
  let currentGraph = null;
135
143
 
136
144
  function generateGraph(numNodes) {
137
- const nodes = Array.from({length: numNodes}, (_, i) => i);
138
- const edges = [];
139
-
140
- // Generate simple connected graph
141
- for(let i = 1; i < numNodes; i++) {
142
- edges.push([Math.floor(Math.random() * i), i]);
145
+ // Use scale-free graph for interesting shell patterns
146
+ if (numNodes < 50) {
147
+ return scaleFreeGraph(numNodes, 2, Date.now());
148
+ } else {
149
+ // For larger graphs, use random graph
150
+ return randomGraph(numNodes, 0.05, Date.now());
143
151
  }
144
-
145
- return { nodes: () => nodes, edges: () => edges };
146
152
  }
147
153
 
148
- function getShellConfiguration(pattern, numNodes) {
154
+ function getShellConfiguration(pattern, graph) {
155
+ const numNodes = graph.nodes().length;
156
+
149
157
  switch(pattern) {
150
- case 'centered':
151
- return [[0], Array.from({length: numNodes - 1}, (_, i) => i + 1)];
152
- case 'triple':
158
+ case 'degree':
159
+ // Group by degree - hubs in center
160
+ return groupNodes(graph, 'degree', Math.min(4, Math.ceil(numNodes / 10)));
161
+
162
+ case 'k-core':
163
+ // Group by k-core decomposition
164
+ return groupNodes(graph, 'k-core');
165
+
166
+ case 'bfs':
167
+ // Group by distance from best root
168
+ const root = findBestRoot(graph);
169
+ return groupNodes(graph, 'bfs', 0, { root });
170
+
171
+ case 'community':
172
+ // Group by detected communities
173
+ return groupNodes(graph, 'community', Math.min(5, Math.ceil(numNodes / 20)));
174
+
175
+ case 'manual-center':
176
+ // Manual: center node + outer ring
177
+ const nodes = graph.nodes();
178
+ return [[nodes[0]], nodes.slice(1)];
179
+
180
+ case 'manual-triple':
181
+ // Manual: three equal rings
153
182
  const third = Math.floor(numNodes / 3);
183
+ const allNodes = graph.nodes();
154
184
  return [
155
- Array.from({length: third}, (_, i) => i),
156
- Array.from({length: third}, (_, i) => i + third),
157
- Array.from({length: numNodes - 2 * third}, (_, i) => i + 2 * third)
185
+ allNodes.slice(0, third),
186
+ allNodes.slice(third, 2 * third),
187
+ allNodes.slice(2 * third)
158
188
  ];
159
- case 'nested':
160
- const shells = [];
161
- let remaining = numNodes;
162
- let start = 0;
163
- while(remaining > 0) {
164
- const shellSize = Math.min(Math.ceil(remaining / 2), remaining);
165
- shells.push(Array.from({length: shellSize}, (_, i) => i + start));
166
- start += shellSize;
167
- remaining -= shellSize;
168
- }
169
- return shells;
189
+
170
190
  default:
171
- return null; // Auto-configuration
191
+ return groupNodes(graph, 'degree', 3);
172
192
  }
173
193
  }
174
194
 
@@ -262,9 +282,8 @@
262
282
  if(!currentGraph) return;
263
283
 
264
284
  const pattern = document.getElementById('shell-pattern').value;
265
- const numNodes = currentGraph.nodes().length;
266
285
 
267
- const shells = getShellConfiguration(pattern, numNodes);
286
+ const shells = getShellConfiguration(pattern, currentGraph);
268
287
  const positions = shellLayout(currentGraph, shells, 1, [0, 0], 2);
269
288
 
270
289
  visualizeGraph(currentGraph, positions);
@@ -274,7 +274,17 @@
274
274
  if(!currentGraph) return;
275
275
 
276
276
  const iterations = parseInt(document.getElementById('iterations').value);
277
- const k = parseFloat(document.getElementById('k-value').value);
277
+ const autoConfig = document.getElementById('auto-config').checked;
278
+
279
+ let k;
280
+ if (autoConfig && currentConfig) {
281
+ k = currentConfig.k;
282
+ // Update slider to show auto-configured value
283
+ document.getElementById('k-value').value = k;
284
+ document.getElementById('k-value-value').textContent = k.toFixed(2);
285
+ } else {
286
+ k = parseFloat(document.getElementById('k-value').value);
287
+ }
278
288
 
279
289
  const positions = springLayout(currentGraph, k, null, null, iterations, 1, [0, 0], 2);
280
290