@elabs-ai/components-flow 4.0.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/index.d.ts +634 -0
  4. package/dist/index.js +1577 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +68 -0
  7. package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
  8. package/src/canvas-shell/canvas-shell.test.tsx +106 -0
  9. package/src/canvas-shell/canvas-shell.tsx +128 -0
  10. package/src/canvas-shell/index.ts +1 -0
  11. package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
  12. package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
  13. package/src/flow-button-edge/flow-button-edge.tsx +80 -0
  14. package/src/flow-button-edge/index.ts +5 -0
  15. package/src/flow-edge/flow-edge.stories.tsx +79 -0
  16. package/src/flow-edge/flow-edge.test.tsx +76 -0
  17. package/src/flow-edge/flow-edge.tsx +34 -0
  18. package/src/flow-edge/index.ts +1 -0
  19. package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
  20. package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
  21. package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
  22. package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
  23. package/src/flow-floating-edge/index.ts +6 -0
  24. package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
  25. package/src/flow-group-node/flow-group-node.test.tsx +100 -0
  26. package/src/flow-group-node/flow-group-node.tsx +134 -0
  27. package/src/flow-group-node/index.ts +6 -0
  28. package/src/flow-layout/flow-layout.stories.tsx +215 -0
  29. package/src/flow-layout/flow-layout.test.tsx +133 -0
  30. package/src/flow-layout/flow-layout.ts +104 -0
  31. package/src/flow-layout/index.ts +14 -0
  32. package/src/flow-layout/layout-graph.test.ts +257 -0
  33. package/src/flow-layout/layout-graph.ts +302 -0
  34. package/src/flow-layout/use-auto-layout.ts +26 -0
  35. package/src/flow-layout/use-flow-layout.ts +60 -0
  36. package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
  37. package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
  38. package/src/flow-mini-map/flow-mini-map.tsx +29 -0
  39. package/src/flow-mini-map/index.ts +1 -0
  40. package/src/flow-node/flow-node.stories.tsx +143 -0
  41. package/src/flow-node/flow-node.test.tsx +156 -0
  42. package/src/flow-node/flow-node.tsx +190 -0
  43. package/src/flow-node/index.ts +8 -0
  44. package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
  45. package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
  46. package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
  47. package/src/flow-placeholder-node/index.ts +5 -0
  48. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
  49. package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
  50. package/src/flow-smart-edge/index.ts +9 -0
  51. package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
  52. package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
  53. package/src/helper-lines/get-helper-lines.ts +98 -0
  54. package/src/helper-lines/helper-lines.stories.tsx +107 -0
  55. package/src/helper-lines/helper-lines.test.tsx +78 -0
  56. package/src/helper-lines/helper-lines.tsx +67 -0
  57. package/src/helper-lines/index.ts +7 -0
  58. package/src/helper-lines/use-helper-lines.ts +109 -0
  59. package/src/index.ts +37 -0
  60. package/src/inspector-panel/index.ts +1 -0
  61. package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
  62. package/src/inspector-panel/inspector-panel.test.tsx +82 -0
  63. package/src/inspector-panel/inspector-panel.tsx +129 -0
  64. package/src/legend/index.ts +1 -0
  65. package/src/legend/legend.stories.tsx +56 -0
  66. package/src/legend/legend.test.tsx +46 -0
  67. package/src/legend/legend.tsx +39 -0
  68. package/src/templates-flow-workspace.stories.tsx +169 -0
  69. package/src/use-flow-groups/group-operations.test.ts +318 -0
  70. package/src/use-flow-groups/group-operations.ts +349 -0
  71. package/src/use-flow-groups/index.ts +13 -0
  72. package/src/use-flow-groups/use-flow-groups.ts +113 -0
  73. package/src/zoom-controls/index.ts +1 -0
  74. package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
  75. package/src/zoom-controls/zoom-controls.test.tsx +80 -0
  76. package/src/zoom-controls/zoom-controls.tsx +78 -0
