@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
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 = {};
@@ -2570,6 +2596,273 @@ function rescaleLayoutDict(
2570
2596
  return scaledPos;
2571
2597
  }
2572
2598
 
2599
+ // Graph generation utilities
2600
+ /**
2601
+ * Create a complete graph with n nodes
2602
+ * @param n - Number of nodes
2603
+ * @returns Graph object with all nodes connected to all other nodes
2604
+ */
2605
+ function completeGraph(n: number): Graph {
2606
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2607
+ const edges: Edge[] = [];
2608
+
2609
+ for (let i = 0; i < n; i++) {
2610
+ for (let j = i + 1; j < n; j++) {
2611
+ edges.push([i, j]);
2612
+ }
2613
+ }
2614
+
2615
+ return {
2616
+ nodes: () => nodes,
2617
+ edges: () => edges
2618
+ };
2619
+ }
2620
+
2621
+ /**
2622
+ * Create a cycle graph with n nodes
2623
+ * @param n - Number of nodes
2624
+ * @returns Graph object with nodes connected in a cycle
2625
+ */
2626
+ function cycleGraph(n: number): Graph {
2627
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2628
+ const edges: Edge[] = [];
2629
+
2630
+ for (let i = 0; i < n; i++) {
2631
+ edges.push([i, (i + 1) % n]);
2632
+ }
2633
+
2634
+ return {
2635
+ nodes: () => nodes,
2636
+ edges: () => edges
2637
+ };
2638
+ }
2639
+
2640
+ /**
2641
+ * Create a star graph with n nodes (1 center + n-1 leaves)
2642
+ * @param n - Total number of nodes
2643
+ * @returns Graph object with star topology
2644
+ */
2645
+ function starGraph(n: number): Graph {
2646
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2647
+ const edges: Edge[] = [];
2648
+
2649
+ // Connect all nodes to node 0 (center)
2650
+ for (let i = 1; i < n; i++) {
2651
+ edges.push([0, i]);
2652
+ }
2653
+
2654
+ return {
2655
+ nodes: () => nodes,
2656
+ edges: () => edges
2657
+ };
2658
+ }
2659
+
2660
+ /**
2661
+ * Create a wheel graph with n nodes (1 center + n-1 rim nodes)
2662
+ * @param n - Total number of nodes
2663
+ * @returns Graph object with wheel topology
2664
+ */
2665
+ function wheelGraph(n: number): Graph {
2666
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2667
+ const edges: Edge[] = [];
2668
+
2669
+ // Connect all rim nodes to center (node 0)
2670
+ for (let i = 1; i < n; i++) {
2671
+ edges.push([0, i]);
2672
+ }
2673
+
2674
+ // Connect rim nodes in a cycle
2675
+ for (let i = 1; i < n - 1; i++) {
2676
+ edges.push([i, i + 1]);
2677
+ }
2678
+ if (n > 2) {
2679
+ edges.push([n - 1, 1]);
2680
+ }
2681
+
2682
+ return {
2683
+ nodes: () => nodes,
2684
+ edges: () => edges
2685
+ };
2686
+ }
2687
+
2688
+ /**
2689
+ * Create a grid graph with rows x cols nodes
2690
+ * @param rows - Number of rows
2691
+ * @param cols - Number of columns
2692
+ * @returns Graph object with grid topology
2693
+ */
2694
+ function gridGraph(rows: number, cols: number): Graph {
2695
+ const nodes: Node[] = [];
2696
+ const edges: Edge[] = [];
2697
+
2698
+ // Create nodes
2699
+ for (let i = 0; i < rows; i++) {
2700
+ for (let j = 0; j < cols; j++) {
2701
+ nodes.push(`${i},${j}`);
2702
+ }
2703
+ }
2704
+
2705
+ // Create edges
2706
+ for (let i = 0; i < rows; i++) {
2707
+ for (let j = 0; j < cols; j++) {
2708
+ // Connect to right neighbor
2709
+ if (j < cols - 1) {
2710
+ edges.push([`${i},${j}`, `${i},${j + 1}`]);
2711
+ }
2712
+ // Connect to bottom neighbor
2713
+ if (i < rows - 1) {
2714
+ edges.push([`${i},${j}`, `${i + 1},${j}`]);
2715
+ }
2716
+ }
2717
+ }
2718
+
2719
+ return {
2720
+ nodes: () => nodes,
2721
+ edges: () => edges
2722
+ };
2723
+ }
2724
+
2725
+ /**
2726
+ * Create a random graph with n nodes and given edge probability
2727
+ * @param n - Number of nodes
2728
+ * @param p - Probability of edge between any two nodes (0-1)
2729
+ * @param seed - Random seed for reproducibility
2730
+ * @returns Graph object with random edges
2731
+ */
2732
+ function randomGraph(n: number, p: number, seed?: number): Graph {
2733
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2734
+ const edges: Edge[] = [];
2735
+
2736
+ // Simple deterministic pseudo-random if seed provided
2737
+ let currentSeed = seed;
2738
+ let random = seed !== undefined
2739
+ ? () => {
2740
+ currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2741
+ return currentSeed / 233280;
2742
+ }
2743
+ : Math.random;
2744
+
2745
+ for (let i = 0; i < n; i++) {
2746
+ for (let j = i + 1; j < n; j++) {
2747
+ if (random() < p) {
2748
+ edges.push([i, j]);
2749
+ }
2750
+ }
2751
+ }
2752
+
2753
+ return {
2754
+ nodes: () => nodes,
2755
+ edges: () => edges
2756
+ };
2757
+ }
2758
+
2759
+ /**
2760
+ * Create a bipartite graph with two sets of nodes
2761
+ * @param n1 - Number of nodes in first set
2762
+ * @param n2 - Number of nodes in second set
2763
+ * @param p - Probability of edge between nodes in different sets
2764
+ * @param seed - Random seed for reproducibility
2765
+ * @returns Graph object with bipartite structure and setA/setB properties
2766
+ */
2767
+ function bipartiteGraph(n1: number, n2: number, p: number, seed?: number): Graph & { setA: Node[], setB: Node[] } {
2768
+ const setA: Node[] = Array.from({ length: n1 }, (_, i) => `A${i}`);
2769
+ const setB: Node[] = Array.from({ length: n2 }, (_, i) => `B${i}`);
2770
+ const nodes = [...setA, ...setB];
2771
+ const edges: Edge[] = [];
2772
+
2773
+ // Simple deterministic pseudo-random if seed provided
2774
+ let currentSeed = seed;
2775
+ let random = seed !== undefined
2776
+ ? () => {
2777
+ currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2778
+ return currentSeed / 233280;
2779
+ }
2780
+ : Math.random;
2781
+
2782
+ // Only connect nodes between sets
2783
+ for (const a of setA) {
2784
+ for (const b of setB) {
2785
+ if (random() < p) {
2786
+ edges.push([a, b]);
2787
+ }
2788
+ }
2789
+ }
2790
+
2791
+ return {
2792
+ nodes: () => nodes,
2793
+ edges: () => edges,
2794
+ setA,
2795
+ setB
2796
+ };
2797
+ }
2798
+
2799
+ /**
2800
+ * Create a scale-free graph using Barabási-Albert model
2801
+ * @param n - Total number of nodes
2802
+ * @param m - Number of edges to attach from new node
2803
+ * @param seed - Random seed for reproducibility
2804
+ * @returns Graph object with scale-free properties
2805
+ */
2806
+ function scaleFreeGraph(n: number, m: number, seed?: number): Graph {
2807
+ if (m >= n) {
2808
+ throw new Error('m must be less than n');
2809
+ }
2810
+
2811
+ const nodes: Node[] = Array.from({ length: n }, (_, i) => i);
2812
+ const edges: Edge[] = [];
2813
+ const degrees = new Array(n).fill(0);
2814
+
2815
+ // Simple deterministic pseudo-random if seed provided
2816
+ let currentSeed = seed;
2817
+ let random = seed !== undefined
2818
+ ? () => {
2819
+ currentSeed = (currentSeed! * 9301 + 49297) % 233280;
2820
+ return currentSeed / 233280;
2821
+ }
2822
+ : Math.random;
2823
+
2824
+ // Start with complete graph of m+1 nodes
2825
+ for (let i = 0; i <= m; i++) {
2826
+ for (let j = i + 1; j <= m; j++) {
2827
+ edges.push([i, j]);
2828
+ degrees[i]++;
2829
+ degrees[j]++;
2830
+ }
2831
+ }
2832
+
2833
+ // Add remaining nodes
2834
+ for (let i = m + 1; i < n; i++) {
2835
+ const targets = new Set<number>();
2836
+ const totalDegree = degrees.reduce((sum, d) => sum + d, 0);
2837
+
2838
+ // Choose m targets based on preferential attachment
2839
+ while (targets.size < m) {
2840
+ let r = random() * totalDegree;
2841
+ let cumSum = 0;
2842
+
2843
+ for (let j = 0; j < i; j++) {
2844
+ cumSum += degrees[j];
2845
+ if (r <= cumSum && !targets.has(j)) {
2846
+ targets.add(j);
2847
+ break;
2848
+ }
2849
+ }
2850
+ }
2851
+
2852
+ // Add edges to targets
2853
+ for (const target of targets) {
2854
+ edges.push([i, target]);
2855
+ degrees[i]++;
2856
+ degrees[target]++;
2857
+ }
2858
+ }
2859
+
2860
+ return {
2861
+ nodes: () => nodes,
2862
+ edges: () => edges
2863
+ };
2864
+ }
2865
+
2573
2866
  // Export the layout functions
