@elabs-ai/components-flow 4.0.0 → 4.1.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 (66) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +734 -20
  3. package/dist/index.js +976 -178
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -6
  6. package/src/canvas-shell/canvas-shell.tsx +116 -1
  7. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  8. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  9. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  10. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  11. package/src/flow-edge/flow-edge.tsx +10 -3
  12. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  13. package/src/flow-edge-path/index.ts +1 -0
  14. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  15. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  16. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  17. package/src/flow-group-node/flow-group-node.tsx +24 -8
  18. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  19. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  20. package/src/flow-handle/index.ts +1 -0
  21. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  22. package/src/flow-layout/flow-layout.test.tsx +91 -0
  23. package/src/flow-layout/flow-layout.ts +77 -1
  24. package/src/flow-layout/layout-graph.test.ts +83 -2
  25. package/src/flow-layout/layout-graph.ts +23 -15
  26. package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
  27. package/src/flow-node/flow-node.stories.tsx +151 -0
  28. package/src/flow-node/flow-node.tsx +56 -1
  29. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  30. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  31. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  33. package/src/flow-self-loop-edge/index.ts +13 -0
  34. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  35. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  36. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  37. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  38. package/src/flow-smart-edge/index.ts +5 -1
  39. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  40. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  41. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  42. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  43. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  44. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  45. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  46. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  47. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  48. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  50. package/src/flow-weighted-edge/index.ts +18 -0
  51. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  52. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  53. package/src/index.ts +9 -0
  54. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  55. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  56. package/src/inspector-panel/inspector-panel.tsx +13 -3
  57. package/src/legend/index.ts +7 -1
  58. package/src/legend/legend.stories.tsx +126 -0
  59. package/src/legend/legend.test.tsx +169 -0
  60. package/src/legend/legend.tsx +214 -3
  61. package/src/templates-flow-workspace.stories.tsx +1 -1
  62. package/src/testing/canvas-framing.test.ts +107 -0
  63. package/src/testing/canvas-framing.ts +396 -0
  64. package/src/testing/edge-anchors.ts +107 -0
  65. package/src/testing/index.ts +36 -0
  66. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -11,7 +11,7 @@ import { FlowGroupNode } from "./flow-group-node";
11
11
  const nodeTypes = { brand: FlowNode, group: FlowGroupNode };
12
12
 
13
13
  const toolbarButton =
14
- "rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
14
+ "rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50";
15
15
 
