@elabs-ai/components-flow 4.1.0 → 5.0.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 (268) hide show
  1. package/README.md +2 -1
  2. package/dist/canvas-shell/canvas-shell.js +13 -0
  3. package/dist/canvas-shell/canvas-shell.js.map +1 -0
  4. package/dist/canvas-shell/clamped-fit-offset.js +8 -0
  5. package/dist/canvas-shell/clamped-fit-offset.js.map +1 -0
  6. package/dist/canvas-shell/index.js +14 -0
  7. package/dist/canvas-shell/index.js.map +1 -0
  8. package/dist/canvas-shell/use-measured-nodes.js +8 -0
  9. package/dist/canvas-shell/use-measured-nodes.js.map +1 -0
  10. package/dist/chunk-3BTU2VWZ.js +42 -0
  11. package/dist/chunk-3BTU2VWZ.js.map +1 -0
  12. package/dist/chunk-3LK6OOXE.js +2 -0
  13. package/dist/chunk-3LK6OOXE.js.map +1 -0
  14. package/dist/chunk-3M3FQAD4.js +105 -0
  15. package/dist/chunk-3M3FQAD4.js.map +1 -0
  16. package/dist/chunk-3Y5TCGC3.js +2 -0
  17. package/dist/chunk-3Y5TCGC3.js.map +1 -0
  18. package/dist/chunk-4VYHZM3S.js +108 -0
  19. package/dist/chunk-4VYHZM3S.js.map +1 -0
  20. package/dist/chunk-5TVVM4GR.js +45 -0
  21. package/dist/chunk-5TVVM4GR.js.map +1 -0
  22. package/dist/chunk-5XT73MQQ.js +2 -0
  23. package/dist/chunk-5XT73MQQ.js.map +1 -0
  24. package/dist/chunk-6LQD5Y5R.js +24 -0
  25. package/dist/chunk-6LQD5Y5R.js.map +1 -0
  26. package/dist/chunk-6SR26QY2.js +18 -0
  27. package/dist/chunk-6SR26QY2.js.map +1 -0
  28. package/dist/chunk-75JJK6BH.js +157 -0
  29. package/dist/chunk-75JJK6BH.js.map +1 -0
  30. package/dist/chunk-A66JBW4X.js +2 -0
  31. package/dist/chunk-A66JBW4X.js.map +1 -0
  32. package/dist/chunk-A7AFM3LI.js +72 -0
  33. package/dist/chunk-A7AFM3LI.js.map +1 -0
  34. package/dist/chunk-ADHLQF56.js +2 -0
  35. package/dist/chunk-ADHLQF56.js.map +1 -0
  36. package/dist/chunk-ATO4LXJF.js +75 -0
  37. package/dist/chunk-ATO4LXJF.js.map +1 -0
  38. package/dist/chunk-ATXVXTMZ.js +227 -0
  39. package/dist/chunk-ATXVXTMZ.js.map +1 -0
  40. package/dist/chunk-B6IUPZU7.js +2 -0
  41. package/dist/chunk-B6IUPZU7.js.map +1 -0
  42. package/dist/chunk-BJ7UMCD3.js +53 -0
  43. package/dist/chunk-BJ7UMCD3.js.map +1 -0
  44. package/dist/chunk-BWGYV4JZ.js +2 -0
  45. package/dist/chunk-BWGYV4JZ.js.map +1 -0
  46. package/dist/chunk-CWNO63LQ.js +109 -0
  47. package/dist/chunk-CWNO63LQ.js.map +1 -0
  48. package/dist/chunk-DHPUXKCZ.js +76 -0
  49. package/dist/chunk-DHPUXKCZ.js.map +1 -0
  50. package/dist/chunk-DNX7G3KA.js +185 -0
  51. package/dist/chunk-DNX7G3KA.js.map +1 -0
  52. package/dist/chunk-DNZRAJAQ.js +41 -0
  53. package/dist/chunk-DNZRAJAQ.js.map +1 -0
  54. package/dist/chunk-ELCLRVD4.js +2 -0
  55. package/dist/chunk-ELCLRVD4.js.map +1 -0
  56. package/dist/chunk-ELOADZSX.js +2 -0
  57. package/dist/chunk-ELOADZSX.js.map +1 -0
  58. package/dist/chunk-EUNV2MR5.js +21 -0
  59. package/dist/chunk-EUNV2MR5.js.map +1 -0
  60. package/dist/chunk-FAFQ5KLB.js +43 -0
  61. package/dist/chunk-FAFQ5KLB.js.map +1 -0
  62. package/dist/chunk-FIRO6Y3R.js +128 -0
  63. package/dist/chunk-FIRO6Y3R.js.map +1 -0
  64. package/dist/chunk-GE73WQUK.js +2 -0
  65. package/dist/chunk-GE73WQUK.js.map +1 -0
  66. package/dist/chunk-GIARAOU6.js +39 -0
  67. package/dist/chunk-GIARAOU6.js.map +1 -0
  68. package/dist/chunk-HTLTCCFA.js +73 -0
  69. package/dist/chunk-HTLTCCFA.js.map +1 -0
  70. package/dist/chunk-I53W3CFN.js +56 -0
  71. package/dist/chunk-I53W3CFN.js.map +1 -0
  72. package/dist/chunk-IJVFPUXO.js +2 -0
  73. package/dist/chunk-IJVFPUXO.js.map +1 -0
  74. package/dist/chunk-JUPMOISE.js +51 -0
  75. package/dist/chunk-JUPMOISE.js.map +1 -0
  76. package/dist/chunk-K5RPSVO6.js +167 -0
  77. package/dist/chunk-K5RPSVO6.js.map +1 -0
  78. package/dist/chunk-K6U27YJ6.js +78 -0
  79. package/dist/chunk-K6U27YJ6.js.map +1 -0
  80. package/dist/chunk-KDVMGMAK.js +2 -0
  81. package/dist/chunk-KDVMGMAK.js.map +1 -0
  82. package/dist/chunk-LQOZCOK3.js +74 -0
  83. package/dist/chunk-LQOZCOK3.js.map +1 -0
  84. package/dist/chunk-NAQWNX2A.js +102 -0
  85. package/dist/chunk-NAQWNX2A.js.map +1 -0
  86. package/dist/chunk-NVJVWGUS.js +152 -0
  87. package/dist/chunk-NVJVWGUS.js.map +1 -0
  88. package/dist/chunk-O2F3R3PV.js +2 -0
  89. package/dist/chunk-O2F3R3PV.js.map +1 -0
  90. package/dist/chunk-OMFQHMA6.js +2 -0
  91. package/dist/chunk-OMFQHMA6.js.map +1 -0
  92. package/dist/chunk-PE6JXVOX.js +2 -0
  93. package/dist/chunk-PE6JXVOX.js.map +1 -0
  94. package/dist/chunk-ROJEIIA4.js +52 -0
  95. package/dist/chunk-ROJEIIA4.js.map +1 -0
  96. package/dist/chunk-ROYAQKJA.js +9 -0
  97. package/dist/chunk-ROYAQKJA.js.map +1 -0
  98. package/dist/chunk-SHCZK4DO.js +45 -0
  99. package/dist/chunk-SHCZK4DO.js.map +1 -0
  100. package/dist/chunk-SXXJV6YJ.js +79 -0
  101. package/dist/chunk-SXXJV6YJ.js.map +1 -0
  102. package/dist/chunk-T7S7NJFD.js +287 -0
  103. package/dist/chunk-T7S7NJFD.js.map +1 -0
  104. package/dist/chunk-TEZ3OXAY.js +63 -0
  105. package/dist/chunk-TEZ3OXAY.js.map +1 -0
  106. package/dist/chunk-TGWPM2CG.js +199 -0
  107. package/dist/chunk-TGWPM2CG.js.map +1 -0
  108. package/dist/chunk-TSB6RE7L.js +2 -0
  109. package/dist/chunk-TSB6RE7L.js.map +1 -0
  110. package/dist/chunk-UPV2MT4O.js +2 -0
  111. package/dist/chunk-UPV2MT4O.js.map +1 -0
  112. package/dist/chunk-UZJZY4A5.js +2 -0
  113. package/dist/chunk-UZJZY4A5.js.map +1 -0
  114. package/dist/chunk-W3TQMUGX.js +2 -0
  115. package/dist/chunk-W3TQMUGX.js.map +1 -0
  116. package/dist/chunk-WN46RIOC.js +29 -0
  117. package/dist/chunk-WN46RIOC.js.map +1 -0
  118. package/dist/chunk-XZFE2YYH.js +70 -0
  119. package/dist/chunk-XZFE2YYH.js.map +1 -0
  120. package/dist/chunk-YG3KXTZW.js +2 -0
  121. package/dist/chunk-YG3KXTZW.js.map +1 -0
  122. package/dist/chunk-ZATVF42E.js +65 -0
  123. package/dist/chunk-ZATVF42E.js.map +1 -0
  124. package/dist/chunk-ZXZTLQIX.js +19 -0
  125. package/dist/chunk-ZXZTLQIX.js.map +1 -0
  126. package/dist/flow-button-edge/flow-button-edge.js +10 -0
  127. package/dist/flow-button-edge/flow-button-edge.js.map +1 -0
  128. package/dist/flow-button-edge/index.js +11 -0
  129. package/dist/flow-button-edge/index.js.map +1 -0
  130. package/dist/flow-edge/flow-edge.js +10 -0
  131. package/dist/flow-edge/flow-edge.js.map +1 -0
  132. package/dist/flow-edge/index.js +11 -0
  133. package/dist/flow-edge/index.js.map +1 -0
  134. package/dist/flow-edge-path/flow-edge-path.js +12 -0
  135. package/dist/flow-edge-path/flow-edge-path.js.map +1 -0
  136. package/dist/flow-edge-path/index.js +13 -0
  137. package/dist/flow-edge-path/index.js.map +1 -0
  138. package/dist/flow-edge-tokens/edge-path-sampler.js +10 -0
  139. package/dist/flow-edge-tokens/edge-path-sampler.js.map +1 -0
  140. package/dist/flow-edge-tokens/flow-edge-tokens.js +12 -0
  141. package/dist/flow-edge-tokens/flow-edge-tokens.js.map +1 -0
  142. package/dist/flow-edge-tokens/index.js +17 -0
  143. package/dist/flow-edge-tokens/index.js.map +1 -0
  144. package/dist/flow-floating-edge/floating-edge-geometry.js +8 -0
  145. package/dist/flow-floating-edge/floating-edge-geometry.js.map +1 -0
  146. package/dist/flow-floating-edge/flow-floating-edge.js +11 -0
  147. package/dist/flow-floating-edge/flow-floating-edge.js.map +1 -0
  148. package/dist/flow-floating-edge/index.js +15 -0
  149. package/dist/flow-floating-edge/index.js.map +1 -0
  150. package/dist/flow-group-node/flow-group-node.js +12 -0
  151. package/dist/flow-group-node/flow-group-node.js.map +1 -0
  152. package/dist/flow-group-node/index.js +13 -0
  153. package/dist/flow-group-node/index.js.map +1 -0
  154. package/dist/flow-handle/flow-handle-anchor.js +8 -0
  155. package/dist/flow-handle/flow-handle-anchor.js.map +1 -0
  156. package/dist/flow-handle/index.js +9 -0
  157. package/dist/flow-handle/index.js.map +1 -0
  158. package/dist/flow-layout/backbone.js +8 -0
  159. package/dist/flow-layout/backbone.js.map +1 -0
  160. package/dist/flow-layout/flow-layout.js +10 -0
  161. package/dist/flow-layout/flow-layout.js.map +1 -0
  162. package/dist/flow-layout/index.js +21 -0
  163. package/dist/flow-layout/index.js.map +1 -0
  164. package/dist/flow-layout/layout-flow-elk.js +9 -0
  165. package/dist/flow-layout/layout-flow-elk.js.map +1 -0
  166. package/dist/flow-layout/layout-graph.js +9 -0
  167. package/dist/flow-layout/layout-graph.js.map +1 -0
  168. package/dist/flow-layout/use-auto-layout.js +10 -0
  169. package/dist/flow-layout/use-auto-layout.js.map +1 -0
  170. package/dist/flow-layout/use-flow-layout.js +9 -0
  171. package/dist/flow-layout/use-flow-layout.js.map +1 -0
  172. package/dist/flow-mini-map/flow-mini-map.js +8 -0
  173. package/dist/flow-mini-map/flow-mini-map.js.map +1 -0
  174. package/dist/flow-mini-map/index.js +9 -0
  175. package/dist/flow-mini-map/index.js.map +1 -0
  176. package/dist/flow-node/flow-node.js +11 -0
  177. package/dist/flow-node/flow-node.js.map +1 -0
  178. package/dist/flow-node/index.js +12 -0
  179. package/dist/flow-node/index.js.map +1 -0
  180. package/dist/flow-placeholder-node/flow-placeholder-node.js +9 -0
  181. package/dist/flow-placeholder-node/flow-placeholder-node.js.map +1 -0
  182. package/dist/flow-placeholder-node/index.js +10 -0
  183. package/dist/flow-placeholder-node/index.js.map +1 -0
  184. package/dist/flow-self-loop-edge/flow-self-loop-edge.js +20 -0
  185. package/dist/flow-self-loop-edge/flow-self-loop-edge.js.map +1 -0
  186. package/dist/flow-self-loop-edge/index.js +28 -0
  187. package/dist/flow-self-loop-edge/index.js.map +1 -0
  188. package/dist/flow-self-loop-edge/self-loop-geometry.js +12 -0
  189. package/dist/flow-self-loop-edge/self-loop-geometry.js.map +1 -0
  190. package/dist/flow-smart-edge/flow-smart-edge.js +11 -0
  191. package/dist/flow-smart-edge/flow-smart-edge.js.map +1 -0
  192. package/dist/flow-smart-edge/index.js +25 -0
  193. package/dist/flow-smart-edge/index.js.map +1 -0
  194. package/dist/flow-smart-edge/smart-edge-geometry.js +18 -0
  195. package/dist/flow-smart-edge/smart-edge-geometry.js.map +1 -0
  196. package/dist/flow-weighted-edge/back-edge-geometry.js +8 -0
  197. package/dist/flow-weighted-edge/back-edge-geometry.js.map +1 -0
  198. package/dist/flow-weighted-edge/edge-aria.js +10 -0
  199. package/dist/flow-weighted-edge/edge-aria.js.map +1 -0
  200. package/dist/flow-weighted-edge/edge-label-pill.js +8 -0
  201. package/dist/flow-weighted-edge/edge-label-pill.js.map +1 -0
  202. package/dist/flow-weighted-edge/flow-weighted-edge.js +16 -0
  203. package/dist/flow-weighted-edge/flow-weighted-edge.js.map +1 -0
  204. package/dist/flow-weighted-edge/index.js +34 -0
  205. package/dist/flow-weighted-edge/index.js.map +1 -0
  206. package/dist/flow-weighted-edge/weight-scale.js +10 -0
  207. package/dist/flow-weighted-edge/weight-scale.js.map +1 -0
  208. package/dist/helper-lines/get-helper-lines.js +8 -0
  209. package/dist/helper-lines/get-helper-lines.js.map +1 -0
  210. package/dist/helper-lines/helper-lines.js +9 -0
  211. package/dist/helper-lines/helper-lines.js.map +1 -0
  212. package/dist/helper-lines/index.js +17 -0
  213. package/dist/helper-lines/index.js.map +1 -0
  214. package/dist/helper-lines/use-helper-lines.js +10 -0
  215. package/dist/helper-lines/use-helper-lines.js.map +1 -0
  216. package/dist/index.d.ts +241 -2
  217. package/dist/index.js +167 -2306
  218. package/dist/index.js.map +1 -1
  219. package/dist/inspector-panel/index.js +9 -0
  220. package/dist/inspector-panel/index.js.map +1 -0
  221. package/dist/inspector-panel/inspector-panel.js +8 -0
  222. package/dist/inspector-panel/inspector-panel.js.map +1 -0
  223. package/dist/legend/index.js +10 -0
  224. package/dist/legend/index.js.map +1 -0
  225. package/dist/legend/legend.js +9 -0
  226. package/dist/legend/legend.js.map +1 -0
  227. package/dist/use-flow-groups/group-operations.js +20 -0
  228. package/dist/use-flow-groups/group-operations.js.map +1 -0
  229. package/dist/use-flow-groups/index.js +25 -0
  230. package/dist/use-flow-groups/index.js.map +1 -0
  231. package/dist/use-flow-groups/use-flow-groups.js +9 -0
  232. package/dist/use-flow-groups/use-flow-groups.js.map +1 -0
  233. package/dist/zoom-controls/index.js +9 -0
  234. package/dist/zoom-controls/index.js.map +1 -0
  235. package/dist/zoom-controls/zoom-controls.js +8 -0
  236. package/dist/zoom-controls/zoom-controls.js.map +1 -0
  237. package/package.json +14 -7
  238. package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
  239. package/src/__contract__/legend.contract.test.tsx +49 -0
  240. package/src/canvas-shell/canvas-shell.stories.tsx +1 -0
  241. package/src/canvas-shell/canvas-shell.tsx +51 -4
  242. package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
  243. package/src/canvas-shell/clamped-fit-offset.ts +30 -0
  244. package/src/custom-nodes/custom-nodes.stories.tsx +718 -0
  245. package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
  246. package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
  247. package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
  248. package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
  249. package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
  250. package/src/flow-edge-tokens/index.ts +12 -0
  251. package/src/flow-layout/backbone.test.ts +88 -0
  252. package/src/flow-layout/backbone.ts +58 -0
  253. package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
  254. package/src/flow-layout/layout-flow-elk.test.ts +152 -0
  255. package/src/flow-layout/layout-flow-elk.ts +399 -0
  256. package/src/flow-mini-map/flow-mini-map.stories.tsx +20 -0
  257. package/src/flow-node/flow-node.stories.tsx +40 -1
  258. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
  259. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
  260. package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
  261. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
  262. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
  263. package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
  264. package/src/index.ts +13 -0
  265. package/src/inspector-panel/inspector-panel.tsx +9 -6
  266. package/src/legend/legend.test.tsx +11 -0
  267. package/src/legend/legend.tsx +15 -7
  268. package/src/templates-flow-workspace.stories.tsx +7 -2
