@elabs-ai/components-flow 4.2.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 (263) 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 +236 -1
  217. package/dist/index.js +167 -2311
  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 +13 -6
  238. package/src/canvas-shell/canvas-shell.stories.tsx +1 -0
  239. package/src/canvas-shell/canvas-shell.tsx +51 -4
  240. package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
  241. package/src/canvas-shell/clamped-fit-offset.ts +30 -0
  242. package/src/custom-nodes/custom-nodes.stories.tsx +718 -0
  243. package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
  244. package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
  245. package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
  246. package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
  247. package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
  248. package/src/flow-edge-tokens/index.ts +12 -0
  249. package/src/flow-layout/backbone.test.ts +88 -0
  250. package/src/flow-layout/backbone.ts +58 -0
  251. package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
  252. package/src/flow-layout/layout-flow-elk.test.ts +152 -0
  253. package/src/flow-layout/layout-flow-elk.ts +399 -0
  254. package/src/flow-mini-map/flow-mini-map.stories.tsx +10 -7
  255. package/src/flow-node/flow-node.stories.tsx +40 -1
  256. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
  257. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
  258. package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
  259. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
  260. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
  261. package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
  262. package/src/index.ts +13 -0
  263. package/src/templates-flow-workspace.stories.tsx +7 -2
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import "@xyflow/react/dist/style.css";
4
+ import type { Edge } from "@xyflow/react";
5
+ import { expect, waitFor } from "storybook/test";
6
+ import { CanvasShell } from "../canvas-shell";
7
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
8
+ import { FlowSmartEdge } from "../flow-smart-edge";
9
+ import { ZoomControls } from "../zoom-controls";
10
+ import { pinBackbone } from "./backbone";
11
+ import {
12
+ layoutFlowElk,
13
+ type FlowLayoutElkOptions,
14
+ type FlowLayoutElkResult,
15
+ } from "./layout-flow-elk";
16
+
17
+ const nodeTypes = { brand: FlowNode };
18
+ const edgeTypes = { smart: FlowSmartEdge };
19
+
20
+ /** A small order-to-cash process with a skip path and a rework loop. */
21
+ const processNodes: BrandFlowNode[] = [
22
+ { id: "start", title: "Order received", tone: "accent" as const },
23
+ { id: "check", title: "Check credit" },
24
+ { id: "info", title: "Request info" },
25
+ { id: "approve", title: "Approve order" },
26
+ { id: "ship", title: "Ship goods" },
27
+ { id: "invoice", title: "Send invoice", tone: "success" as const },
28
+ ].map(({ id, title, tone }) => ({
29
+ id,
30
+ type: "brand",
31
+ position: { x: 0, y: 0 },
32
+ data: { kind: "Activity", title, ...(tone ? { tone } : {}) },
33
+ }));
34
+
35
+ const processEdges: Edge[] = [
36
+ ["start", "check"],
37
+ ["check", "approve"],
38
+ ["approve", "ship"],
39
+ ["ship", "invoice"],
40
+ ["start", "approve"],
41
+ ["check", "info"],
42
+ ["info", "check"],
43
+ ].map(([source, target]) => ({
44
+ id: `${source}-${target}`,
45
+ source: source!,
46
+ target: target!,
47
+ type: "smart",
48
+ }));
49
+
50
+ type Options = Omit<FlowLayoutElkOptions, "backbone"> & { pinTopVariant?: boolean };
51
+
52
+ function ElkLayoutDemo({ pinTopVariant, ...options }: Options) {
53
+ const [result, setResult] = useState<FlowLayoutElkResult<BrandFlowNode, Edge> | null>(null);
54
+
55
+ useEffect(() => {
56
+ let current = true;
57
+ const backbone = pinTopVariant
58
+ ? pinBackbone(processNodes, processEdges, ["start", "check", "approve", "ship", "invoice"])
59
+ : undefined;
60
+ void layoutFlowElk(processNodes, processEdges, {
61
+ ...options,
62
+ ...(backbone ? { backbone } : {}),
63
+ }).then((next) => {
64
+ if (current) setResult(next);
65
+ });
66
+ return () => {
67
+ current = false;
68
+ };
69
+ // The story's args never change after mount.
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- args are static per story
71
+ }, []);
72
+
73
+ return (
74
+ <div className="flex h-[500px] flex-col gap-2 p-4">
75
+ <p role="status" aria-live="polite" className="text-caption text-muted-foreground">
76
+ {result ? `Laid out by ${result.engine}` : "Laying out…"}
77
+ </p>
78
+ <div className="min-h-0 flex-1" data-engine={result?.engine}>
79
+ {result ? (
80
+ <CanvasShell
81
+ nodes={result.nodes}
82
+ edges={result.edges}
83
+ nodeTypes={nodeTypes}
84
+ edgeTypes={edgeTypes}
85
+ fitView
86
+ >
87
+ <ZoomControls />
88
+ </CanvasShell>
89
+ ) : null}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ const meta = {
96
+ title: "Flow/FlowLayoutElk",
97
+ component: ElkLayoutDemo,
98
+ tags: ["autodocs"],
99
+ parameters: {
100
+ layout: "fullscreen",
101
+ docs: {
102
+ description: {
103
+ component:
104
+ "`layoutFlowElk` lays a graph out with ELK’s layered algorithm — same result " +
105
+ "shape as `layoutFlow`, plus the engine that answered. elkjs is an optional " +
106
+ "peer loaded with `import()` on first call, run in a web worker when one is " +
107
+ "available; without it the call falls back to dagre with a development-only " +
108
+ "warning.",
109
+ },
110
+ },
111
+ },
112
+ } satisfies Meta<typeof ElkLayoutDemo>;
113
+ export default meta;
114
+ type Story = StoryObj<typeof meta>;
115
+
116
+ /** ELK, left to right, with orthogonal routing requested. */
117
+ export const ElkLayout: Story = {
118
+ args: { direction: "LR", edgeRouting: "orthogonal" },
119
+ play: async ({ canvasElement }) => {
120
+ await waitFor(() => expect(canvasElement.querySelector('[data-engine="elk"]')).toBeTruthy(), {
121
+ timeout: 10_000,
122
+ });
123
+ await waitFor(() =>
124
+ expect(canvasElement.querySelectorAll(".react-flow__node")).toHaveLength(processNodes.length),
125
+ );
126
+ },
127
+ };
128
+
129
+ /** The most frequent variant pinned to one straight row with `pinBackbone`. */
130
+ export const Backbone: Story = {
131
+ args: { direction: "LR", pinTopVariant: true },
132
+ play: async ({ canvasElement }) => {
133
+ await waitFor(() => expect(canvasElement.querySelector('[data-engine="elk"]')).toBeTruthy(), {
134
+ timeout: 10_000,
135
+ });
136
+ },
137
+ };
138
+
139
+ /**
140
+ * elkjs unavailable: the engine loader rejects the way a missing optional peer does, and
141
+ * the graph is laid out by dagre instead — a console warning in development, never a crash.
142
+ */
143
+ export const DagreFallback: Story = {
144
+ args: {
145
+ direction: "LR",
146
+ loadEngine: () => Promise.reject(new Error("Cannot find module 'elkjs'")),
147
+ },
148
+ play: async ({ canvasElement }) => {
149
+ await waitFor(() => expect(canvasElement.querySelector('[data-engine="dagre"]')).toBeTruthy());
150
+ await waitFor(() =>
151
+ expect(canvasElement.querySelectorAll(".react-flow__node")).toHaveLength(processNodes.length),
152
+ );
153
+ },
154
+ };
@@ -0,0 +1,152 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { Edge, Node } from "@xyflow/react";
6
+ import { layoutFlow } from "./flow-layout";
7
+ import { layoutFlowElk } from "./layout-flow-elk";
8
+
9
+ function chainNodes(): Node[] {
10
+ return ["a", "b", "c"].map((id) => ({
11
+ id,
12
+ type: "brand",
13
+ position: { x: 0, y: 0 },
14
+ data: { title: id.toUpperCase() },
15
+ }));
16
+ }
17
+
18
+ /** RM-044's canonical rework fixture: A → B → C, C loops back to B, and B repeats itself. */
19
+ function reworkEdges(): Edge[] {
20
+ return [
21
+ { id: "e-ab", source: "a", target: "b" },
22
+ { id: "e-bc", source: "b", target: "c" },
23
+ { id: "e-cb", source: "c", target: "b" },
24
+ { id: "e-bb", source: "b", target: "b" },
25
+ ];
26
+ }
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ describe("layoutFlowElk", () => {
33
+ it("lays the rework fixture out with ELK: no NaN, same node order as dagre", async () => {
34
+ const nodes = chainNodes();
35
+ const edges = reworkEdges();
36
+ const elk = await layoutFlowElk(nodes, edges);
37
+ const dagre = layoutFlow(nodes, edges);
38
+
39
+ expect(elk.engine).toBe("elk");
40
+ for (const node of elk.nodes) {
41
+ expect(Number.isFinite(node.position.x)).toBe(true);
42
+ expect(Number.isFinite(node.position.y)).toBe(true);
43
+ }
44
+ const order = (list: Node[]) =>
45
+ [...list].sort((p, q) => p.position.y - q.position.y).map((n) => n.id);
46
+ expect(order(elk.nodes)).toEqual(order(dagre.nodes));
47
+ expect(elk.backEdges).toEqual(dagre.backEdges);
48
+ expect(elk.selfLoops).toEqual(["e-bb"]);
49
+ expect(elk.edges).toBe(edges);
50
+ });
51
+
52
+ it("classifies the back edge in every direction and moves handles with it", async () => {
53
+ for (const direction of ["TB", "BT", "LR", "RL"] as const) {
54
+ const result = await layoutFlowElk(chainNodes(), reworkEdges(), { direction });
55
+ expect(result.backEdges).toEqual(["e-cb"]);
56
+ }
57
+ const lr = await layoutFlowElk(chainNodes(), reworkEdges(), { direction: "LR" });
58
+ expect(lr.nodes[0]!.sourcePosition).toBe("right");
59
+ expect(lr.nodes[0]!.targetPosition).toBe("left");
60
+ });
61
+
62
+ it("round-trips groups to parentId + relative positions, sizing the group", async () => {
63
+ const nodes: Node[] = [
64
+ { id: "g", type: "group", position: { x: 0, y: 0 }, data: {} },
65
+ ...chainNodes(),
66
+ ];
67
+ const result = await layoutFlowElk(nodes, reworkEdges(), {
68
+ edgeRouting: "orthogonal",
69
+ groups: [{ id: "g", children: ["b", "c"] }],
70
+ });
71
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
72
+ expect(byId.get("b")!.parentId).toBe("g");
73
+ expect(byId.get("c")!.parentId).toBe("g");
74
+ expect(byId.get("a")!.parentId).toBeUndefined();
75
+ expect(byId.get("g")!.width).toBeGreaterThan(172);
76
+ expect(byId.get("b")!.position.x).toBeGreaterThanOrEqual(0);
77
+ });
78
+
79
+ it("falls back to dagre with a warning when the engine cannot load", async () => {
80
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
81
+ const nodes = chainNodes();
82
+ const edges = reworkEdges();
83
+ const result = await layoutFlowElk(nodes, edges, {
84
+ loadEngine: () => Promise.reject(new Error("Cannot find module 'elkjs'")),
85
+ });
86
+ expect(result.engine).toBe("dagre");
87
+ expect(result.nodes).toEqual(layoutFlow(nodes, edges).nodes);
88
+ expect(warn).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("falls back to dagre when ELK itself throws", async () => {
92
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
93
+ const result = await layoutFlowElk(chainNodes(), reworkEdges(), {
94
+ loadEngine: async () => ({ layout: () => Promise.reject(new Error("bad graph")) }),
95
+ });
96
+ expect(result.engine).toBe("dagre");
97
+ });
98
+ });
99
+
100
+ describe("elkjs lazy boundary (RM-067)", () => {
101
+ const srcDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
102
+
103
+ /** Every non-test, non-story source file statically reachable from the package barrel. */
104
+ function staticGraph(entry: string): Set<string> {
105
+ const seen = new Set<string>();
106
+ const queue = [entry];
107
+ while (queue.length > 0) {
108
+ const file = queue.pop()!;
109
+ if (seen.has(file)) continue;
110
+ seen.add(file);
111
+ const source = readFileSync(file, "utf8");
112
+ const re = /^\s*(?:import|export)\s+(?!type\s)(?:[\s\S]*?\sfrom\s*)?["'](\.[^"']*)["']/gm;
113
+ for (const match of source.matchAll(re)) {
114
+ const base = join(dirname(file), match[1]!);
115
+ const hit = [
116
+ `${base}.ts`,
117
+ `${base}.tsx`,
118
+ join(base, "index.ts"),
119
+ join(base, "index.tsx"),
120
+ ].find((candidate) => {
121
+ try {
122
+ return statSync(candidate).isFile();
123
+ } catch {
124
+ return false;
125
+ }
126
+ });
127
+ if (hit) queue.push(hit);
128
+ }
129
+ }
130
+ return seen;
131
+ }
132
+
133
+ it("never imports elkjs statically from anything the barrel reaches", () => {
134
+ const reachable = staticGraph(join(srcDir, "index.ts"));
135
+ expect(reachable.has(join(srcDir, "flow-layout", "layout-flow-elk.ts"))).toBe(true);
136
+ const offenders = [...reachable].filter((file) =>
137
+ /^\s*(?:import|export)\s+(?!type\s)(?:[\s\S]*?\sfrom\s*)?["']elkjs(?:\/[^"']*)?["']/m.test(
138
+ readFileSync(file, "utf8"),
139
+ ),
140
+ );
141
+ expect(offenders).toEqual([]);
142
+ });
143
+
144
+ it("ships no worker entry the tsup build would not emit", () => {
145
+ // A `new Worker(new URL("./x", import.meta.url))` to an unemitted file breaks some
146
+ // consumer bundlers; off-thread layout is the host's `loadEngine` instead.
147
+ expect(readdirSync(join(srcDir, "flow-layout"))).not.toContain("elk-worker.ts");
148
+ expect(readFileSync(join(srcDir, "flow-layout", "layout-flow-elk.ts"), "utf8")).not.toMatch(
149
+ /new Worker\(new URL\("\.\//,
150
+ );
151
+ });
152
+ });