@elabs-ai/components-flow 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +734 -20
  3. package/dist/index.js +976 -178
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -6
  6. package/src/canvas-shell/canvas-shell.tsx +116 -1
  7. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  8. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  9. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  10. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  11. package/src/flow-edge/flow-edge.tsx +10 -3
  12. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  13. package/src/flow-edge-path/index.ts +1 -0
  14. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  15. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  16. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  17. package/src/flow-group-node/flow-group-node.tsx +24 -8
  18. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  19. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  20. package/src/flow-handle/index.ts +1 -0
  21. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  22. package/src/flow-layout/flow-layout.test.tsx +91 -0
  23. package/src/flow-layout/flow-layout.ts +77 -1
  24. package/src/flow-layout/layout-graph.test.ts +83 -2
  25. package/src/flow-layout/layout-graph.ts +23 -15
  26. package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
  27. package/src/flow-node/flow-node.stories.tsx +151 -0
  28. package/src/flow-node/flow-node.tsx +56 -1
  29. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  30. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  31. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  33. package/src/flow-self-loop-edge/index.ts +13 -0
  34. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  35. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  36. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  37. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  38. package/src/flow-smart-edge/index.ts +5 -1
  39. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  40. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  41. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  42. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  43. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  44. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  45. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  46. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  47. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  48. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  50. package/src/flow-weighted-edge/index.ts +18 -0
  51. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  52. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  53. package/src/index.ts +9 -0
  54. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  55. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  56. package/src/inspector-panel/inspector-panel.tsx +13 -3
  57. package/src/legend/index.ts +7 -1
  58. package/src/legend/legend.stories.tsx +126 -0
  59. package/src/legend/legend.test.tsx +169 -0
  60. package/src/legend/legend.tsx +214 -3
  61. package/src/templates-flow-workspace.stories.tsx +1 -1
  62. package/src/testing/canvas-framing.test.ts +107 -0
  63. package/src/testing/canvas-framing.ts +396 -0
  64. package/src/testing/edge-anchors.ts +107 -0
  65. package/src/testing/index.ts +36 -0
  66. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -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
+ }