@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
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { type Edge } from "@xyflow/react";
|
|
4
|
+
import { expect, waitFor } from "storybook/test";
|
|
4
5
|
import { CanvasShell } from "../canvas-shell";
|
|
5
|
-
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
6
|
+
import { FLOW_ALL_SIDE_HANDLES, FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
6
8
|
import { FlowSmartEdge } from "./flow-smart-edge";
|
|
7
9
|
|
|
8
10
|
const nodeTypes = { brand: FlowNode };
|
|
@@ -17,6 +19,19 @@ const meta = {
|
|
|
17
19
|
export default meta;
|
|
18
20
|
type Story = StoryObj<typeof meta>;
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Every smart edge must terminate ON a handle dot. Before the anchoring fix the
|
|
24
|
+
* shipped anchors missed the nearest dot by ~22px on all-side nodes and ~124px
|
|
25
|
+
* on nodes carrying only the default top/bottom handles — the latter because a
|
|
26
|
+
* left/right side with no handle at all could be chosen.
|
|
27
|
+
*/
|
|
28
|
+
async function expectEdgesAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
29
|
+
await waitFor(() => {
|
|
30
|
+
expect(edgePaths(canvasElement, "flow-smart-edge")).toHaveLength(edgeCount);
|
|
31
|
+
expect(endpointsOffHandles(canvasElement, "flow-smart-edge")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Multi-side nodes (handles on all four sides) connected by `FlowSmartEdge`.
|
|
22
37
|
* Drag a node around another and the edge re-picks the closest source/target
|
|
@@ -24,10 +39,6 @@ type Story = StoryObj<typeof meta>;
|
|
|
24
39
|
*/
|
|
25
40
|
export const Default: Story = {
|
|
26
41
|
render: () => {
|
|
27
|
-
const allSides = {
|
|
28
|
-
source: ["top", "right", "bottom", "left"],
|
|
29
|
-
target: ["top", "right", "bottom", "left"],
|
|
30
|
-
} as const;
|
|
31
42
|
const nodes: BrandFlowNode[] = [
|
|
32
43
|
{
|
|
33
44
|
id: "a",
|
|
@@ -37,7 +48,7 @@ export const Default: Story = {
|
|
|
37
48
|
kind: "Source",
|
|
38
49
|
title: "Ingest",
|
|
39
50
|
tone: "accent",
|
|
40
|
-
handles:
|
|
51
|
+
handles: FLOW_ALL_SIDE_HANDLES,
|
|
41
52
|
},
|
|
42
53
|
},
|
|
43
54
|
{
|
|
@@ -47,7 +58,7 @@ export const Default: Story = {
|
|
|
47
58
|
data: {
|
|
48
59
|
kind: "Process",
|
|
49
60
|
title: "Transform",
|
|
50
|
-
handles:
|
|
61
|
+
handles: FLOW_ALL_SIDE_HANDLES,
|
|
51
62
|
},
|
|
52
63
|
},
|
|
53
64
|
{
|
|
@@ -58,7 +69,7 @@ export const Default: Story = {
|
|
|
58
69
|
kind: "Output",
|
|
59
70
|
title: "Publish",
|
|
60
71
|
tone: "success",
|
|
61
|
-
handles:
|
|
72
|
+
handles: FLOW_ALL_SIDE_HANDLES,
|
|
62
73
|
},
|
|
63
74
|
},
|
|
64
75
|
];
|
|
@@ -72,4 +83,40 @@ export const Default: Story = {
|
|
|
72
83
|
</div>
|
|
73
84
|
);
|
|
74
85
|
},
|
|
86
|
+
play: async ({ canvasElement }) => {
|
|
87
|
+
await expectEdgesAnchoredToHandles(canvasElement, 2);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Nodes with **no** `data.handles` config — they render `FlowNode`'s default
|
|
93
|
+
* single top target + bottom source. A smart edge must anchor to those two real
|
|
94
|
+
* dots; it must not invent a left/right anchor on a side that has no handle.
|
|
95
|
+
*/
|
|
96
|
+
export const DefaultNodeHandles: Story = {
|
|
97
|
+
render: () => {
|
|
98
|
+
const nodes: BrandFlowNode[] = [
|
|
99
|
+
{
|
|
100
|
+
id: "a",
|
|
101
|
+
type: "brand",
|
|
102
|
+
position: { x: 60, y: 40 },
|
|
103
|
+
data: { kind: "Source", title: "Ingest" },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "b",
|
|
107
|
+
type: "brand",
|
|
108
|
+
position: { x: 360, y: 260 },
|
|
109
|
+
data: { kind: "Output", title: "Publish", tone: "success" },
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const edges: Edge[] = [{ id: "a-b", source: "a", target: "b", type: "smart" }];
|
|
113
|
+
return (
|
|
114
|
+
<div className="h-[420px]">
|
|
115
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
play: async ({ canvasElement }) => {
|
|
120
|
+
await expectEdgesAnchoredToHandles(canvasElement, 1);
|
|
121
|
+
},
|
|
75
122
|
};
|