@graphty/layout 1.1.1 → 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.
@@ -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>
@@ -137,7 +149,7 @@
137
149
  randomGraph,
138
150
  groupNodes,
139
151
  findBestRoot
140
- } from '../dist/layout.js';
152
+ } from "./layout.js";
141
153
 
142
154
  let currentGraph = null;
143
155
 
@@ -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
 
@@ -308,4 +320,4 @@
308
320
  newGraph();
309
321
  </script>
310
322
  </body>
311
- </html>
323
+ </html>
package/layout-helpers.ts CHANGED
@@ -545,7 +545,8 @@ function groupByCommunity(nodes: Node[], edges: Edge[], targetGroups: number): N
545
545
  if (!improved) break;
546
546
 
547
547
  // Count communities
548
- numCommunities = new Set(communities.values()).size;
548
+ const currentNumCommunities = new Set(communities.values()).size;
549
+ numCommunities = currentNumCommunities;
549
550
  }
550
551
 
551
552
  // Group nodes by community
package/layout.ts CHANGED
@@ -245,12 +245,12 @@ function randomLayout(G: Graph, center: number[] | null = null, dim: number = 2,
245
245
  }
246
246
 
247
247
  /**
248
- * Position nodes on a circle.
248
+ * Position nodes on a circle (2D) or sphere (3D).
249
249
  *
250
250
  * @param G - Graph or list of nodes
251
251
  * @param scale - Scale factor for positions
252
252
  * @param center - Coordinate pair around which to center the layout
253
- * @param dim - Dimension of layout (currently only supports dim=2)
253
+ * @param dim - Dimension of layout (supports 2D circle or 3D sphere)
254
254
  * @returns Positions dictionary keyed by node
255
255
  */
256
256
  function circularLayout(G: Graph, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
@@ -273,14 +273,41 @@ function circularLayout(G: Graph, scale: number = 1, center: number[] | null = n
273
273
  return pos;
274
274
  }
275
275
 
276
- // Calculate positions on a circle
277
- const theta = np.linspace(0, 2 * Math.PI, nodes.length + 1).slice(0, -1);
276
+ if (dim === 2) {
277
+ // 2D circle layout
278
+ const theta = np.linspace(0, 2 * Math.PI, nodes.length + 1).slice(0, -1);
278
279
 
279
- nodes.forEach((node: Node, i: number) => {
280
- const x: number = Math.cos(theta[i]) * scale + center[0];
281
- const y: number = Math.sin(theta[i]) * scale + center[1];
282
- pos[node] = Array(dim).fill(0).map((_, j: number) => j === 0 ? x : j === 1 ? y : 0);
283
- });
280
+ nodes.forEach((node: Node, i: number) => {
281
+ const x: number = Math.cos(theta[i]) * scale + center[0];
282
+ const y: number = Math.sin(theta[i]) * scale + center[1];
283
+ pos[node] = [x, y];
284
+ });
285
+ } else if (dim === 3) {
286
+ // 3D sphere layout using Fibonacci spiral
287
+ const n = nodes.length;
288
+ const goldenRatio = (1 + Math.sqrt(5)) / 2;
289
+
290
+ nodes.forEach((node: Node, i: number) => {
291
+ // Use Fibonacci spiral for even distribution on sphere
292
+ const theta = 2 * Math.PI * i / goldenRatio;
293
+ const phi = Math.acos(1 - 2 * (i + 0.5) / n);
294
+
295
+ const x = Math.sin(phi) * Math.cos(theta) * scale + center[0];
296
+ const y = Math.sin(phi) * Math.sin(theta) * scale + center[1];
297
+ const z = Math.cos(phi) * scale + center[2];
298
+
299
+ pos[node] = [x, y, z];
300
+ });
301
+ } else {
302
+ // For higher dimensions, fall back to random on hypersphere
303
+ const rng = new RandomNumberGenerator();
304
+ nodes.forEach((node: Node) => {
305
+ // Generate random point on unit hypersphere
306
+ const coords = Array(dim).fill(0).map(() => rng.rand() as number * 2 - 1);
307
+ const norm = Math.sqrt(coords.reduce((sum, c) => sum + c * c, 0));
308
+ pos[node] = coords.map((c, j) => c / norm * scale + center[j]);
309
+ });
310
+ }
284
311
 
285
312
  return pos;
286
313
  }
