@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.
- package/README.md +8 -8
- package/dist/index.d.ts +734 -20
- package/dist/index.js +976 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +13 -3
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +169 -0
- package/src/legend/legend.tsx +214 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- 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-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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",
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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 [
|
|
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 [
|
|
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
|
|
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"`
|
|
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
|
|
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
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
126
|
-
// not just the position — otherwise the layout
|
|
127
|
-
// anchors on
|
|
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,
|