2574
2867
  export {
2575
2868
  randomLayout,
@@ -2587,5 +2880,14 @@ export {
2587
2880
  forceatlas2Layout,
2588
2881
  arfLayout,
2589
2882
  rescaleLayout,
2590
- rescaleLayoutDict
2883
+ rescaleLayoutDict,
2884
+ // Graph generation utilities
2885
+ completeGraph,
2886
+ cycleGraph,
2887
+ starGraph,
2888
+ wheelGraph,
2889
+ gridGraph,
2890
+ randomGraph,
2891
+ bipartiteGraph,
2892
+ scaleFreeGraph
2591
2893
  };
package/package.json CHANGED
@@ -1,18 +1,25 @@
1
1
  {
2
2
  "name": "@graphty/layout",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "graph layout algorithms based on networkx",
5
- "main": "layout.js",
5
+ "main": "dist/layout.js",
6
6
  "type": "module",
7
7
  "directories": {
8
8
  "example": "examples"
9
9
  },
10
10
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1",
11
+ "test": "vitest",
12
+ "test:ui": "vitest --ui",
13
+ "test:run": "vitest run",
14
+ "test:coverage": "vitest run --coverage",
12
15
  "prepare": "husky",
13
16
  "build": "tsc",
17
+ "build:examples": "npm run build && cp dist/layout.js examples/layout.js",
14
18
  "watch": "tsc --watch",
15
- "dev": "tsc --watch"
19
+ "dev": "tsc --watch",
20
+ "commit": "cz",
21
+ "serve": "npm run build:examples && vite",
22
+ "examples": "npm run build:examples && vite"
16
23
  },
17
24
  "repository": {
18
25
  "type": "git",
@@ -37,8 +44,15 @@
37
44
  "devDependencies": {
38
45
  "@commitlint/cli": "^19.8.1",
39
46
  "@commitlint/config-conventional": "^19.8.1",
47
+ "@semantic-release/changelog": "^6.0.3",
48
+ "@semantic-release/git": "^10.0.1",
49
+ "@vitest/coverage-v8": "^3.2.4",
50
+ "@vitest/ui": "^3.2.4",
40
51
  "cz-conventional-changelog": "^3.3.0",
52
+ "happy-dom": "^18.0.1",
41
53
  "husky": "^9.1.7",
42
- "typescript": "^5.3.3"
54
+ "semantic-release": "^24.2.7",
55
+ "typescript": "^5.3.3",
56
+ "vitest": "^3.2.4"
43
57
  }
44
- }
58
+ }