@graphty/layout 1.1.0 → 1.2.0

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 (57) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +89 -14
  3. package/.releaserc.json +22 -0
  4. package/CHANGELOG.md +31 -0
  5. package/CLAUDE.md +104 -0
  6. package/CONTRIBUTING.md +1 -0
  7. package/DEPLOYMENT.md +59 -0
  8. package/README.md +662 -93
  9. package/dist/layout-helpers.d.ts +123 -0
  10. package/dist/layout-helpers.js +458 -0
  11. package/dist/layout-helpers.js.map +1 -0
  12. package/dist/layout.d.ts +275 -0
  13. package/dist/layout.js +2304 -0
  14. package/dist/layout.js.map +1 -0
  15. package/dist/vitest.config.d.ts +2 -0
  16. package/dist/vitest.config.js +30 -0
  17. package/dist/vitest.config.js.map +1 -0
  18. package/examples/3d-force-directed.html +611 -0
  19. package/examples/3d-kamada-kawai.html +394 -0
  20. package/examples/3d-layout-comparison.html +448 -0
  21. package/examples/3d-spherical-layout.html +319 -0
  22. package/examples/arf-layout.html +13 -1
  23. package/examples/bfs-layout.html +49 -39
  24. package/examples/bipartite-layout.html +89 -69
  25. package/examples/circular-layout.html +26 -35
  26. package/examples/forceatlas2-layout.html +134 -28
  27. package/examples/index.html +75 -0
  28. package/examples/kamada-kawai-layout.html +13 -1
  29. package/examples/multipartite-layout.html +73 -88
  30. package/examples/planar-layout.html +13 -1
  31. package/examples/random-layout.html +13 -1
  32. package/examples/shell-layout.html +65 -34
  33. package/examples/spectral-layout.html +13 -1
  34. package/examples/spiral-layout.html +13 -1
  35. package/examples/spring-layout.html +25 -3
  36. package/layout-helpers.ts +560 -0
  37. package/layout.ts +316 -14
  38. package/package.json +20 -6
  39. package/test/arf-layout.test.ts +443 -0
  40. package/test/bfs-layout.test.ts +427 -0
  41. package/test/bipartite-layout.test.ts +344 -0
  42. package/test/circular-layout.test.ts +442 -0
  43. package/test/forceatlas2-layout.test.ts +405 -0
  44. package/test/fruchterman-reingold-layout.test.ts +477 -0
  45. package/test/graph-generators.test.ts +450 -0
  46. package/test/kamada-kawai-layout.test.ts +623 -0
  47. package/test/multipartite-layout.test.ts +404 -0
  48. package/test/planar-layout.test.ts +266 -0
  49. package/test/random-layout.test.ts +254 -0
  50. package/test/rescale-layout.test.ts +373 -0
  51. package/test/shell-layout.test.ts +347 -0
  52. package/test/spectral-layout.test.ts +378 -0
  53. package/test/spiral-layout.test.ts +338 -0
  54. package/test/spring-layout.test.ts +241 -0
  55. package/vite.config.js +36 -0
  56. package/vitest.config.ts +30 -0
  57. package/.releaserc +0 -3
@@ -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;
@@ -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>Multipartite Layout Test</title>
@@ -114,12 +126,11 @@
114
126
  </div>
115
127
 
116
128
  <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>
129
+ <label for="graph-type">Graph type:</label>
130
+ <select id="graph-type">
131
+ <option value="scale-free">Scale-Free (automatic layers)</option>
132
+ <option value="tree">Tree-like</option>
133
+ <option value="custom-layers">Custom Layers</option>
123
134
  </select>
124
135
  </div>
125
136
 
@@ -141,102 +152,74 @@
141
152
  </select>
142
153
  </div>
143
154
 
155
+ <div class="control-group">
156
+ <label for="layer-method">Layer assignment:</label>
157
+ <select id="layer-method">
158
+ <option value="bfs">BFS (distance-based)</option>
159
+ <option value="degree">Degree-based</option>
160
+ <option value="community">Community-based</option>
161
+ </select>
162
+ </div>
163
+
144
164
  <button onclick="applyLayout()">Apply Layout</button>
145
165
  <div id="layers-info" class="layers-info"></div>
146
166
  </div>
147
167
  </div>
148
168
 
149
169
  <script type="module">
150
- import { multipartiteLayout } from '../dist/layout.js';
170
+ import {
171
+ multipartiteLayout,
172
+ scaleFreeGraph,
173
+ starGraph,
174
+ randomGraph,
175
+ groupNodes,
176
+ findBestRoot
177
+ } from "./layout.js";
151
178
 
152
179
  let currentGraph = null;
