@graphty/layout 1.2.0 → 1.2.2

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 (92) hide show
  1. package/.github/workflows/ci.yml +3 -7
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +1 -1
  4. package/dist/vitest.config.js +2 -2
  5. package/dist/vitest.config.js.map +1 -1
  6. package/examples/3d-kamada-kawai.html +4 -19
  7. package/examples/bfs-layout.html +26 -32
  8. package/examples/bipartite-layout.html +3 -3
  9. package/examples/forceatlas2-layout.html +106 -151
  10. package/{dist → examples}/layout-helpers.js +58 -30
  11. package/examples/multipartite-layout.html +32 -14
  12. package/examples/shell-layout.html +4 -2
  13. package/examples/spring-layout.html +1 -11
  14. package/package.json +3 -3
  15. package/src/algorithms/index.ts +6 -0
  16. package/src/algorithms/optimization/index.ts +12 -0
  17. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  18. package/src/algorithms/optimization/lbfgs.ts +68 -0
  19. package/src/algorithms/optimization/line-search.ts +50 -0
  20. package/src/algorithms/optimization/types.ts +8 -0
  21. package/src/algorithms/planarity/check.ts +38 -0
  22. package/src/algorithms/planarity/embedding.ts +216 -0
  23. package/src/algorithms/planarity/index.ts +12 -0
  24. package/src/algorithms/planarity/lr-test.ts +70 -0
  25. package/src/algorithms/planarity/special-graphs.ts +126 -0
  26. package/src/generators/basic.ts +93 -0
  27. package/src/generators/bipartite.ts +45 -0
  28. package/src/generators/grid.ts +42 -0
  29. package/src/generators/index.ts +15 -0
  30. package/src/generators/random.ts +39 -0
  31. package/src/generators/scale-free.ts +72 -0
  32. package/src/index.ts +18 -0
  33. package/src/layouts/basic/index.ts +5 -0
  34. package/src/layouts/basic/random.ts +32 -0
  35. package/src/layouts/force-directed/arf.ts +130 -0
  36. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  37. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  38. package/src/layouts/force-directed/index.ts +9 -0
  39. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  40. package/src/layouts/force-directed/spring.ts +35 -0
  41. package/src/layouts/geometric/circular.ts +77 -0
  42. package/src/layouts/geometric/index.ts +7 -0
  43. package/src/layouts/geometric/shell.ts +80 -0
  44. package/src/layouts/geometric/spiral.ts +93 -0
  45. package/src/layouts/hierarchical/bfs.ts +81 -0
  46. package/src/layouts/hierarchical/bipartite.ts +94 -0
  47. package/src/layouts/hierarchical/index.ts +7 -0
  48. package/src/layouts/hierarchical/multipartite.ts +88 -0
  49. package/src/layouts/index.ts +9 -0
  50. package/src/layouts/specialized/index.ts +6 -0
  51. package/src/layouts/specialized/planar.ts +65 -0
  52. package/src/layouts/specialized/spectral.ts +128 -0
  53. package/src/types/embedding.ts +11 -0
  54. package/src/types/graph.ts +13 -0
  55. package/src/types/index.ts +7 -0
  56. package/src/types/layout.ts +9 -0
  57. package/src/utils/graph.ts +77 -0
  58. package/src/utils/index.ts +9 -0
  59. package/src/utils/numpy.ts +111 -0
  60. package/src/utils/params.ts +26 -0
  61. package/src/utils/random.ts +53 -0
  62. package/src/utils/rescale.ts +137 -0
  63. package/test/arf-layout.test.ts +1 -1
  64. package/test/bfs-layout.test.ts +1 -1
  65. package/test/bipartite-layout.test.ts +1 -1
  66. package/test/circular-layout.test.ts +1 -1
  67. package/test/forceatlas2-layout.test.ts +1 -1
  68. package/test/fruchterman-reingold-layout.test.ts +1 -1
  69. package/test/graph-generators.test.ts +1 -1
  70. package/test/kamada-kawai-layout.test.ts +1 -1
  71. package/test/multipartite-layout.test.ts +1 -1
  72. package/test/planar-layout.test.ts +1 -1
  73. package/test/random-layout.test.ts +1 -1
  74. package/test/rescale-layout.test.ts +1 -1
  75. package/test/shell-layout.test.ts +1 -1
  76. package/test/spectral-layout.test.ts +1 -1
  77. package/test/spiral-layout.test.ts +1 -1
  78. package/test/spring-layout.test.ts +1 -1
  79. package/test/test-utils.ts +34 -0
  80. package/test/utils-graph.test.ts +155 -0
  81. package/test/utils-index.test.ts +77 -0
  82. package/test/utils-numpy.test.ts +272 -0
  83. package/test/utils-params.test.ts +99 -0
  84. package/test/utils-random.test.ts +229 -0
  85. package/vitest.config.ts +2 -2
  86. package/dist/layout-helpers.d.ts +0 -123
  87. package/dist/layout-helpers.js.map +0 -1
  88. package/dist/layout.d.ts +0 -275
  89. package/dist/layout.js +0 -2304
  90. package/dist/layout.js.map +0 -1
  91. package/layout-helpers.ts +0 -560
  92. package/layout.ts +0 -2893
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Fruchterman-Reingold force-directed layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, Edge, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph, getEdgesFromGraph } from '../../utils/graph';
8
+ import { RandomNumberGenerator } from '../../utils/random';
9
+ import { rescaleLayout } from '../../utils/rescale';
10
+
11
+ /**
12
+ * Position nodes using Fruchterman-Reingold force-directed algorithm.
13
+ *
14
+ * @param {Object} G - Graph or list of nodes
15
+ * @param {number} k - Optimal distance between nodes
16
+ * @param {Object} pos - Initial positions for nodes
17
+ * @param {Array} fixed - Nodes to keep fixed at initial position
18
+ * @param {number} iterations - Maximum number of iterations
19
+ * @param {number} scale - Scale factor for positions
20
+ * @param {Array|null} center - Coordinate pair around which to center the layout
21
+ * @param {number} dim - Dimension of layout
22
+ * @param {number} seed - Random seed for initial positions
23
+ * @returns {Object} Positions dictionary keyed by node
24
+ */
25
+ export function fruchtermanReingoldLayout(
26
+ G: Graph,
27
+ k: number | null = null,
28
+ pos: PositionMap | null = null,
29
+ fixed: Node[] | null = null,
30
+ iterations: number = 50,
31
+ scale: number = 1,
32
+ center: number[] | null = null,
33
+ dim: number = 2,
34
+ seed: number | null = null
35
+ ): PositionMap {
36
+ const processed = _processParams(G, center, dim);
37
+ let graph = processed.G;
38
+ center = processed.center;
39
+
40
+ const nodes = getNodesFromGraph(graph);
41
+ const edges = getEdgesFromGraph(graph);
42
+
43
+ if (nodes.length === 0) {
44
+ return {};
45
+ }
46
+
47
+ if (nodes.length === 1) {
48
+ const singlePos: PositionMap = {};
49
+ singlePos[nodes[0]] = center;
50
+ return singlePos;
51
+ }
52
+
53
+ // Set up initial positions
54
+ let positions: PositionMap = {};
55
+ if (pos) {
56
+ // Use provided positions
57
+ for (const node of nodes) {
58
+ if (pos[node]) {
59
+ positions[node] = [...pos[node]];
60
+ } else {
61
+ const rng = new RandomNumberGenerator(seed ?? undefined);
62
+ positions[node] = rng.rand(dim) as number[];
63
+ }
64
+ }
65
+ } else {
66
+ // Random initial positions
67
+ const rng = new RandomNumberGenerator(seed ?? undefined);
68
+ for (const node of nodes) {
69
+ positions[node] = rng.rand(dim) as number[];
70
+ }
71
+ }
72
+
73
+ // Set up fixed nodes
74
+ const fixedNodes = new Set(fixed || []);
75
+
76
+ // Optimal distance between nodes
77
+ if (!k) {
78
+ k = 1.0 / Math.sqrt(nodes.length);
79
+ }
80
+
81
+ // Initialize temperature
82
+ let t = 0.1;
83
+ // Calculate temperature reduction
84
+ const dt = t / (iterations + 1);
85
+
86
+ // Simple cooling schedule
87
+ for (let i = 0; i < iterations; i++) {
88
+ // Calculate repulsive forces
89
+ const displacement: Record<Node, number[]> = {};
90
+ for (const node of nodes) {
91
+ displacement[node] = Array(dim).fill(0);
92
+ }
93
+
94
+ // Repulsive forces between nodes
95
+ for (let v1i = 0; v1i < nodes.length; v1i++) {
96
+ const v1 = nodes[v1i];
97
+ for (let v2i = v1i + 1; v2i < nodes.length; v2i++) {
98
+ const v2 = nodes[v2i];
99
+
100
+ // Difference vector
101
+ const delta = positions[v1].map((p, i) => p - positions[v2][i]);
102
+
103
+ // Distance
104
+ const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
105
+
106
+ // Force
107
+ const force = (k * k) / distance;
108
+
109
+ // Add force to displacement
110
+ for (let j = 0; j < dim; j++) {
111
+ const direction = delta[j] / distance;
112
+ displacement[v1][j] += direction * force;
113
+ displacement[v2][j] -= direction * force;
114
+ }
115
+ }
116
+ }
117
+
118
+ // Attractive forces between connected nodes
119
+ for (const [source, target] of edges) {
120
+ // Difference vector
121
+ const delta = positions[source].map((p, i) => p - positions[target][i]);
122
+
123
+ // Distance
124
+ const distance = Math.sqrt(delta.reduce((sum, d) => sum + d * d, 0)) || 0.1;
125
+
126
+ // Force
127
+ const force = (distance * distance) / k;
128
+
129
+ // Add force to displacement
130
+ for (let j = 0; j < dim; j++) {
131
+ const direction = delta[j] / distance;
132
+ displacement[source][j] -= direction * force;
133
+ displacement[target][j] += direction * force;
134
+ }
135
+ }
136
+
137
+ // Update positions
138
+ for (const node of nodes) {
139
+ if (fixedNodes.has(node)) continue;
140
+
141
+ // Calculate displacement magnitude
142
+ const magnitude = Math.sqrt(displacement[node].reduce((sum, d) => sum + d * d, 0));
143
+
144
+ // Limit maximum displacement by temperature
145
+ const limitedMagnitude = Math.min(magnitude, t);
146
+
147
+ // Update position
148
+ for (let j = 0; j < dim; j++) {
149
+ const direction = magnitude === 0 ? 0 : displacement[node][j] / magnitude;
150
+ positions[node][j] += direction * limitedMagnitude;
151
+ }
152
+ }
153
+
154
+ // Cool temperature
155
+ t -= dt;
156
+ }
157
+
158
+ // Rescale positions
159
+ if (!fixed) {
160
+ positions = rescaleLayout(positions, scale, center) as PositionMap;
161
+ }
162
+
163
+ return positions;
164
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Force-directed layout algorithms
3
+ */
4
+
5
+ export { springLayout } from './spring';
6
+ export { fruchtermanReingoldLayout } from './fruchterman-reingold';
7
+ export { kamadaKawaiLayout } from './kamada-kawai';
8
+ export { forceatlas2Layout } from './forceatlas2';
9
+ export { arfLayout } from './arf';
@@ -0,0 +1,112 @@
1
+ import type { Graph, Node, PositionMap } from '../../types';
2
+ import type { DistanceMap } from '../../algorithms/optimization';
3
+ import { getNodesFromGraph } from '../../utils/graph';
4
+ import { _processParams } from '../../utils/params';
5
+ import { rescaleLayout } from '../../utils/rescale';
6
+ import { circularLayout } from '../geometric/circular';
7
+ import {
8
+ _computeShortestPathDistances,
9
+ _kamadaKawaiSolve
10
+ } from '../../algorithms/optimization';
11
+
12
+ /**
13
+ * Position nodes using Kamada-Kawai path-length cost-function.
14
+ *
15
+ * @param G - NetworkX graph or list of nodes
16
+ * @param dist - A two-level dictionary of optimal distances between nodes
17
+ * @param pos - Initial positions for nodes
18
+ * @param weight - The edge attribute used for edge weights
19
+ * @param scale - Scale factor for positions
20
+ * @param center - Coordinate pair around which to center the layout
21
+ * @param dim - Dimension of layout
22
+ * @returns Positions dictionary keyed by node
23
+ */
24
+ export function kamadaKawaiLayout(
25
+ G: Graph,
26
+ dist: DistanceMap | null = null,
27
+ pos: PositionMap | null = null,
28
+ weight: string = 'weight',
29
+ scale: number = 1,
30
+ center: number[] | null = null,
31
+ dim: number = 2
32
+ ): PositionMap {
33
+ const processed = _processParams(G, center, dim);
34
+ const graph = processed.G;
35
+ center = processed.center;
36
+
37
+ const nodes = getNodesFromGraph(graph);
38
+
39
+ if (nodes.length === 0) {
40
+ return {};
41
+ }
42
+
43
+ if (nodes.length === 1) {
44
+ return { [nodes[0]]: center };
45
+ }
46
+
47
+ // Initialize distance matrix
48
+ if (!dist) {
49
+ // Kamada-Kawai requires a proper Graph, not just a list of nodes
50
+ if (Array.isArray(graph)) {
51
+ throw new Error('Kamada-Kawai layout requires a Graph with edges, not just a list of nodes');
52
+ }
53
+ dist = _computeShortestPathDistances(graph, weight);
54
+ }
55
+
56
+ // Convert distances to a matrix
57
+ const nodesArray: Node[] = Array.from(nodes);
58
+ const nNodes = nodesArray.length;
59
+ const distMatrix: number[][] = Array(nNodes).fill(0).map(() => Array(nNodes).fill(1e6));
60
+
61
+ for (let i = 0; i < nNodes; i++) {
62
+ const nodeI = nodesArray[i];
63
+ distMatrix[i][i] = 0;
64
+
65
+ if (!dist[nodeI]) continue;
66
+
67
+ for (let j = 0; j < nNodes; j++) {
68
+ const nodeJ = nodesArray[j];
69
+ if (dist[nodeI][nodeJ] !== undefined) {
70
+ distMatrix[i][j] = dist[nodeI][nodeJ];
71
+ }
72
+ }
73
+ }
74
+
75
+ // Initialize positions if not provided
76
+ if (!pos) {
77
+ if (dim >= 2) {
78
+ // Use circular/spherical layout for 2D and 3D
79
+ pos = circularLayout(G, 1, center, dim);
80
+ } else {
81
+ // For 1D, use a linear layout
82
+ const posArray: PositionMap = {};
83
+ nodesArray.forEach((node, i) => {
84
+ posArray[node] = [i / (nNodes - 1 || 1)];
85
+ });
86
+ pos = posArray;
87
+ }
88
+ }
89
+
90
+ // Convert positions to array for computation
91
+ const posArray: number[][] = new Array(nNodes);
92
+ for (let i = 0; i < nNodes; i++) {
93
+ const node = nodesArray[i];
94
+ posArray[i] = pos[node] ? [...pos[node]] : Array(dim).fill(0);
95
+
96
+ // Ensure correct dimensionality
97
+ while (posArray[i].length < dim) {
98
+ posArray[i].push(0);
99
+ }
100
+ }
101
+
102
+ // Run the Kamada-Kawai algorithm
103
+ const newPositions = _kamadaKawaiSolve(distMatrix, posArray, dim);
104
+
105
+ // Convert positions array back to dictionary and rescale
106
+ const finalPos: PositionMap = {};
107
+ for (let i = 0; i < nNodes; i++) {
108
+ finalPos[nodesArray[i]] = newPositions[i];
109
+ }
110
+
111
+ return rescaleLayout(finalPos, scale, center) as PositionMap;
112
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Spring layout algorithm (Fruchterman-Reingold variant)
3
+ */
4
+
5
+ import { Graph, Node, PositionMap } from '../../types';
6
+ import { fruchtermanReingoldLayout } from './fruchterman-reingold';
7
+
8
+ /**
9
+ * Position nodes using Fruchterman-Reingold force-directed algorithm.
10
+ *
11
+ * @param {Object} G - Graph or list of nodes
12
+ * @param {number} k - Optimal distance between nodes
13
+ * @param {Object} pos - Initial positions for nodes
14
+ * @param {Array} fixed - Nodes to keep fixed at initial position
15
+ * @param {number} iterations - Maximum number of iterations
16
+ * @param {number} scale - Scale factor for positions
17
+ * @param {Array|null} center - Coordinate pair around which to center the layout
18
+ * @param {number} dim - Dimension of layout
19
+ * @param {number} seed - Random seed for initial positions
20
+ * @returns {Object} Positions dictionary keyed by node
21
+ */
22
+ export function springLayout(
23
+ G: Graph,
24
+ k: number | null = null,
25
+ pos: PositionMap | null = null,
26
+ fixed: Node[] | null = null,
27
+ iterations: number = 50,
28
+ scale: number = 1,
29
+ center: number[] | null = null,
30
+ dim: number = 2,
31
+ seed: number | null = null
32
+ ): PositionMap {
33
+ // Legacy compatibility alias
34
+ return fruchtermanReingoldLayout(G, k, pos, fixed, iterations, scale, center, dim, seed);
35
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Circular layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph } from '../../utils/graph';
8
+ import { RandomNumberGenerator } from '../../utils/random';
9
+ import { np } from '../../utils/numpy';
10
+
11
+ /**
12
+ * Position nodes on a circle (2D) or sphere (3D).
13
+ *
14
+ * @param G - Graph or list of nodes
15
+ * @param scale - Scale factor for positions
16
+ * @param center - Coordinate pair around which to center the layout
17
+ * @param dim - Dimension of layout (supports 2D circle or 3D sphere)
18
+ * @returns Positions dictionary keyed by node
19
+ */
20
+ export function circularLayout(G: Graph, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
21
+ if (dim < 2) {
22
+ throw new Error("cannot handle dimensions < 2");
23
+ }
24
+
25
+ const processed = _processParams(G, center, dim);
26
+ const nodes = getNodesFromGraph(processed.G);
27
+ center = processed.center;
28
+
29
+ const pos: PositionMap = {};
30
+
31
+ if (nodes.length === 0) {
32
+ return pos;
33
+ }
34
+
35
+ if (nodes.length === 1) {
36
+ pos[nodes[0]] = center;
37
+ return pos;
38
+ }
39
+
40
+ if (dim === 2) {
41
+ // 2D circle layout
42
+ const theta = np.linspace(0, 2 * Math.PI, nodes.length + 1).slice(0, -1);
43
+
44
+ nodes.forEach((node: Node, i: number) => {
45
+ const x: number = Math.cos(theta[i]) * scale + center[0];
46
+ const y: number = Math.sin(theta[i]) * scale + center[1];
47
+ pos[node] = [x, y];
48
+ });
49
+ } else if (dim === 3) {
50
+ // 3D sphere layout using Fibonacci spiral
51
+ const n = nodes.length;
52
+ const goldenRatio = (1 + Math.sqrt(5)) / 2;
53
+
54
+ nodes.forEach((node: Node, i: number) => {
55
+ // Use Fibonacci spiral for even distribution on sphere
56
+ const theta = 2 * Math.PI * i / goldenRatio;
57
+ const phi = Math.acos(1 - 2 * (i + 0.5) / n);
58
+
59
+ const x = Math.sin(phi) * Math.cos(theta) * scale + center[0];
60
+ const y = Math.sin(phi) * Math.sin(theta) * scale + center[1];
61
+ const z = Math.cos(phi) * scale + center[2];
62
+
63
+ pos[node] = [x, y, z];
64
+ });
65
+ } else {
66
+ // For higher dimensions, fall back to random on hypersphere
67
+ const rng = new RandomNumberGenerator();
68
+ nodes.forEach((node: Node) => {
69
+ // Generate random point on unit hypersphere
70
+ const coords = Array(dim).fill(0).map(() => rng.rand() as number * 2 - 1);
71
+ const norm = Math.sqrt(coords.reduce((sum, c) => sum + c * c, 0));
72
+ pos[node] = coords.map((c, j) => c / norm * scale + center[j]);
73
+ });
74
+ }
75
+
76
+ return pos;
77
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Geometric layout algorithms
3
+ */
4
+
5
+ export { circularLayout } from './circular';
6
+ export { shellLayout } from './shell';
7
+ export { spiralLayout } from './spiral';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shell layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph } from '../../utils/graph';
8
+ import { np } from '../../utils/numpy';
9
+
10
+ /**
11
+ * Position nodes in concentric circles.
12
+ *
13
+ * @param G - Graph or list of nodes
14
+ * @param nlist - List of node lists for each shell
15
+ * @param scale - Scale factor for positions
16
+ * @param center - Coordinate pair around which to center the layout
17
+ * @param dim - Dimension of layout (currently only supports dim=2)
18
+ * @returns Positions dictionary keyed by node
19
+ */
20
+ export function shellLayout(G: Graph, nlist: Node[][] | null = null, scale: number = 1, center: number[] | null = null, dim: number = 2): PositionMap {
21
+ if (dim !== 2) {
22
+ throw new Error("can only handle 2 dimensions");
23
+ }
24
+
25
+ const processed = _processParams(G, center, dim);
26
+ const nodes = getNodesFromGraph(processed.G);
27
+ center = processed.center;
28
+
29
+ const pos: PositionMap = {};
30
+
31
+ if (nodes.length === 0) {
32
+ return pos;
33
+ }
34
+
35
+ if (nodes.length === 1) {
36
+ pos[nodes[0]] = center;
37
+ return pos;
38
+ }
39
+
40
+ // If no nlist is specified, put all nodes in a single shell
41
+ if (!nlist) {
42
+ nlist = [nodes];
43
+ }
44
+
45
+ const radiusBump = scale / nlist.length;
46
+ let radius: number;
47
+
48
+ if (nlist[0].length === 1) {
49
+ // Single node at center
50
+ radius = 0;
51
+ pos[nlist[0][0]] = [...center];
52
+ radius += radiusBump;
53
+ } else {
54
+ // Start at radius 1
55
+ radius = radiusBump;
56
+ }
57
+
58
+ for (let i = 0; i < nlist.length; i++) {
59
+ const shell = nlist[i];
60
+ if (shell.length === 0) continue;
61
+
62
+ if (shell.length === 1 && i === 0) {
63
+ // Already handled the case of a single center node
64
+ continue;
65
+ }
66
+
67
+ // Calculate positions on a circle
68
+ const theta = np.linspace(0, 2 * Math.PI, shell.length + 1).slice(0, -1);
69
+
70
+ shell.forEach((node: Node, j) => {
71
+ const x = Math.cos(theta[j]) * radius + center[0];
72
+ const y = Math.sin(theta[j]) * radius + center[1];
73
+ pos[node] = [x, y];
74
+ });
75
+
76
+ radius += radiusBump;
77
+ }
78
+
79
+ return pos;
80
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Spiral layout algorithm
3
+ */
4
+
5
+ import { Graph, Node, PositionMap } from '../../types';
6
+ import { _processParams } from '../../utils/params';
7
+ import { getNodesFromGraph } from '../../utils/graph';
8
+ import { rescaleLayout } from '../../utils/rescale';
9
+
10
+ /**
11
+ * Position nodes in a spiral layout.
12
+ *
13
+ * @param G - Graph or list of nodes
14
+ * @param scale - Scale factor for positions
15
+ * @param center - Coordinate pair around which to center the layout
16
+ * @param dim - Dimension of layout
17
+ * @param resolution - Controls the spacing between spiral elements
18
+ * @param equidistant - Whether to place nodes equidistant from each other
19
+ * @returns Positions dictionary keyed by node
20
+ */
21
+ export function spiralLayout(
22
+ G: Graph,
23
+ scale: number = 1,
24
+ center: number[] | null = null,
25
+ dim: number = 2,
26
+ resolution: number = 0.35,
27
+ equidistant: boolean = false
28
+ ): PositionMap {
29
+ if (dim !== 2) {
30
+ throw new Error("can only handle 2 dimensions");
31
+ }
32
+
33
+ const processed = _processParams(G, center || [0, 0], dim);
34
+ const nodes = getNodesFromGraph(processed.G);
35
+ center = processed.center;
36
+
37
+ const pos: PositionMap = {};
38
+
39
+ if (nodes.length === 0) {
40
+ return pos;
41
+ }
42
+
43
+ if (nodes.length === 1) {
44
+ pos[nodes[0]] = [...center];
45
+ return pos;
46
+ }
47
+
48
+ let positions: number[][] = [];
49
+
50
+ if (equidistant) {
51
+ // Create equidistant points along the spiral
52
+ // This matches the Python implementation logic
53
+ const chord = 1;
54
+ const step = 0.5;
55
+ let theta = resolution;
56
+ theta += chord / (step * theta);
57
+
58
+ for (let i = 0; i < nodes.length; i++) {
59
+ const r = step * theta;
60
+ theta += chord / r;
61
+ positions.push([Math.cos(theta) * r, Math.sin(theta) * r]);
62
+ }
63
+ } else {
64
+ // Create points with equal angle but increasing distance
65
+ const dist = Array.from({ length: nodes.length }, (_, i) => parseFloat(String(i)));
66
+ const angle = dist.map(d => resolution * d);
67
+
68
+ positions = dist.map((d, i) => [
69
+ Math.cos(angle[i]) * d,
70
+ Math.sin(angle[i]) * d
71
+ ]);
72
+ }
73
+
74
+ // Convert position array to position matrix for rescaling
75
+ const posArray: number[][] = [];
76
+ for (let i = 0; i < positions.length; i++) {
77
+ posArray.push(positions[i]);
78
+ }
79
+
80
+ // Rescale positions and add center offset
81
+ const scaledPositions = rescaleLayout(posArray as any, scale) as any;
82
+ for (let i = 0; i < scaledPositions.length; i++) {
83
+ scaledPositions[i][0] += center[0];
84
+ scaledPositions[i][1] += center[1];
85
+ }
86
+
87
+ // Create position dictionary
88
+ for (let i = 0; i < nodes.length; i++) {
89
+ pos[nodes[i]] = scaledPositions[i];
90
+ }
91
+
92
+ return pos;
93
+ }
@@ -0,0 +1,81 @@
1
+ import type { Graph, Node, Edge, PositionMap } from '../../types';
2
+ import { getNodesFromGraph, getNeighbors } from '../../utils/graph';
3
+ import { _processParams } from '../../utils/params';
4
+ import { multipartiteLayout } from './multipartite';
5
+
6
+ /**
7
+ * Position nodes according to breadth-first search algorithm.
8
+ *
9
+ * @param G - Graph
10
+ * @param start - Starting node for bfs
11
+ * @param align - The alignment of layers: 'vertical' or 'horizontal'
12
+ * @param scale - Scale factor for positions
13
+ * @param center - Coordinate pair around which to center the layout
14
+ * @returns Positions dictionary keyed by node
15
+ */
16
+ export function bfsLayout(
17
+ G: Graph,
18
+ start: Node,
19
+ align: 'vertical' | 'horizontal' = 'vertical',
20
+ scale: number = 1,
21
+ center: number[] | null = null
22
+ ): PositionMap {
23
+ const processed = _processParams(G, center || [0, 0], 2);
24
+
25
+ // BFS layout requires a proper Graph, not just a list of nodes
26
+ if (Array.isArray(processed.G)) {
27
+ throw new Error('BFS layout requires a Graph with edges, not just a list of nodes');
28
+ }
29
+
30
+ const graph = processed.G;
31
+ center = processed.center;
32
+
33
+ const allNodes = getNodesFromGraph(graph);
34
+
35
+ if (allNodes.length === 0) {
36
+ return {};
37
+ }
38
+
39
+ // Compute BFS layers
40
+ const layers: Record<number, Node[]> = {};
41
+ const visited = new Set<Node>();
42
+ let currentLayer = 0;
43
+
44
+ // Starting layer
45
+ layers[currentLayer] = [start];
46
+ visited.add(start);
47
+
48
+ // BFS traversal
49
+ while (Object.values(layers).flat().length < allNodes.length) {
50
+ const nextLayer: Node[] = [];
51
+ const currentNodes = layers[currentLayer];
52
+
53
+ for (const node of currentNodes) {
54
+ // Get neighbors - this is a simplified approach
55
+ // In a real implementation, we would get neighbors from the graph
56
+ const neighbors = getNeighbors(graph, node);
57
+
58
+ for (const neighbor of neighbors) {
59
+ if (!visited.has(neighbor)) {
60
+ nextLayer.push(neighbor);
61
+ visited.add(neighbor);
62
+ }
63
+ }
64
+ }
65
+
66
+ if (nextLayer.length === 0) {
67
+ // No more connected nodes
68
+ const unvisited: Node[] = allNodes.filter((node: Node) => !visited.has(node));
69
+ if (unvisited.length > 0) {
70
+ throw new Error("bfs_layout didn't include all nodes. Graph may be disconnected.");
71
+ }
72
+ break;
73
+ }
74
+
75
+ currentLayer++;
76
+ layers[currentLayer] = nextLayer;
77
+ }
78
+
79
+ // Use multipartite_layout to position the layers
80
+ return multipartiteLayout(graph, layers, align, scale, center);
81
+ }