@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// @xyflow/react needs real layout/measurement — mock the engine and assert the
|
|
5
|
+
// brand component's own output, the same way `flow-weighted-edge.test.tsx`
|
|
6
|
+
// does. Real rendering + a11y are covered by the Storybook interaction tests.
|
|
7
|
+
//
|
|
8
|
+
// `vi.mock`'s factory is hoisted above every import, so the mock state lives in
|
|
9
|
+
// `vi.hoisted` to survive the hoist without a TDZ ReferenceError.
|
|
10
|
+
const { edgesBox, internalNodeBox } = vi.hoisted(() => ({
|
|
11
|
+
edgesBox: { current: [] as unknown[] },
|
|
12
|
+
internalNodeBox: { current: null as unknown },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@xyflow/react", () => {
|
|
16
|
+
// 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
|
|
17
|
+
const React = require("react");
|
|
18
|
+
return {
|
|
19
|
+
BaseEdge: ({
|
|
20
|
+
id,
|
|
21
|
+
path,
|
|
22
|
+
style,
|
|
23
|
+
className,
|
|
24
|
+
...rest
|
|
25
|
+
}: {
|
|
26
|
+
id: string;
|
|
27
|
+
path: string;
|
|
28
|
+
style?: React.CSSProperties;
|
|
29
|
+
className?: string;
|
|
30
|
+
}) =>
|
|
31
|
+
React.createElement("svg", { "data-testid": "base-edge" }, [
|
|
32
|
+
React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
|
|
33
|
+
]),
|
|
34
|
+
// Real EdgeLabelRenderer portals into a fixed container; a passthrough is
|
|
35
|
+
// enough here since we only assert the brand component's own output.
|
|
36
|
+
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
|
|
37
|
+
useEdges: () => edgesBox.current,
|
|
38
|
+
useInternalNode: () => internalNodeBox.current,
|
|
39
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
44
|
+
import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "./flow-self-loop-edge";
|
|
45
|
+
import { selfLoopHandleArc, selfLoopPath } from "./self-loop-geometry";
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
edgesBox.current = [];
|
|
50
|
+
internalNodeBox.current = null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** A measured `InternalNode` stand-in: 200×60 box whose top-left is (100, 200). */
|
|
54
|
+
function measuredNode(data: unknown = { title: "Review" }) {
|
|
55
|
+
return {
|
|
56
|
+
id: "node-a",
|
|
57
|
+
data,
|
|
58
|
+
measured: { width: 200, height: 60 },
|
|
59
|
+
internals: { positionAbsolute: { x: 100, y: 200 } },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeEdgeProps(
|
|
64
|
+
overrides: Partial<EdgeProps<BrandFlowSelfLoopEdge>> = {},
|
|
65
|
+
): EdgeProps<BrandFlowSelfLoopEdge> {
|
|
66
|
+
return {
|
|
67
|
+
id: "loop-1",
|
|
68
|
+
type: "self-loop",
|
|
69
|
+
source: "node-a",
|
|
70
|
+
target: "node-a",
|
|
71
|
+
sourceX: 200,
|
|
72
|
+
sourceY: 260,
|
|
73
|
+
targetX: 200,
|
|
74
|
+
targetY: 200,
|
|
75
|
+
sourcePosition: "bottom" as EdgeProps["sourcePosition"],
|
|
76
|
+
targetPosition: "top" as EdgeProps["targetPosition"],
|
|
77
|
+
selected: false,
|
|
78
|
+
animated: false,
|
|
79
|
+
data: {},
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const edgePath = () => screen.getByTestId("base-edge").querySelector("path")!;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The arc the fixture should produce: the 200×60 card at (100, 200), entered and left at
|
|
88
|
+
* the two handle points `makeEdgeProps` supplies.
|
|
89
|
+
*/
|
|
90
|
+
const fixtureArc = (loopRadius = 28) =>
|
|
91
|
+
selfLoopHandleArc(
|
|
92
|
+
{
|
|
93
|
+
sourceX: 200,
|
|
94
|
+
sourceY: 260,
|
|
95
|
+
targetX: 200,
|
|
96
|
+
targetY: 200,
|
|
97
|
+
centerX: 200,
|
|
98
|
+
centerY: 230,
|
|
99
|
+
width: 200,
|
|
100
|
+
height: 60,
|
|
101
|
+
},
|
|
102
|
+
loopRadius,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
describe("FlowSelfLoopEdge", () => {
|
|
106
|
+
it("starts and ends ON the two handle points, clearing the measured box between them", () => {
|
|
107
|
+
internalNodeBox.current = measuredNode();
|
|
108
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
109
|
+
expect(edgePath()).toHaveAttribute("d", fixtureArc().path);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to the handle midpoint before the node is measured — never NaN", () => {
|
|
113
|
+
internalNodeBox.current = { ...measuredNode(), measured: { width: 0, height: 0 } };
|
|
114
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
115
|
+
const d = edgePath().getAttribute("d")!;
|
|
116
|
+
expect(d).not.toMatch(/NaN/);
|
|
117
|
+
expect(d).toBe(selfLoopPath({ centerX: 200, topY: 200 }, 28).path);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("honours data.loopRadius", () => {
|
|
121
|
+
internalNodeBox.current = measuredNode();
|
|
122
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopRadius: 60 } })} />);
|
|
123
|
+
expect(edgePath()).toHaveAttribute("d", fixtureArc(60).path);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("carries its meaning as real text for assistive tech, not only a data attribute", () => {
|
|
127
|
+
internalNodeBox.current = measuredNode();
|
|
128
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
129
|
+
const graphic = screen.getByRole("img", {
|
|
130
|
+
name: "Self-loop on Review — this step repeats",
|
|
131
|
+
});
|
|
132
|
+
expect(graphic).toBeInTheDocument();
|
|
133
|
+
// The data-slot is the test/styling seam, NOT the accessibility channel.
|
|
134
|
+
expect(graphic.querySelector('[data-slot="flow-self-loop-edge"]')).not.toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("names the node by id when its data carries no title, and accepts an override", () => {
|
|
138
|
+
internalNodeBox.current = measuredNode({});
|
|
139
|
+
const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByRole("img", { name: "Self-loop on node-a — this step repeats" }),
|
|
142
|
+
).toBeInTheDocument();
|
|
143
|
+
unmount();
|
|
144
|
+
|
|
145
|
+
internalNodeBox.current = measuredNode();
|
|
146
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopLabel: "Reworked 12 times" } })} />);
|
|
147
|
+
expect(screen.getByRole("img", { name: "Reworked 12 times" })).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("is distinguishable from a forward edge without colour — the shape is the signal", () => {
|
|
151
|
+
internalNodeBox.current = measuredNode();
|
|
152
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
153
|
+
const path = edgePath();
|
|
154
|
+
// A cubic that returns to its own node: it leaves the bottom handle (200, 260) and
|
|
155
|
+
// re-enters at the top one (200, 200) — the two ends bracket the card rather than
|
|
156
|
+
// spanning a gap, which is what no forward edge ever does.
|
|
157
|
+
expect(path.getAttribute("d")).toMatch(/^M 200,260 C .* 200,200$/);
|
|
158
|
+
expect(path.style.stroke).toBe("var(--flow-edge)");
|
|
159
|
+
expect(path.style.fill).toBe("none");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("shares one weight scale with the forward edges around it", () => {
|
|
163
|
+
internalNodeBox.current = measuredNode();
|
|
164
|
+
edgesBox.current = [
|
|
165
|
+
{ id: "fwd-min", data: { weight: 1 } },
|
|
166
|
+
{ id: "loop-1", data: { weight: 10 } },
|
|
167
|
+
{ id: "fwd-mid", data: { weight: 5 } },
|
|
168
|
+
];
|
|
169
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { weight: 10 } })} />);
|
|
170
|
+
// Top of the shared [1.5, 8] range, because it is the heaviest edge present.
|
|
171
|
+
expect(edgePath().style.strokeWidth).toBe("8");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("uses the --ring token when selected, matching FlowNode's selected treatment", () => {
|
|
175
|
+
internalNodeBox.current = measuredNode();
|
|
176
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ selected: true })} />);
|
|
177
|
+
expect(edgePath().style.stroke).toBe("var(--ring)");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("renders an EdgeLabelPill at the apex when labelled, and none when not", () => {
|
|
181
|
+
internalNodeBox.current = measuredNode();
|
|
182
|
+
const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
183
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
184
|
+
unmount();
|
|
185
|
+
|
|
186
|
+
render(
|
|
187
|
+
<FlowSelfLoopEdge {...makeEdgeProps({ data: { label: "12×", secondaryLabel: "2.1d" } })} />,
|
|
188
|
+
);
|
|
189
|
+
const pill = screen.getByRole("button", { name: "12× · 2.1d" });
|
|
190
|
+
// The arc's widest point: 328 is the card's right edge (300) plus the 28px loop
|
|
191
|
+
// radius, and 230 is the card's own vertical centre — i.e. beside the node, not on it.
|
|
192
|
+
const { labelX, labelY } = fixtureArc();
|
|
193
|
+
expect([labelX, labelY]).toEqual([328, 230]);
|
|
194
|
+
expect(pill.parentElement!.style.transform).toContain(`translate(${labelX}px, ${labelY}px)`);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useEdges, useInternalNode, type Edge, type EdgeProps } from "@xyflow/react";
|
|
3
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
4
|
+
import {
|
|
5
|
+
computeEdgeWeightScale,
|
|
6
|
+
DEFAULT_EDGE_WIDTH_RANGE,
|
|
7
|
+
EdgeLabelPill,
|
|
8
|
+
type EdgeLabelPillProps,
|
|
9
|
+
type WeightedEdgeLike,
|
|
10
|
+
} from "../flow-weighted-edge";
|
|
11
|
+
import { DEFAULT_LOOP_RADIUS, selfLoopHandleArc, selfLoopPath } from "./self-loop-geometry";
|
|
12
|
+
|
|
13
|
+
export interface FlowSelfLoopEdgeData extends Record<string, unknown> {
|
|
14
|
+
/**
|
|
15
|
+
* Frequency/volume this loop carries. Scaled into stroke width by the SAME
|
|
16
|
+
* `computeEdgeWeightScale` domain as `FlowWeightedEdge`, so a loop's weight
|
|
17
|
+
* is directly comparable with the forward edges around it.
|
|
18
|
+
*/
|
|
19
|
+
weight?: number;
|
|
20
|
+
/** Edges sharing a `scaleGroup` share one min-max width domain. @default all edges in the flow */
|
|
21
|
+
scaleGroup?: string;
|
|
22
|
+
/** Primary edge-label-pill text, e.g. a repeat count. */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** Secondary edge-label-pill text, e.g. an average duration. */
|
|
25
|
+
secondaryLabel?: string;
|
|
26
|
+
/** Radius of the arc, in px. @default 28 */
|
|
27
|
+
loopRadius?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Overrides the accessible name given to the loop's graphic. Defaults to
|
|
30
|
+
* "Self-loop on <node> — this step repeats".
|
|
31
|
+
*/
|
|
32
|
+
loopLabel?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Passed straight through to the rendered `EdgeLabelPill`'s `className`/`...props`
|
|
35
|
+
* (see `EdgeLabelPillProps`) — the seam a composing package (e.g.
|
|
36
|
+
* `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses to reach the pill's
|
|
37
|
+
* own root button from outside this component, without a new semantic prop here.
|
|
38
|
+
*/
|
|
39
|
+
labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type BrandFlowSelfLoopEdge = Edge<FlowSelfLoopEdgeData, "self-loop">;
|
|
43
|
+
|
|
44
|
+
/** Node `data` shapes a title can be read from — `FlowNode`'s is `{ title }`. */
|
|
45
|
+
function nodeName(data: unknown, fallback: string): string {
|
|
46
|
+
if (data && typeof data === "object" && "title" in data) {
|
|
47
|
+
const title = (data as { title?: unknown }).title;
|
|
48
|
+
if (typeof title === "string" && title.length > 0) return title;
|
|
49
|
+
}
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Branded self-loop edge: an edge whose `source === target` — the "this step
|
|
55
|
+
* repeated" signal of a process map. Register it in
|
|
56
|
+
* `edgeTypes={{ "self-loop": FlowSelfLoopEdge }}` and create edges with
|
|
57
|
+
* `type: "self-loop"` and `data: FlowSelfLoopEdgeData`.
|
|
58
|
+
*
|
|
59
|
+
* dagre cannot lay a self-loop out, so `layoutFlow` withholds them from the
|
|
60
|
+
* graph entirely and reports their ids in `selfLoops` — this component draws
|
|
61
|
+
* the arc itself, from the node's own live geometry (`useInternalNode`).
|
|
62
|
+
*
|
|
63
|
+
* ## It terminates ON the handle dots, like every other brand edge
|
|
64
|
+
*
|
|
65
|
+
* The two handle points of a self-loop sit on OPPOSITE sides of one node, so they
|
|
66
|
+
* describe no useful straight line — which is why this edge used to ignore them and
|
|
67
|
+
* arc over the node's top edge instead, with its feet on bare border a loop-radius
|
|
68
|
+
* away from the nearest dot. Measured on the process map, that read as a detached
|
|
69
|
+
* arc floating above the card: 24 px clear of any dot in a top-to-bottom layout, and
|
|
70
|
+
* 69 px in a left-to-right one, where the loop stayed stubbornly on TOP while the
|
|
71
|
+
* flow (and the handles) had moved to the sides. Both are the "an edge terminates on
|
|
72
|
+
* a handle dot" rule being broken, just by a component that had declared itself
|
|
73
|
+
* exempt.
|
|
74
|
+
*
|
|
75
|
+
* `selfLoopHandleArc` keeps the loop a SHAPE — it just bulges clear of the node on
|
|
76
|
+
* the side a quarter turn from the source handle's own normal, so it lassos down the
|
|
77
|
+
* right in a top-to-bottom layout and arcs over the top in a left-to-right one,
|
|
78
|
+
* without this component knowing which direction is in force. Before the node is
|
|
79
|
+
* measured there is nothing to clear, so it falls back to the node-box arc
|
|
80
|
+
* (`selfLoopPath`) rather than to `NaN`.
|
|
81
|
+
*
|
|
82
|
+
* The loop is distinguished from a forward edge by its SHAPE, not by colour —
|
|
83
|
+
* a closed arc above the node, legible in greyscale and in every theme — and
|
|
84
|
+
* publishes that meaning as a real accessible name, because a `data-slot` is
|
|
85
|
+
* invisible to assistive technology. Its label is an `EdgeLabelPill` at the
|
|
86
|
+
* arc's apex, a genuine keyboard tab stop with a visible focus ring.
|
|
87
|
+
*
|
|
88
|
+
* Stroke width comes from the same `computeEdgeWeightScale` domain
|
|
89
|
+
* `FlowWeightedEdge` uses, so a loop weighted 8 reads as thick as a forward
|
|
90
|
+
* edge weighted 8. Nothing animates, so there is no motion to reduce.
|
|
91
|
+
*
|
|
92
|
+
* The arc is drawn through `FlowEdgePath`, so it inherits the shared keyboard
|
|
93
|
+
* focus indicator (#286) rather than having to opt into it.
|
|
94
|
+
*/
|
|
95
|
+
export function FlowSelfLoopEdge({
|
|
96
|
+
id,
|
|
97
|
+
source,
|
|
98
|
+
sourceX,
|
|
99
|
+
sourceY,
|
|
100
|
+
targetX,
|
|
101
|
+
targetY,
|
|
102
|
+
markerEnd,
|
|
103
|
+
style,
|
|
104
|
+
selected,
|
|
105
|
+
data,
|
|
106
|
+
}: EdgeProps<BrandFlowSelfLoopEdge>) {
|
|
107
|
+
const edges = useEdges();
|
|
108
|
+
const widthByEdgeId = useMemo(
|
|
109
|
+
() => computeEdgeWeightScale(edges as unknown as WeightedEdgeLike[]),
|
|
110
|
+
[edges],
|
|
111
|
+
);
|
|
112
|
+
const node = useInternalNode(source);
|
|
113
|
+
|
|
114
|
+
const measuredWidth = node?.measured?.width;
|
|
115
|
+
const measuredHeight = node?.measured?.height;
|
|
116
|
+
const loopRadius = data?.loopRadius ?? DEFAULT_LOOP_RADIUS;
|
|
117
|
+
|
|
118
|
+
const { path, labelX, labelY } = useMemo(() => {
|
|
119
|
+
if (node && measuredWidth && measuredHeight) {
|
|
120
|
+
return selfLoopHandleArc(
|
|
121
|
+
{
|
|
122
|
+
sourceX,
|
|
123
|
+
sourceY,
|
|
124
|
+
targetX,
|
|
125
|
+
targetY,
|
|
126
|
+
centerX: node.internals.positionAbsolute.x + measuredWidth / 2,
|
|
127
|
+
centerY: node.internals.positionAbsolute.y + measuredHeight / 2,
|
|
128
|
+
width: measuredWidth,
|
|
129
|
+
height: measuredHeight,
|
|
130
|
+
},
|
|
131
|
+
loopRadius,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// Before measurement lands there is no box to clear, and the two handle points
|
|
135
|
+
// still bracket the node — draw the plain arc above them.
|
|
136
|
+
return selfLoopPath(
|
|
137
|
+
{ centerX: (sourceX + targetX) / 2, topY: Math.min(sourceY, targetY) },
|
|
138
|
+
loopRadius,
|
|
139
|
+
);
|
|
140
|
+
}, [node, measuredWidth, measuredHeight, sourceX, sourceY, targetX, targetY, loopRadius]);
|
|
141
|
+
|
|
142
|
+
const scaledWidth = widthByEdgeId.get(id) ?? DEFAULT_EDGE_WIDTH_RANGE[0];
|
|
143
|
+
const stroke = selected ? "var(--ring)" : "var(--flow-edge)";
|
|
144
|
+
const strokeWidth = selected ? scaledWidth + 1.5 : scaledWidth;
|
|
145
|
+
|
|
146
|
+
const accessibleName =
|
|
147
|
+
data?.loopLabel ?? `Self-loop on ${nodeName(node?.data, source)} — this step repeats`;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<>
|
|
151
|
+
<g role="img" aria-label={accessibleName}>
|
|
152
|
+
<FlowEdgePath
|
|
153
|
+
id={id}
|
|
154
|
+
path={path}
|
|
155
|
+
markerEnd={markerEnd}
|
|
156
|
+
data-slot="flow-self-loop-edge"
|
|
157
|
+
stroke={stroke}
|
|
158
|
+
strokeWidth={strokeWidth}
|
|
159
|
+
style={{ fill: "none", ...style }}
|
|
160
|
+
/>
|
|
161
|
+
</g>
|
|
162
|
+
<EdgeLabelPill
|
|
163
|
+
label={data?.label}
|
|
164
|
+
secondaryLabel={data?.secondaryLabel}
|
|
165
|
+
x={labelX}
|
|
166
|
+
y={labelY}
|
|
167
|
+
selected={selected}
|
|
168
|
+
{...data?.labelProps}
|
|
169
|
+
/>
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FlowSelfLoopEdge,
|
|
3
|
+
type FlowSelfLoopEdgeData,
|
|
4
|
+
type BrandFlowSelfLoopEdge,
|
|
5
|
+
} from "./flow-self-loop-edge";
|
|
6
|
+
export {
|
|
7
|
+
selfLoopPath,
|
|
8
|
+
selfLoopHandleArc,
|
|
9
|
+
DEFAULT_LOOP_RADIUS,
|
|
10
|
+
type SelfLoopAnchor,
|
|
11
|
+
type SelfLoopHandleAnchor,
|
|
12
|
+
type SelfLoopPath,
|
|
13
|
+
} from "./self-loop-geometry";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DEFAULT_LOOP_RADIUS, selfLoopHandleArc, selfLoopPath } from "./self-loop-geometry";
|
|
3
|
+
|
|
4
|
+
const numbersIn = (path: string) =>
|
|
5
|
+
(path.match(/-?\d+(\.\d+)?/g) ?? []).map((n) => Number.parseFloat(n));
|
|
6
|
+
|
|
7
|
+
describe("selfLoopPath", () => {
|
|
8
|
+
it("leaves the node's top-right and re-enters at its top-left", () => {
|
|
9
|
+
const { path } = selfLoopPath({ centerX: 100, topY: 50 }, 28);
|
|
10
|
+
// M <start> C <c1> <c2> <end>
|
|
11
|
+
expect(path.startsWith("M 128,50 C ")).toBe(true);
|
|
12
|
+
expect(path.endsWith(" 72,50")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("anchors the label at the arc's apex, above the node and centred on it", () => {
|
|
16
|
+
const { labelX, labelY } = selfLoopPath({ centerX: 100, topY: 50 }, 28);
|
|
17
|
+
expect(labelX).toBe(100);
|
|
18
|
+
// 3/4 of the control reach (2.4r) — the midpoint of a symmetric cubic.
|
|
19
|
+
expect(labelY).toBeCloseTo(50 - 1.8 * 28, 6);
|
|
20
|
+
expect(labelY).toBeLessThan(50);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("scales with loopRadius — a bigger radius reaches higher and wider", () => {
|
|
24
|
+
const small = selfLoopPath({ centerX: 0, topY: 0 }, 10);
|
|
25
|
+
const big = selfLoopPath({ centerX: 0, topY: 0 }, 40);
|
|
26
|
+
expect(big.labelY).toBeLessThan(small.labelY);
|
|
27
|
+
expect(Math.min(...numbersIn(big.path))).toBeLessThan(Math.min(...numbersIn(small.path)));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("never emits NaN — non-finite anchors fall back to 0, bad radii to the default", () => {
|
|
31
|
+
for (const bad of [
|
|
32
|
+
selfLoopPath({ centerX: Number.NaN, topY: Number.NaN }, 28),
|
|
33
|
+
selfLoopPath({ centerX: 0, topY: 0 }, Number.NaN),
|
|
34
|
+
selfLoopPath({ centerX: 0, topY: 0 }, 0),
|
|
35
|
+
selfLoopPath({ centerX: 0, topY: 0 }, -5),
|
|
36
|
+
]) {
|
|
37
|
+
expect(bad.path).not.toMatch(/NaN/);
|
|
38
|
+
expect(Number.isFinite(bad.labelX)).toBe(true);
|
|
39
|
+
expect(Number.isFinite(bad.labelY)).toBe(true);
|
|
40
|
+
for (const n of numbersIn(bad.path)) expect(Number.isFinite(n)).toBe(true);
|
|
41
|
+
}
|
|
42
|
+
// A zero/negative radius is treated as "use the default", not as "no loop".
|
|
43
|
+
expect(selfLoopPath({ centerX: 0, topY: 0 }, 0)).toEqual(
|
|
44
|
+
selfLoopPath({ centerX: 0, topY: 0 }, DEFAULT_LOOP_RADIUS),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is deterministic — the same input always yields the same path", () => {
|
|
49
|
+
expect(selfLoopPath({ centerX: 12.5, topY: -3 }, 28)).toEqual(
|
|
50
|
+
selfLoopPath({ centerX: 12.5, topY: -3 }, 28),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("selfLoopHandleArc", () => {
|
|
56
|
+
/** A 200×60 card at (100, 200): its two handle points, top-to-bottom. */
|
|
57
|
+
const topToBottom = {
|
|
58
|
+
sourceX: 200,
|
|
59
|
+
sourceY: 260,
|
|
60
|
+
targetX: 200,
|
|
61
|
+
targetY: 200,
|
|
62
|
+
centerX: 200,
|
|
63
|
+
centerY: 230,
|
|
64
|
+
width: 200,
|
|
65
|
+
height: 60,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it("starts on the source handle and ends on the target handle", () => {
|
|
69
|
+
const { path } = selfLoopHandleArc(topToBottom, 28);
|
|
70
|
+
expect(path).toMatch(/^M 200,260 C /);
|
|
71
|
+
expect(path).toMatch(/ 200,200$/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("bulges past the card, not across it", () => {
|
|
75
|
+
// The apex is the card's own half-width (100) plus the loop radius (28) clear of its
|
|
76
|
+
// centre — the number a cubic actually reaches, which is 3/4 of its control reach and
|
|
77
|
+
// not the control reach itself. Getting that factor wrong draws the loop ON the card.
|
|
78
|
+
const { labelX, labelY } = selfLoopHandleArc(topToBottom, 28);
|
|
79
|
+
expect(labelX).toBe(200 + 100 + 28);
|
|
80
|
+
expect(labelY).toBe(230);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("turns with the handles: a left-to-right node loops over its top, not its side", () => {
|
|
84
|
+
// Same card, handles now on the right (source) and left (target).
|
|
85
|
+
const { labelX, labelY } = selfLoopHandleArc(
|
|
86
|
+
{ ...topToBottom, sourceX: 300, sourceY: 230, targetX: 100, targetY: 230 },
|
|
87
|
+
28,
|
|
88
|
+
);
|
|
89
|
+
// Half the card's HEIGHT (30) plus the radius, above the centre — no direction prop
|
|
90
|
+
// was passed, and none exists: the bulge is derived from the handles themselves.
|
|
91
|
+
expect(labelX).toBe(200);
|
|
92
|
+
expect(labelY).toBe(230 - 30 - 28);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("scales with loopRadius", () => {
|
|
96
|
+
const small = selfLoopHandleArc(topToBottom, 28);
|
|
97
|
+
const large = selfLoopHandleArc(topToBottom, 60);
|
|
98
|
+
expect(large.labelX - 200).toBeGreaterThan(small.labelX - 200);
|
|
99
|
+
expect(large.labelX).toBe(200 + 100 + 60);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("never emits NaN — non-finite anchors fall back to 0, bad radii to the default", () => {
|
|
103
|
+
const { path, labelX, labelY } = selfLoopHandleArc(
|
|
104
|
+
{
|
|
105
|
+
sourceX: Number.NaN,
|
|
106
|
+
sourceY: Number.POSITIVE_INFINITY,
|
|
107
|
+
targetX: Number.NaN,
|
|
108
|
+
targetY: Number.NaN,
|
|
109
|
+
centerX: Number.NaN,
|
|
110
|
+
centerY: Number.NaN,
|
|
111
|
+
width: Number.NaN,
|
|
112
|
+
height: Number.NaN,
|
|
113
|
+
},
|
|
114
|
+
-5,
|
|
115
|
+
);
|
|
116
|
+
expect(path).not.toMatch(/NaN|Infinity/);
|
|
117
|
+
expect(Number.isFinite(labelX)).toBe(true);
|
|
118
|
+
expect(Number.isFinite(labelY)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls back to a downward normal when a handle sits on the node's centre", () => {
|
|
122
|
+
const degenerate = selfLoopHandleArc(
|
|
123
|
+
{ ...topToBottom, sourceX: 200, sourceY: 230, targetX: 200, targetY: 230 },
|
|
124
|
+
28,
|
|
125
|
+
);
|
|
126
|
+
expect(degenerate.path).not.toMatch(/NaN/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self-loop-geometry — pure, framework-free arc math for a self-referencing edge.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the component (and out of React) so the loop's shape can be
|
|
5
|
+
* unit-tested without a canvas, and so a sibling that needs the same apex
|
|
6
|
+
* point (a legend, an overlay, a screenshot harness) can compute it directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Default arc radius, in px. A loop this size clears `FlowNode`'s header without dominating it. */
|
|
10
|
+
export const DEFAULT_LOOP_RADIUS = 28;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How far the cubic's control points reach sideways and upwards, as multiples
|
|
14
|
+
* of `loopRadius`. Tuned so the arc reads as a closed loop rather than a bump:
|
|
15
|
+
* the horizontal reach opens the throat of the loop, the vertical reach sets
|
|
16
|
+
* its height.
|
|
17
|
+
*/
|
|
18
|
+
const CONTROL_REACH_X = 1.2;
|
|
19
|
+
const CONTROL_REACH_Y = 2.4;
|
|
20
|
+
/** A symmetric cubic's midpoint sits at 3/4 of its control-point height — see `selfLoopPath`. */
|
|
21
|
+
const APEX_FACTOR = (3 / 4) * CONTROL_REACH_Y;
|
|
22
|
+
|
|
23
|
+
/** The box a self-loop is drawn above. Matches a React Flow `InternalNode` structurally. */
|
|
24
|
+
export interface SelfLoopAnchor {
|
|
25
|
+
/** Horizontal centre of the node the loop belongs to. */
|
|
26
|
+
centerX: number;
|
|
27
|
+
/** Top edge of that node — the loop is drawn above this line. */
|
|
28
|
+
topY: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SelfLoopPath {
|
|
32
|
+
/** SVG `d` for the arc. */
|
|
33
|
+
path: string;
|
|
34
|
+
/** Where a label belongs: the arc's apex. */
|
|
35
|
+
labelX: number;
|
|
36
|
+
labelY: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A cubic arc that leaves the node's top-RIGHT, bulges up over the node, and
|
|
41
|
+
* re-enters at its top-LEFT — the conventional "this step repeated" mark in a
|
|
42
|
+
* process map, and a SHAPE rather than a colour, so it survives greyscale.
|
|
43
|
+
*
|
|
44
|
+
* The curve is symmetric about `centerX`, which puts its `t = 0.5` midpoint
|
|
45
|
+
* exactly at `(centerX, topY - APEX_FACTOR × loopRadius)` — the apex the label
|
|
46
|
+
* is anchored to. Never returns `NaN`: a non-finite input falls back to `0`
|
|
47
|
+
* and a non-positive radius falls back to the default.
|
|
48
|
+
*/
|
|
49
|
+
export function selfLoopPath(anchor: SelfLoopAnchor, loopRadius: number): SelfLoopPath {
|
|
50
|
+
const cx = Number.isFinite(anchor.centerX) ? anchor.centerX : 0;
|
|
51
|
+
const ay = Number.isFinite(anchor.topY) ? anchor.topY : 0;
|
|
52
|
+
const r = Number.isFinite(loopRadius) && loopRadius > 0 ? loopRadius : DEFAULT_LOOP_RADIUS;
|
|
53
|
+
|
|
54
|
+
const startX = cx + r;
|
|
55
|
+
const endX = cx - r;
|
|
56
|
+
const controlY = ay - r * CONTROL_REACH_Y;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
path: `M ${startX},${ay} C ${startX + r * CONTROL_REACH_X},${controlY} ${
|
|
60
|
+
endX - r * CONTROL_REACH_X
|
|
61
|
+
},${controlY} ${endX},${ay}`,
|
|
62
|
+
labelX: cx,
|
|
63
|
+
labelY: ay - r * APEX_FACTOR,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The two handle anchors a self-loop actually joins, plus the box it has to clear.
|
|
69
|
+
*
|
|
70
|
+
* A self-loop's `sourceX/sourceY` and `targetX/targetY` are the SAME node's two handle
|
|
71
|
+
* points, which is why they describe no useful straight line — but they are still the
|
|
72
|
+
* only two points on the canvas the reader recognises as connectors, so the arc has to
|
|
73
|
+
* start and end exactly on them. {@link selfLoopHandleArc} is the geometry that does
|
|
74
|
+
* that; {@link selfLoopPath} remains the node-box arc used before a node is measured.
|
|
75
|
+
*/
|
|
76
|
+
export interface SelfLoopHandleAnchor {
|
|
77
|
+
/** Where the loop leaves the node — React Flow's own SOURCE handle anchor. */
|
|
78
|
+
sourceX: number;
|
|
79
|
+
sourceY: number;
|
|
80
|
+
/** Where it re-enters — React Flow's own TARGET handle anchor. */
|
|
81
|
+
targetX: number;
|
|
82
|
+
targetY: number;
|
|
83
|
+
/** The node's centre. Decides which way is "out of the card". */
|
|
84
|
+
centerX: number;
|
|
85
|
+
centerY: number;
|
|
86
|
+
/** The node's rendered size, so the arc clears the card instead of crossing it. */
|
|
87
|
+
width: number;
|
|
88
|
+
height: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Unit vector from the node's centre to a handle point, or `fallback` when degenerate. */
|
|
92
|
+
function outward(
|
|
93
|
+
x: number,
|
|
94
|
+
y: number,
|
|
95
|
+
centerX: number,
|
|
96
|
+
centerY: number,
|
|
97
|
+
fallback: readonly [number, number],
|
|
98
|
+
): readonly [number, number] {
|
|
99
|
+
const dx = x - centerX;
|
|
100
|
+
const dy = y - centerY;
|
|
101
|
+
const length = Math.hypot(dx, dy);
|
|
102
|
+
if (!Number.isFinite(length) || length < 1e-6) return fallback;
|
|
103
|
+
return [dx / length, dy / length];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A cubic that leaves the SOURCE handle dot, bulges clear of the node on one side, and
|
|
108
|
+
* re-enters at the TARGET handle dot.
|
|
109
|
+
*
|
|
110
|
+
* It is direction-agnostic by construction: the bulge is the source normal rotated a
|
|
111
|
+
* quarter turn, so a top-to-bottom layout (handles on the bottom and top) gets a lasso
|
|
112
|
+
* down the node's right-hand side, and a left-to-right layout (handles on the right and
|
|
113
|
+
* left) gets an arc over the top — with no `direction` prop, and no list of cases to keep
|
|
114
|
+
* in step with `layoutFlow`'s `HANDLE_BY_DIRECTION`.
|
|
115
|
+
*
|
|
116
|
+
* `loopRadius` is how far the curve shoots straight out of each dot before it turns, and
|
|
117
|
+
* also the gap the arc's widest point keeps from the card's edge. The label sits at that
|
|
118
|
+
* widest point — the cubic's `t = 0.5` midpoint — so it is off the node by construction.
|
|
119
|
+
*
|
|
120
|
+
* Never returns `NaN`: non-finite inputs fall back to `0`, a non-positive radius to
|
|
121
|
+
* {@link DEFAULT_LOOP_RADIUS}, and a handle point sitting on the node's centre (nothing
|
|
122
|
+
* measured yet) to a downward source normal.
|
|
123
|
+
*/
|
|
124
|
+
export function selfLoopHandleArc(anchor: SelfLoopHandleAnchor, loopRadius: number): SelfLoopPath {
|
|
125
|
+
const num = (value: number) => (Number.isFinite(value) ? value : 0);
|
|
126
|
+
const sx = num(anchor.sourceX);
|
|
127
|
+
const sy = num(anchor.sourceY);
|
|
128
|
+
const tx = num(anchor.targetX);
|
|
129
|
+
const ty = num(anchor.targetY);
|
|
130
|
+
const cx = num(anchor.centerX);
|
|
131
|
+
const cy = num(anchor.centerY);
|
|
132
|
+
const width = Math.max(0, num(anchor.width));
|
|
133
|
+
const height = Math.max(0, num(anchor.height));
|
|
134
|
+
const r = Number.isFinite(loopRadius) && loopRadius > 0 ? loopRadius : DEFAULT_LOOP_RADIUS;
|
|
135
|
+
|
|
136
|
+
const [osx, osy] = outward(sx, sy, cx, cy, [0, 1]);
|
|
137
|
+
const [otx, oty] = outward(tx, ty, cx, cy, [-osx, -osy]);
|
|
138
|
+
// A quarter turn from the source normal: the side the loop bulges out on.
|
|
139
|
+
const lx = osy;
|
|
140
|
+
const ly = -osx;
|
|
141
|
+
// How far the card extends along the bulge, plus the clearance we want beyond it.
|
|
142
|
+
const clearance = Math.abs(lx) * (width / 2) + Math.abs(ly) * (height / 2) + r;
|
|
143
|
+
// A cubic reaches only 3/4 of the way to its control points at the midpoint — its
|
|
144
|
+
// WIDEST point — so `clearance` has to be divided by that factor, not used directly.
|
|
145
|
+
// Used directly (the first cut of this function did) the apex lands at 0.75 × clearance,
|
|
146
|
+
// which for a 176 px card is 87 px against an 88 px half-width: the loop is drawn ON the
|
|
147
|
+
// card it is supposed to encircle, and its label — anchored at that same midpoint —
|
|
148
|
+
// prints on top of the activity's own name. Measured on the process map: one collision
|
|
149
|
+
// per direction that no amount of layout spacing could remove, because the arc's reach
|
|
150
|
+
// is a property of the node it belongs to, not of the gap to its neighbours.
|
|
151
|
+
const reach = (4 / 3) * clearance;
|
|
152
|
+
|
|
153
|
+
const c1x = sx + osx * r + lx * reach;
|
|
154
|
+
const c1y = sy + osy * r + ly * reach;
|
|
155
|
+
const c2x = tx + otx * r + lx * reach;
|
|
156
|
+
const c2y = ty + oty * r + ly * reach;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
path: `M ${sx},${sy} C ${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`,
|
|
160
|
+
// A cubic's midpoint is (P0 + 3·C1 + 3·C2 + P3) / 8 — the arc's apex, which `reach`
|
|
161
|
+
// above places `loopRadius` clear of the card, so the label sits off the node.
|
|
162
|
+
labelX: (sx + 3 * c1x + 3 * c2x + tx) / 8,
|
|
163
|
+
labelY: (sy + 3 * c1y + 3 * c2y + ty) / 8,
|
|
164
|
+
};
|
|
165
|
+
}
|