180
+ let currentLayers = null;
153
181
 
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
- }
182
+ function generateGraph(type, numLayers, nodesPerLayer) {
183
+ const totalNodes = numLayers * nodesPerLayer;
168
184
 
169
- const edges = [];
170
-
171
- // Generate edges based on type
172
- switch(edgeType) {
173
- case 'adjacent':
174
- // Connect only adjacent layers
175
- 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]);
183
- }
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]);
185
+ switch(type) {
186
+ case 'scale-free':
187
+ // Generate scale-free network
188
+ return scaleFreeGraph(totalNodes, 2, Date.now());
189
+
190
+ case 'tree':
191
+ // Generate tree-like structure
192
+ return starGraph(totalNodes);
193
+
194
+ case 'custom-layers':
195
+ default:
196
+ // Generate custom layered graph
197
+ const nodes = [];
198
+ const edges = [];
199
+
200
+ // Create nodes
201
+ for (let layer = 0; layer < numLayers; layer++) {
202
+ for (let node = 0; node < nodesPerLayer; node++) {
203
+ nodes.push(`L${layer}N${node}`);
199
204
  }
200
205
  }
201
- break;
202
-
203
- case 'hierarchical':
204
- // Hierarchical connections (each node connects to nodes in the next layer)
206
+
207
+ // Connect adjacent layers
205
208
  for (let layer = 0; layer < numLayers - 1; layer++) {
206
- const currentLayer = layers[layer];
207
- const nextLayer = layers[layer + 1];
208
-
209
- currentLayer.forEach((node1, idx) => {
210
- // Connect to 1-2 nodes in the next layer
211
- const connections = Math.min(2, nextLayer.length);
212
- for (let c = 0; c < connections; c++) {
213
- const targetIdx = (idx + c) % nextLayer.length;
214
- edges.push([node1, nextLayer[targetIdx]]);
209
+ for (let n1 = 0; n1 < nodesPerLayer; n1++) {
210
+ for (let n2 = 0; n2 < nodesPerLayer; n2++) {
211
+ if (Math.random() < 0.3) {
212
+ edges.push([
213
+ `L${layer}N${n1}`,
214
+ `L${layer + 1}N${n2}`
215
+ ]);
216
+ }
215
217
  }
216
- });
217
- }
218
- break;
219
-
220
- case 'complete':
221
- // Each layer is fully connected to the next
222
- for (let layer = 0; layer < numLayers - 1; layer++) {
223
- const currentLayer = layers[layer];
224
- const nextLayer = layers[layer + 1];
225
-
226
- currentLayer.forEach(node1 => {
227
- nextLayer.forEach(node2 => {
228
- edges.push([node1, node2]);
229
- });
230
- });
218
+ }
231
219
  }
232
- break;
220
+
221
+ return { nodes: () => nodes, edges: () => edges };
233
222
  }
234
-
235
- return {
236
- nodes: () => allNodes,
237
- edges: () => edges,
238
- layers
239
- };
240
223
  }
241
224
 
242
225
  function visualizeGraph(graph, positions) {
@@ -263,7 +246,8 @@
263
246
 
264
247
  // Draw layer guidelines
265
248
  const layerColors = ['#F44336', '#9C27B0', '#3F51B5', '#009688', '#FF9800'];
266
- Object.entries(graph.layers).forEach(([layerIdx, layerNodes]) => {
249
+ if (currentLayers) {
250
+ currentLayers.forEach((layerNodes, layerIdx) => {
267
251
  const layerPositions = layerNodes.map(node => positions[node]);
268
252
 
269
253
  if (layerPositions.length > 1) {
@@ -291,7 +275,8 @@
291
275
  rect.setAttribute('stroke-dasharray', '5,5');
292
276
  svg.appendChild(rect);
293
277
  }
294
- });
278
+ });
279
+ }
295
280
 
296
281
  // Draw edges
297
282
  const edges = graph.edges();
@@ -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>Planar Layout Test</title>
@@ -161,7 +173,7 @@
161
173
  </div>
162
174
 
163
175
  <script type="module">
164
- import { planarLayout } from '../dist/layout.js';
176
+ import { planarLayout } from "./layout.js";
165
177
 
166
178
  let currentGraph = null;
167
179
 
@@ -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>Random Layout Test</title>
@@ -142,7 +154,7 @@
142
154
  </div>
143
155
 
144
156
  <script type="module">
145
- import { randomLayout } from '../dist/layout.js';
157
+ import { randomLayout } from "./layout.js";
146
158
 
147
159
  let currentGraph = null;
148
160
 
@@ -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>Shell Layout Test</title>
@@ -117,10 +129,12 @@
117
129
  <div class="control-group">
118
130
  <label for="shell-pattern">Shell pattern:</label>
119
131
  <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>
132
+ <option value="degree">By Degree (hubs in center)</option>
133
+ <option value="k-core">By K-Core (density)</option>
134
+ <option value="bfs">By Distance (BFS)</option>
135
+ <option value="community">By Community</option>
136
+ <option value="manual-center">Manual: Center + Ring</option>
137
+ <option value="manual-triple">Manual: Three Rings</option>
124
138
  </select>
125
139
  </div>
126
140
 
@@ -129,46 +143,64 @@
129
143
  </div>
130
144
 
131
145
  <script type="module">
132
- import { shellLayout } from '../dist/layout.js';
146
+ import {
147
+ shellLayout,
148
+ scaleFreeGraph,
149
+ randomGraph,
150
+ groupNodes,
151
+ findBestRoot
152
+ } from "./layout.js";
133
153
 
134
154
  let currentGraph = null;
135
155
 
136
156
  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]);
