@graphty/layout 1.1.1 → 1.2.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 (100) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +3 -7
  3. package/CHANGELOG.md +14 -0
  4. package/DEPLOYMENT.md +59 -0
  5. package/README.md +54 -1
  6. package/dist/vitest.config.js +2 -2
  7. package/dist/vitest.config.js.map +1 -1
  8. package/examples/3d-force-directed.html +611 -0
  9. package/examples/3d-kamada-kawai.html +379 -0
  10. package/examples/3d-layout-comparison.html +448 -0
  11. package/examples/3d-spherical-layout.html +319 -0
  12. package/examples/arf-layout.html +13 -1
  13. package/examples/bfs-layout.html +38 -32
  14. package/examples/bipartite-layout.html +16 -4
  15. package/examples/circular-layout.html +14 -2
  16. package/examples/forceatlas2-layout.html +118 -151
  17. package/examples/index.html +75 -0
  18. package/examples/kamada-kawai-layout.html +13 -1
  19. package/{dist → examples}/layout-helpers.js +58 -29
  20. package/examples/multipartite-layout.html +44 -54
  21. package/examples/planar-layout.html +13 -1
  22. package/examples/random-layout.html +13 -1
  23. package/examples/shell-layout.html +16 -2
  24. package/examples/spectral-layout.html +13 -1
  25. package/examples/spiral-layout.html +13 -1
  26. package/examples/spring-layout.html +15 -13
  27. package/package.json +6 -3
  28. package/src/algorithms/index.ts +6 -0
  29. package/src/algorithms/optimization/index.ts +12 -0
  30. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  31. package/src/algorithms/optimization/lbfgs.ts +68 -0
  32. package/src/algorithms/optimization/line-search.ts +50 -0
  33. package/src/algorithms/optimization/types.ts +8 -0
  34. package/src/algorithms/planarity/check.ts +38 -0
  35. package/src/algorithms/planarity/embedding.ts +216 -0
  36. package/src/algorithms/planarity/index.ts +12 -0
  37. package/src/algorithms/planarity/lr-test.ts +70 -0
  38. package/src/algorithms/planarity/special-graphs.ts +126 -0
  39. package/src/generators/basic.ts +93 -0
  40. package/src/generators/bipartite.ts +45 -0
  41. package/src/generators/grid.ts +42 -0
  42. package/src/generators/index.ts +15 -0
  43. package/src/generators/random.ts +39 -0
  44. package/src/generators/scale-free.ts +72 -0
  45. package/src/index.ts +18 -0
  46. package/src/layouts/basic/index.ts +5 -0
  47. package/src/layouts/basic/random.ts +32 -0
  48. package/src/layouts/force-directed/arf.ts +130 -0
  49. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  50. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  51. package/src/layouts/force-directed/index.ts +9 -0
  52. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  53. package/src/layouts/force-directed/spring.ts +35 -0
  54. package/src/layouts/geometric/circular.ts +77 -0
  55. package/src/layouts/geometric/index.ts +7 -0
  56. package/src/layouts/geometric/shell.ts +80 -0
  57. package/src/layouts/geometric/spiral.ts +93 -0
  58. package/src/layouts/hierarchical/bfs.ts +81 -0
  59. package/src/layouts/hierarchical/bipartite.ts +94 -0
  60. package/src/layouts/hierarchical/index.ts +7 -0
  61. package/src/layouts/hierarchical/multipartite.ts +88 -0
  62. package/src/layouts/index.ts +9 -0
  63. package/src/layouts/specialized/index.ts +6 -0
  64. package/src/layouts/specialized/planar.ts +65 -0
  65. package/src/layouts/specialized/spectral.ts +128 -0
  66. package/src/types/embedding.ts +11 -0
  67. package/src/types/graph.ts +13 -0
  68. package/src/types/index.ts +7 -0
  69. package/src/types/layout.ts +9 -0
  70. package/src/utils/graph.ts +77 -0
  71. package/src/utils/index.ts +9 -0
  72. package/src/utils/numpy.ts +108 -0
  73. package/src/utils/params.ts +26 -0
  74. package/src/utils/random.ts +53 -0
  75. package/src/utils/rescale.ts +137 -0
  76. package/test/arf-layout.test.ts +1 -1
  77. package/test/bfs-layout.test.ts +1 -1
  78. package/test/bipartite-layout.test.ts +1 -1
  79. package/test/circular-layout.test.ts +145 -3
  80. package/test/forceatlas2-layout.test.ts +1 -1
  81. package/test/fruchterman-reingold-layout.test.ts +1 -1
  82. package/test/graph-generators.test.ts +1 -1
  83. package/test/kamada-kawai-layout.test.ts +273 -1
  84. package/test/multipartite-layout.test.ts +1 -1
  85. package/test/planar-layout.test.ts +1 -1
  86. package/test/random-layout.test.ts +1 -1
  87. package/test/rescale-layout.test.ts +1 -1
  88. package/test/shell-layout.test.ts +1 -1
  89. package/test/spectral-layout.test.ts +1 -1
  90. package/test/spiral-layout.test.ts +1 -1
  91. package/test/spring-layout.test.ts +1 -1
  92. package/vite.config.js +36 -0
  93. package/vitest.config.ts +2 -2
  94. package/dist/layout-helpers.d.ts +0 -123
  95. package/dist/layout-helpers.js.map +0 -1
  96. package/dist/layout.d.ts +0 -275
  97. package/dist/layout.js +0 -2280
  98. package/dist/layout.js.map +0 -1
  99. package/layout-helpers.ts +0 -559
  100. package/layout.ts +0 -2867
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Bipartite 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 two straight lines (bipartite layout).
12
+ *
13
+ * @param G - Graph or list of nodes
14
+ * @param nodes - Nodes in one node set of the graph
15
+ * @param align - The alignment of nodes: 'vertical' or 'horizontal'
16
+ * @param scale - Scale factor for positions
17
+ * @param center - Coordinate pair around which to center the layout
18
+ * @param aspectRatio - The ratio of the width to the height of the layout
19
+ * @returns Positions dictionary keyed by node
20
+ */
21
+ export function bipartiteLayout(
22
+ G: Graph,
23
+ nodes: Node[] | null = null,
24
+ align: 'vertical' | 'horizontal' = 'vertical',
25
+ scale: number = 1,
26
+ center: number[] | null = null,
27
+ aspectRatio: number = 4 / 3
28
+ ): PositionMap {
29
+ if (align !== 'vertical' && align !== 'horizontal') {
30
+ throw new Error("align must be either vertical or horizontal");
31
+ }
32
+
33
+ const processed = _processParams(G, center || [0, 0], 2);
34
+ const graph = processed.G;
35
+ center = processed.center;
36
+
37
+ const allNodes = getNodesFromGraph(graph);
38
+
39
+ if (allNodes.length === 0) {
40
+ return {};
41
+ }
42
+
43
+ // If nodes not provided, try to determine bipartite sets
44
+ if (!nodes) {
45
+ // A simple heuristic for bipartite detection: use nodes with even/odd indices
46
+ // This is a simplification, in Python NetworkX has bipartite.sets()
47
+ nodes = allNodes.filter((_: Node, i: number): boolean => i % 2 === 0);
48
+ }
49
+
50
+ const left = new Set(nodes);
51
+ const right: Set<Node> = new Set(allNodes.filter((n: Node) => !left.has(n)));
52
+
53
+ const height = 1;
54
+ const width = aspectRatio * height;
55
+ const offset = [width / 2, height / 2];
56
+
57
+ const pos: PositionMap = {};
58
+
59
+ // Position nodes in the left set
60
+ const leftNodes = [...left];
61
+ leftNodes.forEach((node, i) => {
62
+ const x = 0;
63
+ const y = i * height / (leftNodes.length || 1);
64
+ pos[node] = [x, y];
65
+ });
66
+
67
+ // Position nodes in the right set
68
+ const rightNodes = [...right];
69
+ rightNodes.forEach((node, i) => {
70
+ const x = width;
71
+ const y = i * height / (rightNodes.length || 1);
72
+ pos[node] = [x, y];
73
+ });
74
+
75
+ // Center positions around the origin and apply offset
76
+ for (const node in pos) {
77
+ pos[node][0] -= offset[0];
78
+ pos[node][1] -= offset[1];
79
+ }
80
+
81
+ // Rescale positions
82
+ const scaledPos = rescaleLayout(pos, scale, center) as PositionMap;
83
+
84
+ // Handle horizontal alignment
85
+ if (align === 'horizontal') {
86
+ for (const node in scaledPos) {
87
+ const temp = scaledPos[node][0];
88
+ scaledPos[node][0] = scaledPos[node][1];
89
+ scaledPos[node][1] = temp;
90
+ }
91
+ }
92
+
93
+ return scaledPos;
94
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hierarchical layout algorithms
3
+ */
4
+
5
+ export { bipartiteLayout } from './bipartite';
6
+ export { multipartiteLayout } from './multipartite';
7
+ export { bfsLayout } from './bfs';
@@ -0,0 +1,88 @@
1
+ import type { Graph, Node, PositionMap } from '../../types';
2
+ import { getNodesFromGraph, getNeighbors } from '../../utils/graph';
3
+ import { _processParams } from '../../utils/params';
4
+ import { rescaleLayout } from '../../utils/rescale';
5
+
6
+ /**
7
+ * Position nodes in layers of straight lines (multipartite layout).
8
+ *
9
+ * @param G - Graph or list of nodes
10
+ * @param subsetKey - Object mapping layers to node sets, or node attribute name
11
+ * @param align - The alignment of nodes: '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 multipartiteLayout(
17
+ G: Graph,
18
+ subsetKey: Record<number | string, Node | Node[]> | string = 'subset',
19
+ align: 'vertical' | 'horizontal' = 'vertical',
20
+ scale: number = 1,
21
+ center: number[] | null = null
22
+ ): PositionMap {
23
+ if (align !== 'vertical' && align !== 'horizontal') {
24
+ throw new Error("align must be either vertical or horizontal");
25
+ }
26
+
27
+ const processed = _processParams(G, center || [0, 0], 2);
28
+ const graph = processed.G;
29
+ center = processed.center;
30
+
31
+ const allNodes = getNodesFromGraph(graph);
32
+
33
+ if (allNodes.length === 0) {
34
+ return {};
35
+ }
36
+
37
+ // Convert subsetKey to a layer mapping if it's a string
38
+ let layers: Record<number | string, Node[]> = {};
39
+ if (typeof subsetKey === 'string') {
40
+ // In JS we don't have access to node attributes directly
41
+ // This is a simplification - in a real implementation we would need
42
+ // to access node attributes from the graph
43
+ console.warn("Using string subsetKey requires node attributes, using default partitioning");
44
+ // Create a simple partitioning as fallback
45
+ layers = { 0: allNodes };
46
+ } else {
47
+ // subsetKey is already a mapping of layers to nodes
48
+ // Convert single nodes to arrays
49
+ for (const [key, value] of Object.entries(subsetKey)) {
50
+ if (Array.isArray(value)) {
51
+ layers[key] = value;
52
+ } else {
53
+ layers[key] = [value];
54
+ }
55
+ }
56
+ }
57
+
58
+ const layerCount = Object.keys(layers).length;
59
+ let pos: PositionMap = {};
60
+
61
+ // Process each layer
62
+ Object.entries(layers).forEach(([layer, nodes], layerIdx) => {
63
+ const layerNodes = Array.isArray(nodes) ? nodes : [nodes];
64
+ const layerSize = layerNodes.length;
65
+
66
+ layerNodes.forEach((node, nodeIdx) => {
67
+ // Place nodes in a grid: layerIdx determines x-coordinate (column)
68
+ // nodeIdx determines y-coordinate (row position within column)
69
+ const x = layerIdx - (layerCount - 1) / 2;
70
+ const y = nodeIdx - (layerSize - 1) / 2;
71
+ pos[node] = [x, y];
72
+ });
73
+ });
74
+
75
+ // Rescale positions
76
+ pos = rescaleLayout(pos, scale, center) as PositionMap;
77
+
78
+ // Handle horizontal alignment
79
+ if (align === 'horizontal') {
80
+ for (const node in pos) {
81
+ const temp = pos[node][0];
82
+ pos[node][0] = pos[node][1];
83
+ pos[node][1] = temp;
84
+ }
85
+ }
86
+
87
+ return pos;
88
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Re-export all layout algorithms
3
+ */
4
+
5
+ export * from './basic';
6
+ export * from './geometric';
7
+ export * from './force-directed';
8
+ export * from './hierarchical';
9
+ export * from './specialized';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Specialized layout algorithms
3
+ */
4
+
5
+ export { spectralLayout } from './spectral';
6
+ export { planarLayout } from './planar';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Planar 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 { rescaleLayout } from '../../utils/rescale';
9
+ import { checkPlanarity, combinatorialEmbeddingToPos } from '../../algorithms/planarity';
10
+
11
+ /**
12
+ * Position nodes without edge intersections (planar layout).
13
+ *
14
+ * @param G - Graph
15
+ * @param scale - Scale factor for positions
16
+ * @param center - Coordinate pair around which to center the layout
17
+ * @param dim - Dimension of layout (must be 2)
18
+ * @returns Positions dictionary keyed by node
19
+ */
20
+ export function planarLayout(
21
+ G: Graph,
22
+ scale: number = 1,
23
+ center: number[] | null = null,
24
+ dim: number = 2
25
+ ): PositionMap {
26
+ if (dim !== 2) {
27
+ throw new Error("can only handle 2 dimensions");
28
+ }
29
+
30
+ const processed = _processParams(G, center || [0, 0], dim);
31
+
32
+ // Planar layout requires a proper Graph, not just a list of nodes
33
+ if (Array.isArray(processed.G)) {
34
+ throw new Error('Planar layout requires a Graph with edges, not just a list of nodes');
35
+ }
36
+
37
+ const 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
+ // Check if graph is planar and get embedding
48
+ const { isPlanar, embedding } = checkPlanarity(graph, nodes, edges);
49
+
50
+ if (!isPlanar) {
51
+ throw new Error("G is not planar.");
52
+ }
53
+
54
+ if (!embedding) {
55
+ throw new Error("Failed to generate planar embedding.");
56
+ }
57
+
58
+ // Convert embedding to positions
59
+ let pos = combinatorialEmbeddingToPos(embedding, nodes);
60
+
61
+ // Rescale the positions
62
+ pos = rescaleLayout(pos, scale, center) as PositionMap;
63
+
64
+ return pos;
65
+ }