@@ -0,0 +1,104 @@
1
+ import dagre from "@dagrejs/dagre";
2
+ import { Position } from "@xyflow/react";
3
+ import type { Edge, Node } from "@xyflow/react";
4
+
5
+ /** Direction dagre lays the graph out in — top-to-bottom, left-to-right, etc. */
6
+ export type FlowLayoutDirection = "TB" | "LR" | "BT" | "RL";
7
+
8
+ /**
9
+ * The handle sides an edge should attach to for each layout direction. A
10
+ * directional layout must move the anchors with it: a left-to-right flow exits
11
+ * the source's RIGHT and enters the target's LEFT; top-to-bottom uses
12
+ * bottom/top. `layoutFlow` stamps these onto every node so `FlowNode` renders
13
+ * its default handles on the correct sides (and `FlowEdge` routes accordingly)
14
+ * — otherwise every layout is stuck with top/bottom handles.
15
+ */
16
+ export const HANDLE_BY_DIRECTION: Record<
17
+ FlowLayoutDirection,
18
+ { source: Position; target: Position }
19
+ > = {
20
+ TB: { source: Position.Bottom, target: Position.Top },
21
+ BT: { source: Position.Top, target: Position.Bottom },
22
+ LR: { source: Position.Right, target: Position.Left },
23
+ RL: { source: Position.Left, target: Position.Right },
24
+ };
25
+
26
+ export interface FlowLayoutOptions {
27
+ /** Layout direction. @default "TB" */
28
+ direction?: FlowLayoutDirection;
29
+ /** Gap between nodes in the same rank (dagre `nodesep`). @default 48 */
30
+ nodeSpacing?: number;
31
+ /** Gap between ranks (dagre `ranksep`). @default 72 */
32
+ rankSpacing?: number;
33
+ }
34
+
35
+ export interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge = Edge> {
36
+ nodes: NodeType[];
37
+ edges: EdgeType[];
38
+ }
39
+
40
+ /** Fallback size used when a node hasn't been measured yet (React Flow's own default node width). */
41
+ const DEFAULT_NODE_WIDTH = 172;
42
+ const DEFAULT_NODE_HEIGHT = 40;
43
+
44
+ function nodeSize(node: Node): { width: number; height: number } {
45
+ return {
46
+ width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH,
47
+ height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Pure dagre-powered auto layout. Computes new `position`s for `nodes` given
53
+ * `edges` — no React, no side effects, safe to call anywhere (including
54
+ * outside a component or in a test).
55
+ *
56
+ * Respects each node's **measured** size (`node.measured` — how React Flow
57
+ * v12 reports actual rendered dimensions), falling back to `node.width`/
58
+ * `node.height`, then a sensible default. Node identity and `data` are left
59
+ * untouched — only `position` changes.
60
+ *
61
+ * Pair with `useFlowLayout` to apply the result to a live canvas.
62
+ */
63
+ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
64
+ nodes: NodeType[],
65
+ edges: EdgeType[],
66
+ options: FlowLayoutOptions = {},
67
+ ): FlowLayoutResult<NodeType, EdgeType> {
68
+ const { direction = "TB", nodeSpacing = 48, rankSpacing = 72 } = options;
69
+
70
+ const graph = new dagre.graphlib.Graph();
71
+ graph.setDefaultEdgeLabel(() => ({}));
72
+ graph.setGraph({ rankdir: direction, nodesep: nodeSpacing, ranksep: rankSpacing });
73
+
74
+ for (const node of nodes) {
75
+ const { width, height } = nodeSize(node);
76
+ graph.setNode(node.id, { width, height });
77
+ }
78
+
79
+ for (const edge of edges) {
80
+ graph.setEdge(edge.source, edge.target);
81
+ }
82
+
83
+ dagre.layout(graph);
84
+
85
+ const handles = HANDLE_BY_DIRECTION[direction];
86
+ const layoutedNodes = nodes.map((node) => {
87
+ const dagreNode = graph.node(node.id);
88
+ const { width, height } = nodeSize(node);
89
+ // dagre positions nodes by their center; React Flow positions by top-left.
90
+ return {
91
+ ...node,
92
+ // Move the anchors with the layout direction so `FlowNode` puts its
93
+ // handles on the sides the edges actually leave/enter.
94
+ sourcePosition: handles.source,
95
+ targetPosition: handles.target,
96
+ position: {
97
+ x: dagreNode.x - width / 2,
98
+ y: dagreNode.y - height / 2,
99
+ },
100
+ };
101
+ });
102
+
103
+ return { nodes: layoutedNodes, edges };
104
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ layoutFlow,
3
+ type FlowLayoutDirection,
4
+ type FlowLayoutOptions,
5
+ type FlowLayoutResult,
6
+ } from "./flow-layout";
7
+ export { useFlowLayout, type UseFlowLayoutResult } from "./use-flow-layout";
8
+ export {
9
+ layoutGraph,
10
+ type LayoutAlgorithm,
11
+ type LayoutOptions,
12
+ type LayoutSpacing,
13
+ } from "./layout-graph";
14
+ export { useAutoLayout } from "./use-auto-layout";
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Edge, Node } from "@xyflow/react";
3
+ import { layoutGraph } from "./layout-graph";
4
+
5
+ function node(id: string): Node {
6
+ return { id, type: "brand", position: { x: 0, y: 0 }, data: { title: id } };
7
+ }
8
+
9
+ function edge(source: string, target: string): Edge {
10
+ return { id: `e-${source}-${target}`, source, target };
11
+ }
12
+
13
+ /** 1 center + 6 neighbors, all one hop away. */
14
+ function starGraph(): { nodes: Node[]; edges: Edge[] } {
15
+ const nodes = ["center", "n1", "n2", "n3", "n4", "n5", "n6"].map(node);
16
+ const edges = ["n1", "n2", "n3", "n4", "n5", "n6"].map((id) => edge("center", id));
17
+ return { nodes, edges };
18
+ }
19
+
20
+ /** A straight 3-node chain: a (center) -> b (hop 1) -> c (hop 2). */
21
+ function chainGraph(): { nodes: Node[]; edges: Edge[] } {
22
+ return {
23
+ nodes: ["a", "b", "c"].map(node),
24
+ edges: [edge("a", "b"), edge("b", "c")],
25
+ };
26
+ }
27
+
28
+ function positionOf(nodes: Node[], id: string) {
29
+ return nodes.find((n) => n.id === id)!.position;
30
+ }
31
+
32
+ function distance(p: { x: number; y: number }, q: { x: number; y: number } = { x: 0, y: 0 }) {
33
+ return Math.hypot(p.x - q.x, p.y - q.y);
34
+ }
35
+
36
+ describe("layoutGraph", () => {
37
+ describe("empty / disconnected input", () => {
38
+ it("returns [] for an empty graph, for every algorithm", () => {
39
+ for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
40
+ expect(layoutGraph([], [], { algorithm })).toEqual([]);
41
+ }
42
+ });
43
+
44
+ it("never produces NaN positions for a disconnected graph", () => {
45
+ const nodes = ["a", "b", "c", "isolated1", "isolated2"].map(node);
46
+ const edges = [edge("a", "b"), edge("b", "c")];
47
+
48
+ for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
49
+ const laidOut = layoutGraph(nodes, edges, { algorithm, iterations: 20 });
50
+ for (const n of laidOut) {
51
+ expect(Number.isFinite(n.position.x)).toBe(true);
52
+ expect(Number.isFinite(n.position.y)).toBe(true);
53
+ }
54
+ }
55
+ });
56
+
57
+ it("places fully-isolated (edgeless) nodes without NaN under concentric", () => {
58
+ const nodes = ["a", "b", "c"].map(node);
59
+ const laidOut = layoutGraph(nodes, [], { algorithm: "concentric", centerId: "a" });
60
+ for (const n of laidOut) {
61
+ expect(Number.isFinite(n.position.x)).toBe(true);
62
+ expect(Number.isFinite(n.position.y)).toBe(true);
63
+ }
64
+ // b and c are disconnected from the center — they still land on ring 1.
65
+ expect(distance(positionOf(laidOut, "b"))).toBeCloseTo(180, 5);
66
+ expect(distance(positionOf(laidOut, "c"))).toBeCloseTo(180, 5);
67
+ });
68
+ });
69
+
70
+ describe("concentric", () => {
71
+ it("preserves node identity and data — only position changes", () => {
72
+ const { nodes, edges } = starGraph();
73
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "concentric" });
74
+ laidOut.forEach((n, i) => {
75
+ expect(n.id).toBe(nodes[i]!.id);
76
+ expect(n.data).toBe(nodes[i]!.data);
77
+ });
78
+ });
79
+
80
+ it("puts a star graph's 6 neighbors on ring 1 at unique angles, distance ringRadius", () => {
81
+ const { nodes, edges } = starGraph();
82
+ const ringRadius = 180;
83
+ const laidOut = layoutGraph(nodes, edges, {
84
+ algorithm: "concentric",
85
+ centerId: "center",
86
+ ringRadius,
87
+ });
88
+
89
+ const center = positionOf(laidOut, "center");
90
+ expect(center).toEqual({ x: 0, y: 0 });
91
+
92
+ const neighborIds = ["n1", "n2", "n3", "n4", "n5", "n6"];
93
+ const angles = neighborIds.map((id) => {
94
+ const p = positionOf(laidOut, id);
95
+ expect(distance(p)).toBeCloseTo(ringRadius, 5);
96
+ return Math.atan2(p.y, p.x);
97
+ });
98
+
99
+ // All six angles are distinct (rounded to guard float noise).
100
+ const rounded = angles.map((a) => Math.round(a * 1e6));
101
+ expect(new Set(rounded).size).toBe(6);
102
+ });
103
+
104
+ it("defaults centerId to the highest-degree node when omitted", () => {
105
+ const { nodes, edges } = starGraph();
106
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "concentric" });
107
+ // "center" has degree 6 — every other node has degree 1 — so it must be
108
+ // the one left at the origin.
109
+ expect(positionOf(laidOut, "center")).toEqual({ x: 0, y: 0 });
110
+ });
111
+
112
+ it("puts the second hop of a chain at 2 × ringRadius from the center", () => {
113
+ const { nodes, edges } = chainGraph();
114
+ const ringRadius = 180;
115
+ const laidOut = layoutGraph(nodes, edges, {
116
+ algorithm: "concentric",
117
+ centerId: "a",
118
+ ringRadius,
119
+ });
120
+
121
+ expect(distance(positionOf(laidOut, "a"))).toBe(0);
122
+ expect(distance(positionOf(laidOut, "b"))).toBeCloseTo(ringRadius, 5);
123
+ expect(distance(positionOf(laidOut, "c"))).toBeCloseTo(2 * ringRadius, 5);
124
+ });
125
+
126
+ it("is deterministic — two runs on the same input produce identical positions", () => {
127
+ const { nodes, edges } = starGraph();
128
+ const first = layoutGraph(nodes, edges, { algorithm: "concentric", centerId: "center" });
129
+ const second = layoutGraph(nodes, edges, { algorithm: "concentric", centerId: "center" });
130
+ expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
131
+ });
132
+ });
133
+
134
+ describe("force", () => {
135
+ it("separates two initially-coincident nodes", () => {
136
+ const nodes: Node[] = [
137
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, data: {} },
138
+ { id: "b", type: "brand", position: { x: 0, y: 0 }, data: {} },
139
+ ];
140
+ const laidOut = layoutGraph(nodes, [], { algorithm: "force", iterations: 50 });
141
+ const a = positionOf(laidOut, "a");
142
+ const b = positionOf(laidOut, "b");
143
+ expect(distance(a, b)).toBeGreaterThan(0);
144
+ });
145
+
146
+ it("terminates in bounded time for a fixed iteration count", () => {
147
+ const { nodes, edges } = starGraph();
148
+ const start = Date.now();
149
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "force", iterations: 300 });
150
+ expect(Date.now() - start).toBeLessThan(5000);
151
+ expect(laidOut).toHaveLength(nodes.length);
152
+ });
153
+
154
+ it("never produces NaN positions", () => {
155
+ const { nodes, edges } = starGraph();
156
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "force", iterations: 50 });
157
+ for (const n of laidOut) {
158
+ expect(Number.isFinite(n.position.x)).toBe(true);
159
+ expect(Number.isFinite(n.position.y)).toBe(true);
160
+ }
161
+ });
162
+
163
+ it("is deterministic — two runs on the same input produce byte-identical positions", () => {
164
+ const { nodes, edges } = starGraph();
165
+ const first = layoutGraph(nodes, edges, { algorithm: "force", iterations: 100 });
166
+ const second = layoutGraph(nodes, edges, { algorithm: "force", iterations: 100 });
167
+ expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
168
+ });
169
+
170
+ it("preserves node identity and data — only position changes", () => {
171
+ const { nodes, edges } = starGraph();
172
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "force", iterations: 30 });
173
+ laidOut.forEach((n, i) => {
174
+ expect(n.id).toBe(nodes[i]!.id);
175
+ expect(n.data).toBe(nodes[i]!.data);
176
+ });
177
+ });
178
+ });
179
+
180
+ describe("layered-lr", () => {
181
+ it("delegates to layoutFlow direction=LR, ordering a chain left-to-right", () => {
182
+ const { nodes, edges } = chainGraph();
183
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "layered-lr" });
184
+ const [a, b, c] = laidOut as [Node, Node, Node];
185
+ expect(a.position.x).toBeLessThan(b.position.x);
186
+ expect(b.position.x).toBeLessThan(c.position.x);
187
+ });
188
+
189
+ it("stamps right-out / left-in handle sides (not just position)", () => {
190
+ const { nodes, edges } = chainGraph();
191
+ const [a] = layoutGraph(nodes, edges, { algorithm: "layered-lr" }) as [Node];
192
+ expect(a.sourcePosition).toBe("right");
193
+ expect(a.targetPosition).toBe("left");
194
+ });
195
+
196
+ it("maps spacing.x/y onto rank/node spacing", () => {
197
+ const { nodes, edges } = chainGraph();
198
+ const tight = layoutGraph(nodes, edges, {
199
+ algorithm: "layered-lr",
200
+ spacing: { x: 10, y: 10 },
201
+ });
202
+ const wide = layoutGraph(nodes, edges, {
203
+ algorithm: "layered-lr",
204
+ spacing: { x: 500, y: 10 },
205
+ });
206
+ const tightGap = (tight[1]!.position.x as number) - (tight[0]!.position.x as number);
207
+ const wideGap = (wide[1]!.position.x as number) - (wide[0]!.position.x as number);
208
+ expect(wideGap).toBeGreaterThan(tightGap);
209
+ });
210
+
211
+ it("respects a custom nodeSize", () => {
212
+ const nodes: Node[] = [
213
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, data: {} },
214
+ { id: "b", type: "brand", position: { x: 0, y: 0 }, data: {} },
215
+ ];
216
+ const edges: Edge[] = [edge("a", "b")];
217
+ const laidOut = layoutGraph(nodes, edges, {
218
+ algorithm: "layered-lr",
219
+ nodeSize: () => ({ width: 400, height: 200 }),
220
+ });
221
+ const gap = (laidOut[1]!.position.x as number) - (laidOut[0]!.position.x as number);
222
+ expect(gap).toBeGreaterThanOrEqual(400 + 72 - 1);
223
+ // The returned nodes are not permanently resized — only `position` changed.
224
+ expect(laidOut[0]).not.toHaveProperty("measured");
225
+ });
226
+ });
227
+
228
+ describe("grid", () => {
229
+ it("lays nodes out row-major using spacing", () => {
230
+ const nodes = ["a", "b", "c", "d"].map(node);
231
+ const laidOut = layoutGraph(nodes, [], {
232
+ algorithm: "grid",
233
+ spacing: { x: 200, y: 120 },
234
+ });
235
+ // ceil(sqrt(4)) = 2 columns.
236
+ expect(positionOf(laidOut, "a")).toEqual({ x: 0, y: 0 });
237
+ expect(positionOf(laidOut, "b")).toEqual({ x: 200, y: 0 });
238
+ expect(positionOf(laidOut, "c")).toEqual({ x: 0, y: 120 });
239
+ expect(positionOf(laidOut, "d")).toEqual({ x: 200, y: 120 });
240
+ });
241
+
242
+ it("stamps bottom-out / top-in handle sides", () => {
243
+ const [a] = layoutGraph(["a", "b"].map(node), [edge("a", "b")], {
244
+ algorithm: "grid",
245
+ }) as [Node];
246
+ expect(a.sourcePosition).toBe("bottom");
247
+ expect(a.targetPosition).toBe("top");
248
+ });
249
+
250
+ it("is deterministic", () => {
251
+ const nodes = ["a", "b", "c"].map(node);
252
+ const first = layoutGraph(nodes, [], { algorithm: "grid" });
253
+ const second = layoutGraph(nodes, [], { algorithm: "grid" });
254
+ expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,302 @@
1
+ import {
2
+ forceCenter,
3
+ forceCollide,
4
+ forceLink,
5
+ forceManyBody,
6
+ forceSimulation,
7
+ type SimulationLinkDatum,
8
+ type SimulationNodeDatum,
9
+ } from "d3-force";
10
+ import type { Edge, Node } from "@xyflow/react";
11
+ import { HANDLE_BY_DIRECTION, layoutFlow } from "./flow-layout";
12
+
13
+ /** Which generic graph-geometry algorithm `layoutGraph` should run. */
14
+ export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "grid";
15
+
16
+ /** Horizontal/vertical gap used by the `"grid"` and `"layered-lr"` algorithms. */
17
+ export interface LayoutSpacing {
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ export interface LayoutOptions {
23
+ /** Which layout algorithm to run. */
24
+ algorithm: LayoutAlgorithm;
25
+ /**
26
+ * `"concentric"` only — the focal node placed at the origin. Defaults to the
27
+ * highest-degree node when omitted (or when the id doesn't match a node).
28
+ */
29
+ centerId?: string;
30
+ /** `"concentric"` only — radius of ring 1; ring N sits at `N × ringRadius`. @default 180 */
31
+ ringRadius?: number;
32
+ /** `"force"` only — number of synchronous simulation ticks to run. @default 300 */
33
+ iterations?: number;
34
+ /** `"grid"` / `"layered-lr"` — gap between nodes. @default {x:200,y:120} for grid. */
35
+ spacing?: LayoutSpacing;
36
+ /** Resolve a node's size by id. Falls back to `measured`/`width`/`height`/a sensible default. */
37
+ nodeSize?: (id: string) => { width: number; height: number };
38
+ }
39
+
40
+ const DEFAULT_NODE_WIDTH = 172;
41
+ const DEFAULT_NODE_HEIGHT = 40;
42
+ const DEFAULT_RING_RADIUS = 180;
43
+ const DEFAULT_FORCE_ITERATIONS = 300;
44
+ const DEFAULT_GRID_SPACING: LayoutSpacing = { x: 200, y: 120 };
45
+ /** Base charge/link/collide tuning — chosen to read well at brand-ui's default node size. */
46
+ const FORCE_CHARGE_STRENGTH = -300;
47
+ const FORCE_LINK_DISTANCE = 150;
48
+
49
+ function resolveNodeSize(
50
+ node: Node,
51
+ nodeSize?: (id: string) => { width: number; height: number },
52
+ ): { width: number; height: number } {
53
+ if (nodeSize) return nodeSize(node.id);
54
+ return {
55
+ width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH,
56
+ height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Pure graph-geometry auto layout. Computes new `position`s for `nodes` given
62
+ * `edges` and a `LayoutAlgorithm` — no React, no DOM, no side effects, safe to
63
+ * call anywhere (including outside a component or in a test). Deterministic:
64
+ * identical `nodes`/`edges`/`options` always produce identical positions.
65
+ *
66
+ * Node identity and `data` are left untouched — only `position` changes.
67
+ * Pair with `useAutoLayout` for a memoized hook form.
68
+ *
69
+ * - `"layered-lr"` delegates to the dagre-powered `layoutFlow` (direction `"LR"`).
70
+ * - `"concentric"` places `centerId` (or the highest-degree node) at the
71
+ * origin, with BFS shells at `ring × ringRadius`; disconnected nodes land in
72
+ * one extra outer ring so positions are never `NaN`.
73
+ * - `"force"` runs `d3-force` synchronously for a fixed number of `iterations`,
74
+ * seeded on a circle by index (no randomness) so runs are reproducible.
75
+ * - `"grid"` lays nodes out row-major using `spacing`.
76
+ */
77
+ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
78
+ nodes: NodeType[],
79
+ edges: EdgeType[],
80
+ options: LayoutOptions,
81
+ ): NodeType[] {
82
+ if (nodes.length === 0) return [];
83
+
84
+ switch (options.algorithm) {
85
+ case "layered-lr":
86
+ return layoutLayeredLr(nodes, edges, options);
87
+ case "concentric":
88
+ return layoutConcentric(nodes, edges, options);
89
+ case "force":
90
+ return layoutForce(nodes, edges, options);
91
+ case "grid":
92
+ return layoutGrid(nodes, options);
93
+ default: {
94
+ const exhaustiveCheck: never = options.algorithm;
95
+ throw new Error(`layoutGraph: unknown algorithm "${String(exhaustiveCheck)}"`);
96
+ }
97
+ }
98
+ }
99
+
100
+ function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
101
+ nodes: NodeType[],
102
+ edges: EdgeType[],
103
+ options: LayoutOptions,
104
+ ): NodeType[] {
105
+ const { spacing, nodeSize } = options;
106
+
107
+ // dagre (via layoutFlow) reads sizes off `measured`/`width`/`height` — feed it
108
+ // resolved sizes on a throwaway copy so a caller-supplied `nodeSize` is honored
109
+ // without mutating (or permanently resizing) the nodes we return.
110
+ const sizedNodes = nodeSize
111
+ ? nodes.map((node) => {
112
+ const { width, height } = nodeSize(node.id);
113
+ return { ...node, width, height, measured: { width, height } };
114
+ })
115
+ : nodes;
116
+
117
+ const { nodes: laidOut } = layoutFlow(sizedNodes, edges, {
118
+ direction: "LR",
119
+ // For an LR flow, ranks progress horizontally (x) and nodes within a rank
120
+ // stack vertically (y) — map spacing accordingly onto dagre's rank/node sep.
121
+ nodeSpacing: spacing?.y,
122
+ rankSpacing: spacing?.x,
123
+ });
124
+
125
+ // Carry the LR handle sides (right-out / left-in) that layoutFlow stamped on,
126
+ // not just the position — otherwise the layout flips positions but leaves the
127
+ // anchors on top/bottom.
128
+ return nodes.map((node, i) => ({
129
+ ...node,
130
+ sourcePosition: laidOut[i]!.sourcePosition,
131
+ targetPosition: laidOut[i]!.targetPosition,
132
+ position: laidOut[i]!.position,
133
+ }));
134
+ }
135
+
136
+ function resolveCenterId(
137
+ centerId: string | undefined,
138
+ nodeIds: string[],
139
+ adjacency: Map<string, Set<string>>,
140
+ ): string {
141
+ if (centerId && nodeIds.includes(centerId)) return centerId;
142
+
143
+ let best = nodeIds[0]!;
144
+ let bestDegree = -1;
145
+ for (const id of nodeIds) {
146
+ const degree = adjacency.get(id)?.size ?? 0;
147
+ if (degree > bestDegree) {
148
+ bestDegree = degree;
149
+ best = id;
150
+ }
151
+ }
152
+ return best;
153
+ }
154
+
155
+ function layoutConcentric<NodeType extends Node, EdgeType extends Edge>(
156
+ nodes: NodeType[],
157
+ edges: EdgeType[],
158
+ options: LayoutOptions,
159
+ ): NodeType[] {
160
+ const ringRadius = options.ringRadius ?? DEFAULT_RING_RADIUS;
161
+ const nodeIds = nodes.map((node) => node.id);
162
+ const idSet = new Set(nodeIds);
163
+
164
+ const adjacency = new Map<string, Set<string>>();
165
+ for (const id of nodeIds) adjacency.set(id, new Set());
166
+ for (const edge of edges) {
167
+ if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue;
168
+ adjacency.get(edge.source)!.add(edge.target);
169
+ adjacency.get(edge.target)!.add(edge.source);
170
+ }
171
+
172
+ const centerId = resolveCenterId(options.centerId, nodeIds, adjacency);
173
+
174
+ // BFS shells from the center — hop distance doubles as the ring index.
175
+ const hopOf = new Map<string, number>();
176
+ hopOf.set(centerId, 0);
177
+ const queue: string[] = [centerId];
178
+ let maxHop = 0;
179
+ while (queue.length > 0) {
180
+ const current = queue.shift()!;
181
+ const currentHop = hopOf.get(current)!;
182
+ for (const neighbor of adjacency.get(current) ?? []) {
183
+ if (hopOf.has(neighbor)) continue;
184
+ const hop = currentHop + 1;
185
+ hopOf.set(neighbor, hop);
186
+ maxHop = Math.max(maxHop, hop);
187
+ queue.push(neighbor);
188
+ }
189
+ }
190
+
191
+ // Disconnected nodes (unreachable from the center) get one extra ring beyond
192
+ // the farthest reached hop — never left with a NaN/undefined position.
193
+ const leftoverHop = maxHop + 1;
194
+ for (const id of nodeIds) {
195
+ if (!hopOf.has(id)) hopOf.set(id, leftoverHop);
196
+ }
197
+
198
+ const rings = new Map<number, string[]>();
199
+ for (const id of nodeIds) {
200
+ const hop = hopOf.get(id)!;
201
+ if (hop === 0) continue;
202
+ if (!rings.has(hop)) rings.set(hop, []);
203
+ rings.get(hop)!.push(id);
204
+ }
205
+
206
+ const positionById = new Map<string, { x: number; y: number }>();
207
+ positionById.set(centerId, { x: 0, y: 0 });
208
+
209
+ for (const [hop, ids] of rings) {
210
+ const count = ids.length;
211
+ const angleStep = (2 * Math.PI) / count;
212
+ // Stagger successive rings by a growing half-step so radial edges don't
213
+ // trivially line up with the ring behind/ahead of them.
214
+ const ringOffset = (hop - 1) * (angleStep / 2);
215
+ const radius = hop * ringRadius;
216
+ ids.forEach((id, i) => {
217
+ const angle = ringOffset + i * angleStep;
218
+ positionById.set(id, { x: radius * Math.cos(angle), y: radius * Math.sin(angle) });
219
+ });
220
+ }
221
+
222
+ return nodes.map((node) => ({
223
+ ...node,
224
+ position: positionById.get(node.id) ?? { x: 0, y: 0 },
225
+ }));
226
+ }
227
+
228
+ interface ForceSimNode extends SimulationNodeDatum {
229
+ id: string;
230
+ }
231
+
232
+ function layoutForce<NodeType extends Node, EdgeType extends Edge>(
233
+ nodes: NodeType[],
234
+ edges: EdgeType[],
235
+ options: LayoutOptions,
236
+ ): NodeType[] {
237
+ const iterations = options.iterations ?? DEFAULT_FORCE_ITERATIONS;
238
+ const nodeIds = new Set(nodes.map((node) => node.id));
239
+
240
+ // Seed initial positions deterministically on a circle by index — never
241
+ // `Math.random()` — so two runs with the same input converge identically.
242
+ const seedRadius = Math.max(nodes.length * 20, 100);
243
+ const simNodes: ForceSimNode[] = nodes.map((node, i) => {
244
+ const angle = (2 * Math.PI * i) / Math.max(nodes.length, 1);
245
+ return { id: node.id, x: seedRadius * Math.cos(angle), y: seedRadius * Math.sin(angle) };
246
+ });
247
+
248
+ const simLinks: SimulationLinkDatum<ForceSimNode>[] = edges
249
+ .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target))
250
+ .map((edge) => ({ source: edge.source, target: edge.target }));
251
+
252
+ const collisionById = new Map(
253
+ nodes.map((node) => {
254
+ const { width, height } = resolveNodeSize(node, options.nodeSize);
255
+ return [node.id, Math.max(width, height) / 2];
256
+ }),
257
+ );
258
+
259
+ const simulation = forceSimulation(simNodes)
260
+ .force("charge", forceManyBody().strength(FORCE_CHARGE_STRENGTH))
261
+ .force(
262
+ "link",
263
+ forceLink<ForceSimNode, SimulationLinkDatum<ForceSimNode>>(simLinks)
264
+ .id((d) => d.id)
265
+ .distance(FORCE_LINK_DISTANCE),
266
+ )
267
+ .force("center", forceCenter(0, 0))
268
+ .force(
269
+ "collide",
270
+ forceCollide<ForceSimNode>((d) => collisionById.get(d.id) ?? DEFAULT_NODE_WIDTH / 2),
271
+ )
272
+ .stop();
273
+
274
+ for (let i = 0; i < iterations; i++) {
275
+ simulation.tick();
276
+ }
277
+
278
+ const positionById = new Map(simNodes.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]));
279
+
280
+ return nodes.map((node) => ({
281
+ ...node,
282
+ position: positionById.get(node.id) ?? { x: 0, y: 0 },
283
+ }));
284
+ }
285
+
286
+ function layoutGrid<NodeType extends Node>(nodes: NodeType[], options: LayoutOptions): NodeType[] {
287
+ const spacing = options.spacing ?? DEFAULT_GRID_SPACING;
288
+ const cols = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
289
+ // A grid reads top-to-bottom; anchor edges bottom-out / top-in to match.
290
+ const handles = HANDLE_BY_DIRECTION.TB;
291
+
292
+ return nodes.map((node, i) => {
293
+ const row = Math.floor(i / cols);
294
+ const col = i % cols;
295
+ return {
296
+ ...node,
297
+ sourcePosition: handles.source,
298
+ targetPosition: handles.target,
299
+ position: { x: col * spacing.x, y: row * spacing.y },
300
+ };
301
+ });
302
+ }