@elabs-ai/components-flow 4.0.0 → 4.2.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 +738 -20
- package/dist/index.js +985 -182
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
- package/src/__contract__/legend.contract.test.tsx +49 -0
- 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 +103 -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 +22 -9
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +180 -0
- package/src/legend/legend.tsx +222 -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
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BaseEdge,
|
|
3
2
|
getBezierPath,
|
|
4
3
|
useInternalNode,
|
|
5
4
|
type EdgeProps,
|
|
6
5
|
type InternalNode,
|
|
6
|
+
type Node,
|
|
7
|
+
type Position,
|
|
7
8
|
} from "@xyflow/react";
|
|
8
|
-
import
|
|
9
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
10
|
+
import type { FlowHandleSide, FlowNodeData } from "../flow-node";
|
|
9
11
|
import {
|
|
12
|
+
pickClosestAnchors,
|
|
10
13
|
pickClosestHandles,
|
|
11
|
-
|
|
14
|
+
positionToSide,
|
|
12
15
|
sideToPosition,
|
|
13
|
-
|
|
16
|
+
type HandleAnchor,
|
|
14
17
|
type NodeRect,
|
|
15
18
|
} from "./smart-edge-geometry";
|
|
16
19
|
|
|
20
|
+
type HandleKind = "source" | "target";
|
|
21
|
+
|
|
17
22
|
function toRect(node: InternalNode): NodeRect {
|
|
18
23
|
return {
|
|
19
24
|
x: node.internals.positionAbsolute.x,
|
|
@@ -24,52 +29,137 @@ function toRect(node: InternalNode): NodeRect {
|
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
+
* The candidate anchors for one end of the edge, read from React Flow's
|
|
33
|
+
* **measured** handle bounds — the DOM truth about where each dot was painted.
|
|
34
|
+
*
|
|
35
|
+
* `handleBounds` entries are node-relative, so the node's absolute position is
|
|
36
|
+
* added back; the dot's centre is the entry's own box centre, which is why this
|
|
37
|
+
* tracks any handle size or offset the node's CSS chooses instead of assuming
|
|
38
|
+
* the dot sits on the side's midpoint.
|
|
39
|
+
*
|
|
40
|
+
* `handleId` pins the result to one handle when the edge names one
|
|
41
|
+
* (`sourceHandle`/`targetHandle`), so an explicitly wired edge is never
|
|
42
|
+
* re-routed to a different dot.
|
|
43
|
+
*/
|
|
44
|
+
function anchorsOf(
|
|
45
|
+
node: InternalNode,
|
|
46
|
+
kind: HandleKind,
|
|
47
|
+
handleId: string | null | undefined,
|
|
48
|
+
): HandleAnchor[] {
|
|
49
|
+
const bounds = node.internals.handleBounds?.[kind] ?? [];
|
|
50
|
+
const origin = node.internals.positionAbsolute;
|
|
51
|
+
return bounds
|
|
52
|
+
.filter((handle) => (handleId ? handle.id === handleId : true))
|
|
53
|
+
.map((handle) => ({
|
|
54
|
+
id: handle.id ?? null,
|
|
55
|
+
x: origin.x + handle.x + handle.width / 2,
|
|
56
|
+
y: origin.y + handle.y + handle.height / 2,
|
|
57
|
+
side: positionToSide[handle.position],
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The sides a `FlowNode` genuinely renders a handle on, for the pre-measurement
|
|
63
|
+
* fallback only: its `data.handles` config when it has one, otherwise the
|
|
64
|
+
* single default handle React Flow's layout direction placed
|
|
65
|
+
* (`targetPosition`/`sourcePosition`, defaulting to top-in / bottom-out).
|
|
66
|
+
*/
|
|
67
|
+
function declaredSides(node: InternalNode, kind: HandleKind): FlowHandleSide[] {
|
|
68
|
+
const userNode = node.internals.userNode as Node<Record<string, unknown>>;
|
|
69
|
+
const data = userNode.data as FlowNodeData | undefined;
|
|
70
|
+
const configured = data?.handles?.[kind];
|
|
71
|
+
if (configured?.length) return [...configured];
|
|
72
|
+
const position = (kind === "target" ? userNode.targetPosition : userNode.sourcePosition) as
|
|
73
|
+
| Position
|
|
74
|
+
| undefined;
|
|
75
|
+
if (position) return [positionToSide[position]];
|
|
76
|
+
return [kind === "target" ? "top" : "bottom"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Branded edge that picks the closest source/target **handle** pair and routes a
|
|
81
|
+
* bezier between them. Anchors are recomputed every render, so they flip as
|
|
82
|
+
* nodes are dragged. Register it in `edgeTypes={{ smart: FlowSmartEdge }}`; give
|
|
83
|
+
* the connected nodes a `data.handles` config (e.g. `FLOW_ALL_SIDE_HANDLES`) to
|
|
84
|
+
* offer it more than the default two anchors. Uses the `--flow-edge` token,
|
|
85
|
+
* matching `FlowEdge`.
|
|
86
|
+
*
|
|
87
|
+
* ## The path terminates ON the handle dot
|
|
88
|
+
*
|
|
89
|
+
* The endpoints come from React Flow's **measured `handleBounds`** — the centre
|
|
90
|
+
* of the rendered dot — not from a point derived from the node's rectangle.
|
|
91
|
+
* That is a correctness property, not a refinement: an earlier version slid each
|
|
92
|
+
* anchor along the chosen side toward the other node (to fan out edges sharing a
|
|
93
|
+
* side) and picked sides from a four-way fallback list, so a line could meet the
|
|
94
|
+
* node up to half a side away from any dot — measured at ~22px on multi-side
|
|
95
|
+
* nodes and ~124px on nodes carrying only the default top/bottom handles, where
|
|
96
|
+
* a left/right side with no handle at all could be chosen. Two edges leaving the
|
|
97
|
+
* same handle now leave from the same point and diverge, exactly as React Flow's
|
|
98
|
+
* own edges do.
|
|
32
99
|
*/
|
|
33
|
-
export function FlowSmartEdge({
|
|
100
|
+
export function FlowSmartEdge({
|
|
101
|
+
id,
|
|
102
|
+
source,
|
|
103
|
+
target,
|
|
104
|
+
sourceHandleId,
|
|
105
|
+
targetHandleId,
|
|
106
|
+
markerEnd,
|
|
107
|
+
style,
|
|
108
|
+
}: EdgeProps) {
|
|
34
109
|
const sourceNode = useInternalNode(source);
|
|
35
110
|
const targetNode = useInternalNode(target);
|
|
36
111
|
if (!sourceNode || !targetNode) return null;
|
|
37
112
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const sourceRect = toRect(sourceNode);
|
|
42
|
-
const targetRect = toRect(targetNode);
|
|
43
|
-
|
|
44
|
-
// Pick which side of each node the edge exits/enters (the facing handle side).
|
|
45
|
-
const { sourceSide, targetSide } = pickClosestHandles(
|
|
46
|
-
sourceRect,
|
|
47
|
-
sourceData?.handles?.source ?? [],
|
|
48
|
-
targetRect,
|
|
49
|
-
targetData?.handles?.target ?? [],
|
|
113
|
+
const picked = pickClosestAnchors(
|
|
114
|
+
anchorsOf(sourceNode, "source", sourceHandleId),
|
|
115
|
+
anchorsOf(targetNode, "target", targetHandleId),
|
|
50
116
|
);
|
|
51
117
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
118
|
+
let sx: number;
|
|
119
|
+
let sy: number;
|
|
120
|
+
let tx: number;
|
|
121
|
+
let ty: number;
|
|
122
|
+
let sourcePosition: Position;
|
|
123
|
+
let targetPosition: Position;
|
|
124
|
+
|
|
125
|
+
if (picked) {
|
|
126
|
+
({ x: sx, y: sy } = picked.source);
|
|
127
|
+
({ x: tx, y: ty } = picked.target);
|
|
128
|
+
sourcePosition = sideToPosition[picked.source.side];
|
|
129
|
+
targetPosition = sideToPosition[picked.target.side];
|
|
130
|
+
} else {
|
|
131
|
+
// Handles not measured yet (first render). Fall back to the side midpoints
|
|
132
|
+
// of the sides the nodes actually declare, so the edge is drawn sanely for
|
|
133
|
+
// the frame or two before React Flow reports real handle bounds.
|
|
134
|
+
const fallback = pickClosestHandles(
|
|
135
|
+
toRect(sourceNode),
|
|
136
|
+
declaredSides(sourceNode, "source"),
|
|
137
|
+
toRect(targetNode),
|
|
138
|
+
declaredSides(targetNode, "target"),
|
|
139
|
+
);
|
|
140
|
+
({ sx, sy, tx, ty } = fallback);
|
|
141
|
+
sourcePosition = sideToPosition[fallback.sourceSide];
|
|
142
|
+
targetPosition = sideToPosition[fallback.targetSide];
|
|
143
|
+
}
|
|
57
144
|
|
|
58
145
|
const [edgePath] = getBezierPath({
|
|
59
|
-
sourceX:
|
|
60
|
-
sourceY:
|
|
61
|
-
sourcePosition
|
|
62
|
-
targetX:
|
|
63
|
-
targetY:
|
|
64
|
-
targetPosition
|
|
146
|
+
sourceX: sx,
|
|
147
|
+
sourceY: sy,
|
|
148
|
+
sourcePosition,
|
|
149
|
+
targetX: tx,
|
|
150
|
+
targetY: ty,
|
|
151
|
+
targetPosition,
|
|
65
152
|
});
|
|
66
153
|
|
|
67
154
|
return (
|
|
68
|
-
<
|
|
155
|
+
<FlowEdgePath
|
|
69
156
|
id={id}
|
|
70
157
|
path={edgePath}
|
|
71
158
|
markerEnd={markerEnd}
|
|
72
|
-
|
|
159
|
+
data-slot="flow-smart-edge"
|
|
160
|
+
stroke="var(--flow-edge)"
|
|
161
|
+
strokeWidth={1.5}
|
|
162
|
+
style={style}
|
|
73
163
|
/>
|
|
74
164
|
);
|
|
75
165
|
}
|
|
@@ -2,8 +2,12 @@ export { FlowSmartEdge } from "./flow-smart-edge";
|
|
|
2
2
|
export {
|
|
3
3
|
HANDLE_SIDES,
|
|
4
4
|
sideToPosition,
|
|
5
|
+
positionToSide,
|
|
5
6
|
handlePoint,
|
|
7
|
+
pickClosestAnchors,
|
|
6
8
|
pickClosestHandles,
|
|
7
|
-
type
|
|
9
|
+
type ClosestAnchors,
|
|
8
10
|
type ClosestHandles,
|
|
11
|
+
type HandleAnchor,
|
|
12
|
+
type NodeRect,
|
|
9
13
|
} from "./smart-edge-geometry";
|
|
@@ -8,11 +8,12 @@ vi.mock("@xyflow/react", () => ({
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
handlePoint,
|
|
11
|
+
pickClosestAnchors,
|
|
11
12
|
pickClosestHandles,
|
|
12
|
-
|
|
13
|
+
positionToSide,
|
|
13
14
|
sideToPosition,
|
|
14
|
-
slideAnchor,
|
|
15
15
|
HANDLE_SIDES,
|
|
16
|
+
type HandleAnchor,
|
|
16
17
|
type NodeRect,
|
|
17
18
|
} from "./smart-edge-geometry";
|
|
18
19
|
|
|
@@ -23,6 +24,13 @@ const rect = (x: number, y: number, width = 100, height = 60): NodeRect => ({
|
|
|
23
24
|
height,
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
const anchor = (
|
|
28
|
+
id: HandleAnchor["id"],
|
|
29
|
+
x: number,
|
|
30
|
+
y: number,
|
|
31
|
+
side: HandleAnchor["side"],
|
|
32
|
+
): HandleAnchor => ({ id, x, y, side });
|
|
33
|
+
|
|
26
34
|
describe("handlePoint", () => {
|
|
27
35
|
it("returns the midpoint of each side", () => {
|
|
28
36
|
const r = rect(0, 0, 100, 60);
|
|
@@ -33,20 +41,81 @@ describe("handlePoint", () => {
|
|
|
33
41
|
});
|
|
34
42
|
});
|
|
35
43
|
|
|
36
|
-
describe("sideToPosition", () => {
|
|
44
|
+
describe("sideToPosition / positionToSide", () => {
|
|
37
45
|
it("maps every side to the matching Position value", () => {
|
|
38
46
|
expect(sideToPosition.top).toBe("top");
|
|
39
47
|
expect(sideToPosition.right).toBe("right");
|
|
40
48
|
expect(sideToPosition.bottom).toBe("bottom");
|
|
41
49
|
expect(sideToPosition.left).toBe("left");
|
|
42
50
|
});
|
|
51
|
+
|
|
52
|
+
it("round-trips every side", () => {
|
|
53
|
+
for (const side of HANDLE_SIDES) {
|
|
54
|
+
expect(positionToSide[sideToPosition[side]]).toBe(side);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
43
57
|
});
|
|
44
58
|
|
|
45
|
-
describe("
|
|
59
|
+
describe("pickClosestAnchors", () => {
|
|
60
|
+
// A node at (0,0,100,60) with all-side handles, and one 300px to its right.
|
|
61
|
+
const leftNode: HandleAnchor[] = [
|
|
62
|
+
anchor("top", 50, 0, "top"),
|
|
63
|
+
anchor("right", 100, 30, "right"),
|
|
64
|
+
anchor("bottom", 50, 60, "bottom"),
|
|
65
|
+
anchor("left", 0, 30, "left"),
|
|
66
|
+
];
|
|
67
|
+
const rightNode: HandleAnchor[] = [
|
|
68
|
+
anchor("top", 350, 0, "top"),
|
|
69
|
+
anchor("right", 400, 30, "right"),
|
|
70
|
+
anchor("bottom", 350, 60, "bottom"),
|
|
71
|
+
anchor("left", 300, 30, "left"),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
it("returns the closest facing pair", () => {
|
|
75
|
+
const picked = pickClosestAnchors(leftNode, rightNode);
|
|
76
|
+
expect(picked?.source.id).toBe("right");
|
|
77
|
+
expect(picked?.target.id).toBe("left");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns the anchor coordinates VERBATIM — never a derived point", () => {
|
|
81
|
+
// This is the whole contract: the edge terminates exactly where the dot was
|
|
82
|
+
// measured, so no arithmetic may sit between the two.
|
|
83
|
+
const picked = pickClosestAnchors(leftNode, rightNode);
|
|
84
|
+
expect(picked?.source).toEqual(anchor("right", 100, 30, "right"));
|
|
85
|
+
expect(picked?.target).toEqual(anchor("left", 300, 30, "left"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("picks a stacked pair when the other node is below", () => {
|
|
89
|
+
const below = rightNode.map((a) => ({ ...a, x: a.x - 300, y: a.y + 300 }));
|
|
90
|
+
const picked = pickClosestAnchors(leftNode, below);
|
|
91
|
+
expect(picked?.source.id).toBe("bottom");
|
|
92
|
+
expect(picked?.target.id).toBe("top");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("uses the only handle a node has, whatever side it faces", () => {
|
|
96
|
+
// A default FlowNode: one bottom source, one top target. It must never be
|
|
97
|
+
// routed to a left/right anchor it does not render.
|
|
98
|
+
const picked = pickClosestAnchors(
|
|
99
|
+
[anchor(null, 50, 60, "bottom")],
|
|
100
|
+
[anchor(null, 350, 0, "top")],
|
|
101
|
+
);
|
|
102
|
+
expect(picked?.source.side).toBe("bottom");
|
|
103
|
+
expect(picked?.target.side).toBe("top");
|
|
104
|
+
expect(picked?.source).toEqual({ id: null, x: 50, y: 60, side: "bottom" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns undefined when either end has no measured handle", () => {
|
|
108
|
+
expect(pickClosestAnchors([], rightNode)).toBeUndefined();
|
|
109
|
+
expect(pickClosestAnchors(leftNode, [])).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("pickClosestHandles (pre-measurement fallback)", () => {
|
|
46
114
|
it("target to the right → source uses its right handle, target its left", () => {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
115
|
+
const picked = pickClosestHandles(rect(0, 0), ["right", "left"], rect(300, 0), [
|
|
116
|
+
"left",
|
|
117
|
+
"right",
|
|
118
|
+
]);
|
|
50
119
|
|
|
51
120
|
expect(picked.sourceSide).toBe("right");
|
|
52
121
|
expect(picked.targetSide).toBe("left");
|
|
@@ -70,51 +139,23 @@ describe("pickClosestHandles", () => {
|
|
|
70
139
|
expect(["bottom", "right"]).toContain(picked.targetSide);
|
|
71
140
|
});
|
|
72
141
|
|
|
142
|
+
it("honours a single declared side instead of widening to all four", () => {
|
|
143
|
+
// A default FlowNode declares bottom-out / top-in only. Even with the other
|
|
144
|
+
// node straight to the right, the anchor stays on a side that has a handle.
|
|
145
|
+
const picked = pickClosestHandles(rect(0, 0), ["bottom"], rect(300, 0), ["top"]);
|
|
146
|
+
expect(picked.sourceSide).toBe("bottom");
|
|
147
|
+
expect(picked.targetSide).toBe("top");
|
|
148
|
+
});
|
|
149
|
+
|
|
73
150
|
it("falls back to all four sides when a side list is empty", () => {
|
|
74
151
|
const picked = pickClosestHandles(rect(0, 0), [], rect(300, 0), []);
|
|
75
152
|
expect(picked.sourceSide).toBe("right");
|
|
76
153
|
expect(picked.targetSide).toBe("left");
|
|
77
154
|
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("rectCenter", () => {
|
|
81
|
-
it("returns the node's centre", () => {
|
|
82
|
-
expect(rectCenter(rect(0, 0, 100, 60))).toEqual({ x: 50, y: 30 });
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("slideAnchor", () => {
|
|
87
|
-
const r = rect(0, 0, 100, 60); // usable right/left span [12, 48], top/bottom span [12, 88]
|
|
88
|
-
|
|
89
|
-
it("stays on the chosen side's axis", () => {
|
|
90
|
-
expect(slideAnchor(r, "right", { x: 999, y: 30 }).x).toBe(100);
|
|
91
|
-
expect(slideAnchor(r, "left", { x: -999, y: 30 }).x).toBe(0);
|
|
92
|
-
expect(slideAnchor(r, "top", { x: 30, y: -999 }).y).toBe(0);
|
|
93
|
-
expect(slideAnchor(r, "bottom", { x: 30, y: 999 }).y).toBe(60);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("slides toward the target and clamps within the inset", () => {
|
|
97
|
-
// Target far above → clamp to the top of the usable range (inset 12).
|
|
98
|
-
expect(slideAnchor(r, "right", { x: 100, y: -500 }).y).toBe(12);
|
|
99
|
-
// Target far below → clamp to the bottom of the usable range.
|
|
100
|
-
expect(slideAnchor(r, "right", { x: 100, y: 500 }).y).toBe(48);
|
|
101
|
-
// Target within range → follows it.
|
|
102
|
-
expect(slideAnchor(r, "right", { x: 100, y: 30 }).y).toBe(30);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("fans two edges on the same side to distinct anchors", () => {
|
|
106
|
-
// The Ingest→Transform (up) vs Ingest→Publish (down) case, both leaving right.
|
|
107
|
-
const up = slideAnchor(r, "right", { x: 400, y: -200 });
|
|
108
|
-
const down = slideAnchor(r, "right", { x: 400, y: 200 });
|
|
109
|
-
expect(up.y).toBeLessThan(down.y);
|
|
110
|
-
expect(up.y).not.toBe(down.y);
|
|
111
|
-
});
|
|
112
155
|
|
|
113
|
-
it("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
expect(
|
|
117
|
-
expect(p.y).toBeGreaterThanOrEqual(0);
|
|
118
|
-
expect(p.y).toBeLessThanOrEqual(10);
|
|
156
|
+
it("anchors on side midpoints, never a slid point", () => {
|
|
157
|
+
const picked = pickClosestHandles(rect(0, 0), ["right"], rect(300, -500), ["left"]);
|
|
158
|
+
// The target is far above; the anchor stays on the side's midpoint.
|
|
159
|
+
expect(picked.sy).toBe(30);
|
|
119
160
|
});
|
|
120
161
|
});
|
|
@@ -20,40 +20,71 @@ export const sideToPosition: Record<FlowHandleSide, Position> = {
|
|
|
20
20
|
left: Position.Left,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
export
|
|
25
|
-
|
|
23
|
+
/** Maps a React Flow `Position` back to the handle side it names. */
|
|
24
|
+
export const positionToSide: Record<Position, FlowHandleSide> = {
|
|
25
|
+
[Position.Top]: "top",
|
|
26
|
+
[Position.Right]: "right",
|
|
27
|
+
[Position.Bottom]: "bottom",
|
|
28
|
+
[Position.Left]: "left",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One candidate connection point: the **measured centre of a rendered handle
|
|
33
|
+
* dot**, in absolute flow coordinates, plus the side it sits on.
|
|
34
|
+
*
|
|
35
|
+
* This is the unit `FlowSmartEdge` routes between. Anchoring on a measured
|
|
36
|
+
* handle — rather than on a point derived from the node's rectangle — is what
|
|
37
|
+
* guarantees the drawn path terminates exactly on the dot the user sees,
|
|
38
|
+
* whatever the handle's size, offset or CSS. See {@link pickClosestAnchors}.
|
|
39
|
+
*/
|
|
40
|
+
export interface HandleAnchor {
|
|
41
|
+
/** The handle's `id`, when it has one (`FlowNode` uses the side name). */
|
|
42
|
+
id: string | null;
|
|
43
|
+
/** Absolute x of the handle dot's centre. */
|
|
44
|
+
x: number;
|
|
45
|
+
/** Absolute y of the handle dot's centre. */
|
|
46
|
+
y: number;
|
|
47
|
+
/** The node side the handle sits on — the bezier's control direction. */
|
|
48
|
+
side: FlowHandleSide;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The chosen source/target anchor pair. */
|
|
52
|
+
export interface ClosestAnchors {
|
|
53
|
+
source: HandleAnchor;
|
|
54
|
+
target: HandleAnchor;
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
58
|
+
* Picks the source/target pair of **rendered handles** with the shortest
|
|
59
|
+
* straight-line distance between them.
|
|
60
|
+
*
|
|
61
|
+
* Returns `undefined` when either side has no candidates, so the caller can
|
|
62
|
+
* fall back to rectangle geometry for a node whose handles have not been
|
|
63
|
+
* measured yet (React Flow populates `handleBounds` on its first measurement
|
|
64
|
+
* pass; before that there is nothing to anchor to).
|
|
36
65
|
*/
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
export function pickClosestAnchors(
|
|
67
|
+
sources: HandleAnchor[],
|
|
68
|
+
targets: HandleAnchor[],
|
|
69
|
+
): ClosestAnchors | undefined {
|
|
70
|
+
if (!sources.length || !targets.length) return undefined;
|
|
71
|
+
|
|
72
|
+
let best: ClosestAnchors | undefined;
|
|
73
|
+
let bestDist = Infinity;
|
|
74
|
+
|
|
75
|
+
for (const source of sources) {
|
|
76
|
+
for (const target of targets) {
|
|
77
|
+
const dx = target.x - source.x;
|
|
78
|
+
const dy = target.y - source.y;
|
|
79
|
+
const dist = dx * dx + dy * dy;
|
|
80
|
+
if (dist < bestDist) {
|
|
81
|
+
bestDist = dist;
|
|
82
|
+
best = { source, target };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
56
85
|
}
|
|
86
|
+
|
|
87
|
+
return best;
|
|
57
88
|
}
|
|
58
89
|
|
|
59
90
|
/** Absolute coordinate of a handle on the given side (the side's midpoint). */
|
|
@@ -70,7 +101,7 @@ export function handlePoint(rect: NodeRect, side: FlowHandleSide): { x: number;
|
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
|
|
73
|
-
/** The
|
|
104
|
+
/** The side pair picked by {@link pickClosestHandles}, with both anchor points. */
|
|
74
105
|
export interface ClosestHandles {
|
|
75
106
|
sourceSide: FlowHandleSide;
|
|
76
107
|
targetSide: FlowHandleSide;
|
|
@@ -81,9 +112,14 @@ export interface ClosestHandles {
|
|
|
81
112
|
}
|
|
82
113
|
|
|
83
114
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
115
|
+
* Rectangle-only fallback: picks the closest pair of **side midpoints** from the
|
|
116
|
+
* candidate sides of each node.
|
|
117
|
+
*
|
|
118
|
+
* `FlowSmartEdge` prefers {@link pickClosestAnchors} (measured handle centres)
|
|
119
|
+
* and only reaches for this before React Flow has measured the nodes. Pass the
|
|
120
|
+
* sides each node genuinely renders a handle on — passing sides that carry no
|
|
121
|
+
* handle produces an anchor floating on a bare border, which is the defect this
|
|
122
|
+
* module exists to avoid.
|
|
87
123
|
*/
|
|
88
124
|
export function pickClosestHandles(
|
|
89
125
|
source: NodeRect,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { backEdgeDetour, type BackEdgeNodeRect } from "./back-edge-geometry";
|
|
3
|
+
|
|
4
|
+
/** Three cards on one rank, the shape a rework edge has to get around. */
|
|
5
|
+
const rank: BackEdgeNodeRect[] = [
|
|
6
|
+
{ x: 0, y: 200, width: 176, height: 83 },
|
|
7
|
+
{ x: 220, y: 200, width: 176, height: 83 },
|
|
8
|
+
{ x: 440, y: 200, width: 176, height: 83 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("backEdgeDetour", () => {
|
|
12
|
+
it("clears the OUTERMOST card in the band, not just the edge's own two nodes", () => {
|
|
13
|
+
// The edge runs between the rank above (y 0) and the rank at y 200. Clearing only
|
|
14
|
+
// its own endpoints would put the leg at 396 + 40 — inside the third card.
|
|
15
|
+
expect(backEdgeDetour(rank, "vertical", [283, 100], 40)).toBe(656);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("ignores a card on another rank entirely", () => {
|
|
19
|
+
const withFarRank = [...rank, { x: 2000, y: 600, width: 176, height: 83 }];
|
|
20
|
+
expect(backEdgeDetour(withFarRank, "vertical", [283, 100], 40)).toBe(656);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("includes a card the span only touches at its edge", () => {
|
|
24
|
+
// Span ends exactly on the rank's top edge: the leg still has to pass it.
|
|
25
|
+
expect(backEdgeDetour(rank, "vertical", [100, 200], 40)).toBe(656);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("measures along the other axis in a left-to-right layout", () => {
|
|
29
|
+
// Ranks advance horizontally, so the return leg runs BELOW the cards.
|
|
30
|
+
expect(backEdgeDetour(rank, "horizontal", [0, 616], 40)).toBe(323);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips unmeasured cards rather than treating them as zero-size boxes at the origin", () => {
|
|
34
|
+
const unmeasured = [{ x: 900, y: 200, width: 0, height: 0 }, ...rank];
|
|
35
|
+
expect(backEdgeDetour(unmeasured, "vertical", [283, 100], 40)).toBe(656);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null when nothing is measured, so React Flow's own midpoint stands", () => {
|
|
39
|
+
expect(backEdgeDetour([], "vertical", [283, 100], 40)).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
backEdgeDetour([{ x: 0, y: 0, width: 0, height: 0 }], "vertical", [0, 1], 40),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads the span in either order", () => {
|
|
46
|
+
expect(backEdgeDetour(rank, "vertical", [100, 283], 40)).toBe(
|
|
47
|
+
backEdgeDetour(rank, "vertical", [283, 100], 40),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null when every card sits outside the span", () => {
|
|
52
|
+
expect(backEdgeDetour(rank, "vertical", [400, 500], 40)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a back edge's return leg runs, so it stays VISIBLE.
|
|
3
|
+
*
|
|
4
|
+
* A back edge is drawn as a smoothstep with one long cross-segment running against the
|
|
5
|
+
* layout direction. React Flow puts that segment halfway between the two handles by
|
|
6
|
+
* default, and a fixed nudge off the midpoint does not help: in a top-down layout the
|
|
7
|
+
* midpoint of a rework edge sits between two ranks, i.e. squarely inside the column of
|
|
8
|
+
* cards it is supposed to run past. Edges paint UNDER nodes, so the segment vanishes
|
|
9
|
+
* behind the cards and all the reader is left with is a dashed stub below one node and
|
|
10
|
+
* another above the other — which reads as a stray rectangle, not as a loop back.
|
|
11
|
+
*
|
|
12
|
+
* So the leg is placed OUTSIDE every card it would otherwise pass behind: past the far
|
|
13
|
+
* side of every node whose extent overlaps the span the edge crosses. That is how a
|
|
14
|
+
* process-mining tool draws a rework loop — out, around the rank, back in — and it is
|
|
15
|
+
* the only placement that is correct for a graph rather than for a pair of nodes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One node's laid-out box, in flow coordinates. */
|
|
19
|
+
export interface BackEdgeNodeRect {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Which axis the layout's ranks advance along. */
|
|
27
|
+
export type BackEdgeAxis = "vertical" | "horizontal";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `centerX` (vertical layout) or `centerY` (horizontal layout) to hand
|
|
31
|
+
* `getSmoothStepPath`, or `null` when nothing is measured yet and React Flow's own
|
|
32
|
+
* midpoint should stand.
|
|
33
|
+
*
|
|
34
|
+
* @param rects Every laid-out node on the canvas. Nodes that cannot be hit are filtered
|
|
35
|
+
* out here rather than by the caller, so the caller stays a plain map.
|
|
36
|
+
* @param span The interval the edge crosses on the RANK axis (`[min, max]` of the two
|
|
37
|
+
* handle coordinates) — a node outside it is never behind this edge.
|
|
38
|
+
* @param clearance Gap left between the leg and the outermost card it clears.
|
|
39
|
+
*/
|
|
40
|
+
export function backEdgeDetour(
|
|
41
|
+
rects: readonly BackEdgeNodeRect[],
|
|
42
|
+
axis: BackEdgeAxis,
|
|
43
|
+
span: readonly [number, number],
|
|
44
|
+
clearance: number,
|
|
45
|
+
): number | null {
|
|
46
|
+
const [lo, hi] = span[0] <= span[1] ? span : [span[1], span[0]];
|
|
47
|
+
let far = Number.NEGATIVE_INFINITY;
|
|
48
|
+
|
|
49
|
+
for (const rect of rects) {
|
|
50
|
+
if (!(rect.width > 0) || !(rect.height > 0)) continue;
|
|
51
|
+
// Overlap on the RANK axis (the one the edge travels along): a node beside the
|
|
52
|
+
// corridor but on another rank cannot be crossed by this leg.
|
|
53
|
+
const [near, beyond] =
|
|
54
|
+
axis === "vertical" ? [rect.y, rect.y + rect.height] : [rect.x, rect.x + rect.width];
|
|
55
|
+
if (beyond < lo || near > hi) continue;
|
|
56
|
+
far = Math.max(far, axis === "vertical" ? rect.x + rect.width : rect.y + rect.height);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Number.isFinite(far) ? far + clearance : null;
|
|
60
|
+
}
|