16
16
  const meta = {
17
17
  title: "Flow/FlowGroupNode",
@@ -3,6 +3,7 @@ import { Handle, NodeResizer, Position, useNodes, type Node, type NodeProps } fr
3
3
  import { ChevronDown, ChevronRight } from "lucide-react";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import { useFlowGroups } from "../use-flow-groups";
6
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
6
7
 
7
8
  /** Visual accent for a group container. */
8
9
  export type FlowGroupTone = "default" | "accent" | "success" | "warning" | "destructive";
@@ -29,18 +30,33 @@ export interface FlowGroupNodeData extends Record<string, unknown> {
29
30
 
30
31
  export type BrandFlowGroupNode = Node<FlowGroupNodeData, "group">;
31
32
 
32
- const toneAccent: Record<FlowGroupTone, string> = {
33
+ // #124 split this map in two: it used to serve both the icon slot (a MARK,
34
+ // >=3:1) and the child-count text badge (running text, >=4.5:1) from one set
35
+ // of values, which is exactly the fill-rung-as-text shape #124 fixes
36
+ // elsewhere. `accent` is unchanged (`#399` already put it on the ink rung for
37
+ // both slots — legal, since the ink rung clears the 3:1 mark bar trivially).
38
+
39
+ /** Icon-slot tone (the leading glyph next to the title) — the 3:1 mark rung. */
40
+ const toneMark: Record<FlowGroupTone, string> = {
33
41
  default: "text-muted-foreground",
34
- // #399 — the group LABEL is text on the canvas, so it takes the on-surface
35
- // `-text` rung. (The status rows below stay on their fill rungs; those are a
36
- // separate, still-open question, not part of #399's enumerated call sites.)
37
42
  accent: "text-primary-text",
38
43
  success: "text-success",
39
44
  warning: "text-warning",
40
45
  destructive: "text-destructive",
41
46
  };
42
47
 
43
- const handleClassName = "!size-2 !border-2 !border-flow-group-border !bg-flow-group";
48
+ /** Child-count badge tone running text, so it takes the >=4.5:1 ink rung. */
49
+ const toneInk: Record<FlowGroupTone, string> = {
50
+ default: "text-muted-foreground",
51
+ accent: "text-primary-text",
52
+ success: "text-success-text",
53
+ warning: "text-warning-text",
54
+ destructive: "text-destructive-text",
55
+ };
56
+
57
+ // `FLOW_HANDLE_ANCHOR_CLASS` last: a connector dot must never be in flight when React
58
+ // Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
59
+ const handleClassName = `!size-2 !border-2 !border-flow-group-border !bg-flow-group ${FLOW_HANDLE_ANCHOR_CLASS}`;
44
60
 
45
61
  /**
46
62
  * Branded group container node. Register it as `nodeTypes={{ group: FlowGroupNode }}`
@@ -103,7 +119,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
103
119
  className={cn(
104
120
  "-ml-1 grid size-5 shrink-0 place-items-center rounded",
105
121
  "text-muted-foreground hover:bg-muted hover:text-foreground",
106
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
122
+ "focus-ring",
107
123
  )}
108
124
  >
109
125
  {collapsed ? (
@@ -114,7 +130,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
114
130
  </button>
115
131
 
116
132
  {data.icon ? (
117
- <span className={cn("[&_svg]:size-4", toneAccent[tone])}>{data.icon}</span>
133
+ <span className={cn("[&_svg]:size-4", toneMark[tone])}>{data.icon}</span>
118
134
  ) : null}
119
135
 
120
136
  <span className="min-w-0 flex-1 truncate text-body font-medium">{data.title}</span>
@@ -122,7 +138,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
122
138
  <span
123
139
  className={cn(
124
140
  "shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-meta font-medium tabular-nums",
125
- toneAccent[tone],
141
+ toneInk[tone],
126
142
  )}
127
143
  aria-label={`${childCount} ${childCount === 1 ? "node" : "nodes"}`}
128
144
  >
@@ -0,0 +1,97 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ /**
5
+ * The engine is mocked so the handle's CLASS LIST is observable in jsdom, which has no
6
+ * layout and therefore cannot reproduce the defect itself. The real lock on the picture
7
+ * is `Process/ProcessMap > Left To Right`, a Chromium story test that measures every
8
+ * edge endpoint against the dot it should terminate on; this file locks the mechanism
9
+ * that story exposed — a connector dot that carries no transition, so it is never in
10
+ * flight when React Flow measures `handleBounds`.
11
+ */
12
+ vi.mock("@xyflow/react", () => {
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
14
+ const React = require("react");
15
+ return {
16
+ Handle: ({ type, className }: { type: string; className?: string }) =>
17
+ React.createElement("div", { "data-testid": `handle-${type}`, className }),
18
+ NodeResizer: () => React.createElement("div", { "data-testid": "node-resizer" }),
19
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
20
+ useNodes: () => [] as unknown[],
21
+ useReactFlow: () => ({
22
+ getNodes: () => [],
23
+ getEdges: () => [],
24
+ setNodes: () => {},
25
+ setEdges: () => {},
26
+ }),
27
+ getNodesBounds: () => ({ x: 0, y: 0, width: 0, height: 0 }),
28
+ };
29
+ });
30
+
31
+ import type { Node, NodeProps } from "@xyflow/react";
32
+ import { FlowGroupNode, type BrandFlowGroupNode } from "../flow-group-node/flow-group-node";
33
+ import { FlowNode, type BrandFlowNode } from "../flow-node/flow-node";
34
+ import {
35
+ FlowPlaceholderNode,
36
+ type BrandFlowPlaceholderNode,
37
+ } from "../flow-placeholder-node/flow-placeholder-node";
38
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "./flow-handle-anchor";
39
+
40
+ afterEach(cleanup);
41
+
42
+ function makeProps<NodeType extends Node>(id: string, data: NodeType["data"]): NodeProps<NodeType> {
43
+ return {
44
+ id,
45
+ data,
46
+ selected: false,
47
+ dragging: false,
48
+ zIndex: 0,
49
+ isConnectable: true,
50
+ draggable: true,
51
+ deletable: true,
52
+ selectable: true,
53
+ positionAbsoluteX: 0,
54
+ positionAbsoluteY: 0,
55
+ width: 160,
56
+ height: 48,
57
+ type: "brand",
58
+ } as NodeProps<NodeType>;
59
+ }
60
+
61
+ describe("FLOW_HANDLE_ANCHOR_CLASS", () => {
62
+ it("switches transitions off rather than shortening them", () => {
63
+ // A duration is not enough: the tokens reduced-motion backstop already forces
64
+ // `transition-duration: 0.01ms !important` on every element, and it is that
65
+ // one-frame transition — on `transition-property: all` — which the handle has to
66
+ // be out of. Only `transition-property: none` takes a dot out of it.
67
+ expect(FLOW_HANDLE_ANCHOR_CLASS).toBe("transition-none");
68
+ });
69
+
70
+ it.each([
71
+ [
72
+ "FlowNode",
73
+ () => render(<FlowNode {...makeProps<BrandFlowNode>("node", { title: "Node" })} />),
74
+ ],
75
+ [
76
+ "FlowGroupNode",
77
+ () =>
78
+ render(<FlowGroupNode {...makeProps<BrandFlowGroupNode>("group", { title: "Group" })} />),
79
+ ],
80
+ [
81
+ "FlowPlaceholderNode",
82
+ () =>
83
+ render(
84
+ <FlowPlaceholderNode
85
+ {...makeProps<BrandFlowPlaceholderNode>("placeholder", { label: "Add node" })}
86
+ />,
87
+ ),
88
+ ],
89
+ ])("%s renders every handle as a static anchor", (_name, renderNode) => {
90
+ renderNode();
91
+ const handles = screen.getAllByTestId(/^handle-/);
92
+ expect(handles.length).toBeGreaterThan(0);
93
+ for (const handle of handles) {
94
+ expect(handle.className.split(/\s+/)).toContain(FLOW_HANDLE_ANCHOR_CLASS);
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The class every React Flow `<Handle>` in this package carries — and the one a
3
+ * consumer's own custom node must carry too.
4
+ *
5
+ * A connector dot is a MEASUREMENT ANCHOR, not a moving part. React Flow measures
6
+ * `handleBounds` from the DOM exactly once per layout change (`updateNodeInternals`,
7
+ * fired from the effect that sees `sourcePosition`/`targetPosition` change) and then
8
+ * draws every edge endpoint from that stored number until something invalidates it.
9
+ * So a dot that is still ON ITS WAY to its new side when that measurement is taken is
10
+ * measured at the wrong place — permanently, because nothing measures again.
11
+ *
12
+ * That is not hypothetical, and it is not only about author-written transitions:
13
+ *
14
+ * - `@elabs-ai/components-tokens`' reduced-motion backstop (`themes.css`, MOTION GATE)
15
+ * sets `transition-duration: 0.01ms !important` on `*` so third-party engines that
16
+ * ignore the `--t-*` tokens (Monaco, `@xyflow/react`, Streamdown) cannot animate.
17
+ * `transition-property` is left at its initial value, `all` — so under
18
+ * `prefers-reduced-motion: reduce` that rule does not remove a transition from a
19
+ * handle, it CREATES one: every geometric property of every element becomes
20
+ * transitioned, for one frame.
21
+ * - One frame is all it takes. Measured on `ProcessMap direction="LR"`: at the commit
22
+ * that flips the handles from top/bottom to left/right, the dot's computed box was
23
+ * still the OLD one (`getAnimations()` on it returned live `left`+`top`+`transform`
24
+ * transitions), React Flow measured `{ x: 164, y: 45.5 }` where the settled DOM has
25
+ * `{ x: 172, y: 37.5 }`, and every edge on the map then terminated up to 67 px away
26
+ * from the dot it points at — for reduced-motion readers only, forever.
27
+ *
28
+ * `transition-property: none` is the fix at the right layer: it costs nothing under
29
+ * normal motion (no rule animates a handle there anyway) and it makes the reposition
30
+ * synchronous, so whenever React Flow measures, it measures the settled dot.
31
+ *
32
+ * The literal is written out here rather than assembled, because Tailwind extracts
33
+ * candidates from source TEXT: this file is scanned, so the utility is emitted, and
34
+ * components may interpolate the constant freely.
35
+ */
36
+ export const FLOW_HANDLE_ANCHOR_CLASS = "transition-none";
@@ -0,0 +1 @@
1
+ export { FLOW_HANDLE_ANCHOR_CLASS } from "./flow-handle-anchor";
@@ -65,7 +65,7 @@ function AutoLayoutButton({ direction }: { direction: FlowLayoutDirection }) {
65
65
  type="button"
66
66
  disabled={layouting}
67
67
  onClick={() => layout(direction)}
68
- className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
68
+ className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50"
69
69
  >
70
70
  Auto layout ({direction === "TB" ? "top → bottom" : "left → right"})
71
71
  </button>
@@ -127,7 +127,7 @@ function AutoLayoutGraphButton({ algorithm }: { algorithm: LayoutAlgorithm }) {
127
127
  type="button"
128
128
  disabled={layouting}
129
129
  onClick={handleClick}
130
- className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
130
+ className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50"
131
131
  >
132
132
  Auto layout ({ALGORITHM_LABEL[algorithm]})
133
133
  </button>
@@ -130,4 +130,95 @@ describe("layoutFlow", () => {
130
130
  const wideGap = wide.nodes[1]!.position.y - wide.nodes[0]!.position.y;
131
131
  expect(wideGap).toBeGreaterThan(tightGap);
132
132
  });
133
+ describe("back edges and self-loops (RM-044)", () => {
134
+ /** The canonical rework fixture: A → B → C, C loops back to B, and B repeats itself. */
135
+ function reworkGraph(): { nodes: Node[]; edges: Edge[] } {
136
+ return {
137
+ nodes: chainNodes(),
138
+ edges: [
139
+ { id: "e-ab", source: "a", target: "b" },
140
+ { id: "e-bc", source: "b", target: "c" },
141
+ { id: "e-cb", source: "c", target: "b" },
142
+ { id: "e-bb", source: "b", target: "b" },
143
+ ],
144
+ };
145
+ }
146
+
147
+ it("reports the reversed edge in backEdges and the self-loop in selfLoops", () => {
148
+ const { nodes, edges } = reworkGraph();
149
+ const result = layoutFlow(nodes, edges);
150
+
151
+ expect(result.backEdges).toEqual(["e-cb"]);
152
+ expect(result.selfLoops).toEqual(["e-bb"]);
153
+ // Both classifications are disjoint — a self-loop is never a back edge.
154
+ expect(result.backEdges).not.toContain("e-bb");
155
+ });
156
+
157
+ it("keeps the self-loop out of the rank computation", () => {
158
+ const { nodes, edges } = reworkGraph();
159
+ const withLoop = layoutFlow(nodes, edges);
160
+ const withoutLoop = layoutFlow(
161
+ nodes,
162
+ edges.filter((e) => e.id !== "e-bb"),
163
+ );
164
+
165
+ // Identical positions: the loop contributed nothing to the layout.
166
+ expect(withLoop.nodes.map((n) => n.position)).toEqual(
167
+ withoutLoop.nodes.map((n) => n.position),
168
+ );
169
+ });
170
+
171
+ it("returns every edge untouched, self-loops included", () => {
172
+ const { nodes, edges } = reworkGraph();
173
+ const result = layoutFlow(nodes, edges);
174
+ expect(result.edges).toBe(edges);
175
+ expect(result.edges.map((e) => e.id)).toContain("e-bb");
176
+ });
177
+
178
+ it("produces no NaN position on the rework fixture", () => {
179
+ const { nodes, edges } = reworkGraph();
180
+ for (const n of layoutFlow(nodes, edges).nodes) {
181
+ expect(Number.isFinite(n.position.x)).toBe(true);
182
+ expect(Number.isFinite(n.position.y)).toBe(true);
183
+ }
184
+ });
185
+
186
+ it("classifies the same edge as a back edge in every direction", () => {
187
+ const { nodes, edges } = reworkGraph();
188
+ for (const direction of ["TB", "BT", "LR", "RL"] as const) {
189
+ expect(layoutFlow(nodes, edges, { direction }).backEdges).toEqual(["e-cb"]);
190
+ }
191
+ });
192
+
193
+ it("reports nothing for an acyclic, loop-free graph", () => {
194
+ const result = layoutFlow(chainNodes(), chainEdges());
195
+ expect(result.backEdges).toEqual([]);
196
+ expect(result.selfLoops).toEqual([]);
197
+ });
198
+
199
+ it("counts a same-rank edge as a back edge — it does not advance the process", () => {
200
+ const nodes: Node[] = [
201
+ { id: "a", position: { x: 0, y: 0 }, data: {} },
202
+ { id: "b", position: { x: 0, y: 0 }, data: {} },
203
+ ];
204
+ // Two mutually-referencing nodes: dagre has to break the cycle somewhere.
205
+ const result = layoutFlow(nodes, [
206
+ { id: "e-ab", source: "a", target: "b" },
207
+ { id: "e-ba", source: "b", target: "a" },
208
+ ]);
209
+ expect(result.backEdges).toEqual(["e-ba"]);
210
+ });
211
+
212
+ it("pins node positions on the rework fixture (regression snapshot)", () => {
213
+ const { nodes, edges } = reworkGraph();
214
+ const positions = Object.fromEntries(
215
+ layoutFlow(nodes, edges).nodes.map((n) => [n.id, n.position]),
216
+ );
217
+ expect(positions).toEqual({
218
+ a: { x: 0, y: 0 },
219
+ b: { x: 0, y: 112 },
220
+ c: { x: 0, y: 224 },
221
+ });
222
+ });
223
+ });
133
224
  });
@@ -2,6 +2,20 @@ import dagre from "@dagrejs/dagre";
2
2
  import { Position } from "@xyflow/react";
3
3
  import type { Edge, Node } from "@xyflow/react";
4
4
 
5
+ /** The graphlib graph `dagre.graphlib.Graph` produces — dagre exports no standalone type for it. */
6
+ type DagreGraph = InstanceType<typeof dagre.graphlib.Graph>;
7
+
8
+ /**
9
+ * The fields dagre writes back onto a node label during `layout()`. `rank` is
10
+ * absent from dagre's own `.d.ts` (it is documented output, not declared
11
+ * output), so it is narrowed here rather than asserted at the call site.
12
+ */
13
+ interface DagreNodeLabel {
14
+ x: number;
15
+ y: number;
16
+ rank?: number;
17
+ }
18
+
5
19
  /** Direction dagre lays the graph out in — top-to-bottom, left-to-right, etc. */
6
20
  export type FlowLayoutDirection = "TB" | "LR" | "BT" | "RL";
7
21
 
@@ -35,6 +49,22 @@ export interface FlowLayoutOptions {
35
49
  export interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge = Edge> {
36
50
  nodes: NodeType[];
37
51
  edges: EdgeType[];
52
+ /**
53
+ * Ids of edges that run **against** the layout direction — a rework / retry
54
+ * loop in a process graph. dagre breaks cycles by reversing such edges
55
+ * internally and never surfaces which ones it reversed, so this is derived
56
+ * from the ranks dagre stamps on the laid-out graph: an edge whose source
57
+ * ranks at or after its target went backwards. Render these with
58
+ * `FlowWeightedEdge`'s `variant="back"`.
59
+ */
60
+ backEdges: string[];
61
+ /**
62
+ * Ids of edges whose `source === target`. dagre does not lay out self-loops,
63
+ * so they are withheld from the graph entirely (never `setEdge`-ed) and are
64
+ * returned unchanged in `edges` — they take part in no rank computation and
65
+ * cannot distort the layout. Render these with `FlowSelfLoopEdge`.
66
+ */
67
+ selfLoops: string[];
38
68
  }
39
69
 
40
70
  /** Fallback size used when a node hasn't been measured yet (React Flow's own default node width). */
@@ -58,6 +88,11 @@ function nodeSize(node: Node): { width: number; height: number } {
58
88
  * `node.height`, then a sensible default. Node identity and `data` are left
59
89
  * untouched — only `position` changes.
60
90
  *
91
+ * Also reports the graph's two structural signals — `backEdges` (edges that run
92
+ * against the layout direction) and `selfLoops` (`source === target`). Both are
93
+ * additive fields on the result; a caller that only destructures
94
+ * `{ nodes, edges }` is unaffected.
95
+ *
61
96
  * Pair with `useFlowLayout` to apply the result to a live canvas.
62
97
  */
63
98
  export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
@@ -76,12 +111,22 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
76
111
  graph.setNode(node.id, { width, height });
77
112
  }
78
113
 
114
+ // Self-loops are withheld from dagre entirely: dagre does not lay them out,
115
+ // and feeding them in only perturbs the ranks of a graph they say nothing
116
+ // about. They are re-attached untouched in the returned `edges`.
117
+ const selfLoops: string[] = [];
79
118
  for (const edge of edges) {
119
+ if (edge.source === edge.target) {
120
+ selfLoops.push(edge.id);
121
+ continue;
122
+ }
80
123
  graph.setEdge(edge.source, edge.target);
81
124
  }
82
125
 
83
126
  dagre.layout(graph);
84
127
 
128
+ const backEdges = collectBackEdges(graph, edges);
129
+
85
130
  const handles = HANDLE_BY_DIRECTION[direction];
86
131
  const layoutedNodes = nodes.map((node) => {
87
132
  const dagreNode = graph.node(node.id);
@@ -100,5 +145,36 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
100
145
  };
101
146
  });
102
147
 
103
- return { nodes: layoutedNodes, edges };
148
+ return { nodes: layoutedNodes, edges, backEdges, selfLoops };
149
+ }
150
+
151
+ /**
152
+ * Which edges dagre had to run backwards, derived from the ranks it stamps on
153
+ * the laid-out graph (`graph.node(id).rank`).
154
+ *
155
+ * The obvious alternative — reading `graph.edge(e).reversed` after dagre's
156
+ * `acyclic.run` — is **not usable on the pinned `@dagrejs/dagre` 3.0.0**:
157
+ * cycle breaking happens on an internal copy of the graph, and the caller's
158
+ * graph carries no `reversed` flag once `dagre.layout()` returns (verified
159
+ * against the installed version — every edge label is bare `{ points }`).
160
+ * `rank` *is* on the public graph, and is direction-independent: it counts up
161
+ * along the flow for every `rankdir`, so `rank(source) >= rank(target)` means
162
+ * "this edge does not advance the process" in TB, BT, LR and RL alike.
163
+ *
164
+ * `>=` rather than `>` on purpose: a same-rank edge between two siblings is
165
+ * not forward progress either, and dagre would have had to reverse or flatten
166
+ * it. Self-loops never reach here — they are filtered out before layout.
167
+ * An edge naming a node that isn't in the graph has no ranks to compare and is
168
+ * left out rather than guessed at.
169
+ */
170
+ function collectBackEdges(graph: DagreGraph, edges: Edge[]): string[] {
171
+ const backEdges: string[] = [];
172
+ for (const edge of edges) {
173
+ if (edge.source === edge.target) continue;
174
+ const sourceRank = (graph.node(edge.source) as DagreNodeLabel | undefined)?.rank;
175
+ const targetRank = (graph.node(edge.target) as DagreNodeLabel | undefined)?.rank;
176
+ if (typeof sourceRank !== "number" || typeof targetRank !== "number") continue;
177
+ if (sourceRank >= targetRank) backEdges.push(edge.id);
178
+ }
179
+ return backEdges;
104
180
  }
@@ -36,7 +36,13 @@ function distance(p: { x: number; y: number }, q: { x: number; y: number } = { x
36
36
  describe("layoutGraph", () => {
37
37
  describe("empty / disconnected input", () => {
38
38
  it("returns [] for an empty graph, for every algorithm", () => {
39
- for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
39
+ for (const algorithm of [
40
+ "concentric",
41
+ "force",
42
+ "layered-lr",
43
+ "layered-tb",
44
+ "grid",
45
+ ] as const) {
40
46
  expect(layoutGraph([], [], { algorithm })).toEqual([]);
41
47
  }
42
48
  });
@@ -45,7 +51,13 @@ describe("layoutGraph", () => {
45
51
  const nodes = ["a", "b", "c", "isolated1", "isolated2"].map(node);
46
52
  const edges = [edge("a", "b"), edge("b", "c")];
47
53
 
48
- for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
54
+ for (const algorithm of [
55
+ "concentric",
56
+ "force",
57
+ "layered-lr",
58
+ "layered-tb",
59
+ "grid",
60
+ ] as const) {
49
61
  const laidOut = layoutGraph(nodes, edges, { algorithm, iterations: 20 });
50
62
  for (const n of laidOut) {
51
63
  expect(Number.isFinite(n.position.x)).toBe(true);
@@ -254,4 +266,73 @@ describe("layoutGraph", () => {
254
266
  expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
255
267
  });
256
268
  });
269
+ describe("layered-tb", () => {
270
+ it("delegates to layoutFlow direction=TB, ordering a chain top-to-bottom", () => {
271
+ const { nodes, edges } = chainGraph();
272
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
273
+ const [a, b, c] = laidOut as [Node, Node, Node];
274
+ expect(a.position.y).toBeLessThan(b.position.y);
275
+ expect(b.position.y).toBeLessThan(c.position.y);
276
+ });
277
+
278
+ it("stamps bottom-out / top-in handle sides (not just position)", () => {
279
+ const { nodes, edges } = chainGraph();
280
+ const [a] = layoutGraph(nodes, edges, { algorithm: "layered-tb" }) as [Node];
281
+ expect(a.sourcePosition).toBe("bottom");
282
+ expect(a.targetPosition).toBe("top");
283
+ });
284
+
285
+ it("maps spacing.y onto rank spacing (the axis TB ranks along)", () => {
286
+ const { nodes, edges } = chainGraph();
287
+ const tight = layoutGraph(nodes, edges, {
288
+ algorithm: "layered-tb",
289
+ spacing: { x: 10, y: 10 },
290
+ });
291
+ const wide = layoutGraph(nodes, edges, {
292
+ algorithm: "layered-tb",
293
+ spacing: { x: 10, y: 500 },
294
+ });
295
+ const tightGap = tight[1]!.position.y - tight[0]!.position.y;
296
+ const wideGap = wide[1]!.position.y - wide[0]!.position.y;
297
+ expect(wideGap).toBeGreaterThan(tightGap);
298
+ });
299
+
300
+ it("respects a custom nodeSize without permanently resizing the nodes", () => {
301
+ const nodes: Node[] = [
302
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, data: {} },
303
+ { id: "b", type: "brand", position: { x: 0, y: 0 }, data: {} },
304
+ ];
305
+ const laidOut = layoutGraph(nodes, [edge("a", "b")], {
306
+ algorithm: "layered-tb",
307
+ nodeSize: () => ({ width: 200, height: 400 }),
308
+ });
309
+ expect(laidOut[1]!.position.y - laidOut[0]!.position.y).toBeGreaterThanOrEqual(400 + 72 - 1);
310
+ expect(laidOut[0]).not.toHaveProperty("measured");
311
+ });
312
+
313
+ it("is the same layout as layered-lr with the axes swapped", () => {
314
+ // A fork exercises BOTH axes: a rank progression and a within-rank stack.
315
+ // Square nodes + isotropic spacing make the two layouts exact mirrors, so
316
+ // the assertion is "x and y trade places", not "the numbers look similar".
317
+ const nodes = ["a", "b", "c"].map(node);
318
+ const edges = [edge("a", "b"), edge("a", "c")];
319
+ const options = {
320
+ spacing: { x: 150, y: 150 },
321
+ nodeSize: () => ({ width: 100, height: 100 }),
322
+ };
323
+ const lr = layoutGraph(nodes, edges, { ...options, algorithm: "layered-lr" });
324
+ const tb = layoutGraph(nodes, edges, { ...options, algorithm: "layered-tb" });
325
+
326
+ expect(tb.map((n) => n.position)).toEqual(
327
+ lr.map((n) => ({ x: n.position.y, y: n.position.x })),
328
+ );
329
+ });
330
+
331
+ it("is deterministic", () => {
332
+ const { nodes, edges } = chainGraph();
333
+ const first = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
334
+ const second = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
335
+ expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
336
+ });
337
+ });
257
338
  });
@@ -8,12 +8,12 @@ import {
8
8
  type SimulationNodeDatum,
9
9
  } from "d3-force";
10
10
  import type { Edge, Node } from "@xyflow/react";
11
- import { HANDLE_BY_DIRECTION, layoutFlow } from "./flow-layout";
11
+ import { HANDLE_BY_DIRECTION, layoutFlow, type FlowLayoutDirection } from "./flow-layout";
12
12
 
13
13
  /** Which generic graph-geometry algorithm `layoutGraph` should run. */
14
- export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "grid";
14
+ export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "layered-tb" | "grid";
15
15
 
16
- /** Horizontal/vertical gap used by the `"grid"` and `"layered-lr"` algorithms. */
16
+ /** Horizontal/vertical gap used by the `"grid"` and `"layered-*"` algorithms. */
17
17
  export interface LayoutSpacing {
18
18
  x: number;
19
19
  y: number;
@@ -31,7 +31,7 @@ export interface LayoutOptions {
31
31
  ringRadius?: number;
32
32
  /** `"force"` only — number of synchronous simulation ticks to run. @default 300 */
33
33
  iterations?: number;
34
- /** `"grid"` / `"layered-lr"` — gap between nodes. @default {x:200,y:120} for grid. */
34
+ /** `"grid"` / `"layered-lr"` / `"layered-tb"` — gap between nodes. @default {x:200,y:120} for grid. */
35
35
  spacing?: LayoutSpacing;
36
36
  /** Resolve a node's size by id. Falls back to `measured`/`width`/`height`/a sensible default. */
37
37
  nodeSize?: (id: string) => { width: number; height: number };
@@ -66,7 +66,10 @@ function resolveNodeSize(
66
66
  * Node identity and `data` are left untouched — only `position` changes.
67
67
  * Pair with `useAutoLayout` for a memoized hook form.
68
68
  *
69
- * - `"layered-lr"` delegates to the dagre-powered `layoutFlow` (direction `"LR"`).
69
+ * - `"layered-lr"` / `"layered-tb"` delegate to the dagre-powered `layoutFlow`
70
+ * (direction `"LR"` / `"TB"`). `spacing.x` is always the horizontal gap and
71
+ * `spacing.y` the vertical one, so the two differ only in which axis carries
72
+ * the ranks.
70
73
  * - `"concentric"` places `centerId` (or the highest-degree node) at the
71
74
  * origin, with BFS shells at `ring × ringRadius`; disconnected nodes land in
72
75
  * one extra outer ring so positions are never `NaN`.
@@ -83,7 +86,9 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
83
86
 
84
87
  switch (options.algorithm) {
85
88
  case "layered-lr":
86
- return layoutLayeredLr(nodes, edges, options);
89
+ return layoutLayered(nodes, edges, options, "LR");
90
+ case "layered-tb":
91
+ return layoutLayered(nodes, edges, options, "TB");
87
92
  case "concentric":
88
93
  return layoutConcentric(nodes, edges, options);
89
94
  case "force":
@@ -97,10 +102,11 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
97
102
  }
98
103
  }
99
104
 
100
- function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
105
+ function layoutLayered<NodeType extends Node, EdgeType extends Edge>(
101
106
  nodes: NodeType[],
102
107
  edges: EdgeType[],
103
108
  options: LayoutOptions,
109
+ direction: FlowLayoutDirection,
104
110
  ): NodeType[] {
105
111
  const { spacing, nodeSize } = options;
106
112
 
@@ -114,17 +120,19 @@ function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
114
120
  })
115
121
  : nodes;
116
122
 
123
+ // Ranks progress along the layout axis and nodes stack across it, so the two
124
+ // directions map the SAME `spacing.x`/`spacing.y` onto dagre's rank/node sep
125
+ // with the axes swapped: LR ranks horizontally (x), TB ranks vertically (y).
126
+ const horizontalRanks = direction === "LR" || direction === "RL";
117
127
  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,
128
+ direction,
129
+ nodeSpacing: horizontalRanks ? spacing?.y : spacing?.x,
130
+ rankSpacing: horizontalRanks ? spacing?.x : spacing?.y,
123
131
  });
124
132
 
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.
133
+ // Carry the handle sides layoutFlow stamped on (LR right-out/left-in, TB
134
+ // bottom-out/top-in), not just the position — otherwise the layout moves the
135
+ // nodes but leaves the anchors on the wrong sides.
128
136
  return nodes.map((node, i) => ({
129
137
  ...node,
130
138
  sourcePosition: laidOut[i]!.sourcePosition,