@@ -1615,10 +1642,9 @@ function kamadaKawaiLayout(
1615
1642
 
1616
1643
  // Initialize positions if not provided
1617
1644
  if (!pos) {
1618
- if (dim >= 3) {
1619
- pos = randomLayout(G, null, dim);
1620
- } else if (dim === 2) {
1621
- pos = circularLayout(G, 1, [0, 0], dim);
1645
+ if (dim >= 2) {
1646
+ // Use circular/spherical layout for 2D and 3D
1647
+ pos = circularLayout(G, 1, center, dim);
1622
1648
  } else {
1623
1649
  // For 1D, use a linear layout
1624
1650
  const posArray: PositionMap = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/layout",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "graph layout algorithms based on networkx",
5
5
  "main": "dist/layout.js",
6
6
  "type": "module",
@@ -14,9 +14,12 @@
14
14
  "test:coverage": "vitest run --coverage",
15
15
  "prepare": "husky",
16
16
  "build": "tsc",
17
+ "build:examples": "npm run build && cp dist/layout.js examples/layout.js",
17
18
  "watch": "tsc --watch",
18
19
  "dev": "tsc --watch",
19
- "commit": "cz"
20
+ "commit": "cz",
21
+ "serve": "npm run build:examples && vite",
22
+ "examples": "npm run build:examples && vite"
20
23
  },
21
24
  "repository": {
22
25
  "type": "git",
@@ -7,7 +7,7 @@ import {
7
7
  wheelGraph,
8
8
  gridGraph,
9
9
  randomGraph
10
- } from '../layout.ts';
10
+ } from '../layout.js';
11
11
 
12
12
  describe('Circular Layout', () => {
13
13
  describe('Basic functionality', () => {
@@ -172,8 +172,8 @@ describe('Circular Layout', () => {
172
172
  const positions3D = circularLayout(graph, 1, [0, 0, 0], 3);
173
173
  graph.nodes().forEach(node => {
174
174
  assert.equal(positions3D[node].length, 3);
175
- // In 3D circular layout, all z-coordinates should be 0
176
- assert.equal(positions3D[node][2], 0);
175
+ // In 3D spherical layout, z-coordinates should vary
176
+ assert.isNumber(positions3D[node][2]);
177
177
  });
178
178
  });
179
179
 
@@ -297,4 +297,146 @@ describe('Circular Layout', () => {
297
297
  }
298
298
  });
299
299
  });
300
+
301
+ describe('3D spherical layout', () => {
302
+ it('should create spherical layout for dim=3', () => {
303
+ const graph = completeGraph(8);
304
+ const positions = circularLayout(graph, 1, [0, 0, 0], 3);
305
+
306
+ assert.equal(Object.keys(positions).length, 8);
307
+ graph.nodes().forEach(node => {
308
+ assert.isDefined(positions[node]);
309
+ assert.equal(positions[node].length, 3);
310
+ assert.isNumber(positions[node][0]);
311
+ assert.isNumber(positions[node][1]);
312
+ assert.isNumber(positions[node][2]);
313
+ });
314
+ });
315
+
316
+ it('should place all nodes on unit sphere', () => {
317
+ const graph = cycleGraph(12);
318
+ const positions = circularLayout(graph, 1, [0, 0, 0], 3);
319
+
320
+ graph.nodes().forEach(node => {
321
+ const [x, y, z] = positions[node];
322
+ const radius = Math.sqrt(x * x + y * y + z * z);
323
+ assert.approximately(radius, 1, 1e-10, `Node ${node} should be on unit sphere`);
324
+ });
325
+ });
326
+
327
+ it('should respect scale in 3D', () => {
328
+ const graph = starGraph(6);
329
+ const scale = 2.5;
330
+ const positions = circularLayout(graph, scale, [0, 0, 0], 3);
331
+
332
+ graph.nodes().forEach(node => {
333
+ const [x, y, z] = positions[node];
334
+ const radius = Math.sqrt(x * x + y * y + z * z);
335
+ assert.approximately(radius, scale, 1e-10);
336
+ });
337
+ });
338
+
339
+ it('should respect center in 3D', () => {
340
+ const graph = completeGraph(4);
341
+ const center = [10, -5, 7];
342
+ const positions = circularLayout(graph, 1, center, 3);
343
+
344
+ graph.nodes().forEach(node => {
345
+ const [x, y, z] = positions[node];
346
+ const dx = x - center[0];
347
+ const dy = y - center[1];
348
+ const dz = z - center[2];
349
+ const radius = Math.sqrt(dx * dx + dy * dy + dz * dz);
350
+ assert.approximately(radius, 1, 1e-10);
351
+ });
352
+ });
353
+
354
+ it('should distribute nodes evenly on sphere using Fibonacci spiral', () => {
355
+ const graph = completeGraph(20);
356
+ const positions = circularLayout(graph, 1, [0, 0, 0], 3);
357
+
358
+ // Check that no two nodes are too close together
359
+ const nodes = graph.nodes();
360
+ const minDistance = 0.3; // Reasonable minimum distance for 20 nodes on unit sphere
361
+
362
+ for (let i = 0; i < nodes.length; i++) {
363
+ for (let j = i + 1; j < nodes.length; j++) {
364
+ const pos1 = positions[nodes[i]];
365
+ const pos2 = positions[nodes[j]];
366
+ const dx = pos1[0] - pos2[0];
367
+ const dy = pos1[1] - pos2[1];
368
+ const dz = pos1[2] - pos2[2];
369
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
370
+ assert.isAbove(distance, minDistance, `Nodes ${i} and ${j} are too close`);
371
+ }
372
+ }
373
+ });
374
+
375
+ it('should handle single node in 3D', () => {
376
+ const singleNode = { nodes: () => ['A'], edges: () => [] };
377
+ const center = [1, 2, 3];
378
+ const positions = circularLayout(singleNode, 1, center, 3);
379
+
380
+ assert.equal(Object.keys(positions).length, 1);
381
+ assert.deepEqual(positions['A'], center);
382
+ });
383
+
384
+ it('should produce deterministic results in 3D', () => {
385
+ const graph = cycleGraph(10);
386
+
387
+ const positions1 = circularLayout(graph, 1, [0, 0, 0], 3);
388
+ const positions2 = circularLayout(graph, 1, [0, 0, 0], 3);
389
+
390
+ graph.nodes().forEach(node => {
391
+ assert.deepEqual(positions1[node], positions2[node]);
392
+ });
393
+ });
394
+
395
+ it('should handle higher dimensions gracefully', () => {
396
+ const graph = completeGraph(5);
397
+ const positions = circularLayout(graph, 1, [0, 0, 0, 0], 4);
398
+
399
+ assert.equal(Object.keys(positions).length, 5);
400
+ graph.nodes().forEach(node => {
401
+ assert.equal(positions[node].length, 4);
402
+ // Should be on unit hypersphere
403
+ const radius = Math.sqrt(positions[node].reduce((sum, coord) => sum + coord * coord, 0));
404
+ assert.approximately(radius, 1, 0.1); // Allow some tolerance for random hypersphere
405
+ });
406
+ });
407
+
408
+ it('should use all three dimensions meaningfully', () => {
409
+ const graph = completeGraph(30);
410
+ const positions = circularLayout(graph, 1, [0, 0, 0], 3);
411
+
412
+ // Extract all z-coordinates
413
+ const zCoords = graph.nodes().map(node => positions[node][2]);
414
+
415
+ // Check that z-coordinates have meaningful variation
416
+ const minZ = Math.min(...zCoords);
417
+ const maxZ = Math.max(...zCoords);
418
+ const zRange = maxZ - minZ;
419
+
420
+ assert.isAbove(zRange, 1.5, 'Z-coordinates should have significant variation');
421
+ assert.approximately(maxZ, 1, 0.1, 'Max Z should be near 1');
422
+ assert.approximately(minZ, -1, 0.1, 'Min Z should be near -1');
423
+ });
424
+
425
+ it('should produce valid 3D layout for string node IDs', () => {
426
+ const graph = {
427
+ nodes: () => ['alice', 'bob', 'charlie', 'david', 'eve'],
428
+ edges: () => [['alice', 'bob'], ['bob', 'charlie'], ['charlie', 'david'],
429
+ ['david', 'eve'], ['eve', 'alice']]
430
+ };
431
+
432
+ const positions = circularLayout(graph, 1, [0, 0, 0], 3);
433
+
434
+ assert.equal(Object.keys(positions).length, 5);
435
+ graph.nodes().forEach(node => {
436
+ const [x, y, z] = positions[node];
437
+ const radius = Math.sqrt(x * x + y * y + z * z);
438
+ assert.approximately(radius, 1, 1e-10);
439
+ });
440
+ });
441
+ });
300
442
  });