157
+ // Use scale-free graph for interesting shell patterns
158
+ if (numNodes < 50) {
159
+ return scaleFreeGraph(numNodes, 2, Date.now());
160
+ } else {
161
+ // For larger graphs, use random graph
162
+ return randomGraph(numNodes, 0.05, Date.now());
143
163
  }
144
-
145
- return { nodes: () => nodes, edges: () => edges };
146
164
  }
147
165
 
148
- function getShellConfiguration(pattern, numNodes) {
166
+ function getShellConfiguration(pattern, graph) {
167
+ const numNodes = graph.nodes().length;
168
+
149
169
  switch(pattern) {
150
- case 'centered':
151
- return [[0], Array.from({length: numNodes - 1}, (_, i) => i + 1)];
152
- case 'triple':
170
+ case 'degree':
171
+ // Group by degree - hubs in center
172
+ return groupNodes(graph, 'degree', Math.min(4, Math.ceil(numNodes / 10)));
173
+
174
+ case 'k-core':
175
+ // Group by k-core decomposition
176
+ return groupNodes(graph, 'k-core');
177
+
178
+ case 'bfs':
179
+ // Group by distance from best root
180
+ const root = findBestRoot(graph);
181
+ return groupNodes(graph, 'bfs', 0, { root });
182
+
183
+ case 'community':
184
+ // Group by detected communities
185
+ return groupNodes(graph, 'community', Math.min(5, Math.ceil(numNodes / 20)));
186
+
187
+ case 'manual-center':
188
+ // Manual: center node + outer ring
189
+ const nodes = graph.nodes();
190
+ return [[nodes[0]], nodes.slice(1)];
191
+
192
+ case 'manual-triple':
193
+ // Manual: three equal rings
153
194
  const third = Math.floor(numNodes / 3);
195
+ const allNodes = graph.nodes();
154
196
  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)
197
+ allNodes.slice(0, third),
198
+ allNodes.slice(third, 2 * third),
199
+ allNodes.slice(2 * third)
158
200
  ];
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;
201
+
170
202
  default:
171
- return null; // Auto-configuration
203
+ return groupNodes(graph, 'degree', 3);
172
204
  }
173
205
  }
174
206
 
@@ -262,9 +294,8 @@
262
294
  if(!currentGraph) return;
263
295
 
264
296
  const pattern = document.getElementById('shell-pattern').value;
265
- const numNodes = currentGraph.nodes().length;
266
297
 
267
- const shells = getShellConfiguration(pattern, numNodes);
298
+ const shells = getShellConfiguration(pattern, currentGraph);
268
299
  const positions = shellLayout(currentGraph, shells, 1, [0, 0], 2);
269
300
 
270
301
  visualizeGraph(currentGraph, positions);
@@ -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>Spectral Layout Test</title>
@@ -135,7 +147,7 @@
135
147
  </div>
136
148
 
137
149
  <script type="module">
138
- import { spectralLayout } from '../dist/layout.js';
150
+ import { spectralLayout } from "./layout.js";
139
151
 
140
152
  let currentGraph = null;
141
153
 
@@ -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>Spiral Layout Test</title>
@@ -171,7 +183,7 @@
171
183
  </div>
172
184
 
173
185
  <script type="module">
174
- import { spiralLayout } from '../dist/layout.js';
186
+ import { spiralLayout } from "./layout.js";
175
187
 
176
188
  let currentGraph = null;
177
189
 
@@ -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>Spring Layout Test</title>
@@ -141,7 +153,7 @@
141
153
  </div>
142
154
 
143
155
  <script type="module">
144
- import { springLayout } from '../dist/layout.js';
156
+ import { springLayout } from "./layout.js";
145
157
 
146
158
  let currentGraph = null;
147
159
 
@@ -274,7 +286,17 @@
274
286
  if(!currentGraph) return;
275
287
 
276
288
  const iterations = parseInt(document.getElementById('iterations').value);
277
- const k = parseFloat(document.getElementById('k-value').value);
289
+ const autoConfig = document.getElementById('auto-config').checked;
290
+
291
+ let k;
292
+ if (autoConfig && currentConfig) {
293
+ k = currentConfig.k;
294
+ // Update slider to show auto-configured value
295
+ document.getElementById('k-value').value = k;
296
+ document.getElementById('k-value-value').textContent = k.toFixed(2);
297
+ } else {
298
+ k = parseFloat(document.getElementById('k-value').value);
299
+ }
278
300
 
279
301
  const positions = springLayout(currentGraph, k, null, null, iterations, 1, [0, 0], 2);
280
302
 
@@ -298,4 +320,4 @@
298
320
  newGraph();
299
321
  </script>
300
322
  </body>
301
- </html>
323
+ </html>