@@ -0,0 +1,312 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import "@xyflow/react/dist/style.css";
4
+ import { expect, userEvent, waitFor, within } from "storybook/test";
5
+ import { Button, Slider } from "@elabs-ai/components-ui";
6
+ import { CanvasShell } from "../canvas-shell";
7
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
8
+ import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "../flow-weighted-edge";
9
+ import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "../flow-self-loop-edge";
10
+ import { FlowEdgeTokens } from "./flow-edge-tokens";
11
+
12
+ const nodeTypes = { brand: FlowNode };
13
+ const edgeTypes = { weighted: FlowWeightedEdge, "self-loop": FlowSelfLoopEdge };
14
+
15
+ type ProcessEdge = BrandFlowWeightedEdge | BrandFlowSelfLoopEdge;
16
+
17
+ const meta = {
18
+ title: "Flow/FlowEdgeTokens",
19
+ component: FlowEdgeTokens,
20
+ tags: ["autodocs"],
21
+ parameters: {
22
+ layout: "fullscreen",
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "Markers (“tokens”) that sit along an edge’s path at a `progress` between 0 and 1 — " +
27
+ "the primitive behind process replay. The parent drives `progress`; the part keeps no " +
28
+ "clock. `FlowWeightedEdge` and `FlowSelfLoopEdge` draw them from `data.tokens` on their " +
29
+ "own computed path; a custom edge renders `FlowEdgeTokens` with the same `path` it hands " +
30
+ "to `FlowEdgePath`. Tokens are hidden from assistive technology, so pair them with a " +
31
+ "text alternative such as a live case count.",
32
+ },
33
+ },
34
+ },
35
+ } satisfies Meta<typeof FlowEdgeTokens>;
36
+ export default meta;
37
+ type Story = StoryObj<typeof meta>;
38
+
39
+ function chainNodes(): BrandFlowNode[] {
40
+ return [
41
+ {
42
+ id: "a",
43
+ type: "brand",
44
+ position: { x: 0, y: 0 },
45
+ data: { kind: "Start", title: "Received" },
46
+ },
47
+ { id: "b", type: "brand", position: { x: 0, y: 200 }, data: { kind: "Step", title: "Review" } },
48
+ {
49
+ id: "c",
50
+ type: "brand",
51
+ position: { x: 0, y: 400 },
52
+ data: { kind: "End", title: "Approved" },
53
+ },
54
+ ];
55
+ }
56
+
57
+ const tokenEls = (root: HTMLElement) =>
58
+ Array.from(root.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'));
59
+
60
+ /** `translate(12.5px, 40px)` → `[12.5, 40]`. */
61
+ function tokenPosition(token: SVGCircleElement): [number, number] {
62
+ const m = /translate\(([-\d.e]+)px,\s*([-\d.e]+)px\)/.exec(token.style.transform);
63
+ if (!m) throw new Error(`unexpected token transform: ${token.style.transform}`);
64
+ return [Number(m[1]), Number(m[2])];
65
+ }
66
+
67
+ /**
68
+ * The browser's own `getPointAtLength` on the edge's rendered path is the oracle: every token
69
+ * must sit within a pixel and a half of it, which checks the pure sampler against the real engine.
70
+ */
71
+ function expectOnPath(token: SVGCircleElement, path: SVGPathElement, progress: number) {
72
+ const point = path.getPointAtLength(progress * path.getTotalLength());
73
+ const [x, y] = tokenPosition(token);
74
+ expect(Math.hypot(x - point.x, y - point.y)).toBeLessThan(1.5);
75
+ }
76
+
77
+ function Canvas({ edges, caption }: { edges: ProcessEdge[]; caption: string }) {
78
+ return (
79
+ <div className="flex flex-col gap-2 p-4">
80
+ {/* The tokens are aria-hidden; this is the text alternative a consumer owes. */}
81
+ <p className="text-caption text-muted-foreground">{caption}</p>
82
+ <div className="h-[560px]">
83
+ <CanvasShell
84
+ nodes={chainNodes()}
85
+ edges={edges}
86
+ nodeTypes={nodeTypes}
87
+ edgeTypes={edgeTypes}
88
+ />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ const STATIC_PROGRESS = [0, 0.25, 0.5, 0.75, 1];
95
+
96
+ /** Five tokens pinned at 0, ¼, ½, ¾ and 1 of a bezier edge, and a smoothstep edge carrying one at ½. */
97
+ export const StaticPositions: Story = {
98
+ args: { path: "", tokens: [] },
99
+ render: () => (
100
+ <Canvas
101
+ caption="Five cases between Received and Review; one case between Review and Approved."
102
+ edges={[
103
+ {
104
+ id: "e-ab",
105
+ source: "a",
106
+ target: "b",
107
+ type: "weighted",
108
+ data: {
109
+ weight: 5,
110
+ tokens: STATIC_PROGRESS.map((progress) => ({ id: `ab-${progress}`, progress })),
111
+ },
112
+ },
113
+ {
114
+ id: "e-bc",
115
+ source: "b",
116
+ target: "c",
117
+ type: "weighted",
118
+ data: {
119
+ weight: 1,
120
+ path: "smoothstep",
121
+ tokens: [{ id: "bc-half", progress: 0.5, radius: 6 }],
122
+ },
123
+ },
124
+ ]}
125
+ />
126
+ ),
127
+ play: async ({ canvasElement }) => {
128
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(6));
129
+ const [ab, bc] = Array.from(
130
+ canvasElement.querySelectorAll<SVGPathElement>('[data-slot="flow-weighted-edge"]'),
131
+ );
132
+ const tokens = tokenEls(canvasElement);
133
+ STATIC_PROGRESS.forEach((progress, i) => expectOnPath(tokens[i]!, ab!, progress));
134
+ expectOnPath(tokens[5]!, bc!, 0.5);
135
+ await expect(tokens[5]).toHaveAttribute("r", "6");
136
+ // Decorative to assistive technology: the caption carries the meaning.
137
+ for (const group of canvasElement.querySelectorAll('[data-slot="flow-edge-tokens"]')) {
138
+ await expect(group).toHaveAttribute("aria-hidden", "true");
139
+ }
140
+ },
141
+ };
142
+
143
+ function ScrubbedEdge() {
144
+ const [percent, setPercent] = useState(10);
145
+ return (
146
+ <div className="flex flex-col gap-2 p-4">
147
+ <div className="flex max-w-sm flex-col gap-2">
148
+ <p className="text-caption text-muted-foreground">
149
+ Case 1042 is {percent}% of the way to Review.
150
+ </p>
151
+ <Slider
152
+ aria-label="Case 1042 progress"
153
+ min={0}
154
+ max={100}
155
+ step={10}
156
+ value={[percent]}
157
+ onValueChange={(v) => setPercent(v[0] ?? 0)}
158
+ />
159
+ </div>
160
+ <div className="h-[520px]">
161
+ <CanvasShell
162
+ nodes={chainNodes().slice(0, 2)}
163
+ edges={[
164
+ {
165
+ id: "e-ab",
166
+ source: "a",
167
+ target: "b",
168
+ type: "weighted",
169
+ data: { weight: 3, tokens: [{ id: "case-1042", progress: percent / 100 }] },
170
+ },
171
+ ]}
172
+ nodeTypes={nodeTypes}
173
+ edgeTypes={edgeTypes}
174
+ />
175
+ </div>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ /** The parent owns `progress`: drag or arrow-key the slider and the token follows the edge. */
181
+ export const ParentDrivenProgress: Story = {
182
+ args: { path: "", tokens: [] },
183
+ render: () => <ScrubbedEdge />,
184
+ play: async ({ canvasElement }) => {
185
+ const canvas = within(canvasElement);
186
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(1));
187
+ const path = canvasElement.querySelector<SVGPathElement>('[data-slot="flow-weighted-edge"]')!;
188
+ const token = tokenEls(canvasElement)[0]!;
189
+ expectOnPath(token, path, 0.1);
190
+
191
+ const thumb = canvas.getByRole("slider", { name: "Case 1042 progress" });
192
+ thumb.focus();
193
+ await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}");
194
+ await canvas.findByText("Case 1042 is 50% of the way to Review.");
195
+ // Same element — keyed by token id — now at the new progress.
196
+ await waitFor(() => expectOnPath(tokenEls(canvasElement)[0]!, path, 0.5));
197
+ await expect(tokenEls(canvasElement)[0]).toBe(token);
198
+
199
+ await userEvent.keyboard("{End}");
200
+ await waitFor(() => expectOnPath(tokenEls(canvasElement)[0]!, path, 1));
201
+ },
202
+ };
203
+
204
+ function SteppedEdge() {
205
+ const [progress, setProgress] = useState(0);
206
+ return (
207
+ <div className="flex flex-col gap-2 p-4">
208
+ <div className="flex items-center gap-2">
209
+ <Button
210
+ size="sm"
211
+ variant="outline"
212
+ onClick={() => setProgress((p) => (p >= 1 ? 0 : p + 0.5))}
213
+ >
214
+ Advance case
215
+ </Button>
216
+ <p className="text-caption text-muted-foreground">Case 7 at {progress * 100}%.</p>
217
+ </div>
218
+ <div className="h-[520px]">
219
+ <CanvasShell
220
+ nodes={chainNodes().slice(0, 2)}
221
+ edges={[
222
+ {
223
+ id: "e-ab",
224
+ source: "a",
225
+ target: "b",
226
+ type: "weighted",
227
+ data: { weight: 3, tokens: [{ id: "case-7", progress }] },
228
+ },
229
+ ]}
230
+ nodeTypes={nodeTypes}
231
+ edgeTypes={edgeTypes}
232
+ />
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Under reduced motion a token jumps straight to its new position: the motion gate collapses the
240
+ * transform transition to `--motion-min`, and a reduced `useReducedMotion()` drops the transition
241
+ * class outright.
242
+ */
243
+ export const ReducedMotion: Story = {
244
+ args: { path: "", tokens: [] },
245
+ globals: { motionPref: "reduced" },
246
+ render: () => <SteppedEdge />,
247
+ play: async ({ canvasElement }) => {
248
+ const canvas = within(canvasElement);
249
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(1));
250
+
251
+ await userEvent.click(canvas.getByRole("button", { name: "Advance case" }));
252
+ await canvas.findByText("Case 7 at 50%.");
253
+ const token = tokenEls(canvasElement)[0]!;
254
+ // No perceptible transition: either the class is gone (a reduced `useReducedMotion()`) or the
255
+ // motion gate has collapsed `duration-fast` to a few hundredths of a millisecond.
256
+ const durations = getComputedStyle(token)
257
+ .transitionDuration.split(",")
258
+ .map((d) => parseFloat(d) * (d.trim().endsWith("ms") ? 1 : 1000));
259
+ await expect(Math.max(...durations)).toBeLessThan(1);
260
+ // The painted position is the target one once the canvas settles. The path is re-queried
261
+ // on every attempt: React Flow may re-render the edge (a late fitView on a slow CI runner),
262
+ // and a path element read before that would still report the old geometry.
263
+ await waitFor(() => {
264
+ const path = canvasElement.querySelector<SVGPathElement>('[data-slot="flow-weighted-edge"]')!;
265
+ const point = path.getPointAtLength(0.5 * path.getTotalLength());
266
+ const matrix = new DOMMatrixReadOnly(getComputedStyle(tokenEls(canvasElement)[0]!).transform);
267
+ expect(Math.hypot(matrix.e - point.x, matrix.f - point.y)).toBeLessThan(1.5);
268
+ });
269
+ },
270
+ };
271
+
272
+ /** Tokens travel the self-loop’s own arc, from the source handle round to the target handle. */
273
+ export const SelfLoop: Story = {
274
+ args: { path: "", tokens: [] },
275
+ render: () => (
276
+ <Canvas
277
+ caption="Three cases repeating Review."
278
+ edges={[
279
+ { id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 6 } },
280
+ { id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 4 } },
281
+ {
282
+ id: "e-bb",
283
+ source: "b",
284
+ target: "b",
285
+ type: "self-loop",
286
+ data: {
287
+ weight: 2,
288
+ tokens: [
289
+ { id: "r1", progress: 0.2 },
290
+ { id: "r2", progress: 0.5 },
291
+ { id: "r3", progress: 0.8 },
292
+ ],
293
+ },
294
+ },
295
+ ]}
296
+ />
297
+ ),
298
+ play: async ({ canvasElement }) => {
299
+ await waitFor(() => {
300
+ expect(tokenEls(canvasElement)).toHaveLength(3);
301
+ expect(canvasElement.querySelector('[data-slot="flow-self-loop-edge"]')).not.toBeNull();
302
+ });
303
+ // The arc is recomputed once the node is measured — read it after it settles.
304
+ await waitFor(() => {
305
+ const loop = canvasElement.querySelector<SVGPathElement>(
306
+ '[data-slot="flow-self-loop-edge"]',
307
+ )!;
308
+ const tokens = tokenEls(canvasElement);
309
+ [0.2, 0.5, 0.8].forEach((progress, i) => expectOnPath(tokens[i]!, loop, progress));
310
+ });
311
+ },
312
+ };
@@ -0,0 +1,115 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { createRef, type ReactElement } from "react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { DEFAULT_EDGE_TOKEN_RADIUS, FlowEdgeTokens } from "./flow-edge-tokens";
5
+
6
+ afterEach(cleanup);
7
+
8
+ function renderTokens(ui: ReactElement) {
9
+ return render(<svg>{ui}</svg>);
10
+ }
11
+
12
+ const tokenEls = (root: HTMLElement) =>
13
+ Array.from(root.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'));
14
+
15
+ describe("FlowEdgeTokens", () => {
16
+ it("places each token at its progress along the path", () => {
17
+ const { container } = renderTokens(
18
+ <FlowEdgeTokens
19
+ path="M0,0 L200,0"
20
+ tokens={[
21
+ { id: "a", progress: 0 },
22
+ { id: "b", progress: 0.5 },
23
+ { id: "c", progress: 1 },
24
+ ]}
25
+ />,
26
+ );
27
+ const [a, b, c] = tokenEls(container);
28
+ expect(a!.style.transform).toBe("translate(0px, 0px)");
29
+ expect(b!.style.transform).toBe("translate(100px, 0px)");
30
+ expect(c!.style.transform).toBe("translate(200px, 0px)");
31
+ });
32
+
33
+ it("moves a token when the parent changes its progress, keeping the same element", () => {
34
+ const { container, rerender } = renderTokens(
35
+ <FlowEdgeTokens path="M0,0 L200,0" tokens={[{ id: "a", progress: 0.25 }]} />,
36
+ );
37
+ const before = tokenEls(container)[0]!;
38
+ expect(before.style.transform).toBe("translate(50px, 0px)");
39
+ rerender(
40
+ <svg>
41
+ <FlowEdgeTokens path="M0,0 L200,0" tokens={[{ id: "a", progress: 0.75 }]} />
42
+ </svg>,
43
+ );
44
+ const after = tokenEls(container)[0]!;
45
+ expect(after).toBe(before);
46
+ expect(after.style.transform).toBe("translate(150px, 0px)");
47
+ });
48
+
49
+ it("is decorative to assistive technology and carries its slots", () => {
50
+ const { container } = renderTokens(
51
+ <FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "case-1", progress: 0.2 }]} />,
52
+ );
53
+ const root = container.querySelector('[data-slot="flow-edge-tokens"]')!;
54
+ expect(root).toHaveAttribute("aria-hidden", "true");
55
+ expect(tokenEls(container)[0]).toHaveAttribute("data-token-id", "case-1");
56
+ });
57
+
58
+ it("uses the default radius and honours a per-token radius", () => {
59
+ const { container } = renderTokens(
60
+ <FlowEdgeTokens
61
+ path="M0,0 L10,0"
62
+ tokens={[
63
+ { id: "a", progress: 0 },
64
+ { id: "b", progress: 0, radius: 9 },
65
+ ]}
66
+ />,
67
+ );
68
+ const [a, b] = tokenEls(container);
69
+ expect(a).toHaveAttribute("r", String(DEFAULT_EDGE_TOKEN_RADIUS));
70
+ expect(b).toHaveAttribute("r", "9");
71
+ });
72
+
73
+ it("paints with semantic tokens only", () => {
74
+ const { container } = renderTokens(
75
+ <FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "a", progress: 0 }]} />,
76
+ );
77
+ const cls = tokenEls(container)[0]!.getAttribute("class")!;
78
+ expect(cls).toContain("fill-primary");
79
+ expect(cls).toContain("stroke-background");
80
+ });
81
+
82
+ it("transitions between positions when motion is allowed (jsdom has no reduced-motion signal)", () => {
83
+ const { container } = renderTokens(
84
+ <FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "a", progress: 0 }]} />,
85
+ );
86
+ expect(container.querySelector('[data-slot="flow-edge-tokens"]')).toHaveAttribute(
87
+ "data-motion",
88
+ "animated",
89
+ );
90
+ expect(tokenEls(container)[0]!.getAttribute("class")).toContain("transition-transform");
91
+ });
92
+
93
+ it("renders no tokens for a path with no geometry", () => {
94
+ const { container } = renderTokens(
95
+ <FlowEdgeTokens path="" tokens={[{ id: "a", progress: 0.5 }]} />,
96
+ );
97
+ expect(tokenEls(container)).toHaveLength(0);
98
+ });
99
+
100
+ it("merges className, spreads props and forwards its ref", () => {
101
+ const ref = createRef<SVGGElement>();
102
+ const { container } = renderTokens(
103
+ <FlowEdgeTokens
104
+ ref={ref}
105
+ path="M0,0 L10,0"
106
+ tokens={[]}
107
+ className="opacity-50"
108
+ data-testid="tokens"
109
+ />,
110
+ );
111
+ const root = container.querySelector('[data-testid="tokens"]')!;
112
+ expect(ref.current).toBe(root);
113
+ expect(root.getAttribute("class")).toBe("pointer-events-none opacity-50");
114
+ });
115
+ });
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useMemo, type SVGProps } from "react";
4
+ import { useReducedMotion } from "@elabs-ai/components-tokens";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+ import { createEdgePathSampler } from "./edge-path-sampler";
7
+
8
+ /** Default token radius, in px. */
9
+ export const DEFAULT_EDGE_TOKEN_RADIUS = 4;
10
+
11
+ export interface FlowEdgeToken {
12
+ /** Stable identity (e.g. a case id). Keys the rendered marker, so a token keeps its element — and its transition — as it moves. */
13
+ id: string;
14
+ /** Position along the path's arc length: `0` = source end, `1` = target end. Clamped; a non-finite value sits at `0`. */
15
+ progress: number;
16
+ /** Marker radius in px — e.g. scaled by congestion. @default 4 */
17
+ radius?: number;
18
+ }
19
+
20
+ export interface FlowEdgeTokensProps extends Omit<SVGProps<SVGGElement>, "ref" | "children"> {
21
+ /** SVG path `d` the tokens travel along — the same string the edge draws. */
22
+ path: string;
23
+ /** Tokens to place. The parent owns `progress`; this part keeps no clock. */
24
+ tokens: readonly FlowEdgeToken[];
25
+ }
26
+
27
+ /**
28
+ * Markers ("tokens") placed along an edge's path — the presentational primitive
29
+ * behind process replay. Each token is a circle at `progress` (0..1) of the
30
+ * path's arc length.
31
+ *
32
+ * **Parent-driven, no internal clock.** Nothing here animates on its own: the
33
+ * parent re-renders with new `progress` values (from a slider, a playback loop,
34
+ * a play function). Position is a pure function of `(path, progress)` — see
35
+ * `createEdgePathSampler` — so every render is deterministic and identical in
36
+ * jsdom, on the server and in the browser.
37
+ *
38
+ * Between two renders a token glides with a short `duration-fast` transform
39
+ * transition, so a parent stepping at a coarse rate still reads as motion. Under
40
+ * reduced motion (`useReducedMotion()`: the ThemeProvider preference, else the
41
+ * OS setting) the transition class is not rendered at all and a token jumps
42
+ * straight to its new position; the `duration-fast` token also collapses to
43
+ * `--motion-min` through the global motion gate (`data-motion-pref`).
44
+ *
45
+ * **Decorative to assistive technology.** The group is `aria-hidden` and ignores
46
+ * pointer events: a moving dot has no stable meaning to announce. The consumer
47
+ * owns the text alternative (a live case count, a summary). Colour is the one
48
+ * `--primary` fill for every token, with a `--background` halo so a token stays
49
+ * distinct from the edge stroke beneath it.
50
+ *
51
+ * Existing brand edges render this for you from `data.tokens`
52
+ * (`FlowWeightedEdge`, `FlowSelfLoopEdge`); a custom edge renders it after its
53
+ * `FlowEdgePath` with the same `path` string.
54
+ */
55
+ export const FlowEdgeTokens = forwardRef<SVGGElement, FlowEdgeTokensProps>(function FlowEdgeTokens(
56
+ { path, tokens, className, ...props },
57
+ ref,
58
+ ) {
59
+ const reducedMotion = useReducedMotion();
60
+ const sampler = useMemo(() => createEdgePathSampler(path), [path]);
61
+
62
+ return (
63
+ <g
64
+ ref={ref}
65
+ aria-hidden="true"
66
+ data-slot="flow-edge-tokens"
67
+ data-motion={reducedMotion ? "reduced" : "animated"}
68
+ className={cn("pointer-events-none", className)}
69
+ {...props}
70
+ >
71
+ {sampler
72
+ ? tokens.map((token) => {
73
+ const { x, y } = sampler.pointAt(token.progress);
74
+ return (
75
+ <circle
76
+ key={token.id}
77
+ data-slot="flow-edge-tokens-token"
78
+ data-token-id={token.id}
79
+ cx={0}
80
+ cy={0}
81
+ r={token.radius ?? DEFAULT_EDGE_TOKEN_RADIUS}
82
+ strokeWidth={1.5}
83
+ className={cn(
84
+ "fill-primary stroke-background",
85
+ !reducedMotion && "transition-transform duration-fast ease-standard",
86
+ )}
87
+ style={{ transform: `translate(${x}px, ${y}px)` }}
88
+ />
89
+ );
90
+ })
91
+ : null}
92
+ </g>
93
+ );
94
+ });
@@ -0,0 +1,12 @@
1
+ export {
2
+ FlowEdgeTokens,
3
+ DEFAULT_EDGE_TOKEN_RADIUS,
4
+ type FlowEdgeToken,
5
+ type FlowEdgeTokensProps,
6
+ } from "./flow-edge-tokens";
7
+ export {
8
+ createEdgePathSampler,
9
+ flattenEdgePath,
10
+ type EdgePathPoint,
11
+ type EdgePathSampler,
12
+ } from "./edge-path-sampler";
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Edge, Node } from "@xyflow/react";
3
+ import { pinBackbone } from "./backbone";
4
+ import { layoutFlowElk } from "./layout-flow-elk";
5
+
6
+ /**
7
+ * A 3-variant process: the top variant runs start → register → check → approve → pay → end;
8
+ * a second skips `check`; a third reworks `check` via `request info`.
9
+ */
10
+ function threeVariantGraph(): { nodes: Node[]; edges: Edge[] } {
11
+ const ids = ["start", "register", "check", "info", "approve", "reject", "pay", "end"];
12
+ const nodes: Node[] = ids.map((id) => ({ id, position: { x: 0, y: 0 }, data: {} }));
13
+ const pairs: [string, string][] = [
14
+ ["start", "register"],
15
+ ["register", "check"],
16
+ ["check", "approve"],
17
+ ["approve", "pay"],
18
+ ["pay", "end"],
19
+ ["register", "approve"],
20
+ ["check", "info"],
21
+ ["info", "check"],
22
+ ["check", "reject"],
23
+ ["reject", "end"],
24
+ ];
25
+ const edges: Edge[] = pairs.map(([source, target]) => ({
26
+ id: `${source}-${target}`,
27
+ source,
28
+ target,
29
+ }));
30
+ return { nodes, edges };
31
+ }
32
+
33
+ const TOP_VARIANT = ["register", "check", "approve", "pay"];
34
+
35
+ describe("pinBackbone", () => {
36
+ it("pins the variant's nodes and the edges that join them, in order", () => {
37
+ const { nodes, edges } = threeVariantGraph();
38
+ const backbone = pinBackbone(nodes, edges, [...TOP_VARIANT, "missing", "check"]);
39
+ expect(backbone.nodeIds).toEqual(TOP_VARIANT);
40
+ expect(backbone.edgeIds).toEqual(["register-check", "check-approve", "approve-pay"]);
41
+ });
42
+
43
+ it("lays the top variant's four activities on one straight row (LR)", async () => {
44
+ const { nodes, edges } = threeVariantGraph();
45
+ const backbone = pinBackbone(nodes, edges, TOP_VARIANT);
46
+ const result = await layoutFlowElk(nodes, edges, { direction: "LR", backbone });
47
+ expect(result.engine).toBe("elk");
48
+
49
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
50
+ const rows = TOP_VARIANT.map((id) => byId.get(id)!.position.y);
51
+ expect(new Set(rows).size).toBe(1);
52
+ const columns = TOP_VARIANT.map((id) => byId.get(id)!.position.x);
53
+ expect(columns).toEqual([...columns].sort((p, q) => p - q));
54
+ });
55
+
56
+ it("lays it on one straight column (TB) and every node still connects start to end", async () => {
57
+ const { nodes, edges } = threeVariantGraph();
58
+ const backbone = pinBackbone(nodes, edges, TOP_VARIANT);
59
+ const result = await layoutFlowElk(nodes, edges, { direction: "TB", backbone });
60
+
61
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
62
+ expect(new Set(TOP_VARIANT.map((id) => byId.get(id)!.position.x)).size).toBe(1);
63
+
64
+ // Connectivity invariant: every node is reachable from start and reaches end.
65
+ const reach = (from: string, forward: boolean): Set<string> => {
66
+ const seen = new Set([from]);
67
+ const queue = [from];
68
+ while (queue.length > 0) {
69
+ const id = queue.pop()!;
70
+ for (const edge of result.edges) {
71
+ const [a, b] = forward ? [edge.source, edge.target] : [edge.target, edge.source];
72
+ if (a === id && !seen.has(b)) {
73
+ seen.add(b);
74
+ queue.push(b);
75
+ }
76
+ }
77
+ }
78
+ return seen;
79
+ };
80
+ const fromStart = reach("start", true);
81
+ const toEnd = reach("end", false);
82
+ for (const node of result.nodes) {
83
+ expect(fromStart.has(node.id)).toBe(true);
84
+ expect(toEnd.has(node.id)).toBe(true);
85
+ expect(Number.isFinite(node.position.x) && Number.isFinite(node.position.y)).toBe(true);
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,58 @@
1
+ import type { Edge, Node } from "@xyflow/react";
2
+
3
+ /**
4
+ * One path through the graph to lay out on a single straight line (RM-067).
5
+ *
6
+ * Produced by {@link pinBackbone}, consumed by `layoutFlowElk`'s `backbone` option. Plain
7
+ * data, so it clones, serializes and memoizes like any other layout input.
8
+ */
9
+ export interface FlowElkBackbone {
10
+ /** Node ids on the pinned path, in path order. */
11
+ nodeIds: string[];
12
+ /** Ids of the edges joining consecutive pinned nodes, in path order. */
13
+ edgeIds: string[];
14
+ }
15
+
16
+ /**
17
+ * Pin a variant's activity sequence to one straight row (LR/RL) or column (TB/BT) — the
18
+ * Apromore-style "backbone" layout (RM-067).
19
+ *
20
+ * A pre-pass, not a layout: hand the result to `layoutFlowElk` as `backbone` and the rest of
21
+ * the graph lays out around it. ELK gets the joining edges at the highest straightness
22
+ * priority, then every layer holding a pinned node is translated as a whole along the
23
+ * cross axis so the pinned nodes share one line — a whole-layer shift, so no two nodes of
24
+ * a layer can ever be pushed onto each other.
25
+ *
26
+ * `variant` is the sequence of NODE IDS — for a process graph, the most frequent variant's
27
+ * activities, which are the activity nodes' ids. An id with no node is skipped; a repeated
28
+ * activity (rework inside the variant) is pinned once, at its first occurrence, because a
29
+ * node cannot sit on a straight line twice.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const backbone = pinBackbone(nodes, edges, variants[0].activities);
34
+ * const { nodes: laidOut } = await layoutFlowElk(nodes, edges, { direction: "LR", backbone });
35
+ * ```
36
+ */
37
+ export function pinBackbone(
38
+ nodes: readonly Node[],
39
+ edges: readonly Edge[],
40
+ variant: readonly string[],
41
+ ): FlowElkBackbone {
42
+ const known = new Set(nodes.map((node) => node.id));
43
+ const nodeIds: string[] = [];
44
+ const seen = new Set<string>();
45
+ for (const id of variant) {
46
+ if (!known.has(id) || seen.has(id)) continue;
47
+ seen.add(id);
48
+ nodeIds.push(id);
49
+ }
50
+
51
+ const edgeIds: string[] = [];
52
+ for (let i = 0; i < nodeIds.length - 1; i += 1) {
53
+ const edge = edges.find((e) => e.source === nodeIds[i] && e.target === nodeIds[i + 1]);
54
+ if (edge) edgeIds.push(edge.id);
55
+ }
56
+
57
+ return { nodeIds, edgeIds };
58
+ }