@principal-ai/subsystems-react 0.17.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 (496) hide show
  1. package/README.md +93 -0
  2. package/dist/components/ConfigurationSelector.d.ts +35 -0
  3. package/dist/components/ConfigurationSelector.d.ts.map +1 -0
  4. package/dist/components/ConfigurationSelector.js +63 -0
  5. package/dist/components/ConfigurationSelector.js.map +1 -0
  6. package/dist/components/GraphRenderer.d.ts +295 -0
  7. package/dist/components/GraphRenderer.d.ts.map +1 -0
  8. package/dist/components/GraphRenderer.js +2008 -0
  9. package/dist/components/GraphRenderer.js.map +1 -0
  10. package/dist/components/MultiCanvasRenderer.d.ts +75 -0
  11. package/dist/components/MultiCanvasRenderer.d.ts.map +1 -0
  12. package/dist/components/MultiCanvasRenderer.js +203 -0
  13. package/dist/components/MultiCanvasRenderer.js.map +1 -0
  14. package/dist/components/NodeTooltip.d.ts +24 -0
  15. package/dist/components/NodeTooltip.d.ts.map +1 -0
  16. package/dist/components/NodeTooltip.js +160 -0
  17. package/dist/components/NodeTooltip.js.map +1 -0
  18. package/dist/components/SelectionSidebar.d.ts +18 -0
  19. package/dist/components/SelectionSidebar.d.ts.map +1 -0
  20. package/dist/components/SelectionSidebar.js +190 -0
  21. package/dist/components/SelectionSidebar.js.map +1 -0
  22. package/dist/components/SequenceDiagramRenderer.d.ts +77 -0
  23. package/dist/components/SequenceDiagramRenderer.d.ts.map +1 -0
  24. package/dist/components/SequenceDiagramRenderer.js +604 -0
  25. package/dist/components/SequenceDiagramRenderer.js.map +1 -0
  26. package/dist/components/WorkflowSequenceDiagram.d.ts +63 -0
  27. package/dist/components/WorkflowSequenceDiagram.d.ts.map +1 -0
  28. package/dist/components/WorkflowSequenceDiagram.js +147 -0
  29. package/dist/components/WorkflowSequenceDiagram.js.map +1 -0
  30. package/dist/components/dashboard/DashboardRenderer.d.ts +10 -0
  31. package/dist/components/dashboard/DashboardRenderer.d.ts.map +1 -0
  32. package/dist/components/dashboard/DashboardRenderer.js +179 -0
  33. package/dist/components/dashboard/DashboardRenderer.js.map +1 -0
  34. package/dist/components/dashboard/MetricPanel.d.ts +10 -0
  35. package/dist/components/dashboard/MetricPanel.d.ts.map +1 -0
  36. package/dist/components/dashboard/MetricPanel.js +103 -0
  37. package/dist/components/dashboard/MetricPanel.js.map +1 -0
  38. package/dist/components/dashboard/MockDataProvider.d.ts +30 -0
  39. package/dist/components/dashboard/MockDataProvider.d.ts.map +1 -0
  40. package/dist/components/dashboard/MockDataProvider.js +270 -0
  41. package/dist/components/dashboard/MockDataProvider.js.map +1 -0
  42. package/dist/components/dashboard/components/BarChart.d.ts +10 -0
  43. package/dist/components/dashboard/components/BarChart.d.ts.map +1 -0
  44. package/dist/components/dashboard/components/BarChart.js +167 -0
  45. package/dist/components/dashboard/components/BarChart.js.map +1 -0
  46. package/dist/components/dashboard/components/LineChart.d.ts +10 -0
  47. package/dist/components/dashboard/components/LineChart.d.ts.map +1 -0
  48. package/dist/components/dashboard/components/LineChart.js +141 -0
  49. package/dist/components/dashboard/components/LineChart.js.map +1 -0
  50. package/dist/components/dashboard/components/MetricCard.d.ts +9 -0
  51. package/dist/components/dashboard/components/MetricCard.d.ts.map +1 -0
  52. package/dist/components/dashboard/components/MetricCard.js +163 -0
  53. package/dist/components/dashboard/components/MetricCard.js.map +1 -0
  54. package/dist/components/dashboard/components/SourceLink.d.ts +9 -0
  55. package/dist/components/dashboard/components/SourceLink.d.ts.map +1 -0
  56. package/dist/components/dashboard/components/SourceLink.js +39 -0
  57. package/dist/components/dashboard/components/SourceLink.js.map +1 -0
  58. package/dist/components/dashboard/components/TimeRangeSelector.d.ts +9 -0
  59. package/dist/components/dashboard/components/TimeRangeSelector.d.ts.map +1 -0
  60. package/dist/components/dashboard/components/TimeRangeSelector.js +167 -0
  61. package/dist/components/dashboard/components/TimeRangeSelector.js.map +1 -0
  62. package/dist/components/dashboard/components/index.d.ts +6 -0
  63. package/dist/components/dashboard/components/index.d.ts.map +1 -0
  64. package/dist/components/dashboard/components/index.js +6 -0
  65. package/dist/components/dashboard/components/index.js.map +1 -0
  66. package/dist/components/dashboard/index.d.ts +6 -0
  67. package/dist/components/dashboard/index.d.ts.map +1 -0
  68. package/dist/components/dashboard/index.js +8 -0
  69. package/dist/components/dashboard/index.js.map +1 -0
  70. package/dist/components/dashboard/types.d.ts +74 -0
  71. package/dist/components/dashboard/types.d.ts.map +1 -0
  72. package/dist/components/dashboard/types.js +8 -0
  73. package/dist/components/dashboard/types.js.map +1 -0
  74. package/dist/components/session-events/SessionEventFeed.d.ts +34 -0
  75. package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -0
  76. package/dist/components/session-events/SessionEventFeed.js +1196 -0
  77. package/dist/components/session-events/SessionEventFeed.js.map +1 -0
  78. package/dist/components/session-events/index.d.ts +7 -0
  79. package/dist/components/session-events/index.d.ts.map +1 -0
  80. package/dist/components/session-events/index.js +6 -0
  81. package/dist/components/session-events/index.js.map +1 -0
  82. package/dist/components/state-view/PipelineView.d.ts +14 -0
  83. package/dist/components/state-view/PipelineView.d.ts.map +1 -0
  84. package/dist/components/state-view/PipelineView.js +195 -0
  85. package/dist/components/state-view/PipelineView.js.map +1 -0
  86. package/dist/components/state-view/index.d.ts +14 -0
  87. package/dist/components/state-view/index.d.ts.map +1 -0
  88. package/dist/components/state-view/index.js +12 -0
  89. package/dist/components/state-view/index.js.map +1 -0
  90. package/dist/components/state-view/types.d.ts +188 -0
  91. package/dist/components/state-view/types.d.ts.map +1 -0
  92. package/dist/components/state-view/types.js +10 -0
  93. package/dist/components/state-view/types.js.map +1 -0
  94. package/dist/components/state-view/useStateView.d.ts +32 -0
  95. package/dist/components/state-view/useStateView.d.ts.map +1 -0
  96. package/dist/components/state-view/useStateView.js +129 -0
  97. package/dist/components/state-view/useStateView.js.map +1 -0
  98. package/dist/contexts/GraphEditContext.d.ts +20 -0
  99. package/dist/contexts/GraphEditContext.d.ts.map +1 -0
  100. package/dist/contexts/GraphEditContext.js +8 -0
  101. package/dist/contexts/GraphEditContext.js.map +1 -0
  102. package/dist/contexts/TooltipPortalContext.d.ts +8 -0
  103. package/dist/contexts/TooltipPortalContext.d.ts.map +1 -0
  104. package/dist/contexts/TooltipPortalContext.js +8 -0
  105. package/dist/contexts/TooltipPortalContext.js.map +1 -0
  106. package/dist/edges/CustomEdge.d.ts +24 -0
  107. package/dist/edges/CustomEdge.d.ts.map +1 -0
  108. package/dist/edges/CustomEdge.js +222 -0
  109. package/dist/edges/CustomEdge.js.map +1 -0
  110. package/dist/edges/GenericEdge.d.ts +18 -0
  111. package/dist/edges/GenericEdge.d.ts.map +1 -0
  112. package/dist/edges/GenericEdge.js +10 -0
  113. package/dist/edges/GenericEdge.js.map +1 -0
  114. package/dist/graphify/anchor.d.ts +28 -0
  115. package/dist/graphify/anchor.d.ts.map +1 -0
  116. package/dist/graphify/anchor.js +132 -0
  117. package/dist/graphify/anchor.js.map +1 -0
  118. package/dist/graphify/consolidated.d.ts +210 -0
  119. package/dist/graphify/consolidated.d.ts.map +1 -0
  120. package/dist/graphify/consolidated.js +11 -0
  121. package/dist/graphify/consolidated.js.map +1 -0
  122. package/dist/graphify/ids.d.ts +19 -0
  123. package/dist/graphify/ids.d.ts.map +1 -0
  124. package/dist/graphify/ids.js +46 -0
  125. package/dist/graphify/ids.js.map +1 -0
  126. package/dist/graphify/index.d.ts +18 -0
  127. package/dist/graphify/index.d.ts.map +1 -0
  128. package/dist/graphify/index.js +12 -0
  129. package/dist/graphify/index.js.map +1 -0
  130. package/dist/graphify/kind.d.ts +23 -0
  131. package/dist/graphify/kind.d.ts.map +1 -0
  132. package/dist/graphify/kind.js +77 -0
  133. package/dist/graphify/kind.js.map +1 -0
  134. package/dist/graphify/resolve.d.ts +48 -0
  135. package/dist/graphify/resolve.d.ts.map +1 -0
  136. package/dist/graphify/resolve.js +83 -0
  137. package/dist/graphify/resolve.js.map +1 -0
  138. package/dist/graphify/signature.d.ts +87 -0
  139. package/dist/graphify/signature.d.ts.map +1 -0
  140. package/dist/graphify/signature.js +276 -0
  141. package/dist/graphify/signature.js.map +1 -0
  142. package/dist/graphify/types.d.ts +141 -0
  143. package/dist/graphify/types.d.ts.map +1 -0
  144. package/dist/graphify/types.js +8 -0
  145. package/dist/graphify/types.js.map +1 -0
  146. package/dist/hooks/useElkLayout.d.ts +66 -0
  147. package/dist/hooks/useElkLayout.d.ts.map +1 -0
  148. package/dist/hooks/useElkLayout.js +136 -0
  149. package/dist/hooks/useElkLayout.js.map +1 -0
  150. package/dist/hooks/usePathBasedEvents.d.ts +42 -0
  151. package/dist/hooks/usePathBasedEvents.d.ts.map +1 -0
  152. package/dist/hooks/usePathBasedEvents.js +119 -0
  153. package/dist/hooks/usePathBasedEvents.js.map +1 -0
  154. package/dist/hooks/useSequenceLayout.d.ts +186 -0
  155. package/dist/hooks/useSequenceLayout.d.ts.map +1 -0
  156. package/dist/hooks/useSequenceLayout.js +327 -0
  157. package/dist/hooks/useSequenceLayout.js.map +1 -0
  158. package/dist/hooks/useUndoRedo.d.ts +80 -0
  159. package/dist/hooks/useUndoRedo.d.ts.map +1 -0
  160. package/dist/hooks/useUndoRedo.js +70 -0
  161. package/dist/hooks/useUndoRedo.js.map +1 -0
  162. package/dist/index.d.ts +73 -0
  163. package/dist/index.d.ts.map +1 -0
  164. package/dist/index.js +51 -0
  165. package/dist/index.js.map +1 -0
  166. package/dist/nodes/CustomNode.d.ts +28 -0
  167. package/dist/nodes/CustomNode.d.ts.map +1 -0
  168. package/dist/nodes/CustomNode.js +820 -0
  169. package/dist/nodes/CustomNode.js.map +1 -0
  170. package/dist/nodes/GenericNode.d.ts +20 -0
  171. package/dist/nodes/GenericNode.d.ts.map +1 -0
  172. package/dist/nodes/GenericNode.js +20 -0
  173. package/dist/nodes/GenericNode.js.map +1 -0
  174. package/dist/nodes/otel/OtelBoundaryNode.d.ts +55 -0
  175. package/dist/nodes/otel/OtelBoundaryNode.d.ts.map +1 -0
  176. package/dist/nodes/otel/OtelBoundaryNode.js +81 -0
  177. package/dist/nodes/otel/OtelBoundaryNode.js.map +1 -0
  178. package/dist/nodes/otel/OtelEventNode.d.ts +59 -0
  179. package/dist/nodes/otel/OtelEventNode.d.ts.map +1 -0
  180. package/dist/nodes/otel/OtelEventNode.js +82 -0
  181. package/dist/nodes/otel/OtelEventNode.js.map +1 -0
  182. package/dist/nodes/otel/OtelResourceNode.d.ts +53 -0
  183. package/dist/nodes/otel/OtelResourceNode.d.ts.map +1 -0
  184. package/dist/nodes/otel/OtelResourceNode.js +105 -0
  185. package/dist/nodes/otel/OtelResourceNode.js.map +1 -0
  186. package/dist/nodes/otel/OtelScopeNode.d.ts +53 -0
  187. package/dist/nodes/otel/OtelScopeNode.d.ts.map +1 -0
  188. package/dist/nodes/otel/OtelScopeNode.js +81 -0
  189. package/dist/nodes/otel/OtelScopeNode.js.map +1 -0
  190. package/dist/nodes/otel/OtelSpanConventionNode.d.ts +61 -0
  191. package/dist/nodes/otel/OtelSpanConventionNode.d.ts.map +1 -0
  192. package/dist/nodes/otel/OtelSpanConventionNode.js +131 -0
  193. package/dist/nodes/otel/OtelSpanConventionNode.js.map +1 -0
  194. package/dist/nodes/otel/index.d.ts +17 -0
  195. package/dist/nodes/otel/index.d.ts.map +1 -0
  196. package/dist/nodes/otel/index.js +13 -0
  197. package/dist/nodes/otel/index.js.map +1 -0
  198. package/dist/nodes/otel/shared/NodeBadges.d.ts +10 -0
  199. package/dist/nodes/otel/shared/NodeBadges.d.ts.map +1 -0
  200. package/dist/nodes/otel/shared/NodeBadges.js +178 -0
  201. package/dist/nodes/otel/shared/NodeBadges.js.map +1 -0
  202. package/dist/nodes/otel/shared/NodeContent.d.ts +10 -0
  203. package/dist/nodes/otel/shared/NodeContent.d.ts.map +1 -0
  204. package/dist/nodes/otel/shared/NodeContent.js +96 -0
  205. package/dist/nodes/otel/shared/NodeContent.js.map +1 -0
  206. package/dist/nodes/otel/shared/index.d.ts +8 -0
  207. package/dist/nodes/otel/shared/index.d.ts.map +1 -0
  208. package/dist/nodes/otel/shared/index.js +8 -0
  209. package/dist/nodes/otel/shared/index.js.map +1 -0
  210. package/dist/nodes/otel/shared/types.d.ts +129 -0
  211. package/dist/nodes/otel/shared/types.d.ts.map +1 -0
  212. package/dist/nodes/otel/shared/types.js +5 -0
  213. package/dist/nodes/otel/shared/types.js.map +1 -0
  214. package/dist/nodes/otel/shared/useNodeBehavior.d.ts +42 -0
  215. package/dist/nodes/otel/shared/useNodeBehavior.d.ts.map +1 -0
  216. package/dist/nodes/otel/shared/useNodeBehavior.js +61 -0
  217. package/dist/nodes/otel/shared/useNodeBehavior.js.map +1 -0
  218. package/dist/pierre/PierreFileView.d.ts +17 -0
  219. package/dist/pierre/PierreFileView.d.ts.map +1 -0
  220. package/dist/pierre/PierreFileView.js +46 -0
  221. package/dist/pierre/PierreFileView.js.map +1 -0
  222. package/dist/pierre/PierreSnippetView.d.ts +28 -0
  223. package/dist/pierre/PierreSnippetView.d.ts.map +1 -0
  224. package/dist/pierre/PierreSnippetView.js +105 -0
  225. package/dist/pierre/PierreSnippetView.js.map +1 -0
  226. package/dist/pierre/PierreThroughlineCodeView.d.ts +22 -0
  227. package/dist/pierre/PierreThroughlineCodeView.d.ts.map +1 -0
  228. package/dist/pierre/PierreThroughlineCodeView.js +206 -0
  229. package/dist/pierre/PierreThroughlineCodeView.js.map +1 -0
  230. package/dist/pierre/constructColors.d.ts +24 -0
  231. package/dist/pierre/constructColors.d.ts.map +1 -0
  232. package/dist/pierre/constructColors.js +91 -0
  233. package/dist/pierre/constructColors.js.map +1 -0
  234. package/dist/pierre/index.d.ts +12 -0
  235. package/dist/pierre/index.d.ts.map +1 -0
  236. package/dist/pierre/index.js +7 -0
  237. package/dist/pierre/index.js.map +1 -0
  238. package/dist/pierre/pierreBackground.d.ts +15 -0
  239. package/dist/pierre/pierreBackground.d.ts.map +1 -0
  240. package/dist/pierre/pierreBackground.js +31 -0
  241. package/dist/pierre/pierreBackground.js.map +1 -0
  242. package/dist/pierre/pierreFileLang.d.ts +27 -0
  243. package/dist/pierre/pierreFileLang.d.ts.map +1 -0
  244. package/dist/pierre/pierreFileLang.js +52 -0
  245. package/dist/pierre/pierreFileLang.js.map +1 -0
  246. package/dist/pierre/pierreSyntaxTheme.d.ts +11 -0
  247. package/dist/pierre/pierreSyntaxTheme.d.ts.map +1 -0
  248. package/dist/pierre/pierreSyntaxTheme.js +20 -0
  249. package/dist/pierre/pierreSyntaxTheme.js.map +1 -0
  250. package/dist/pierre/scrollAnchor.d.ts +5 -0
  251. package/dist/pierre/scrollAnchor.d.ts.map +1 -0
  252. package/dist/pierre/scrollAnchor.js +9 -0
  253. package/dist/pierre/scrollAnchor.js.map +1 -0
  254. package/dist/pierre/sliceSnippet.d.ts +10 -0
  255. package/dist/pierre/sliceSnippet.d.ts.map +1 -0
  256. package/dist/pierre/sliceSnippet.js +16 -0
  257. package/dist/pierre/sliceSnippet.js.map +1 -0
  258. package/dist/pierre/syntaxTokens.d.ts +11 -0
  259. package/dist/pierre/syntaxTokens.d.ts.map +1 -0
  260. package/dist/pierre/syntaxTokens.js +51 -0
  261. package/dist/pierre/syntaxTokens.js.map +1 -0
  262. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
  263. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
  264. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
  265. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
  266. package/dist/subsystem/ComponentDeclaration.d.ts +87 -0
  267. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -0
  268. package/dist/subsystem/ComponentDeclaration.js +374 -0
  269. package/dist/subsystem/ComponentDeclaration.js.map +1 -0
  270. package/dist/subsystem/FileDrawer.d.ts +17 -0
  271. package/dist/subsystem/FileDrawer.d.ts.map +1 -0
  272. package/dist/subsystem/FileDrawer.js +72 -0
  273. package/dist/subsystem/FileDrawer.js.map +1 -0
  274. package/dist/subsystem/GraphLayoutCover.d.ts +20 -0
  275. package/dist/subsystem/GraphLayoutCover.d.ts.map +1 -0
  276. package/dist/subsystem/GraphLayoutCover.js +42 -0
  277. package/dist/subsystem/GraphLayoutCover.js.map +1 -0
  278. package/dist/subsystem/SubsystemComponentGraph.d.ts +96 -0
  279. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
  280. package/dist/subsystem/SubsystemComponentGraph.js +1059 -0
  281. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
  282. package/dist/subsystem/SubsystemFileTree.d.ts +24 -0
  283. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -0
  284. package/dist/subsystem/SubsystemFileTree.js +150 -0
  285. package/dist/subsystem/SubsystemFileTree.js.map +1 -0
  286. package/dist/subsystem/declarationRef.d.ts +39 -0
  287. package/dist/subsystem/declarationRef.d.ts.map +1 -0
  288. package/dist/subsystem/declarationRef.js +32 -0
  289. package/dist/subsystem/declarationRef.js.map +1 -0
  290. package/dist/subsystem/formatDeclaration.d.ts +11 -0
  291. package/dist/subsystem/formatDeclaration.d.ts.map +1 -0
  292. package/dist/subsystem/formatDeclaration.js +146 -0
  293. package/dist/subsystem/formatDeclaration.js.map +1 -0
  294. package/dist/subsystem/model.d.ts +353 -0
  295. package/dist/subsystem/model.d.ts.map +1 -0
  296. package/dist/subsystem/model.js +503 -0
  297. package/dist/subsystem/model.js.map +1 -0
  298. package/dist/subsystem/nodes.d.ts +69 -0
  299. package/dist/subsystem/nodes.d.ts.map +1 -0
  300. package/dist/subsystem/nodes.js +289 -0
  301. package/dist/subsystem/nodes.js.map +1 -0
  302. package/dist/subsystem/paths.d.ts +63 -0
  303. package/dist/subsystem/paths.d.ts.map +1 -0
  304. package/dist/subsystem/paths.js +86 -0
  305. package/dist/subsystem/paths.js.map +1 -0
  306. package/dist/subsystem/tokenizeComponent.d.ts +20 -0
  307. package/dist/subsystem/tokenizeComponent.d.ts.map +1 -0
  308. package/dist/subsystem/tokenizeComponent.js +56 -0
  309. package/dist/subsystem/tokenizeComponent.js.map +1 -0
  310. package/dist/subsystem/tokenizeFormatted.d.ts +12 -0
  311. package/dist/subsystem/tokenizeFormatted.d.ts.map +1 -0
  312. package/dist/subsystem/tokenizeFormatted.js +13 -0
  313. package/dist/subsystem/tokenizeFormatted.js.map +1 -0
  314. package/dist/utils/animationMapping.d.ts +53 -0
  315. package/dist/utils/animationMapping.d.ts.map +1 -0
  316. package/dist/utils/animationMapping.js +126 -0
  317. package/dist/utils/animationMapping.js.map +1 -0
  318. package/dist/utils/canvasBounds.d.ts +50 -0
  319. package/dist/utils/canvasBounds.d.ts.map +1 -0
  320. package/dist/utils/canvasBounds.js +88 -0
  321. package/dist/utils/canvasBounds.js.map +1 -0
  322. package/dist/utils/elkLayout.d.ts +137 -0
  323. package/dist/utils/elkLayout.d.ts.map +1 -0
  324. package/dist/utils/elkLayout.js +596 -0
  325. package/dist/utils/elkLayout.js.map +1 -0
  326. package/dist/utils/graphConverter.d.ts +18 -0
  327. package/dist/utils/graphConverter.d.ts.map +1 -0
  328. package/dist/utils/graphConverter.js +114 -0
  329. package/dist/utils/graphConverter.js.map +1 -0
  330. package/dist/utils/iconResolver.d.ts +29 -0
  331. package/dist/utils/iconResolver.d.ts.map +1 -0
  332. package/dist/utils/iconResolver.js +30 -0
  333. package/dist/utils/iconResolver.js.map +1 -0
  334. package/dist/utils/orientationUtils.d.ts +19 -0
  335. package/dist/utils/orientationUtils.d.ts.map +1 -0
  336. package/dist/utils/orientationUtils.js +62 -0
  337. package/dist/utils/orientationUtils.js.map +1 -0
  338. package/package.json +80 -0
  339. package/src/components/ConfigurationSelector.tsx +133 -0
  340. package/src/components/GraphRenderer.tsx +3195 -0
  341. package/src/components/MultiCanvasRenderer.tsx +295 -0
  342. package/src/components/NodeTooltip.tsx +279 -0
  343. package/src/components/PendingChanges.test.tsx +375 -0
  344. package/src/components/SelectionSidebar.tsx +352 -0
  345. package/src/components/SequenceDiagramRenderer.tsx +1156 -0
  346. package/src/components/WorkflowSequenceDiagram.tsx +270 -0
  347. package/src/components/dashboard/DashboardRenderer.tsx +317 -0
  348. package/src/components/dashboard/MetricPanel.tsx +254 -0
  349. package/src/components/dashboard/MockDataProvider.ts +330 -0
  350. package/src/components/dashboard/components/BarChart.tsx +299 -0
  351. package/src/components/dashboard/components/LineChart.tsx +279 -0
  352. package/src/components/dashboard/components/MetricCard.tsx +270 -0
  353. package/src/components/dashboard/components/SourceLink.tsx +63 -0
  354. package/src/components/dashboard/components/TimeRangeSelector.tsx +280 -0
  355. package/src/components/dashboard/components/index.ts +5 -0
  356. package/src/components/dashboard/index.ts +47 -0
  357. package/src/components/dashboard/types.ts +126 -0
  358. package/src/components/session-events/SessionEventFeed.tsx +1797 -0
  359. package/src/components/session-events/index.ts +13 -0
  360. package/src/components/state-view/PipelineView.tsx +347 -0
  361. package/src/components/state-view/index.ts +14 -0
  362. package/src/components/state-view/types.ts +261 -0
  363. package/src/components/state-view/useStateView.ts +205 -0
  364. package/src/contexts/GraphEditContext.tsx +27 -0
  365. package/src/contexts/TooltipPortalContext.ts +8 -0
  366. package/src/edges/CustomEdge.tsx +402 -0
  367. package/src/edges/GenericEdge.tsx +36 -0
  368. package/src/graphify/anchor.test.ts +124 -0
  369. package/src/graphify/anchor.ts +160 -0
  370. package/src/graphify/consolidated.ts +246 -0
  371. package/src/graphify/ids.ts +48 -0
  372. package/src/graphify/index.ts +72 -0
  373. package/src/graphify/kind.test.ts +108 -0
  374. package/src/graphify/kind.ts +120 -0
  375. package/src/graphify/resolve.test.ts +89 -0
  376. package/src/graphify/resolve.ts +115 -0
  377. package/src/graphify/signature.test.ts +278 -0
  378. package/src/graphify/signature.ts +354 -0
  379. package/src/graphify/types.ts +164 -0
  380. package/src/hooks/useElkLayout.test.ts +134 -0
  381. package/src/hooks/useElkLayout.ts +191 -0
  382. package/src/hooks/usePathBasedEvents.ts +192 -0
  383. package/src/hooks/useSequenceLayout.ts +543 -0
  384. package/src/hooks/useUndoRedo.ts +144 -0
  385. package/src/index.ts +341 -0
  386. package/src/nodes/CustomNode.tsx +1231 -0
  387. package/src/nodes/GenericNode.tsx +55 -0
  388. package/src/nodes/otel/OtelBoundaryNode.tsx +224 -0
  389. package/src/nodes/otel/OtelEventNode.tsx +225 -0
  390. package/src/nodes/otel/OtelResourceNode.tsx +251 -0
  391. package/src/nodes/otel/OtelScopeNode.tsx +221 -0
  392. package/src/nodes/otel/OtelSpanConventionNode.tsx +295 -0
  393. package/src/nodes/otel/index.ts +23 -0
  394. package/src/nodes/otel/shared/NodeBadges.tsx +295 -0
  395. package/src/nodes/otel/shared/NodeContent.tsx +204 -0
  396. package/src/nodes/otel/shared/index.ts +8 -0
  397. package/src/nodes/otel/shared/types.ts +138 -0
  398. package/src/nodes/otel/shared/useNodeBehavior.ts +114 -0
  399. package/src/pierre/PierreFileView.tsx +81 -0
  400. package/src/pierre/PierreSnippetView.tsx +184 -0
  401. package/src/pierre/PierreThroughlineCodeView.tsx +304 -0
  402. package/src/pierre/constructColors.test.ts +52 -0
  403. package/src/pierre/constructColors.ts +112 -0
  404. package/src/pierre/index.ts +18 -0
  405. package/src/pierre/pierreBackground.ts +33 -0
  406. package/src/pierre/pierreFileLang.test.ts +26 -0
  407. package/src/pierre/pierreFileLang.ts +55 -0
  408. package/src/pierre/pierreSyntaxTheme.ts +23 -0
  409. package/src/pierre/scrollAnchor.test.ts +13 -0
  410. package/src/pierre/scrollAnchor.ts +11 -0
  411. package/src/pierre/sliceSnippet.test.ts +21 -0
  412. package/src/pierre/sliceSnippet.ts +31 -0
  413. package/src/pierre/syntaxTokens.test.ts +35 -0
  414. package/src/pierre/syntaxTokens.ts +60 -0
  415. package/src/stories/Audit/BoundaryNodes.stories.tsx +385 -0
  416. package/src/stories/Audit/CanvasEdgeTypes.stories.tsx +1448 -0
  417. package/src/stories/Audit/CanvasNodeTypes.stories.tsx +1049 -0
  418. package/src/stories/Audit/ColorPriority.stories.tsx +895 -0
  419. package/src/stories/Audit/DiamondBadges.stories.tsx +629 -0
  420. package/src/stories/Audit/NodeDimensionsTesting.stories.tsx +724 -0
  421. package/src/stories/Audit/NodeFieldsAudit.stories.tsx +996 -0
  422. package/src/stories/Audit/NodeFontSizeTesting.stories.tsx +715 -0
  423. package/src/stories/Audit/NodeShapes.stories.tsx +1208 -0
  424. package/src/stories/Audit/WideNodesTesting.stories.tsx +1045 -0
  425. package/src/stories/Components/DashboardRenderer.stories.tsx +263 -0
  426. package/src/stories/Components/GraphRenderer.stories.tsx +3236 -0
  427. package/src/stories/Components/MultiCanvasRenderer.stories.tsx +370 -0
  428. package/src/stories/Components/SequenceDiagram.stories.tsx +255 -0
  429. package/src/stories/Components/StateView.stories.tsx +417 -0
  430. package/src/stories/Components/TraceViewer.stories.tsx +442 -0
  431. package/src/stories/Components/WorkflowSequenceDiagram.stories.tsx +802 -0
  432. package/src/stories/Features/AnimationWorkshop.stories.tsx +727 -0
  433. package/src/stories/Features/ChangeTypeVisual.stories.tsx +67 -0
  434. package/src/stories/Features/EventDrivenAnimations.stories.tsx +513 -0
  435. package/src/stories/Features/MultiConfig.stories.tsx +539 -0
  436. package/src/stories/Introduction.mdx +34 -0
  437. package/src/stories/Layout/ElkEdgeRouting.stories.tsx +539 -0
  438. package/src/stories/Layout/GraphOrientation.stories.tsx +500 -0
  439. package/src/stories/Layout/MultiDirectionalConnections.stories.tsx +352 -0
  440. package/src/stories/Nodes/NodeDefinitionComparison.stories.tsx +560 -0
  441. package/src/stories/Nodes/NodeNameFallbacks.stories.tsx +491 -0
  442. package/src/stories/Nodes/NumericColors.stories.tsx +440 -0
  443. package/src/stories/OTEL/EventNameDisplay.stories.tsx +694 -0
  444. package/src/stories/OTEL/EventNameFallback.stories.tsx +578 -0
  445. package/src/stories/OTEL/OtelComponents.stories.tsx +422 -0
  446. package/src/stories/OTEL/OtelNodeTypesPrototype.stories.tsx +806 -0
  447. package/src/stories/OTEL/RawEventCards.stories.tsx +57 -0
  448. package/src/stories/OTEL/SpanBadges.stories.tsx +840 -0
  449. package/src/stories/OTEL/ValidationEventsCanvas.stories.tsx +209 -0
  450. package/src/stories/Pierre/CodeView.stories.tsx +219 -0
  451. package/src/stories/RealWorld/BacklogMdCanvases.stories.tsx +721 -0
  452. package/src/stories/RealWorld/FileCitySequence.stories.tsx +516 -0
  453. package/src/stories/RealWorld/RealCanvasFiles.stories.tsx +336 -0
  454. package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +321 -0
  455. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
  456. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
  457. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
  458. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
  459. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +239 -0
  460. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +216 -0
  461. package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +70 -0
  462. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
  463. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
  464. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +681 -0
  465. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
  466. package/src/stories/__traces__/test-run.canvas.json +241 -0
  467. package/src/stories/data/activity-feed-analytics.dashboard.json +300 -0
  468. package/src/stories/data/file-city-images-transformed.otel.canvas.json +295 -0
  469. package/src/stories/data/file-city-workflows.json +269 -0
  470. package/src/stories/data/graph-converter-test-execution.json +443 -0
  471. package/src/stories/data/graph-converter-test.workflow.json +105 -0
  472. package/src/stories/data/graph-converter-validated-execution.json +58 -0
  473. package/src/stories/data/opencode-session-raw-events.json +273396 -0
  474. package/src/subsystem/ComponentDeclaration.tsx +643 -0
  475. package/src/subsystem/FileDrawer.tsx +106 -0
  476. package/src/subsystem/GraphLayoutCover.tsx +69 -0
  477. package/src/subsystem/SubsystemComponentGraph.tsx +1644 -0
  478. package/src/subsystem/SubsystemFileTree.tsx +190 -0
  479. package/src/subsystem/declarationRef.test.ts +33 -0
  480. package/src/subsystem/declarationRef.ts +63 -0
  481. package/src/subsystem/formatDeclaration.ts +174 -0
  482. package/src/subsystem/model.test.ts +244 -0
  483. package/src/subsystem/model.ts +850 -0
  484. package/src/subsystem/nodes.test.ts +56 -0
  485. package/src/subsystem/nodes.tsx +443 -0
  486. package/src/subsystem/paths.test.ts +123 -0
  487. package/src/subsystem/paths.ts +127 -0
  488. package/src/subsystem/tokenizeComponent.ts +70 -0
  489. package/src/subsystem/tokenizeFormatted.ts +19 -0
  490. package/src/utils/animationMapping.ts +166 -0
  491. package/src/utils/canvasBounds.ts +146 -0
  492. package/src/utils/elkLayout.test.ts +240 -0
  493. package/src/utils/elkLayout.ts +779 -0
  494. package/src/utils/graphConverter.ts +148 -0
  495. package/src/utils/iconResolver.tsx +53 -0
  496. package/src/utils/orientationUtils.ts +73 -0
@@ -0,0 +1,3195 @@
1
+ import React, {
2
+ useMemo,
3
+ useState,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useCallback,
7
+ useRef,
8
+ useImperativeHandle,
9
+ forwardRef,
10
+ } from 'react';
11
+ import {
12
+ ReactFlow,
13
+ Background,
14
+ BackgroundVariant,
15
+ Controls,
16
+ MiniMap,
17
+ ReactFlowProvider,
18
+ useReactFlow,
19
+ useUpdateNodeInternals,
20
+ useViewport,
21
+ applyNodeChanges,
22
+ applyEdgeChanges,
23
+ type Edge,
24
+ type EdgeChange,
25
+ type EdgeTypes,
26
+ type NodeChange,
27
+ type Node,
28
+ type NodeTypes,
29
+ type Connection,
30
+ } from '@xyflow/react';
31
+ // CSS import removed - consumers should import '@xyflow/react/dist/style.css' in their app
32
+ import type {
33
+ GraphConfiguration,
34
+ NodeState,
35
+ EdgeState,
36
+ Violation,
37
+ GraphEvent,
38
+ NodeEvent,
39
+ EdgeEvent,
40
+ StateEvent,
41
+ ExtendedCanvas,
42
+ ComponentLibrary,
43
+ } from '@principal-ai/subsystems-core';
44
+ import {
45
+ CanvasConverter,
46
+ buildScopeColorMap,
47
+ buildSpanColorMap,
48
+ resolveEventSpanColor,
49
+ DRAFT_NODE_COLOR,
50
+ DEFAULT_SPAN_COLOR,
51
+ isStandardCanvasNode,
52
+ } from '@principal-ai/subsystems-core';
53
+ import { useTheme } from '@principal-ade/industry-theme';
54
+ import { CustomNode } from '../nodes/CustomNode';
55
+ import type { CustomNodeData } from '../nodes/CustomNode';
56
+ import { CustomEdge } from '../edges/CustomEdge';
57
+ import type { CustomEdgeData } from '../edges/CustomEdge';
58
+ import {
59
+ convertToXYFlowNodes,
60
+ convertToXYFlowEdges,
61
+ } from '../utils/graphConverter';
62
+ import { useElkLayout, applyElkPathsToEdges } from '../hooks/useElkLayout';
63
+ import {
64
+ getCanvasBounds,
65
+ calculateInitialViewport,
66
+ type Viewport,
67
+ } from '../utils/canvasBounds';
68
+ import { GraphEditProvider } from '../contexts/GraphEditContext';
69
+ import { useUndoRedo, type HistoryEntry } from '../hooks/useUndoRedo';
70
+ import { TooltipPortalContext } from '../contexts/TooltipPortalContext';
71
+
72
+ // Re-export for backwards compatibility
73
+ export { TooltipPortalContext };
74
+
75
+ /** Position change event for tracking node movements */
76
+ export interface NodePositionChange {
77
+ nodeId: string;
78
+ position: { x: number; y: number };
79
+ }
80
+
81
+ /** Dimension change event for tracking node resizing */
82
+ export interface NodeDimensionChange {
83
+ nodeId: string;
84
+ dimensions: { width: number; height: number };
85
+ }
86
+
87
+ /** Text change event for tracking inline text edits */
88
+ export interface NodeTextChange {
89
+ nodeId: string;
90
+ text: string;
91
+ }
92
+
93
+ /** All pending changes that can be saved */
94
+ export interface PendingChanges {
95
+ /** Node position changes */
96
+ positionChanges: NodePositionChange[];
97
+ /** Node dimension changes (from resizing) */
98
+ dimensionChanges: NodeDimensionChange[];
99
+ /** Text changes for text and group nodes */
100
+ textChanges: NodeTextChange[];
101
+ /** Node updates (type, data changes) */
102
+ nodeUpdates: Array<{
103
+ nodeId: string;
104
+ updates: { type?: string; data?: Record<string, unknown> };
105
+ }>;
106
+ /** Deleted node IDs */
107
+ deletedNodeIds: string[];
108
+ /** New edges created (with optional handle info for connection points) */
109
+ createdEdges: Array<{
110
+ from: string;
111
+ to: string;
112
+ type: string;
113
+ sourceHandle?: string;
114
+ targetHandle?: string;
115
+ }>;
116
+ /** Deleted edges (with full connection info for config removal) */
117
+ deletedEdges: Array<{ from: string; to: string; type: string }>;
118
+ /** Whether there are any changes */
119
+ hasChanges: boolean;
120
+ }
121
+
122
+ /** Ref handle for imperative actions */
123
+ export interface GraphRendererHandle {
124
+ /** Get all pending changes */
125
+ getPendingChanges: () => PendingChanges;
126
+ /** Reset edit state to match current props */
127
+ resetEditState: () => void;
128
+ /** Check if there are unsaved changes */
129
+ hasUnsavedChanges: () => boolean;
130
+ /** Whether there are changes that can be undone */
131
+ canUndo: () => boolean;
132
+ /** Whether there are changes that can be redone */
133
+ canRedo: () => boolean;
134
+ /** Undo the last change */
135
+ undo: () => void;
136
+ /** Redo the last undone change */
137
+ redo: () => void;
138
+ }
139
+
140
+ /** Base props shared by all render modes */
141
+ interface GraphRendererBaseProps {
142
+ /** Optional violations to highlight */
143
+ violations?: Violation[];
144
+
145
+ /**
146
+ * Duration of the fitView animation in milliseconds.
147
+ * Set to 0 for instant positioning (no animation).
148
+ * Defaults to 200.
149
+ */
150
+ fitViewDuration?: number;
151
+
152
+ /**
153
+ * Whether to show tooltips on hover for nodes and edges.
154
+ * Defaults to true.
155
+ */
156
+ showTooltips?: boolean;
157
+
158
+ /**
159
+ * Optional node ID to highlight (e.g., when playing back execution events).
160
+ * Highlighted nodes will have a special visual treatment.
161
+ */
162
+ highlightedNodeId?: string | null;
163
+
164
+ /**
165
+ * Optional list of active node IDs (nodes involved in current execution scenario).
166
+ * Nodes NOT in this list will be dimmed (reduced opacity).
167
+ * If undefined or empty, all nodes are shown at full opacity.
168
+ */
169
+ activeNodeIds?: string[] | null;
170
+
171
+ /** Optional configuration name for identification (used with multi-config setups) */
172
+ configName?: string;
173
+
174
+ /** Optional class name */
175
+ className?: string;
176
+
177
+ /** Optional width */
178
+ width?: number | string;
179
+
180
+ /** Optional height */
181
+ height?: number | string;
182
+
183
+ /** Whether to show minimap */
184
+ showMinimap?: boolean;
185
+
186
+ /** Whether to show controls */
187
+ showControls?: boolean;
188
+
189
+ /** Whether to show background */
190
+ showBackground?: boolean;
191
+
192
+ /**
193
+ * Background variant style.
194
+ * - 'dots': Small dots pattern
195
+ * - 'lines': Grid lines (default)
196
+ * - 'cross': Cross pattern
197
+ */
198
+ backgroundVariant?: 'dots' | 'lines' | 'cross';
199
+
200
+ /**
201
+ * Gap between background pattern elements in pixels.
202
+ * Defaults to 50 for lines/cross, 16 for dots.
203
+ */
204
+ backgroundGap?: number;
205
+
206
+ /**
207
+ * Whether to show a center indicator at the canvas origin (0,0).
208
+ * Useful for visualizing where the center point is when items aren't centered.
209
+ * Defaults to false.
210
+ */
211
+ showCenterIndicator?: boolean;
212
+
213
+ /** Optional event stream for triggering animations */
214
+ events?: GraphEvent[];
215
+
216
+ /** Optional callback when an event is processed */
217
+ onEventProcessed?: (event: GraphEvent) => void;
218
+
219
+ /**
220
+ * Whether edit mode is enabled.
221
+ * When true, nodes can be dragged, edited, deleted, and edges can be created/deleted.
222
+ * All changes are tracked internally and can be retrieved via ref.getPendingChanges()
223
+ */
224
+ editable?: boolean;
225
+
226
+ /**
227
+ * Callback when pending changes state changes.
228
+ * Called with true when there are unsaved changes, false when there are none.
229
+ * Use this to enable/disable save buttons in the parent component.
230
+ */
231
+ onPendingChangesChange?: (hasChanges: boolean) => void;
232
+
233
+ /**
234
+ * Callback when a node is clicked.
235
+ * Receives the node ID and the click event. If provided, overrides default node selection behavior.
236
+ */
237
+ onNodeClick?: (nodeId: string, event: React.MouseEvent) => void;
238
+
239
+ /**
240
+ * When set, fits the viewport to show these specific nodes.
241
+ * Useful for focusing on a subset of the graph (e.g., scenario nodes).
242
+ * Pass null/undefined/empty array to use default fitView behavior.
243
+ */
244
+ fitViewToNodeIds?: string[] | null;
245
+
246
+ /**
247
+ * Padding around nodes when fitting view to specific nodes.
248
+ * @default 0.2
249
+ */
250
+ fitViewPadding?: number;
251
+
252
+ /**
253
+ * Set of node IDs that should be draggable even when editable=false.
254
+ * Useful for allowing specific nodes (like group containers) to be moved
255
+ * while keeping other nodes locked in place.
256
+ */
257
+ draggableNodeIds?: Set<string>;
258
+
259
+ /**
260
+ * Callback fired when a node drag operation completes.
261
+ * Receives the node ID and its new position.
262
+ */
263
+ onNodeDragStop?: (nodeId: string, position: { x: number; y: number }) => void;
264
+
265
+ /**
266
+ * Callback fired when user presses Cmd+C (Mac) or Ctrl+C (Windows/Linux)
267
+ * with nodes selected. Receives the array of selected node IDs.
268
+ * Use this to implement custom copy behavior.
269
+ */
270
+ onCopy?: (selectedNodeIds: string[]) => void;
271
+
272
+ /**
273
+ * Container width in pixels. When provided along with containerHeight,
274
+ * enables calculating the initial viewport synchronously to avoid the
275
+ * "zoom in then animate out" effect on initial render.
276
+ */
277
+ containerWidth?: number;
278
+
279
+ /**
280
+ * Container height in pixels. When provided along with containerWidth,
281
+ * enables calculating the initial viewport synchronously to avoid the
282
+ * "zoom in then animate out" effect on initial render.
283
+ */
284
+ containerHeight?: number;
285
+
286
+ /**
287
+ * ELK layout configuration for circuit-board style edge routing.
288
+ * When enabled, edges are routed with orthogonal paths that don't overlap.
289
+ */
290
+ elkLayout?: {
291
+ /**
292
+ * Whether ELK layout is enabled
293
+ * @default false
294
+ */
295
+ enabled: boolean;
296
+ /**
297
+ * Edge routing style
298
+ * - 'orthogonal': Circuit-board style with 90-degree angles (default)
299
+ * - 'splines': Smooth curved edges
300
+ * - 'polyline': Straight line segments
301
+ */
302
+ routingStyle?: 'orthogonal' | 'splines' | 'polyline';
303
+ /**
304
+ * Minimum spacing between parallel edges in pixels
305
+ * @default 15
306
+ */
307
+ edgeSpacing?: number;
308
+ /**
309
+ * Spacing between edges and nodes in pixels
310
+ * @default 20
311
+ */
312
+ edgeNodeSpacing?: number;
313
+ };
314
+
315
+ /**
316
+ * Scenario-derived edges with sequence information.
317
+ * When provided, edges matching these transitions will show sequence numbers.
318
+ * Use with deriveEdgesFromSequence() from @principal-ai/core to generate these.
319
+ */
320
+ scenarioEdges?: Array<{
321
+ fromSpan: string;
322
+ toSpan: string;
323
+ sequenceNumber: number;
324
+ }>;
325
+
326
+ /**
327
+ * Whether to show sequence labels on edges when scenarioEdges is provided.
328
+ * @default true
329
+ */
330
+ showSequenceLabels?: boolean;
331
+
332
+ }
333
+
334
+ /** GraphRenderer props - canvas format only */
335
+ export interface GraphRendererProps extends GraphRendererBaseProps {
336
+ /** Extended Canvas document */
337
+ canvas: ExtendedCanvas;
338
+
339
+ /**
340
+ * Optional component library containing reusable node and edge type definitions.
341
+ * Types from the library are merged with canvas-level types, with canvas types taking precedence.
342
+ * This allows sharing type definitions across multiple canvas files via a library.yaml file.
343
+ */
344
+ library?: ComponentLibrary;
345
+
346
+ /**
347
+ * Optional spans canvas containing span convention definitions.
348
+ * Span colors from this canvas are used as fill colors for event nodes.
349
+ * When provided with workflowSpanPattern, events will be colored based on their span.
350
+ */
351
+ spansCanvas?: ExtendedCanvas;
352
+
353
+ /**
354
+ * Optional scopes canvas containing scope definitions with colors.
355
+ * Required for scope-based node coloring.
356
+ * Replaces library.scopes (which is now deprecated).
357
+ */
358
+ scopesCanvas?: ExtendedCanvas;
359
+
360
+ /**
361
+ * Optional span pattern for the current workflow context.
362
+ * When provided, event nodes will use the span color from spansCanvas as their fill.
363
+ * This should match a spanPattern from a workflow.json file.
364
+ */
365
+ workflowSpanPattern?: string;
366
+ }
367
+
368
+ // Define custom node types
369
+ // Type assertion needed because ReactFlow's NodeTypes expects generic components
370
+ // but CustomNode is properly typed with CustomNodeData for type safety internally
371
+ const nodeTypes = { custom: CustomNode } as NodeTypes;
372
+
373
+ // Define custom edge types
374
+ // Type assertion needed because ReactFlow's EdgeTypes expects generic components
375
+ // but CustomEdge is properly typed with CustomEdgeData for type safety internally
376
+ const edgeTypes = { custom: CustomEdge } as EdgeTypes;
377
+
378
+ // Animation state for nodes and edges
379
+ interface AnimationState {
380
+ nodeAnimations: Record<
381
+ string,
382
+ { type: 'pulse' | 'flash' | 'shake' | 'entry'; duration: number; timestamp: number }
383
+ >;
384
+ edgeAnimations: Record<
385
+ string,
386
+ {
387
+ type: 'flow' | 'particle' | 'pulse' | 'glow';
388
+ duration: number;
389
+ direction?: 'forward' | 'backward' | 'bidirectional';
390
+ timestamp: number;
391
+ }
392
+ >;
393
+ }
394
+
395
+ // Alignment guide for visual feedback during node dragging
396
+ interface AlignmentGuide {
397
+ /** Orientation of the guide line */
398
+ type: 'horizontal' | 'vertical';
399
+ /** Position in pixels (x for vertical, y for horizontal) */
400
+ position: number;
401
+ /** Optional label describing the alignment (e.g., "Left edge", "Center") */
402
+ label?: string;
403
+ }
404
+
405
+ // Internal edit state tracking
406
+ interface EditState {
407
+ positionChanges: Map<string, { x: number; y: number }>;
408
+ dimensionChanges: Map<string, { width: number; height: number }>;
409
+ textChanges: Map<string, string>;
410
+ nodeUpdates: Map<string, { type?: string; data?: Record<string, unknown> }>;
411
+ deletedNodeIds: Set<string>;
412
+ createdEdges: Array<{
413
+ id: string;
414
+ from: string;
415
+ to: string;
416
+ type: string;
417
+ sourceHandle?: string;
418
+ targetHandle?: string;
419
+ }>;
420
+ deletedEdges: Array<{ id: string; from: string; to: string; type: string }>;
421
+ }
422
+
423
+ const createEmptyEditState = (): EditState => ({
424
+ positionChanges: new Map(),
425
+ dimensionChanges: new Map(),
426
+ textChanges: new Map(),
427
+ nodeUpdates: new Map(),
428
+ deletedNodeIds: new Set(),
429
+ createdEdges: [],
430
+ deletedEdges: [],
431
+ });
432
+
433
+ /**
434
+ * Alignment guides component that renders visual lines when nodes align during drag.
435
+ * Shows vertical lines for horizontal alignment (left/center/right) and
436
+ * horizontal lines for vertical alignment (top/middle/bottom).
437
+ */
438
+ const AlignmentGuidesOverlay: React.FC<{
439
+ guides: AlignmentGuide[];
440
+ color: string;
441
+ }> = ({ guides, color }) => {
442
+ const { x: viewportX, y: viewportY, zoom } = useViewport();
443
+
444
+ if (guides.length === 0) return null;
445
+
446
+ return (
447
+ <svg
448
+ style={{
449
+ position: 'absolute',
450
+ top: 0,
451
+ left: 0,
452
+ width: '100%',
453
+ height: '100%',
454
+ pointerEvents: 'none',
455
+ zIndex: 10,
456
+ }}
457
+ >
458
+ {guides.map((guide, index) => {
459
+ if (guide.type === 'vertical') {
460
+ // Vertical line (for horizontal alignment)
461
+ const screenX = guide.position * zoom + viewportX;
462
+ return (
463
+ <line
464
+ key={`${guide.type}-${guide.position}-${index}`}
465
+ x1={screenX}
466
+ y1={0}
467
+ x2={screenX}
468
+ y2="100%"
469
+ stroke={color}
470
+ strokeWidth={1.5}
471
+ strokeDasharray="4,4"
472
+ opacity={0.8}
473
+ />
474
+ );
475
+ } else {
476
+ // Horizontal line (for vertical alignment)
477
+ const screenY = guide.position * zoom + viewportY;
478
+ return (
479
+ <line
480
+ key={`${guide.type}-${guide.position}-${index}`}
481
+ x1={0}
482
+ y1={screenY}
483
+ x2="100%"
484
+ y2={screenY}
485
+ stroke={color}
486
+ strokeWidth={1.5}
487
+ strokeDasharray="4,4"
488
+ opacity={0.8}
489
+ />
490
+ );
491
+ }
492
+ })}
493
+ </svg>
494
+ );
495
+ };
496
+
497
+ /**
498
+ * Center indicator component that shows a crosshair at the canvas origin (0,0).
499
+ * Uses viewport transform to position correctly regardless of pan/zoom.
500
+ */
501
+ const CenterIndicator: React.FC<{ color: string }> = ({ color }) => {
502
+ const { x, y } = useViewport();
503
+
504
+ // Size of the crosshair in screen pixels (stays constant regardless of zoom)
505
+ const size = 20;
506
+ const strokeWidth = 1.5;
507
+
508
+ // The viewport transform places origin (0,0) at screen position (x, y)
509
+ const screenX = x;
510
+ const screenY = y;
511
+
512
+ return (
513
+ <svg
514
+ style={{
515
+ position: 'absolute',
516
+ top: 0,
517
+ left: 0,
518
+ width: '100%',
519
+ height: '100%',
520
+ pointerEvents: 'none',
521
+ zIndex: 5,
522
+ }}
523
+ >
524
+ {/* Vertical line */}
525
+ <line
526
+ x1={screenX}
527
+ y1={screenY - size}
528
+ x2={screenX}
529
+ y2={screenY + size}
530
+ stroke={color}
531
+ strokeWidth={strokeWidth}
532
+ opacity={0.7}
533
+ />
534
+ {/* Horizontal line */}
535
+ <line
536
+ x1={screenX - size}
537
+ y1={screenY}
538
+ x2={screenX + size}
539
+ y2={screenY}
540
+ stroke={color}
541
+ strokeWidth={strokeWidth}
542
+ opacity={0.7}
543
+ />
544
+ {/* Center dot */}
545
+ <circle
546
+ cx={screenX}
547
+ cy={screenY}
548
+ r={3}
549
+ fill={color}
550
+ opacity={0.7}
551
+ />
552
+ </svg>
553
+ );
554
+ };
555
+
556
+ /** Inner component receives normalized legacy format */
557
+ /** Ref for undo/redo functions that inner component will populate */
558
+ interface UndoRedoFunctionsRef {
559
+ applyUndo: () => void;
560
+ applyRedo: () => void;
561
+ }
562
+
563
+ interface GraphRendererInnerProps {
564
+ configuration: GraphConfiguration;
565
+ nodes: NodeState[];
566
+ edges: EdgeState[];
567
+ violations?: Violation[];
568
+ configName?: string;
569
+ showMinimap?: boolean;
570
+ showControls?: boolean;
571
+ showBackground?: boolean;
572
+ backgroundVariant?: 'dots' | 'lines' | 'cross';
573
+ backgroundGap?: number;
574
+ showCenterIndicator?: boolean;
575
+ showTooltips?: boolean;
576
+ fitViewDuration?: number;
577
+ highlightedNodeId?: string | null;
578
+ activeNodeIds?: string[] | null;
579
+ events?: GraphEvent[];
580
+ onEventProcessed?: (event: GraphEvent) => void;
581
+ editable?: boolean;
582
+ onPendingChangesChange?: (hasChanges: boolean) => void;
583
+ onEditStateChange?: (editState: EditState) => void;
584
+ editStateRef: React.MutableRefObject<EditState>;
585
+ resetVisualStateRef: React.MutableRefObject<(() => void) | null>;
586
+ resetTextChangesVersionRef: React.MutableRefObject<(() => void) | null>;
587
+ undoRedoFunctionsRef: React.MutableRefObject<UndoRedoFunctionsRef | null>;
588
+ pushHistory: (entries: HistoryEntry[]) => void;
589
+ clearHistory: () => void;
590
+ undoFromStack: () => HistoryEntry[] | null;
591
+ redoFromStack: () => HistoryEntry[] | null;
592
+ onNodeClick?: (nodeId: string, event: React.MouseEvent) => void;
593
+ fitViewToNodeIds?: string[] | null;
594
+ fitViewPadding?: number;
595
+ draggableNodeIds?: Set<string>;
596
+ onNodeDragStop?: (nodeId: string, position: { x: number; y: number }) => void;
597
+ onCopy?: (selectedNodeIds: string[]) => void;
598
+ /** Pre-calculated initial viewport to avoid zoom animation on mount */
599
+ initialViewport?: Viewport;
600
+ /** ELK layout configuration for circuit-board style edge routing */
601
+ elkLayout?: {
602
+ enabled: boolean;
603
+ routingStyle?: 'orthogonal' | 'splines' | 'polyline';
604
+ edgeSpacing?: number;
605
+ edgeNodeSpacing?: number;
606
+ };
607
+ /** Scenario-derived edges with sequence information */
608
+ scenarioEdges?: Array<{
609
+ fromSpan: string;
610
+ toSpan: string;
611
+ sequenceNumber: number;
612
+ }>;
613
+ /** Whether to show sequence labels on edges when scenarioEdges is provided */
614
+ showSequenceLabels?: boolean;
615
+ }
616
+
617
+ /**
618
+ * Inner component that uses ReactFlow hooks
619
+ */
620
+ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
621
+ configuration,
622
+ nodes: propNodes,
623
+ edges: propEdges,
624
+ violations = [],
625
+ configName: _configName,
626
+ showMinimap = false,
627
+ showControls = true,
628
+ showBackground = true,
629
+ backgroundVariant = 'lines',
630
+ backgroundGap,
631
+ showCenterIndicator = false,
632
+ showTooltips = true,
633
+ fitViewDuration = 200,
634
+ highlightedNodeId,
635
+ activeNodeIds,
636
+ events = [],
637
+ onEventProcessed,
638
+ editable = false,
639
+ onPendingChangesChange,
640
+ onEditStateChange,
641
+ editStateRef,
642
+ resetVisualStateRef,
643
+ resetTextChangesVersionRef,
644
+ undoRedoFunctionsRef,
645
+ pushHistory,
646
+ clearHistory,
647
+ undoFromStack,
648
+ redoFromStack,
649
+ onNodeClick: onNodeClickProp,
650
+ fitViewToNodeIds,
651
+ fitViewPadding = 0.2,
652
+ draggableNodeIds,
653
+ onNodeDragStop: onNodeDragStopProp,
654
+ onCopy,
655
+ initialViewport,
656
+ elkLayout,
657
+ scenarioEdges,
658
+ showSequenceLabels = true,
659
+ }) => {
660
+ const { fitView, fitBounds, getNodes } = useReactFlow();
661
+ const updateNodeInternals = useUpdateNodeInternals();
662
+ const { theme } = useTheme();
663
+
664
+ // Track shift key state for tooltip control
665
+ const [shiftKeyPressed, setShiftKeyPressed] = useState(false);
666
+
667
+ // Track text changes version to force re-renders when text is edited
668
+ const [textChangesVersion, setTextChangesVersion] = useState(0);
669
+
670
+ // Track if we're currently processing a node hide operation
671
+ const hidingNodeRef = useRef(false);
672
+
673
+ // Setup keyboard event listeners for shift key
674
+ useEffect(() => {
675
+ const handleKeyDown = (e: KeyboardEvent) => {
676
+ if (e.key === 'Shift') {
677
+ setShiftKeyPressed(true);
678
+ }
679
+ };
680
+
681
+ const handleKeyUp = (e: KeyboardEvent) => {
682
+ if (e.key === 'Shift') {
683
+ setShiftKeyPressed(false);
684
+ }
685
+ };
686
+
687
+ window.addEventListener('keydown', handleKeyDown);
688
+ window.addEventListener('keyup', handleKeyUp);
689
+
690
+ return () => {
691
+ window.removeEventListener('keydown', handleKeyDown);
692
+ window.removeEventListener('keyup', handleKeyUp);
693
+ };
694
+ }, []);
695
+
696
+ // Track active animations
697
+ const [animationState, setAnimationState] = useState<AnimationState>({
698
+ nodeAnimations: {},
699
+ edgeAnimations: {},
700
+ });
701
+
702
+ // Track alignment guides during drag operations
703
+ const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
704
+
705
+ // Track selected edges for info panel (supports multi-select)
706
+ const [selectedEdgeIds, setSelectedEdgeIds] = useState<Set<string>>(new Set());
707
+
708
+ // Track selected nodes for info panel (supports multi-select)
709
+ const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
710
+
711
+ // Setup keyboard event listener for copy (Cmd+C / Ctrl+C)
712
+ useEffect(() => {
713
+ if (!onCopy) return;
714
+
715
+ const handleCopy = (e: KeyboardEvent) => {
716
+ // Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux)
717
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
718
+ if (selectedNodeIds.size > 0) {
719
+ e.preventDefault();
720
+ onCopy(Array.from(selectedNodeIds));
721
+ }
722
+ }
723
+ };
724
+
725
+ window.addEventListener('keydown', handleCopy);
726
+ return () => {
727
+ window.removeEventListener('keydown', handleCopy);
728
+ };
729
+ }, [onCopy, selectedNodeIds]);
730
+
731
+ // Track hidden nodes (shift-click to toggle)
732
+ const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
733
+
734
+ // Track pending connection for edge type picker
735
+ const [pendingConnection, setPendingConnection] = useState<{
736
+ from: string;
737
+ to: string;
738
+ sourceHandle?: string;
739
+ targetHandle?: string;
740
+ validTypes: string[];
741
+ } | null>(null);
742
+
743
+ // Sync highlightedNodeId to selection state
744
+ useEffect(() => {
745
+ if (highlightedNodeId) {
746
+ setSelectedNodeIds(new Set([highlightedNodeId]));
747
+ // Clear edge selection when highlighting a node
748
+ setSelectedEdgeIds(new Set());
749
+ }
750
+ }, [highlightedNodeId]);
751
+
752
+ // ============================================
753
+ // INTERNAL EDIT STATE
754
+ // ============================================
755
+
756
+ // Local copies of nodes and edges for editing
757
+ const [localNodes, setLocalNodes] = useState<NodeState[]>(propNodes);
758
+ const [localEdges, setLocalEdges] = useState<EdgeState[]>(propEdges);
759
+
760
+ // Track the prop values to detect external changes
761
+ const propNodesKeyRef = useRef(
762
+ propNodes
763
+ .map((n) => n.id)
764
+ .sort()
765
+ .join(',')
766
+ );
767
+ const propEdgesKeyRef = useRef(
768
+ propEdges
769
+ .map((e) => e.id)
770
+ .sort()
771
+ .join(',')
772
+ );
773
+
774
+ // Sync local state with props when props change (e.g., config reload)
775
+ // This only happens when the structure changes, not during editing
776
+ useEffect(() => {
777
+ const newNodesKey = propNodes
778
+ .map((n) => n.id)
779
+ .sort()
780
+ .join(',');
781
+ const newEdgesKey = propEdges
782
+ .map((e) => e.id)
783
+ .sort()
784
+ .join(',');
785
+
786
+ if (newNodesKey !== propNodesKeyRef.current || newEdgesKey !== propEdgesKeyRef.current) {
787
+ // Debug: Log sync events
788
+ if (process.env.NODE_ENV === 'development') {
789
+ console.log('[GraphRenderer] Syncing local state with props:', {
790
+ prevNodesKey: propNodesKeyRef.current,
791
+ newNodesKey,
792
+ prevEdgesKey: propEdgesKeyRef.current,
793
+ newEdgesKey,
794
+ propNodesCount: propNodes.length,
795
+ propEdgesCount: propEdges.length,
796
+ });
797
+ }
798
+ propNodesKeyRef.current = newNodesKey;
799
+ propEdgesKeyRef.current = newEdgesKey;
800
+ setLocalNodes(propNodes);
801
+ setLocalEdges(propEdges);
802
+ // Reset edit state when props change
803
+ editStateRef.current = createEmptyEditState();
804
+ onEditStateChange?.(editStateRef.current);
805
+ onPendingChangesChange?.(false);
806
+ // Clear animation state to prevent stale animations from affecting new edges
807
+ setAnimationState({ nodeAnimations: {}, edgeAnimations: {} });
808
+ // Clear undo/redo history when props change
809
+ clearHistory();
810
+ }
811
+ }, [propNodes, propEdges, editStateRef, onEditStateChange, onPendingChangesChange]);
812
+
813
+ // When draggableNodeIds is provided, positions are managed externally
814
+ // Sync positions from props whenever they change (only when props actually change from parent)
815
+ const propNodesRef = useRef(propNodes);
816
+ useEffect(() => {
817
+ if (!draggableNodeIds || draggableNodeIds.size === 0) return;
818
+ // Only sync if propNodes reference actually changed (from parent update)
819
+ if (propNodesRef.current === propNodes) return;
820
+ propNodesRef.current = propNodes;
821
+ setLocalNodes(propNodes);
822
+ }, [propNodes, draggableNodeIds]);
823
+
824
+ // Always use localNodes for rendering - it syncs with props when structure changes
825
+ // and receives state_changed event updates. localEdges only used in edit mode.
826
+ // Filter out deleted nodes in edit mode
827
+ const nodes = editable
828
+ ? localNodes.filter((node) => !editStateRef.current.deletedNodeIds.has(node.id))
829
+ : localNodes;
830
+ const edges = editable ? localEdges : propEdges;
831
+
832
+ // Ref to track current xyflow nodes for undo/redo (set later via useEffect)
833
+ const xyflowNodesRef = useRef<Node<CustomNodeData>[]>([]);
834
+
835
+ // Ref to capture node positions at drag start (for accurate undo "before" values)
836
+ const dragStartPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
837
+
838
+ // Helper to check if there are pending changes
839
+ const checkHasChanges = useCallback((state: EditState): boolean => {
840
+ return (
841
+ state.positionChanges.size > 0 ||
842
+ state.dimensionChanges.size > 0 ||
843
+ state.textChanges.size > 0 ||
844
+ state.nodeUpdates.size > 0 ||
845
+ state.deletedNodeIds.size > 0 ||
846
+ state.createdEdges.length > 0 ||
847
+ state.deletedEdges.length > 0
848
+ );
849
+ }, []);
850
+
851
+ // Helper to update edit state and notify parent
852
+ const updateEditState = useCallback(
853
+ (updater: (prev: EditState) => EditState) => {
854
+ const newState = updater(editStateRef.current);
855
+ editStateRef.current = newState;
856
+ const hasChanges = checkHasChanges(newState);
857
+
858
+ // Debug logging
859
+ if (process.env.NODE_ENV === 'development') {
860
+ console.log('[GraphRenderer] Edit state updated:', {
861
+ positionChanges: newState.positionChanges.size,
862
+ dimensionChanges: newState.dimensionChanges.size,
863
+ textChanges: newState.textChanges.size,
864
+ nodeUpdates: newState.nodeUpdates.size,
865
+ hasChanges,
866
+ });
867
+ }
868
+
869
+ onEditStateChange?.(newState);
870
+ onPendingChangesChange?.(hasChanges);
871
+ },
872
+ [editStateRef, onEditStateChange, onPendingChangesChange, checkHasChanges]
873
+ );
874
+
875
+ // Handler for node resize end - called from CustomNode via context
876
+ const handleNodeResizeEnd = useCallback(
877
+ (nodeId: string, dimensions: { width: number; height: number }) => {
878
+ if (!editable) return;
879
+
880
+ // Capture before dimensions for undo
881
+ const currentNode = xyflowNodesRef.current.find((n) => n.id === nodeId);
882
+ const beforeDimensions = currentNode
883
+ ? { width: currentNode.width ?? 0, height: currentNode.height ?? 0 }
884
+ : { width: 0, height: 0 };
885
+
886
+ // Push to history
887
+ pushHistory([
888
+ {
889
+ type: 'dimension',
890
+ nodeId,
891
+ before: beforeDimensions,
892
+ after: dimensions,
893
+ },
894
+ ]);
895
+
896
+ updateEditState((prev) => {
897
+ const newDimensions = new Map(prev.dimensionChanges);
898
+ newDimensions.set(nodeId, dimensions);
899
+ return { ...prev, dimensionChanges: newDimensions };
900
+ });
901
+ },
902
+ [editable, updateEditState, pushHistory]
903
+ );
904
+
905
+ // Handler for node text change - called from CustomNode via context
906
+ const handleNodeTextChange = useCallback(
907
+ (nodeId: string, text: string) => {
908
+ if (!editable) return;
909
+
910
+ // Capture before text for undo
911
+ // For text nodes, the text is in the canvas node's 'text' field
912
+ // For group nodes, it's in the 'label' field
913
+ // We need to look at the original canvas to get the before value
914
+ const beforeText = editStateRef.current.textChanges.get(nodeId) ?? '';
915
+
916
+ // Push to history
917
+ pushHistory([
918
+ {
919
+ type: 'text',
920
+ nodeId,
921
+ before: beforeText,
922
+ after: text,
923
+ },
924
+ ]);
925
+
926
+ updateEditState((prev) => {
927
+ const newTextChanges = new Map(prev.textChanges);
928
+ newTextChanges.set(nodeId, text);
929
+ return { ...prev, textChanges: newTextChanges };
930
+ });
931
+
932
+ // Force re-render by incrementing version
933
+ setTextChangesVersion((v) => v + 1);
934
+ },
935
+ [editable, updateEditState, pushHistory]
936
+ );
937
+
938
+ // Handler for deleting selected nodes (Delete/Backspace key)
939
+ const handleDeleteSelectedNodes = useCallback(() => {
940
+ if (!editable || selectedNodeIds.size === 0) return;
941
+
942
+ const nodesToDelete = Array.from(selectedNodeIds);
943
+ const historyEntries: HistoryEntry[] = [];
944
+
945
+ // Build history entries for each deleted node
946
+ for (const nodeId of nodesToDelete) {
947
+ const node = localNodes.find((n) => n.id === nodeId);
948
+ if (!node) continue;
949
+
950
+ historyEntries.push({
951
+ type: 'nodeDelete',
952
+ nodeId,
953
+ nodeData: node,
954
+ });
955
+ }
956
+
957
+ // Push all deletions as a single batch
958
+ if (historyEntries.length > 0) {
959
+ pushHistory(historyEntries);
960
+
961
+ updateEditState((prev) => {
962
+ const newDeletedNodeIds = new Set(prev.deletedNodeIds);
963
+ nodesToDelete.forEach((id) => newDeletedNodeIds.add(id));
964
+ return { ...prev, deletedNodeIds: newDeletedNodeIds };
965
+ });
966
+
967
+ // Clear selection after deletion
968
+ setSelectedNodeIds(new Set());
969
+ }
970
+ }, [editable, selectedNodeIds, localNodes, pushHistory, updateEditState]);
971
+
972
+ // Handle toggling node hidden state (Cmd/Ctrl+click)
973
+ // This is exposed via context so CustomNode can call it on mousedown
974
+ // (mousedown works in edit mode where click is intercepted by drag handling)
975
+ const handleToggleNodeHidden = useCallback(
976
+ (nodeId: string) => {
977
+ hidingNodeRef.current = true;
978
+
979
+ setHiddenNodeIds((prev) => {
980
+ const next = new Set(prev);
981
+ if (next.has(nodeId)) {
982
+ next.delete(nodeId);
983
+ } else {
984
+ next.add(nodeId);
985
+ }
986
+ return next;
987
+ });
988
+
989
+ // Reset the flag after state update completes
990
+ setTimeout(() => {
991
+ hidingNodeRef.current = false;
992
+ }, 0);
993
+ },
994
+ []
995
+ );
996
+
997
+ // Handle hiding all nodes not directly connected to the given node (Cmd/Ctrl+Shift+click)
998
+ // If all unconnected nodes are already hidden, this will unhide them (toggle behavior)
999
+ const handleHideUnconnectedNodes = useCallback(
1000
+ (nodeId: string) => {
1001
+ hidingNodeRef.current = true;
1002
+
1003
+ // Find all nodes directly connected to this node
1004
+ const connectedNodeIds = new Set<string>([nodeId]);
1005
+ for (const edge of localEdges) {
1006
+ if (edge.from === nodeId) {
1007
+ connectedNodeIds.add(edge.to);
1008
+ } else if (edge.to === nodeId) {
1009
+ connectedNodeIds.add(edge.from);
1010
+ }
1011
+ }
1012
+
1013
+ // Get all unconnected node IDs
1014
+ const unconnectedNodeIds = localNodes
1015
+ .map((n) => n.id)
1016
+ .filter((id) => !connectedNodeIds.has(id));
1017
+
1018
+ setHiddenNodeIds((prev) => {
1019
+ // Check if all unconnected nodes are already hidden
1020
+ const allUnconnectedHidden = unconnectedNodeIds.every((id) => prev.has(id));
1021
+
1022
+ if (allUnconnectedHidden) {
1023
+ // Toggle off - unhide all unconnected nodes
1024
+ const next = new Set(prev);
1025
+ for (const id of unconnectedNodeIds) {
1026
+ next.delete(id);
1027
+ }
1028
+ return next;
1029
+ } else {
1030
+ // Hide all unconnected nodes
1031
+ const next = new Set(prev);
1032
+ for (const id of unconnectedNodeIds) {
1033
+ next.add(id);
1034
+ }
1035
+ return next;
1036
+ }
1037
+ });
1038
+
1039
+ // Reset the flag after state update completes
1040
+ setTimeout(() => {
1041
+ hidingNodeRef.current = false;
1042
+ }, 0);
1043
+ },
1044
+ [localEdges, localNodes]
1045
+ );
1046
+
1047
+ // Memoize the context value to prevent unnecessary re-renders
1048
+ const graphEditContextValue = useMemo(
1049
+ () => ({
1050
+ onNodeResizeEnd: handleNodeResizeEnd,
1051
+ onNodeTextChange: handleNodeTextChange,
1052
+ onToggleNodeHidden: handleToggleNodeHidden,
1053
+ onHideUnconnectedNodes: handleHideUnconnectedNodes,
1054
+ }),
1055
+ [handleNodeResizeEnd, handleNodeTextChange, handleToggleNodeHidden, handleHideUnconnectedNodes]
1056
+ );
1057
+
1058
+ // ============================================
1059
+ // ALIGNMENT GUIDES
1060
+ // ============================================
1061
+
1062
+ /**
1063
+ * Detect alignment guides between a dragging node and other nodes
1064
+ * @param draggingNodeId - ID of the node being dragged
1065
+ * @param nodes - All nodes in the graph
1066
+ * @returns Array of alignment guides to display
1067
+ */
1068
+ const detectAlignmentGuides = useCallback(
1069
+ (draggingNodeId: string, nodes: Node<CustomNodeData>[]): AlignmentGuide[] => {
1070
+ const threshold = 5; // pixels - how close to snap/show guide
1071
+ const guides: AlignmentGuide[] = [];
1072
+
1073
+ const draggingNode = nodes.find((n) => n.id === draggingNodeId);
1074
+ if (!draggingNode) return guides;
1075
+
1076
+ const dragLeft = draggingNode.position.x;
1077
+ const dragRight = draggingNode.position.x + (draggingNode.width ?? 0);
1078
+ const dragCenterX = draggingNode.position.x + (draggingNode.width ?? 0) / 2;
1079
+ const dragTop = draggingNode.position.y;
1080
+ const dragBottom = draggingNode.position.y + (draggingNode.height ?? 0);
1081
+ const dragCenterY = draggingNode.position.y + (draggingNode.height ?? 0) / 2;
1082
+
1083
+ // Check alignment with other nodes
1084
+ for (const node of nodes) {
1085
+ if (node.id === draggingNodeId) continue;
1086
+
1087
+ const nodeLeft = node.position.x;
1088
+ const nodeRight = node.position.x + (node.width ?? 0);
1089
+ const nodeCenterX = node.position.x + (node.width ?? 0) / 2;
1090
+ const nodeTop = node.position.y;
1091
+ const nodeBottom = node.position.y + (node.height ?? 0);
1092
+ const nodeCenterY = node.position.y + (node.height ?? 0) / 2;
1093
+
1094
+ // Vertical guides (align horizontally - left/center/right)
1095
+ if (Math.abs(dragLeft - nodeLeft) < threshold) {
1096
+ guides.push({ type: 'vertical', position: nodeLeft, label: 'Left' });
1097
+ } else if (Math.abs(dragCenterX - nodeCenterX) < threshold) {
1098
+ guides.push({ type: 'vertical', position: nodeCenterX, label: 'Center' });
1099
+ } else if (Math.abs(dragRight - nodeRight) < threshold) {
1100
+ guides.push({ type: 'vertical', position: nodeRight, label: 'Right' });
1101
+ }
1102
+
1103
+ // Horizontal guides (align vertically - top/middle/bottom)
1104
+ if (Math.abs(dragTop - nodeTop) < threshold) {
1105
+ guides.push({ type: 'horizontal', position: nodeTop, label: 'Top' });
1106
+ } else if (Math.abs(dragCenterY - nodeCenterY) < threshold) {
1107
+ guides.push({ type: 'horizontal', position: nodeCenterY, label: 'Middle' });
1108
+ } else if (Math.abs(dragBottom - nodeBottom) < threshold) {
1109
+ guides.push({ type: 'horizontal', position: nodeBottom, label: 'Bottom' });
1110
+ }
1111
+ }
1112
+
1113
+ // Remove duplicates (same position guides)
1114
+ const uniqueGuides = guides.filter(
1115
+ (guide, index, self) =>
1116
+ index ===
1117
+ self.findIndex((g) => g.type === guide.type && g.position === guide.position)
1118
+ );
1119
+
1120
+ return uniqueGuides;
1121
+ },
1122
+ []
1123
+ );
1124
+
1125
+ // ============================================
1126
+ // EVENT HANDLERS
1127
+ // ============================================
1128
+
1129
+ // Handle edge click (toggle selection, supports Shift for multi-select)
1130
+ const onEdgeClick = useCallback(
1131
+ (event: React.MouseEvent, edge: Edge) => {
1132
+ if (event.shiftKey && editable) {
1133
+ // Shift+click: toggle edge in selection
1134
+ setSelectedEdgeIds((prev) => {
1135
+ const next = new Set(prev);
1136
+ if (next.has(edge.id)) {
1137
+ next.delete(edge.id);
1138
+ } else {
1139
+ next.add(edge.id);
1140
+ }
1141
+ return next;
1142
+ });
1143
+ } else {
1144
+ // Regular click: single select (replace selection)
1145
+ const shouldDeselect = selectedEdgeIds.size === 1 && selectedEdgeIds.has(edge.id);
1146
+ if (shouldDeselect) {
1147
+ setSelectedEdgeIds(new Set());
1148
+ } else {
1149
+ setSelectedEdgeIds(new Set([edge.id]));
1150
+ }
1151
+ setSelectedNodeIds(new Set());
1152
+ }
1153
+ },
1154
+ [editable, selectedEdgeIds]
1155
+ );
1156
+
1157
+ // Handle node click (toggle selection, supports Cmd/Ctrl for hide/dim)
1158
+ const onNodeClick = useCallback(
1159
+ (event: React.MouseEvent, node: Node) => {
1160
+ // Cmd+click (Mac) or Ctrl+click (Windows/Linux): toggle node visibility (hide edges and dim node)
1161
+ // Cmd+Shift+click: hide all nodes not directly connected to this node
1162
+ if (event.metaKey || event.ctrlKey) {
1163
+ event.preventDefault();
1164
+ event.stopPropagation();
1165
+
1166
+ if (event.shiftKey) {
1167
+ // Cmd/Ctrl+Shift+click: hide unconnected nodes
1168
+ handleHideUnconnectedNodes(node.id);
1169
+ } else {
1170
+ // Cmd/Ctrl+click: toggle single node
1171
+ handleToggleNodeHidden(node.id);
1172
+ }
1173
+
1174
+ return;
1175
+ }
1176
+
1177
+ // Shift+click: toggle node in selection (add/remove from multi-select)
1178
+ if (event.shiftKey) {
1179
+ event.preventDefault();
1180
+ event.stopPropagation();
1181
+
1182
+ setSelectedNodeIds((prev) => {
1183
+ const next = new Set(prev);
1184
+ if (next.has(node.id)) {
1185
+ next.delete(node.id);
1186
+ } else {
1187
+ next.add(node.id);
1188
+ }
1189
+
1190
+ // Update local nodes selection state immediately for edit mode
1191
+ if (editable) {
1192
+ setXyflowLocalNodes((nodes) =>
1193
+ nodes.map((n) => ({
1194
+ ...n,
1195
+ selected: next.has(n.id),
1196
+ }))
1197
+ );
1198
+ }
1199
+
1200
+ return next;
1201
+ });
1202
+ return;
1203
+ }
1204
+
1205
+ // Regular click: single select (replace selection)
1206
+ event.preventDefault();
1207
+ event.stopPropagation();
1208
+
1209
+ const shouldDeselect = selectedNodeIds.size === 1 && selectedNodeIds.has(node.id);
1210
+ if (shouldDeselect) {
1211
+ setSelectedNodeIds(new Set());
1212
+
1213
+ // Update local nodes selection state immediately for edit mode
1214
+ if (editable) {
1215
+ setXyflowLocalNodes((nodes) =>
1216
+ nodes.map((n) => ({
1217
+ ...n,
1218
+ selected: false,
1219
+ }))
1220
+ );
1221
+ }
1222
+ } else {
1223
+ setSelectedNodeIds(new Set([node.id]));
1224
+
1225
+ // Update local nodes selection state immediately for edit mode
1226
+ if (editable) {
1227
+ setXyflowLocalNodes((nodes) =>
1228
+ nodes.map((n) => ({
1229
+ ...n,
1230
+ selected: n.id === node.id,
1231
+ }))
1232
+ );
1233
+ }
1234
+ }
1235
+ setSelectedEdgeIds(new Set());
1236
+
1237
+ // If custom node click handler is provided, call it after selection is updated
1238
+ if (onNodeClickProp) {
1239
+ onNodeClickProp(node.id, event);
1240
+ }
1241
+ },
1242
+ [selectedNodeIds, onNodeClickProp, editable, handleToggleNodeHidden, handleHideUnconnectedNodes]
1243
+ );
1244
+
1245
+
1246
+ // Track the last node we fitted to (for toggle behavior)
1247
+ const lastFittedNodeRef = useRef<string | null>(null);
1248
+
1249
+ // Handle double-click on node in non-edit mode to fit/focus around it
1250
+ const onNodeDoubleClick = useCallback(
1251
+ (event: React.MouseEvent, node: Node) => {
1252
+ // Prevent default zoom behavior
1253
+ event.preventDefault();
1254
+ event.stopPropagation();
1255
+
1256
+ // Only fit to node in non-edit mode
1257
+ if (editable) return;
1258
+
1259
+ // Toggle behavior: if we already fitted to this node, zoom out to full view
1260
+ if (lastFittedNodeRef.current === node.id) {
1261
+ lastFittedNodeRef.current = null;
1262
+
1263
+ // Calculate bounds for ALL nodes
1264
+ const allNodes = getNodes();
1265
+ if (allNodes.length > 0) {
1266
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1267
+
1268
+ for (const n of allNodes) {
1269
+ const w = n.measured?.width ?? n.width ?? 200;
1270
+ const h = n.measured?.height ?? n.height ?? 100;
1271
+ minX = Math.min(minX, n.position.x);
1272
+ minY = Math.min(minY, n.position.y);
1273
+ maxX = Math.max(maxX, n.position.x + w);
1274
+ maxY = Math.max(maxY, n.position.y + h);
1275
+ }
1276
+
1277
+ fitBounds({
1278
+ x: minX,
1279
+ y: minY,
1280
+ width: maxX - minX,
1281
+ height: maxY - minY,
1282
+ }, {
1283
+ padding: 0.2,
1284
+ duration: fitViewDuration,
1285
+ });
1286
+ }
1287
+ return;
1288
+ }
1289
+
1290
+ // Get the node's dimensions (use measured dimensions if available)
1291
+ const width = node.measured?.width ?? node.width ?? 200;
1292
+ const height = node.measured?.height ?? node.height ?? 100;
1293
+
1294
+ // Fit the view to the node's bounds
1295
+ lastFittedNodeRef.current = node.id;
1296
+ fitBounds({
1297
+ x: node.position.x,
1298
+ y: node.position.y,
1299
+ width,
1300
+ height,
1301
+ }, {
1302
+ padding: 0.5,
1303
+ duration: fitViewDuration,
1304
+ });
1305
+ },
1306
+ [editable, fitBounds, getNodes, fitViewDuration]
1307
+ );
1308
+
1309
+ // Handle pane click (clear selection when clicking empty space)
1310
+ const onPaneClick = useCallback(() => {
1311
+ setSelectedNodeIds(new Set());
1312
+ setSelectedEdgeIds(new Set());
1313
+
1314
+ // In edit mode, also update local nodes selection state
1315
+ if (editable) {
1316
+ setXyflowLocalNodes((nodes) =>
1317
+ nodes.map((n) => ({
1318
+ ...n,
1319
+ selected: false,
1320
+ }))
1321
+ );
1322
+ }
1323
+ }, [editable]);
1324
+
1325
+ // Handle selection change from ReactFlow (box selection and clicks)
1326
+ const handleSelectionChange = useCallback(
1327
+ ({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
1328
+ // Ignore selection changes when we're hiding a node
1329
+ if (hidingNodeRef.current) {
1330
+ return;
1331
+ }
1332
+
1333
+ // In edit mode, we manage selection ourselves via onNodeClick
1334
+ // Skip handleSelectionChange to avoid ReactFlow overwriting our selection state
1335
+ if (editable) {
1336
+ return;
1337
+ }
1338
+
1339
+ const newSelectedNodeIds = new Set(selectedNodes.map((n) => n.id));
1340
+
1341
+ // Update selection state for read-only mode (for visual feedback)
1342
+ setSelectedNodeIds(newSelectedNodeIds);
1343
+ setSelectedEdgeIds(new Set(selectedEdges.map((e) => e.id)));
1344
+ },
1345
+ [editable]
1346
+ );
1347
+
1348
+
1349
+ // Create edge helper
1350
+ const createEdge = useCallback(
1351
+ (from: string, to: string, type: string, sourceHandle?: string, targetHandle?: string) => {
1352
+ const edgeId = `${from}-${to}-${type}-${Date.now()}`;
1353
+ const now = Date.now();
1354
+
1355
+ // Add to local state with handle information
1356
+ const newEdge: EdgeState & { sourceHandle?: string; targetHandle?: string } = {
1357
+ id: edgeId,
1358
+ type,
1359
+ from,
1360
+ to,
1361
+ data: {},
1362
+ createdAt: now,
1363
+ updatedAt: now,
1364
+ sourceHandle,
1365
+ targetHandle,
1366
+ };
1367
+ setLocalEdges((prev) => [...prev, newEdge]);
1368
+
1369
+ // Push to history for undo
1370
+ pushHistory([
1371
+ {
1372
+ type: 'edgeCreate',
1373
+ edge: newEdge,
1374
+ },
1375
+ ]);
1376
+
1377
+ // Track the change
1378
+ updateEditState((prev) => ({
1379
+ ...prev,
1380
+ createdEdges: [
1381
+ ...prev.createdEdges,
1382
+ { id: edgeId, from, to, type, sourceHandle, targetHandle },
1383
+ ],
1384
+ }));
1385
+ },
1386
+ [updateEditState, pushHistory]
1387
+ );
1388
+
1389
+ // Handle new connection from drag
1390
+ const handleConnect = useCallback(
1391
+ (connection: Connection) => {
1392
+ if (!editable || !connection.source || !connection.target) return;
1393
+
1394
+ // Find source and target node types
1395
+ const sourceNode = nodes.find((n) => n.id === connection.source);
1396
+ const targetNode = nodes.find((n) => n.id === connection.target);
1397
+ if (!sourceNode || !targetNode) return;
1398
+
1399
+ // Find valid edge types for this connection
1400
+ const validTypes = configuration.allowedConnections
1401
+ .filter((ac) => ac.from === sourceNode.type && ac.to === targetNode.type)
1402
+ .map((ac) => ac.via);
1403
+
1404
+ const uniqueTypes = [...new Set(validTypes)];
1405
+
1406
+ if (uniqueTypes.length === 0) {
1407
+ console.warn(
1408
+ `No valid edge types for connection from ${sourceNode.type} to ${targetNode.type}`
1409
+ );
1410
+ return;
1411
+ }
1412
+
1413
+ if (uniqueTypes.length === 1) {
1414
+ // Create edge immediately with handle information
1415
+ createEdge(
1416
+ connection.source,
1417
+ connection.target,
1418
+ uniqueTypes[0],
1419
+ connection.sourceHandle ?? undefined,
1420
+ connection.targetHandle ?? undefined
1421
+ );
1422
+ } else {
1423
+ // Show picker
1424
+ setPendingConnection({
1425
+ from: connection.source,
1426
+ to: connection.target,
1427
+ sourceHandle: connection.sourceHandle ?? undefined,
1428
+ targetHandle: connection.targetHandle ?? undefined,
1429
+ validTypes: uniqueTypes,
1430
+ });
1431
+ }
1432
+ },
1433
+ [editable, nodes, configuration.allowedConnections, createEdge]
1434
+ );
1435
+
1436
+ // Handle edge type selection from picker
1437
+ const handleEdgeTypeSelect = useCallback(
1438
+ (type: string) => {
1439
+ if (!pendingConnection) return;
1440
+ createEdge(
1441
+ pendingConnection.from,
1442
+ pendingConnection.to,
1443
+ type,
1444
+ pendingConnection.sourceHandle,
1445
+ pendingConnection.targetHandle
1446
+ );
1447
+ setPendingConnection(null);
1448
+ },
1449
+ [pendingConnection, createEdge]
1450
+ );
1451
+
1452
+ // Cancel edge type picker
1453
+ const handleCancelEdgeTypePicker = useCallback(() => {
1454
+ setPendingConnection(null);
1455
+ }, []);
1456
+
1457
+ // Helper to convert handle ID back to side name
1458
+ const handleToSide = useCallback((handle: string | null | undefined, isSource: boolean): string | undefined => {
1459
+ if (!handle) return undefined;
1460
+ // Source handles are like "right-out", target handles are like "left"
1461
+ if (isSource) {
1462
+ return handle.replace('-out', '');
1463
+ }
1464
+ return handle;
1465
+ }, []);
1466
+
1467
+ // Track whether reconnection succeeded
1468
+ const edgeReconnectSuccessful = useRef(true);
1469
+
1470
+ // Called when user starts dragging an edge endpoint
1471
+ const handleReconnectStart = useCallback(() => {
1472
+ edgeReconnectSuccessful.current = false;
1473
+ }, []);
1474
+
1475
+ // Handle edge reconnection (dragging edge endpoint to new node)
1476
+ const handleReconnect = useCallback(
1477
+ (oldEdge: Edge, newConnection: Connection) => {
1478
+ if (!editable || !newConnection.source || !newConnection.target) return;
1479
+
1480
+ // Find the original edge in our local state
1481
+ const originalEdge = localEdges.find((e) => e.id === oldEdge.id);
1482
+ if (!originalEdge) return;
1483
+
1484
+ // Find source and target nodes for validation
1485
+ const sourceNode = nodes.find((n) => n.id === newConnection.source);
1486
+ const targetNode = nodes.find((n) => n.id === newConnection.target);
1487
+ if (!sourceNode || !targetNode) return;
1488
+
1489
+ // Check if the new connection is valid for this edge type
1490
+ // Note: allowedConnections uses node IDs as the from/to values
1491
+ const isValidConnection = configuration.allowedConnections.some(
1492
+ (ac) =>
1493
+ ac.from === sourceNode.id && ac.to === targetNode.id && ac.via === originalEdge.type
1494
+ );
1495
+
1496
+ if (!isValidConnection) {
1497
+ console.warn(
1498
+ `Cannot reconnect: ${originalEdge.type} edge not allowed from ${sourceNode.id} to ${targetNode.id}`
1499
+ );
1500
+ return;
1501
+ }
1502
+
1503
+ // Mark as successful before updating
1504
+ edgeReconnectSuccessful.current = true;
1505
+
1506
+ // Convert handles back to sides for edge data
1507
+ const newFromSide = handleToSide(newConnection.sourceHandle, true);
1508
+ const newToSide = handleToSide(newConnection.targetHandle, false);
1509
+
1510
+ // Update local edges - manually update the edge to preserve its type and id
1511
+ setLocalEdges((prev) =>
1512
+ prev.map((edge) => {
1513
+ if (edge.id === oldEdge.id) {
1514
+ // Build data object, filtering out undefined values
1515
+ const updatedData = { ...edge.data };
1516
+ if (newFromSide !== undefined) updatedData.fromSide = newFromSide;
1517
+ if (newToSide !== undefined) updatedData.toSide = newToSide;
1518
+
1519
+ return {
1520
+ ...edge,
1521
+ from: newConnection.source!,
1522
+ to: newConnection.target!,
1523
+ sourceHandle: newConnection.sourceHandle ?? undefined,
1524
+ targetHandle: newConnection.targetHandle ?? undefined,
1525
+ data: updatedData,
1526
+ updatedAt: Date.now(),
1527
+ };
1528
+ }
1529
+ return edge;
1530
+ })
1531
+ );
1532
+
1533
+ // Also update the visual edge state directly to reflect the reconnection immediately
1534
+ setXyflowLocalEdges((prev) =>
1535
+ prev.map((edge) => {
1536
+ if (edge.id === oldEdge.id) {
1537
+ return {
1538
+ ...edge,
1539
+ source: newConnection.source!,
1540
+ target: newConnection.target!,
1541
+ sourceHandle: newConnection.sourceHandle ?? undefined,
1542
+ targetHandle: newConnection.targetHandle ?? undefined,
1543
+ };
1544
+ }
1545
+ return edge;
1546
+ })
1547
+ );
1548
+
1549
+ // Track the change - remove old edge and add new one
1550
+ updateEditState((prev) => {
1551
+ // Check if this was a newly created edge
1552
+ const createdEdgeIndex = prev.createdEdges.findIndex((e) => e.id === oldEdge.id);
1553
+
1554
+ if (createdEdgeIndex >= 0) {
1555
+ // Update the created edge entry
1556
+ const newCreatedEdges = [...prev.createdEdges];
1557
+ newCreatedEdges[createdEdgeIndex] = {
1558
+ ...newCreatedEdges[createdEdgeIndex],
1559
+ from: newConnection.source!,
1560
+ to: newConnection.target!,
1561
+ sourceHandle: newConnection.sourceHandle ?? undefined,
1562
+ targetHandle: newConnection.targetHandle ?? undefined,
1563
+ };
1564
+ return { ...prev, createdEdges: newCreatedEdges };
1565
+ }
1566
+
1567
+ // For existing edges, track as delete + create
1568
+ const newDeletedEdges = [
1569
+ ...prev.deletedEdges,
1570
+ {
1571
+ id: oldEdge.id,
1572
+ from: originalEdge.from,
1573
+ to: originalEdge.to,
1574
+ type: originalEdge.type,
1575
+ },
1576
+ ];
1577
+
1578
+ const newCreatedEdges = [
1579
+ ...prev.createdEdges,
1580
+ {
1581
+ id: oldEdge.id,
1582
+ from: newConnection.source!,
1583
+ to: newConnection.target!,
1584
+ type: originalEdge.type,
1585
+ sourceHandle: newConnection.sourceHandle ?? undefined,
1586
+ targetHandle: newConnection.targetHandle ?? undefined,
1587
+ },
1588
+ ];
1589
+
1590
+ return { ...prev, deletedEdges: newDeletedEdges, createdEdges: newCreatedEdges };
1591
+ });
1592
+ },
1593
+ [editable, localEdges, nodes, configuration.allowedConnections, updateEditState, handleToSide]
1594
+ );
1595
+
1596
+ // Called when reconnection ends (whether successful or not)
1597
+ const handleReconnectEnd = useCallback(() => {
1598
+ // If reconnection wasn't successful, the edge was dropped in empty space
1599
+ // We need to keep the original edge (do nothing, it's still in localEdges)
1600
+ // Edge is still in localEdges, no action needed - ReactFlow will re-render with it
1601
+ edgeReconnectSuccessful.current = true;
1602
+ }, []);
1603
+
1604
+ // ============================================
1605
+ // SELECTED ITEMS
1606
+ // ============================================
1607
+
1608
+ // Get first selected edge (for single-selection info panel)
1609
+
1610
+ // ============================================
1611
+ // ANIMATIONS
1612
+ // ============================================
1613
+
1614
+ useEffect(() => {
1615
+ if (events.length === 0) return;
1616
+
1617
+ const latestEvent = events[events.length - 1];
1618
+
1619
+ if (latestEvent.operation === 'animate' && latestEvent.category === 'edge') {
1620
+ const edgeEvent = latestEvent.payload as EdgeEvent;
1621
+ const edgeId = edgeEvent.edgeId;
1622
+ const animation = edgeEvent.animation;
1623
+
1624
+ if (animation && edgeId) {
1625
+ setAnimationState((prev) => ({
1626
+ ...prev,
1627
+ edgeAnimations: {
1628
+ ...prev.edgeAnimations,
1629
+ [edgeId]: {
1630
+ type: 'flow',
1631
+ duration: animation.duration || 1000,
1632
+ direction: animation.direction || 'forward',
1633
+ timestamp: Date.now(),
1634
+ },
1635
+ },
1636
+ }));
1637
+
1638
+ const duration = animation.duration || 1000;
1639
+ setTimeout(() => {
1640
+ setAnimationState((prev) => {
1641
+ const newEdgeAnimations = { ...prev.edgeAnimations };
1642
+ delete newEdgeAnimations[edgeId];
1643
+ return { ...prev, edgeAnimations: newEdgeAnimations };
1644
+ });
1645
+ }, duration);
1646
+
1647
+ onEventProcessed?.(latestEvent);
1648
+ }
1649
+ }
1650
+
1651
+ if (latestEvent.category === 'state') {
1652
+ const stateEvent = latestEvent.payload as StateEvent;
1653
+ const nodeId = stateEvent.nodeId;
1654
+ const newState = stateEvent.newState;
1655
+
1656
+ if (nodeId && newState) {
1657
+ // Update the node's state
1658
+ setLocalNodes((prev) =>
1659
+ prev.map((node) => (node.id === nodeId ? { ...node, state: newState } : node))
1660
+ );
1661
+
1662
+ // Trigger animation based on state
1663
+ const stateToAnimation: Record<string, 'pulse' | 'flash' | 'shake'> = {
1664
+ processing: 'pulse',
1665
+ completed: 'flash',
1666
+ error: 'shake',
1667
+ };
1668
+
1669
+ const animationType = stateToAnimation[newState];
1670
+ if (animationType) {
1671
+ const duration =
1672
+ animationType === 'pulse' ? 1500 : animationType === 'flash' ? 1000 : 500;
1673
+
1674
+ setAnimationState((prev) => ({
1675
+ ...prev,
1676
+ nodeAnimations: {
1677
+ ...prev.nodeAnimations,
1678
+ [nodeId]: { type: animationType, duration, timestamp: Date.now() },
1679
+ },
1680
+ }));
1681
+
1682
+ if (animationType !== 'pulse') {
1683
+ setTimeout(() => {
1684
+ setAnimationState((prev) => {
1685
+ const newNodeAnimations = { ...prev.nodeAnimations };
1686
+ delete newNodeAnimations[nodeId];
1687
+ return { ...prev, nodeAnimations: newNodeAnimations };
1688
+ });
1689
+ }, duration);
1690
+ }
1691
+ }
1692
+
1693
+ onEventProcessed?.(latestEvent);
1694
+ }
1695
+ }
1696
+
1697
+ if (latestEvent.category === 'node' && latestEvent.operation === 'create') {
1698
+ const nodeEvent = latestEvent.payload as NodeEvent;
1699
+ const nodeId = nodeEvent.nodeId;
1700
+
1701
+ if (nodeId) {
1702
+ setAnimationState((prev) => ({
1703
+ ...prev,
1704
+ nodeAnimations: {
1705
+ ...prev.nodeAnimations,
1706
+ [nodeId]: { type: 'entry', duration: 600, timestamp: Date.now() },
1707
+ },
1708
+ }));
1709
+
1710
+ setTimeout(() => {
1711
+ setAnimationState((prev) => {
1712
+ const newNodeAnimations = { ...prev.nodeAnimations };
1713
+ delete newNodeAnimations[nodeId];
1714
+ return { ...prev, nodeAnimations: newNodeAnimations };
1715
+ });
1716
+ }, 600);
1717
+
1718
+ onEventProcessed?.(latestEvent);
1719
+ }
1720
+ }
1721
+ }, [events, onEventProcessed]);
1722
+
1723
+ // ============================================
1724
+ // ============================================
1725
+
1726
+
1727
+ // ============================================
1728
+ // XYFLOW CONVERSION
1729
+ // ============================================
1730
+
1731
+ const xyflowNodesBase = useMemo(() => {
1732
+ const converted = convertToXYFlowNodes(localNodes, configuration, violations);
1733
+
1734
+ return converted.map((node) => {
1735
+ const animation = animationState.nodeAnimations[node.id];
1736
+ // Apply any pending position changes
1737
+ const pendingPosition = editStateRef.current.positionChanges.get(node.id);
1738
+ // Apply any pending text changes
1739
+ const pendingText = editStateRef.current.textChanges.get(node.id);
1740
+ // Allow specific nodes to be draggable even when not in edit mode
1741
+ const isDraggable = editable || draggableNodeIds?.has(node.id);
1742
+ // When draggableNodeIds is provided, we need to explicitly control each node's draggability
1743
+ // because nodesDraggable will be true to allow the specific nodes to drag
1744
+ const hasDraggableNodeIds = draggableNodeIds && draggableNodeIds.size > 0;
1745
+ // React Flow v12 requires measured dimensions for controlled drag to work
1746
+ const dataWidth = typeof node.data?.width === 'number' ? node.data.width : undefined;
1747
+ const dataHeight = typeof node.data?.height === 'number' ? node.data.height : undefined;
1748
+ const nodeWidth = node.width ?? dataWidth ?? 200;
1749
+ const nodeHeight = node.height ?? dataHeight ?? 100;
1750
+ return {
1751
+ ...node,
1752
+ ...(pendingPosition ? { position: pendingPosition } : {}),
1753
+ selected: selectedNodeIds.has(node.id),
1754
+ // Explicitly set draggable for each node when using draggableNodeIds
1755
+ draggable: hasDraggableNodeIds ? isDraggable : (editable || false),
1756
+ // Set measured dimensions for React Flow v12 controlled drag
1757
+ measured: { width: nodeWidth, height: nodeHeight },
1758
+ data: {
1759
+ ...node.data,
1760
+ editable,
1761
+ pendingText,
1762
+ tooltipsEnabled: showTooltips,
1763
+ shiftKeyPressed,
1764
+ isHighlighted: highlightedNodeId === node.id,
1765
+ isActive: !activeNodeIds || activeNodeIds.length === 0 || activeNodeIds.includes(node.id),
1766
+ isHidden: hiddenNodeIds.has(node.id),
1767
+ ...(animation
1768
+ ? {
1769
+ animationType: animation.type,
1770
+ animationDuration: animation.duration,
1771
+ }
1772
+ : {}),
1773
+ } as CustomNodeData,
1774
+ };
1775
+ });
1776
+ }, [localNodes, configuration, violations, animationState.nodeAnimations, editable, showTooltips, highlightedNodeId, activeNodeIds, editStateRef, shiftKeyPressed, selectedNodeIds, hiddenNodeIds, draggableNodeIds, textChangesVersion]);
1777
+
1778
+ const baseNodesKey = useMemo(() => {
1779
+ return nodes
1780
+ .map((n) => n.id)
1781
+ .sort()
1782
+ .join(',');
1783
+ }, [nodes]);
1784
+
1785
+ const baseEdgesKey = useMemo(() => {
1786
+ return edges
1787
+ .map((e) => e.id)
1788
+ .sort()
1789
+ .join(',');
1790
+ }, [edges]);
1791
+
1792
+ // Local xyflow nodes state for dragging
1793
+ const [xyflowLocalNodes, setXyflowLocalNodes] = useState<Node<CustomNodeData>[]>(xyflowNodesBase);
1794
+
1795
+ // Keep xyflowNodesRef in sync for undo/redo access to current node state
1796
+ useEffect(() => {
1797
+ xyflowNodesRef.current = xyflowLocalNodes;
1798
+ }, [xyflowLocalNodes]);
1799
+
1800
+ // Sync when base node IDs change
1801
+ const prevBaseNodesKeyRef = useRef(baseNodesKey);
1802
+ useEffect(() => {
1803
+ if (prevBaseNodesKeyRef.current !== baseNodesKey) {
1804
+ prevBaseNodesKeyRef.current = baseNodesKey;
1805
+ setXyflowLocalNodes(xyflowNodesBase);
1806
+ }
1807
+ }, [baseNodesKey, xyflowNodesBase]);
1808
+
1809
+ // Sync selection state to local nodes when selectedNodeIds changes (edit mode only)
1810
+ // Use useLayoutEffect to ensure sync happens before paint (prevents flash)
1811
+ const prevSelectedNodeIdsRef = useRef(selectedNodeIds);
1812
+ useLayoutEffect(() => {
1813
+ if (!editable) return;
1814
+ if (prevSelectedNodeIdsRef.current === selectedNodeIds) return;
1815
+ prevSelectedNodeIdsRef.current = selectedNodeIds;
1816
+
1817
+ setXyflowLocalNodes((localNodes) =>
1818
+ localNodes.map((localNode) => ({
1819
+ ...localNode,
1820
+ selected: selectedNodeIds.has(localNode.id),
1821
+ }))
1822
+ );
1823
+ }, [editable, selectedNodeIds]);
1824
+
1825
+ // Sync hidden state to local nodes when hiddenNodeIds changes (edit mode only)
1826
+ // This ensures isHidden is in sync when we switch back to xyflowNodesBase on exit
1827
+ const prevHiddenNodeIdsRef = useRef(hiddenNodeIds);
1828
+ useEffect(() => {
1829
+ if (!editable) return;
1830
+ if (prevHiddenNodeIdsRef.current === hiddenNodeIds) return;
1831
+ prevHiddenNodeIdsRef.current = hiddenNodeIds;
1832
+
1833
+ setXyflowLocalNodes((nodes) =>
1834
+ nodes.map((n) => ({
1835
+ ...n,
1836
+ data: { ...n.data, isHidden: hiddenNodeIds.has(n.id) },
1837
+ }))
1838
+ );
1839
+ }, [editable, hiddenNodeIds]);
1840
+
1841
+ // Sync pending text changes to local nodes when textChangesVersion changes (edit mode only)
1842
+ const prevTextChangesVersionRef = useRef(textChangesVersion);
1843
+ useEffect(() => {
1844
+ if (!editable) return;
1845
+ if (prevTextChangesVersionRef.current === textChangesVersion) return;
1846
+ prevTextChangesVersionRef.current = textChangesVersion;
1847
+
1848
+ setXyflowLocalNodes((nodes) =>
1849
+ nodes.map((n) => {
1850
+ const pendingText = editStateRef.current.textChanges.get(n.id);
1851
+ return {
1852
+ ...n,
1853
+ data: { ...n.data, pendingText },
1854
+ };
1855
+ })
1856
+ );
1857
+ }, [editable, textChangesVersion]);
1858
+
1859
+ // Also sync when entering edit mode or when base nodes change content
1860
+ const prevEditableRef = useRef(editable);
1861
+ useEffect(() => {
1862
+ if (editable && !prevEditableRef.current) {
1863
+ // Entering edit mode - sync positions
1864
+ setXyflowLocalNodes(xyflowNodesBase);
1865
+
1866
+ // Reset ReactFlow's internal state for all nodes to prevent NaN errors
1867
+ // This ensures ReactFlow remeasures nodes and updates drag tracking
1868
+ setTimeout(() => {
1869
+ xyflowNodesBase.forEach((node) => {
1870
+ updateNodeInternals(node.id);
1871
+ });
1872
+ }, 0);
1873
+ }
1874
+ prevEditableRef.current = editable;
1875
+ }, [editable, xyflowNodesBase, updateNodeInternals]);
1876
+
1877
+ // Use local nodes state when editable OR when we have draggable nodes (for drag visual updates)
1878
+ const hasDraggableNodes = draggableNodeIds && draggableNodeIds.size > 0;
1879
+ const xyflowNodes = (editable || hasDraggableNodes) ? xyflowLocalNodes : xyflowNodesBase;
1880
+
1881
+ // Sync xyflowLocalNodes from xyflowNodesBase when draggable nodes are present
1882
+ // This ensures React Flow has the correct node state for drag calculations
1883
+ const prevHasDraggableNodesRef = useRef(false);
1884
+ useEffect(() => {
1885
+ const hadDraggableNodes = prevHasDraggableNodesRef.current;
1886
+ prevHasDraggableNodesRef.current = hasDraggableNodes || false;
1887
+
1888
+ // When draggable nodes first appear, sync local nodes and update internals
1889
+ if (hasDraggableNodes && !hadDraggableNodes) {
1890
+ setXyflowLocalNodes(xyflowNodesBase);
1891
+
1892
+ // Give React Flow time to render, then update internals
1893
+ setTimeout(() => {
1894
+ draggableNodeIds?.forEach((nodeId) => {
1895
+ updateNodeInternals(nodeId);
1896
+ });
1897
+ }, 50);
1898
+ }
1899
+ }, [hasDraggableNodes, xyflowNodesBase, draggableNodeIds, updateNodeInternals]);
1900
+
1901
+
1902
+ // Handle node drag to show alignment guides
1903
+ const handleNodeDrag = useCallback(
1904
+ (_event: MouseEvent | TouchEvent, node: Node) => {
1905
+ // Allow dragging if editable OR if this node is in draggableNodeIds
1906
+ const canDrag = editable || draggableNodeIds?.has(node.id);
1907
+ if (!canDrag) return;
1908
+
1909
+ // Only show alignment guides in full edit mode
1910
+ if (editable) {
1911
+ const guides = detectAlignmentGuides(node.id, xyflowNodes);
1912
+ setAlignmentGuides(guides);
1913
+ }
1914
+ },
1915
+ [editable, draggableNodeIds, xyflowNodes, detectAlignmentGuides]
1916
+ );
1917
+
1918
+ // Clear guides when drag ends and notify parent
1919
+ const handleNodeDragStop = useCallback(
1920
+ (_event: MouseEvent | TouchEvent, node: Node) => {
1921
+ setAlignmentGuides([]);
1922
+
1923
+ // Call the callback if provided (for draggable nodes outside edit mode)
1924
+ if (onNodeDragStopProp && node.position) {
1925
+ onNodeDragStopProp(node.id, node.position);
1926
+ }
1927
+ },
1928
+ [onNodeDragStopProp]
1929
+ );
1930
+
1931
+ // ============================================
1932
+ // UNDO/REDO APPLY FUNCTIONS
1933
+ // ============================================
1934
+
1935
+ // Apply undo - reverse the effects of history entries
1936
+ const applyUndo = useCallback(() => {
1937
+ const batch = undoFromStack();
1938
+ if (!batch) return;
1939
+
1940
+ // Process entries in reverse order for undo
1941
+ for (const entry of batch.slice().reverse()) {
1942
+ switch (entry.type) {
1943
+ case 'position':
1944
+ // Restore previous position
1945
+ setXyflowLocalNodes((nodes) =>
1946
+ nodes.map((n) =>
1947
+ n.id === entry.nodeId
1948
+ ? { ...n, position: entry.before }
1949
+ : n
1950
+ )
1951
+ );
1952
+ // Update edit state
1953
+ updateEditState((prev) => {
1954
+ const newPositions = new Map(prev.positionChanges);
1955
+ newPositions.set(entry.nodeId, entry.before);
1956
+ return { ...prev, positionChanges: newPositions };
1957
+ });
1958
+ break;
1959
+
1960
+ case 'dimension':
1961
+ // Restore previous dimensions
1962
+ setXyflowLocalNodes((nodes) =>
1963
+ nodes.map((n) =>
1964
+ n.id === entry.nodeId
1965
+ ? { ...n, width: entry.before.width, height: entry.before.height }
1966
+ : n
1967
+ )
1968
+ );
1969
+ // Update edit state
1970
+ updateEditState((prev) => {
1971
+ const newDimensions = new Map(prev.dimensionChanges);
1972
+ newDimensions.set(entry.nodeId, entry.before);
1973
+ return { ...prev, dimensionChanges: newDimensions };
1974
+ });
1975
+ break;
1976
+
1977
+ case 'text':
1978
+ // Restore previous text
1979
+ // Update edit state
1980
+ updateEditState((prev) => {
1981
+ const newTextChanges = new Map(prev.textChanges);
1982
+ newTextChanges.set(entry.nodeId, entry.before);
1983
+ return { ...prev, textChanges: newTextChanges };
1984
+ });
1985
+ // Force re-render
1986
+ setTextChangesVersion((v) => v + 1);
1987
+ break;
1988
+
1989
+ case 'nodeDelete':
1990
+ // Restore the deleted node
1991
+ updateEditState((prev) => {
1992
+ const newDeletedNodeIds = new Set(prev.deletedNodeIds);
1993
+ newDeletedNodeIds.delete(entry.nodeId);
1994
+ return { ...prev, deletedNodeIds: newDeletedNodeIds };
1995
+ });
1996
+ break;
1997
+
1998
+ case 'edgeCreate':
1999
+ // Remove the created edge
2000
+ setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
2001
+ // Update edit state
2002
+ updateEditState((prev) => ({
2003
+ ...prev,
2004
+ createdEdges: prev.createdEdges.filter((e) => e.id !== entry.edge.id),
2005
+ }));
2006
+ break;
2007
+
2008
+ case 'edgeDelete':
2009
+ // Restore the deleted edge
2010
+ setLocalEdges((edges) => [...edges, entry.edge]);
2011
+ // Update edit state
2012
+ updateEditState((prev) => ({
2013
+ ...prev,
2014
+ deletedEdges: prev.deletedEdges.filter((e) => e.id !== entry.edge.id),
2015
+ }));
2016
+ break;
2017
+ }
2018
+ }
2019
+ }, [undoFromStack, updateEditState]);
2020
+
2021
+ // Apply redo - re-apply the effects of history entries
2022
+ const applyRedo = useCallback(() => {
2023
+ const batch = redoFromStack();
2024
+ if (!batch) return;
2025
+
2026
+ // Process entries in original order for redo
2027
+ for (const entry of batch) {
2028
+ switch (entry.type) {
2029
+ case 'position':
2030
+ // Apply new position
2031
+ setXyflowLocalNodes((nodes) =>
2032
+ nodes.map((n) =>
2033
+ n.id === entry.nodeId
2034
+ ? { ...n, position: entry.after }
2035
+ : n
2036
+ )
2037
+ );
2038
+ // Update edit state
2039
+ updateEditState((prev) => {
2040
+ const newPositions = new Map(prev.positionChanges);
2041
+ newPositions.set(entry.nodeId, entry.after);
2042
+ return { ...prev, positionChanges: newPositions };
2043
+ });
2044
+ break;
2045
+
2046
+ case 'dimension':
2047
+ // Apply new dimensions
2048
+ setXyflowLocalNodes((nodes) =>
2049
+ nodes.map((n) =>
2050
+ n.id === entry.nodeId
2051
+ ? { ...n, width: entry.after.width, height: entry.after.height }
2052
+ : n
2053
+ )
2054
+ );
2055
+ // Update edit state
2056
+ updateEditState((prev) => {
2057
+ const newDimensions = new Map(prev.dimensionChanges);
2058
+ newDimensions.set(entry.nodeId, entry.after);
2059
+ return { ...prev, dimensionChanges: newDimensions };
2060
+ });
2061
+ break;
2062
+
2063
+ case 'text':
2064
+ // Apply new text
2065
+ // Update edit state
2066
+ updateEditState((prev) => {
2067
+ const newTextChanges = new Map(prev.textChanges);
2068
+ newTextChanges.set(entry.nodeId, entry.after);
2069
+ return { ...prev, textChanges: newTextChanges };
2070
+ });
2071
+ // Force re-render
2072
+ setTextChangesVersion((v) => v + 1);
2073
+ break;
2074
+
2075
+ case 'nodeDelete':
2076
+ // Re-delete the node
2077
+ updateEditState((prev) => {
2078
+ const newDeletedNodeIds = new Set(prev.deletedNodeIds);
2079
+ newDeletedNodeIds.add(entry.nodeId);
2080
+ return { ...prev, deletedNodeIds: newDeletedNodeIds };
2081
+ });
2082
+ break;
2083
+
2084
+ case 'edgeCreate':
2085
+ // Re-create the edge
2086
+ setLocalEdges((edges) => [...edges, entry.edge]);
2087
+ // Update edit state
2088
+ updateEditState((prev) => ({
2089
+ ...prev,
2090
+ createdEdges: [
2091
+ ...prev.createdEdges,
2092
+ {
2093
+ id: entry.edge.id,
2094
+ from: entry.edge.from,
2095
+ to: entry.edge.to,
2096
+ type: entry.edge.type,
2097
+ sourceHandle: entry.edge.sourceHandle,
2098
+ targetHandle: entry.edge.targetHandle,
2099
+ },
2100
+ ],
2101
+ }));
2102
+ break;
2103
+
2104
+ case 'edgeDelete':
2105
+ // Re-delete the edge
2106
+ setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
2107
+ // Update edit state
2108
+ updateEditState((prev) => ({
2109
+ ...prev,
2110
+ deletedEdges: [
2111
+ ...prev.deletedEdges,
2112
+ {
2113
+ id: entry.edge.id,
2114
+ from: entry.edge.from,
2115
+ to: entry.edge.to,
2116
+ type: entry.edge.type,
2117
+ },
2118
+ ],
2119
+ }));
2120
+ break;
2121
+ }
2122
+ }
2123
+ }, [redoFromStack, updateEditState]);
2124
+
2125
+ // Keyboard shortcuts for undo/redo and delete
2126
+ useEffect(() => {
2127
+ if (!editable) return;
2128
+
2129
+ const handleKeyDown = (e: KeyboardEvent) => {
2130
+ // Cmd+Z (Mac) or Ctrl+Z (Windows/Linux) for undo
2131
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
2132
+ e.preventDefault();
2133
+ if (e.shiftKey) {
2134
+ // Cmd+Shift+Z for redo
2135
+ applyRedo();
2136
+ } else {
2137
+ applyUndo();
2138
+ }
2139
+ }
2140
+ // Ctrl+Y for redo (Windows)
2141
+ if (e.ctrlKey && e.key === 'y') {
2142
+ e.preventDefault();
2143
+ applyRedo();
2144
+ }
2145
+ // Delete or Backspace to delete selected nodes
2146
+ if (e.key === 'Delete' || e.key === 'Backspace') {
2147
+ // Only delete if not editing text (check if active element is an input/textarea)
2148
+ const activeElement = document.activeElement;
2149
+ const isEditingText = activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA';
2150
+ if (!isEditingText) {
2151
+ e.preventDefault();
2152
+ handleDeleteSelectedNodes();
2153
+ }
2154
+ }
2155
+ };
2156
+
2157
+ window.addEventListener('keydown', handleKeyDown);
2158
+ return () => window.removeEventListener('keydown', handleKeyDown);
2159
+ }, [editable, applyUndo, applyRedo, handleDeleteSelectedNodes]);
2160
+
2161
+ // Set undo/redo functions in ref for outer component access
2162
+ useEffect(() => {
2163
+ undoRedoFunctionsRef.current = {
2164
+ applyUndo,
2165
+ applyRedo,
2166
+ };
2167
+ }, [applyUndo, applyRedo, undoRedoFunctionsRef]);
2168
+
2169
+ // Handle node changes (drag and resize events)
2170
+ const handleNodesChange = useCallback(
2171
+ (changes: NodeChange[]) => {
2172
+ const hasDraggableNodes = draggableNodeIds && draggableNodeIds.size > 0;
2173
+
2174
+ // In edit mode, apply all changes
2175
+ // When we have draggable nodes, apply position changes for those nodes
2176
+ if (!editable && !hasDraggableNodes) return;
2177
+
2178
+ // Filter out selection changes - we manage selection ourselves via selectedNodeIds
2179
+ let changesToApply = changes.filter((change) => change.type !== 'select');
2180
+
2181
+ // When not in full edit mode but have draggable nodes,
2182
+ // only apply position changes for draggable nodes
2183
+ if (!editable && hasDraggableNodes) {
2184
+ changesToApply = changesToApply.filter(
2185
+ (change) =>
2186
+ change.type === 'position' &&
2187
+ 'id' in change &&
2188
+ draggableNodeIds.has(change.id)
2189
+ );
2190
+ }
2191
+
2192
+ // Only apply changes if there are changes to apply
2193
+ if (changesToApply.length > 0) {
2194
+ setXyflowLocalNodes((nds) => {
2195
+ // For group nodes (ending in :__group__), also move child nodes with same prefix
2196
+ const groupPositionChanges = changesToApply.filter(
2197
+ (change): change is NodeChange & { type: 'position'; id: string; position: { x: number; y: number } } =>
2198
+ change.type === 'position' &&
2199
+ 'id' in change &&
2200
+ typeof change.id === 'string' &&
2201
+ change.id.endsWith(':__group__') &&
2202
+ 'position' in change &&
2203
+ change.position !== undefined
2204
+ );
2205
+
2206
+ // Calculate deltas for each group
2207
+ const groupDeltas = new Map<string, { dx: number; dy: number }>();
2208
+ for (const change of groupPositionChanges) {
2209
+ const canvasPrefix = change.id.replace(':__group__', ':');
2210
+ const currentNode = nds.find((n) => n.id === change.id);
2211
+ if (currentNode && change.position) {
2212
+ const dx = change.position.x - currentNode.position.x;
2213
+ const dy = change.position.y - currentNode.position.y;
2214
+ groupDeltas.set(canvasPrefix, { dx, dy });
2215
+ }
2216
+ }
2217
+
2218
+ // Apply changes but preserve our selection state
2219
+ let updated = applyNodeChanges(changesToApply, nds) as Node<CustomNodeData>[];
2220
+
2221
+ // Also apply deltas to child nodes of moved groups
2222
+ if (groupDeltas.size > 0) {
2223
+ updated = updated.map((node) => {
2224
+ // Check if this node belongs to a moved group (but isn't the group itself)
2225
+ for (const [prefix, delta] of groupDeltas) {
2226
+ if (node.id.startsWith(prefix) && !node.id.endsWith(':__group__')) {
2227
+ return {
2228
+ ...node,
2229
+ position: {
2230
+ x: node.position.x + delta.dx,
2231
+ y: node.position.y + delta.dy,
2232
+ },
2233
+ };
2234
+ }
2235
+ }
2236
+ return node;
2237
+ });
2238
+ }
2239
+
2240
+ // Restore selection state from our managed selectedNodeIds
2241
+ return updated.map((node) => ({
2242
+ ...node,
2243
+ selected: selectedNodeIds.has(node.id),
2244
+ }));
2245
+ });
2246
+ }
2247
+
2248
+ // Capture positions at drag START for accurate undo "before" values
2249
+ const dragStartChanges = changes.filter(
2250
+ (change): change is NodeChange & {
2251
+ type: 'position';
2252
+ id: string;
2253
+ dragging: boolean;
2254
+ } =>
2255
+ change.type === 'position' &&
2256
+ 'id' in change &&
2257
+ 'dragging' in change &&
2258
+ change.dragging === true
2259
+ );
2260
+
2261
+ // For each node that just started dragging, capture its current position
2262
+ for (const change of dragStartChanges) {
2263
+ if (!dragStartPositionsRef.current.has(change.id)) {
2264
+ const currentNode = xyflowNodesRef.current.find((n) => n.id === change.id);
2265
+ if (currentNode) {
2266
+ dragStartPositionsRef.current.set(change.id, {
2267
+ x: currentNode.position.x,
2268
+ y: currentNode.position.y,
2269
+ });
2270
+ }
2271
+ }
2272
+ }
2273
+
2274
+ // Track position changes on drag END
2275
+ const positionChanges = changes.filter(
2276
+ (
2277
+ change
2278
+ ): change is NodeChange & {
2279
+ type: 'position';
2280
+ id: string;
2281
+ position: { x: number; y: number };
2282
+ dragging: boolean;
2283
+ } =>
2284
+ change.type === 'position' &&
2285
+ 'id' in change &&
2286
+ 'position' in change &&
2287
+ change.position !== undefined &&
2288
+ 'dragging' in change &&
2289
+ change.dragging === false
2290
+ );
2291
+
2292
+ // Note: Dimension changes are tracked via onResizeEnd callback in GraphEditContext,
2293
+ // not through onNodesChange (which only fires with resizing=true during drag).
2294
+
2295
+ if (positionChanges.length > 0) {
2296
+ // Use positions captured at drag start for accurate undo
2297
+ const historyEntries: HistoryEntry[] = [];
2298
+ for (const change of positionChanges) {
2299
+ const beforePos = dragStartPositionsRef.current.get(change.id) ?? { x: 0, y: 0 };
2300
+ historyEntries.push({
2301
+ type: 'position',
2302
+ nodeId: change.id,
2303
+ before: beforePos,
2304
+ after: {
2305
+ x: Math.round(change.position.x),
2306
+ y: Math.round(change.position.y),
2307
+ },
2308
+ });
2309
+ // Clear the drag start position for this node
2310
+ dragStartPositionsRef.current.delete(change.id);
2311
+ }
2312
+ pushHistory(historyEntries);
2313
+
2314
+ updateEditState((prev) => {
2315
+ const newPositions = new Map(prev.positionChanges);
2316
+ for (const change of positionChanges) {
2317
+ newPositions.set(change.id, {
2318
+ x: Math.round(change.position.x),
2319
+ y: Math.round(change.position.y),
2320
+ });
2321
+ }
2322
+ return { ...prev, positionChanges: newPositions };
2323
+ });
2324
+ }
2325
+ },
2326
+ [editable, updateEditState, selectedNodeIds, draggableNodeIds, pushHistory]
2327
+ );
2328
+
2329
+ // Build lookup map for scenario edge sequence numbers
2330
+ const scenarioEdgeSequenceMap = useMemo(() => {
2331
+ const map = new Map<string, number>();
2332
+ if (scenarioEdges && showSequenceLabels) {
2333
+ for (const se of scenarioEdges) {
2334
+ map.set(`${se.fromSpan}->${se.toSpan}`, se.sequenceNumber);
2335
+ }
2336
+ }
2337
+ return map;
2338
+ }, [scenarioEdges, showSequenceLabels]);
2339
+
2340
+ const xyflowEdgesBase = useMemo(() => {
2341
+ const converted = convertToXYFlowEdges(edges, configuration, violations);
2342
+
2343
+ // Filter out edges connected to inactive or hidden nodes
2344
+ const filtered = converted.filter(edge => {
2345
+ // Filter by hidden nodes (shift-clicked)
2346
+ const sourceHidden = hiddenNodeIds.has(edge.source);
2347
+ const targetHidden = hiddenNodeIds.has(edge.target);
2348
+ if (sourceHidden || targetHidden) {
2349
+ return false;
2350
+ }
2351
+
2352
+ // Filter by active nodes (scenario playback)
2353
+ if (activeNodeIds && activeNodeIds.length > 0) {
2354
+ const sourceActive = activeNodeIds.includes(edge.source);
2355
+ const targetActive = activeNodeIds.includes(edge.target);
2356
+ return sourceActive && targetActive;
2357
+ }
2358
+
2359
+ return true;
2360
+ });
2361
+
2362
+ const mappedEdges = filtered.map((edge) => {
2363
+ const animation = animationState.edgeAnimations[edge.id];
2364
+ const isSelected = selectedEdgeIds.has(edge.id);
2365
+
2366
+ // Look up sequence number from scenario edges
2367
+ const edgeKey = `${edge.source}->${edge.target}`;
2368
+ const sequenceNumber = scenarioEdgeSequenceMap.get(edgeKey);
2369
+
2370
+ return {
2371
+ ...edge,
2372
+ data: {
2373
+ ...edge.data,
2374
+ // Inject sequence number into edge data for label rendering
2375
+ ...(sequenceNumber !== undefined
2376
+ ? {
2377
+ data: {
2378
+ ...(edge.data?.data as Record<string, unknown> | undefined),
2379
+ sequenceNumber,
2380
+ },
2381
+ // Configure label to show sequence number
2382
+ typeDefinition: {
2383
+ ...edge.data?.typeDefinition,
2384
+ label: {
2385
+ field: 'sequenceNumber',
2386
+ position: 'middle' as const,
2387
+ },
2388
+ },
2389
+ }
2390
+ : {}),
2391
+ tooltipsEnabled: showTooltips,
2392
+ shiftKeyPressed,
2393
+ ...(animation
2394
+ ? {
2395
+ animationType: animation.type,
2396
+ animationDuration: animation.duration,
2397
+ animationDirection: animation.direction,
2398
+ }
2399
+ : {}),
2400
+ } as CustomEdgeData,
2401
+ // Preserve edge zIndex from converter (50 = above groups, below nodes)
2402
+ // Selected edges get elevated to 1000
2403
+ zIndex: isSelected ? 1000 : edge.zIndex,
2404
+ };
2405
+ });
2406
+
2407
+ // Sort edges so selected ones appear last (on top in SVG)
2408
+ return mappedEdges.sort((a, b) => {
2409
+ const aSelected = selectedEdgeIds.has(a.id);
2410
+ const bSelected = selectedEdgeIds.has(b.id);
2411
+ if (aSelected && !bSelected) return 1; // a comes after b (rendered on top)
2412
+ if (!aSelected && bSelected) return -1; // b comes after a (rendered on top)
2413
+ return 0; // maintain original order
2414
+ });
2415
+ }, [edges, configuration, violations, animationState.edgeAnimations, showTooltips, selectedEdgeIds, shiftKeyPressed, activeNodeIds, hiddenNodeIds, scenarioEdgeSequenceMap]);
2416
+
2417
+ // ELK layout for circuit-board style edge routing
2418
+ const { edgePaths: elkEdgePaths, edgeLabelPositions: elkLabelPositions } = useElkLayout(
2419
+ xyflowNodesBase,
2420
+ xyflowEdgesBase,
2421
+ {
2422
+ enabled: elkLayout?.enabled ?? false,
2423
+ routingStyle: elkLayout?.routingStyle ?? 'orthogonal',
2424
+ edgeSpacing: elkLayout?.edgeSpacing ?? 5,
2425
+ edgeNodeSpacing: elkLayout?.edgeNodeSpacing ?? 10,
2426
+ preserveNodePositions: true,
2427
+ }
2428
+ );
2429
+
2430
+ // Apply ELK paths to edges when ELK layout is enabled
2431
+ const xyflowEdgesWithElk = useMemo(() => {
2432
+ if (!elkLayout?.enabled) {
2433
+ return xyflowEdgesBase;
2434
+ }
2435
+ // Hide edges until ELK has computed paths to prevent flash of default edge routing
2436
+ if (elkEdgePaths.size === 0) {
2437
+ return [];
2438
+ }
2439
+ return applyElkPathsToEdges(xyflowEdgesBase, elkEdgePaths, elkLabelPositions);
2440
+ }, [elkLayout?.enabled, xyflowEdgesBase, elkEdgePaths, elkLabelPositions]);
2441
+
2442
+ // Local xyflow edges state for reconnection
2443
+ const [xyflowLocalEdges, setXyflowLocalEdges] = useState<Edge<CustomEdgeData>[]>(xyflowEdgesWithElk);
2444
+
2445
+ // Create a stable key for ELK paths to detect when they actually change
2446
+ const elkPathsKey = useMemo(() => {
2447
+ if (!elkLayout?.enabled || elkEdgePaths.size === 0) return '';
2448
+ // Create a key from edge IDs and their full paths
2449
+ return Array.from(elkEdgePaths.entries())
2450
+ .map(([id, path]) => `${id}:${path}`)
2451
+ .join('|');
2452
+ }, [elkLayout?.enabled, elkEdgePaths]);
2453
+
2454
+ // Sync local edges when base edges or ELK paths change
2455
+ const prevSyncKeyRef = useRef({ baseEdgesKey, elkPathsKey });
2456
+ useEffect(() => {
2457
+ const keyChanged =
2458
+ prevSyncKeyRef.current.baseEdgesKey !== baseEdgesKey ||
2459
+ prevSyncKeyRef.current.elkPathsKey !== elkPathsKey;
2460
+
2461
+ if (keyChanged && xyflowEdgesWithElk.length > 0) {
2462
+ prevSyncKeyRef.current = { baseEdgesKey, elkPathsKey };
2463
+ setXyflowLocalEdges(xyflowEdgesWithElk);
2464
+ }
2465
+ }, [baseEdgesKey, elkPathsKey, xyflowEdgesWithElk]);
2466
+
2467
+ // Set the reset visual state function for use by resetEditState
2468
+ // This resets both nodes and edges to their original state
2469
+ useEffect(() => {
2470
+ resetVisualStateRef.current = () => {
2471
+ setXyflowLocalNodes(xyflowNodesBase);
2472
+ setXyflowLocalEdges(xyflowEdgesWithElk);
2473
+ // Notify parent that changes have been cleared
2474
+ onPendingChangesChange?.(false);
2475
+ };
2476
+ }, [xyflowNodesBase, xyflowEdgesWithElk, onPendingChangesChange]);
2477
+
2478
+ // Set the reset text changes version function for use by resetEditState
2479
+ useEffect(() => {
2480
+ resetTextChangesVersionRef.current = () => {
2481
+ setTextChangesVersion(0);
2482
+ };
2483
+ }, []);
2484
+
2485
+ // Use local edges in edit mode, base edges otherwise
2486
+ const xyflowEdges = editable ? xyflowLocalEdges : xyflowEdgesWithElk;
2487
+
2488
+ // Handle edge changes (selection, reconnection, etc.)
2489
+ const handleEdgesChange = useCallback(
2490
+ (changes: EdgeChange[]) => {
2491
+ if (!editable) return;
2492
+
2493
+ setXyflowLocalEdges((eds) => applyEdgeChanges(changes, eds) as Edge<CustomEdgeData>[]);
2494
+ },
2495
+ [editable]
2496
+ );
2497
+
2498
+ // Fit view on mount and structure changes
2499
+ useEffect(() => {
2500
+ // Skip default fitView if we have specific nodes to fit to
2501
+ if (fitViewToNodeIds && fitViewToNodeIds.length > 0) {
2502
+ return;
2503
+ }
2504
+
2505
+ // Skip delayed fitView if we have a pre-calculated initial viewport
2506
+ if (initialViewport) {
2507
+ return;
2508
+ }
2509
+
2510
+ const timeoutId = setTimeout(() => {
2511
+ fitView({
2512
+ padding: 0.2,
2513
+ includeHiddenNodes: false,
2514
+ minZoom: 0.1,
2515
+ maxZoom: 1.5,
2516
+ duration: fitViewDuration,
2517
+ });
2518
+ }, 100);
2519
+
2520
+ return () => clearTimeout(timeoutId);
2521
+ }, [baseNodesKey, baseEdgesKey, fitView, fitViewDuration, fitViewToNodeIds, initialViewport]);
2522
+
2523
+ // Create a stable key from fitViewToNodeIds for dependency tracking
2524
+ const fitViewToNodeIdsKey = useMemo(
2525
+ () => (fitViewToNodeIds && fitViewToNodeIds.length > 0 ? fitViewToNodeIds.slice().sort().join(',') : ''),
2526
+ [fitViewToNodeIds]
2527
+ );
2528
+
2529
+ // Fit view to specific nodes when fitViewToNodeIds changes
2530
+ useEffect(() => {
2531
+ if (!fitViewToNodeIdsKey || !fitViewToNodeIds || fitViewToNodeIds.length === 0) {
2532
+ return;
2533
+ }
2534
+
2535
+ // Use a longer timeout to ensure nodes are rendered and measured
2536
+ const timeoutId = setTimeout(() => {
2537
+ // Get actual node objects from React Flow's internal state
2538
+ const allNodes = getNodes();
2539
+ const nodeIdsSet = new Set(fitViewToNodeIds);
2540
+ const nodesToFit = allNodes.filter((node) => nodeIdsSet.has(node.id));
2541
+
2542
+ if (nodesToFit.length > 0) {
2543
+ // Calculate bounding box from node positions
2544
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2545
+
2546
+ for (const node of nodesToFit) {
2547
+ const width = node.measured?.width ?? node.width ?? 200;
2548
+ const height = node.measured?.height ?? node.height ?? 100;
2549
+
2550
+ minX = Math.min(minX, node.position.x);
2551
+ minY = Math.min(minY, node.position.y);
2552
+ maxX = Math.max(maxX, node.position.x + width);
2553
+ maxY = Math.max(maxY, node.position.y + height);
2554
+ }
2555
+
2556
+ const bounds = {
2557
+ x: minX,
2558
+ y: minY,
2559
+ width: maxX - minX,
2560
+ height: maxY - minY,
2561
+ };
2562
+
2563
+ fitBounds(bounds, {
2564
+ padding: fitViewPadding,
2565
+ duration: fitViewDuration,
2566
+ });
2567
+ }
2568
+ }, 150);
2569
+
2570
+ return () => clearTimeout(timeoutId);
2571
+ }, [fitViewToNodeIdsKey, fitViewToNodeIds, fitViewPadding, fitView, fitViewDuration, getNodes, fitBounds]);
2572
+
2573
+ // ============================================
2574
+ // RENDER
2575
+ // ============================================
2576
+
2577
+ return (
2578
+ <GraphEditProvider value={graphEditContextValue}>
2579
+ <ReactFlow
2580
+ key={`${baseNodesKey}-${baseEdgesKey}`}
2581
+ nodes={xyflowNodes}
2582
+ edges={xyflowEdges}
2583
+ nodeTypes={nodeTypes}
2584
+ edgeTypes={edgeTypes}
2585
+ minZoom={0.1}
2586
+ maxZoom={4}
2587
+ defaultViewport={initialViewport}
2588
+ defaultEdgeOptions={{ type: 'custom' }}
2589
+ onEdgeClick={onEdgeClick}
2590
+ onNodeClick={onNodeClick}
2591
+ onNodeDoubleClick={onNodeDoubleClick}
2592
+ onNodeDrag={handleNodeDrag}
2593
+ onNodeDragStop={handleNodeDragStop}
2594
+ proOptions={{ hideAttribution: true }}
2595
+ nodesDraggable={editable || (draggableNodeIds && draggableNodeIds.size > 0)}
2596
+ elementsSelectable={editable}
2597
+ selectNodesOnDrag={false}
2598
+ nodesConnectable={editable}
2599
+ edgesReconnectable={editable}
2600
+ reconnectRadius={25}
2601
+ elevateEdgesOnSelect={true}
2602
+ onNodesChange={handleNodesChange}
2603
+ onEdgesChange={handleEdgesChange}
2604
+ onConnect={handleConnect}
2605
+ onReconnectStart={handleReconnectStart}
2606
+ onReconnect={handleReconnect}
2607
+ onReconnectEnd={handleReconnectEnd}
2608
+ onPaneClick={onPaneClick}
2609
+ onSelectionChange={handleSelectionChange}
2610
+ panOnDrag={!editable}
2611
+ panOnScroll={true}
2612
+ zoomOnScroll={false}
2613
+ zoomOnPinch={true}
2614
+ zoomOnDoubleClick={false}
2615
+ selectionOnDrag={false}
2616
+ selectionKeyCode={null}
2617
+ multiSelectionKeyCode="Shift"
2618
+ >
2619
+ {showBackground && (
2620
+ <Background
2621
+ color={backgroundVariant === 'dots' ? theme.colors.border : theme.colors.textMuted}
2622
+ gap={backgroundGap ?? (backgroundVariant === 'dots' ? 16 : 50)}
2623
+ size={backgroundVariant === 'dots' ? 1 : 0.5}
2624
+ variant={
2625
+ backgroundVariant === 'dots'
2626
+ ? BackgroundVariant.Dots
2627
+ : backgroundVariant === 'lines'
2628
+ ? BackgroundVariant.Lines
2629
+ : BackgroundVariant.Cross
2630
+ }
2631
+ />
2632
+ )}
2633
+ {showControls && <Controls showZoom showFitView showInteractive />}
2634
+ {showMinimap && (
2635
+ <MiniMap
2636
+ nodeColor={(node) => {
2637
+ const nodeData = node.data as CustomNodeData;
2638
+ return nodeData?.typeDefinition?.color || theme.colors.secondary;
2639
+ }}
2640
+ nodeBorderRadius={2}
2641
+ pannable
2642
+ zoomable
2643
+ />
2644
+ )}
2645
+ {showCenterIndicator && <CenterIndicator color={theme.colors.textMuted} />}
2646
+ {editable && alignmentGuides.length > 0 && (
2647
+ <AlignmentGuidesOverlay guides={alignmentGuides} color={theme.colors.primary} />
2648
+ )}
2649
+ </ReactFlow>
2650
+
2651
+
2652
+
2653
+ {pendingConnection && (
2654
+ <div
2655
+ style={{
2656
+ position: 'absolute',
2657
+ top: '50%',
2658
+ left: '50%',
2659
+ transform: 'translate(-50%, -50%)',
2660
+ backgroundColor: theme.colors.background,
2661
+ color: theme.colors.text,
2662
+ borderRadius: '8px',
2663
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
2664
+ padding: '16px',
2665
+ minWidth: '200px',
2666
+ zIndex: 1000,
2667
+ border: `1px solid ${theme.colors.border}`,
2668
+ }}
2669
+ >
2670
+ <div style={{ fontWeight: 'bold', marginBottom: '12px', fontSize: '14px' }}>
2671
+ Select Edge Type
2672
+ </div>
2673
+ <div
2674
+ style={{ fontSize: '12px', color: theme.colors.textSecondary, marginBottom: '12px' }}
2675
+ >
2676
+ {pendingConnection.from} → {pendingConnection.to}
2677
+ </div>
2678
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
2679
+ {pendingConnection.validTypes.map((type) => {
2680
+ const typeDefinition = configuration.edgeTypes[type];
2681
+ return (
2682
+ <button
2683
+ key={type}
2684
+ onClick={() => handleEdgeTypeSelect(type)}
2685
+ style={{
2686
+ padding: '8px 12px',
2687
+ backgroundColor: typeDefinition?.color || theme.colors.secondary,
2688
+ color: theme.colors.background,
2689
+ border: 'none',
2690
+ borderRadius: '4px',
2691
+ cursor: 'pointer',
2692
+ fontSize: '12px',
2693
+ fontWeight: 'bold',
2694
+ textAlign: 'left',
2695
+ }}
2696
+ >
2697
+ {type}
2698
+ </button>
2699
+ );
2700
+ })}
2701
+ </div>
2702
+ <button
2703
+ onClick={handleCancelEdgeTypePicker}
2704
+ style={{
2705
+ marginTop: '12px',
2706
+ width: '100%',
2707
+ padding: '8px 12px',
2708
+ backgroundColor: theme.colors.surface,
2709
+ color: theme.colors.textSecondary,
2710
+ border: `1px solid ${theme.colors.border}`,
2711
+ borderRadius: '4px',
2712
+ cursor: 'pointer',
2713
+ fontSize: '12px',
2714
+ }}
2715
+ >
2716
+ Cancel
2717
+ </button>
2718
+ </div>
2719
+ )}
2720
+ </GraphEditProvider>
2721
+ );
2722
+ };
2723
+
2724
+ /**
2725
+ * Convert canvas to legacy configuration format for internal use
2726
+ *
2727
+ * Color Contract:
2728
+ * - scopeColor: Used as border color (from .scopes.canvas)
2729
+ * - spanColor: Used as fill color (from .spans.canvas based on workflow context)
2730
+ */
2731
+ function useCanvasToLegacy(
2732
+ canvas: ExtendedCanvas | undefined,
2733
+ library?: ComponentLibrary,
2734
+ spansCanvas?: ExtendedCanvas,
2735
+ scopesCanvas?: ExtendedCanvas,
2736
+ workflowSpanPattern?: string
2737
+ ): {
2738
+ configuration: GraphConfiguration;
2739
+ nodes: NodeState[];
2740
+ edges: EdgeState[];
2741
+ } | null {
2742
+ return useMemo(() => {
2743
+ if (!canvas) return null;
2744
+
2745
+ const { nodes, edges } = CanvasConverter.canvasToGraph(canvas);
2746
+
2747
+ // Fix edge types: published core doesn't support top-level edgeType yet
2748
+ // Re-map edge types from canvas to support both old (pv.edgeType) and new (edgeType) formats
2749
+ const canvasEdgeMap = new Map(canvas.edges?.map(e => [e.id, e]) || []);
2750
+ for (const edge of edges) {
2751
+ const canvasEdge = canvasEdgeMap.get(edge.id);
2752
+ if (canvasEdge) {
2753
+ // Support both top-level edgeType (new) and pv.edgeType (deprecated)
2754
+ const edgeType = canvasEdge.edgeType || canvasEdge.pv?.edgeType || 'default';
2755
+ edge.type = edgeType;
2756
+ }
2757
+ }
2758
+
2759
+ // Build scope color map from scopes canvas (for fill colors)
2760
+ const scopeColorMap = buildScopeColorMap(scopesCanvas);
2761
+
2762
+ // Build span color map from spans canvas (for border colors in workflow context)
2763
+ const spanColorMap = buildSpanColorMap(spansCanvas);
2764
+
2765
+ // Resolve span color for current workflow context
2766
+ const spanColor = resolveEventSpanColor(workflowSpanPattern, spanColorMap);
2767
+
2768
+ // Inject scope and span colors into nodes
2769
+ // - scopeColor: fill/background color (from otel.scope → scopes.canvas)
2770
+ // - spanColor: border color in workflow context (from workflow spanPattern → spans.canvas)
2771
+ for (const node of nodes) {
2772
+ const otel = node.data?.otel as { scope?: string } | undefined;
2773
+ const scope = otel?.scope;
2774
+
2775
+ // Determine scope color (for fill/background)
2776
+ let nodeScopeColor: string;
2777
+ if (scope && scopeColorMap[scope]) {
2778
+ // Node has a scope with a defined color - use it as fill color
2779
+ nodeScopeColor = scopeColorMap[scope];
2780
+ } else {
2781
+ // Draft nodes or nodes without scope get the draft color
2782
+ nodeScopeColor = DRAFT_NODE_COLOR;
2783
+ }
2784
+
2785
+ // Inject both scope and span colors
2786
+ node.data = {
2787
+ ...node.data,
2788
+ scopeColor: nodeScopeColor,
2789
+ spanColor: workflowSpanPattern ? spanColor : DEFAULT_SPAN_COLOR,
2790
+ };
2791
+ }
2792
+
2793
+ // Build GraphConfiguration from canvas
2794
+ const nodeTypes: GraphConfiguration['nodeTypes'] = {};
2795
+ const edgeTypes: GraphConfiguration['edgeTypes'] = {};
2796
+
2797
+ // Add node types from canvas nodeTypes
2798
+ if (canvas.nodeTypes) {
2799
+ for (const [id, def] of Object.entries(canvas.nodeTypes)) {
2800
+ nodeTypes[id] = {
2801
+ description: def.description,
2802
+ shape: def.shape || 'rectangle',
2803
+ icon: def.icon,
2804
+ color: def.color,
2805
+ dataSchema: {},
2806
+ };
2807
+ }
2808
+ }
2809
+
2810
+ // Then extract node types from canvas nodes (for nodes that define their own types)
2811
+ // Only process standard canvas nodes (OTEL nodes have their types defined separately)
2812
+ for (const node of canvas.nodes || []) {
2813
+ if (!isStandardCanvasNode(node)) {
2814
+ continue;
2815
+ }
2816
+
2817
+ const vv = node.pv;
2818
+ const nodeType = vv?.nodeType || node.type;
2819
+
2820
+ if (!nodeTypes[nodeType]) {
2821
+ // Color priority: vv.fill > node.color > vv.states.idle.color
2822
+ const fillColor =
2823
+ vv?.fill ||
2824
+ (typeof node.color === 'string' ? node.color : undefined) ||
2825
+ vv?.states?.idle?.color;
2826
+
2827
+ // Derive description from text content (everything after line 1) for text nodes
2828
+ let nodeDescription = `${nodeType} node`;
2829
+ if (node.type === 'text' && 'text' in node) {
2830
+ const lines = node.text.split('\n');
2831
+ const descFromText = lines.slice(1).join('\n').trim();
2832
+ if (descFromText) {
2833
+ nodeDescription = descFromText;
2834
+ }
2835
+ }
2836
+
2837
+ nodeTypes[nodeType] = {
2838
+ description: nodeDescription,
2839
+ shape: vv?.shape || 'rectangle',
2840
+ icon: vv?.icon,
2841
+ color: fillColor,
2842
+ stroke: vv?.stroke,
2843
+ size: { width: node.width, height: node.height },
2844
+ dataSchema: vv?.dataSchema || {},
2845
+ states: vv?.states,
2846
+ layout: vv?.layout,
2847
+ };
2848
+ }
2849
+ }
2850
+
2851
+ // Extract edge types from canvas edgeTypes (new) or pv.edgeTypes (deprecated)
2852
+ const canvasEdgeTypes = canvas.edgeTypes || canvas.pv?.edgeTypes;
2853
+ if (canvasEdgeTypes) {
2854
+ for (const [id, def] of Object.entries(canvasEdgeTypes)) {
2855
+ edgeTypes[id] = {
2856
+ style: def.style || 'solid',
2857
+ color: def.color,
2858
+ width: def.width,
2859
+ directed: def.directed,
2860
+ animation: def.animation,
2861
+ label: def.labelConfig,
2862
+ };
2863
+ }
2864
+ }
2865
+
2866
+ // Build allowed connections from edges
2867
+ const allowedConnections: GraphConfiguration['allowedConnections'] = [];
2868
+ for (const edge of canvas.edges || []) {
2869
+ // Support both top-level edgeType (new) and pv.edgeType (deprecated)
2870
+ const edgeType = edge.edgeType || edge.pv?.edgeType || 'default';
2871
+
2872
+ // Ensure edge type exists
2873
+ if (!edgeTypes[edgeType]) {
2874
+ edgeTypes[edgeType] = {
2875
+ style: 'solid',
2876
+ color: typeof edge.color === 'string' ? edge.color : undefined,
2877
+ directed: true,
2878
+ };
2879
+ }
2880
+
2881
+ // Store allowed connections using node IDs
2882
+ allowedConnections.push({
2883
+ from: edge.fromNode,
2884
+ to: edge.toNode,
2885
+ via: edgeType,
2886
+ });
2887
+ }
2888
+
2889
+ // Build display config with required layout field
2890
+ const display: GraphConfiguration['display'] = canvas.display
2891
+ ? {
2892
+ layout: canvas.display.layout || 'manual',
2893
+ theme: canvas.display.theme,
2894
+ animations: canvas.display.animations,
2895
+ }
2896
+ : { layout: 'manual' };
2897
+
2898
+ const configuration: GraphConfiguration = {
2899
+ metadata: {
2900
+ name: canvas.name || 'Untitled',
2901
+ description: canvas.description,
2902
+ },
2903
+ nodeTypes,
2904
+ edgeTypes,
2905
+ allowedConnections,
2906
+ display,
2907
+ };
2908
+
2909
+ return { configuration, nodes, edges };
2910
+ }, [canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern]);
2911
+ }
2912
+
2913
+ /**
2914
+ * Core graph visualization component using xyflow.
2915
+ *
2916
+ * Accepts an ExtendedCanvas document for rendering.
2917
+ *
2918
+ * When `editable` is true, the component manages its own edit state internally.
2919
+ * Use the ref to get pending changes when the user wants to save:
2920
+ *
2921
+ * ```tsx
2922
+ * <GraphRenderer canvas={myCanvas} />
2923
+ *
2924
+ * // With edit mode
2925
+ * const graphRef = useRef<GraphRendererHandle>(null);
2926
+ * <GraphRenderer
2927
+ * ref={graphRef}
2928
+ * canvas={myCanvas}
2929
+ * editable={isEditMode}
2930
+ * onPendingChangesChange={setHasUnsavedChanges}
2931
+ * />
2932
+ * ```
2933
+ */
2934
+ export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>((props, ref) => {
2935
+ const {
2936
+ canvas,
2937
+ library,
2938
+ spansCanvas,
2939
+ scopesCanvas,
2940
+ workflowSpanPattern,
2941
+ className,
2942
+ width = '100%',
2943
+ height = '100%',
2944
+ containerWidth,
2945
+ containerHeight,
2946
+ elkLayout,
2947
+ scenarioEdges,
2948
+ showSequenceLabels,
2949
+ } = props;
2950
+ const { theme } = useTheme();
2951
+ const containerRef = useRef<HTMLDivElement>(null);
2952
+ const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
2953
+
2954
+ // Set portal target after mount (ref is null during first render)
2955
+ useEffect(() => {
2956
+ setPortalTarget(containerRef.current);
2957
+ }, []);
2958
+
2959
+ // Convert canvas to internal format (merging library types if provided)
2960
+ // Also inject scope colors (for border) and span colors (for fill)
2961
+ const canvasData = useCanvasToLegacy(canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern);
2962
+
2963
+ // Calculate initial viewport if container dimensions are provided
2964
+ // This avoids the "zoom in then animate out" effect on initial render
2965
+ const initialViewport = useMemo(() => {
2966
+ if (!containerWidth || !containerHeight || !canvas) {
2967
+ return undefined;
2968
+ }
2969
+ const bounds = getCanvasBounds(canvas);
2970
+ return calculateInitialViewport(bounds, containerWidth, containerHeight, {
2971
+ padding: 0.2,
2972
+ minZoom: 0.1,
2973
+ maxZoom: 1.5,
2974
+ });
2975
+ }, [canvas, containerWidth, containerHeight]);
2976
+
2977
+ // Debug: Log canvas data to help diagnose disappearing edges
2978
+ if (process.env.NODE_ENV === 'development') {
2979
+ console.log('[GraphRenderer] Canvas data:', {
2980
+ hasCanvas: !!canvas,
2981
+ canvasEdgesCount: canvas?.edges?.length ?? 0,
2982
+ hasCanvasData: !!canvasData,
2983
+ convertedNodesCount: canvasData?.nodes.length ?? 0,
2984
+ convertedEdgesCount: canvasData?.edges.length ?? 0,
2985
+ });
2986
+ }
2987
+
2988
+ // Internal edit state ref - must be before any conditional returns
2989
+ const editStateRef = useRef<EditState>(createEmptyEditState());
2990
+
2991
+ // Ref to hold the reset visual state function - will be set after xyflowLocalNodes is defined
2992
+ const resetVisualStateRef = useRef<(() => void) | null>(null);
2993
+
2994
+ // Ref to hold the reset text changes version function - will be set by inner component
2995
+ const resetTextChangesVersionRef = useRef<(() => void) | null>(null);
2996
+
2997
+ // Undo/redo management
2998
+ const {
2999
+ canUndo: canUndoState,
3000
+ canRedo: canRedoState,
3001
+ undo: undoFromStack,
3002
+ redo: redoFromStack,
3003
+ pushHistory,
3004
+ clearHistory,
3005
+ } = useUndoRedo();
3006
+
3007
+ // Ref to hold undo/redo apply functions - will be set by inner component
3008
+ const undoRedoFunctionsRef = useRef<UndoRedoFunctionsRef | null>(null);
3009
+
3010
+ // Expose imperative handle - must be before any conditional returns
3011
+ useImperativeHandle(
3012
+ ref,
3013
+ () => ({
3014
+ getPendingChanges: (): PendingChanges => {
3015
+ const state = editStateRef.current;
3016
+ return {
3017
+ positionChanges: Array.from(state.positionChanges.entries()).map(
3018
+ ([nodeId, position]) => ({
3019
+ nodeId,
3020
+ position,
3021
+ })
3022
+ ),
3023
+ dimensionChanges: Array.from(state.dimensionChanges.entries()).map(
3024
+ ([nodeId, dimensions]) => ({
3025
+ nodeId,
3026
+ dimensions,
3027
+ })
3028
+ ),
3029
+ textChanges: Array.from(state.textChanges.entries()).map(
3030
+ ([nodeId, text]) => ({
3031
+ nodeId,
3032
+ text,
3033
+ })
3034
+ ),
3035
+ nodeUpdates: Array.from(state.nodeUpdates.entries()).map(([nodeId, updates]) => ({
3036
+ nodeId,
3037
+ updates,
3038
+ })),
3039
+ deletedNodeIds: Array.from(state.deletedNodeIds),
3040
+ createdEdges: state.createdEdges.map((e) => ({
3041
+ from: e.from,
3042
+ to: e.to,
3043
+ type: e.type,
3044
+ sourceHandle: e.sourceHandle,
3045
+ targetHandle: e.targetHandle,
3046
+ })),
3047
+ deletedEdges: state.deletedEdges.map((e) => ({ from: e.from, to: e.to, type: e.type })),
3048
+ hasChanges:
3049
+ state.positionChanges.size > 0 ||
3050
+ state.dimensionChanges.size > 0 ||
3051
+ state.textChanges.size > 0 ||
3052
+ state.nodeUpdates.size > 0 ||
3053
+ state.deletedNodeIds.size > 0 ||
3054
+ state.createdEdges.length > 0 ||
3055
+ state.deletedEdges.length > 0,
3056
+ };
3057
+ },
3058
+ resetEditState: () => {
3059
+ editStateRef.current = createEmptyEditState();
3060
+ // Reset text changes version to trigger re-render
3061
+ resetTextChangesVersionRef.current?.();
3062
+ // Also reset visual state (node positions/dimensions) if available
3063
+ resetVisualStateRef.current?.();
3064
+ // Clear undo/redo history
3065
+ clearHistory();
3066
+ },
3067
+ hasUnsavedChanges: (): boolean => {
3068
+ const state = editStateRef.current;
3069
+ return (
3070
+ state.positionChanges.size > 0 ||
3071
+ state.dimensionChanges.size > 0 ||
3072
+ state.textChanges.size > 0 ||
3073
+ state.nodeUpdates.size > 0 ||
3074
+ state.deletedNodeIds.size > 0 ||
3075
+ state.createdEdges.length > 0 ||
3076
+ state.deletedEdges.length > 0
3077
+ );
3078
+ },
3079
+ canUndo: () => canUndoState,
3080
+ canRedo: () => canRedoState,
3081
+ undo: () => undoRedoFunctionsRef.current?.applyUndo(),
3082
+ redo: () => undoRedoFunctionsRef.current?.applyRedo(),
3083
+ }),
3084
+ [canUndoState, canRedoState, clearHistory]
3085
+ );
3086
+
3087
+ // Validate we have required data
3088
+ if (!canvasData) {
3089
+ return (
3090
+ <div
3091
+ className={className}
3092
+ style={{
3093
+ width,
3094
+ height,
3095
+ display: 'flex',
3096
+ alignItems: 'center',
3097
+ justifyContent: 'center',
3098
+ backgroundColor: theme.colors.background,
3099
+ color: theme.colors.textSecondary,
3100
+ }}
3101
+ >
3102
+ <p>No canvas data provided.</p>
3103
+ </div>
3104
+ );
3105
+ }
3106
+
3107
+ const { configuration, nodes, edges } = canvasData;
3108
+
3109
+
3110
+ // Extract only the props that inner component needs
3111
+ const {
3112
+ violations,
3113
+ configName,
3114
+ showMinimap,
3115
+ showControls,
3116
+ showBackground,
3117
+ backgroundVariant,
3118
+ backgroundGap,
3119
+ showCenterIndicator,
3120
+ showTooltips,
3121
+ fitViewDuration,
3122
+ highlightedNodeId,
3123
+ activeNodeIds,
3124
+ events,
3125
+ onEventProcessed,
3126
+ editable,
3127
+ onPendingChangesChange,
3128
+ onNodeClick,
3129
+ fitViewToNodeIds,
3130
+ fitViewPadding,
3131
+ draggableNodeIds,
3132
+ onNodeDragStop,
3133
+ onCopy,
3134
+ } = props;
3135
+
3136
+ // If container dimensions are provided but viewport isn't calculated yet, wait
3137
+ // This prevents the flash from rendering with default viewport then re-rendering
3138
+ const isWaitingForViewport = (containerWidth !== undefined || containerHeight !== undefined) && !initialViewport;
3139
+
3140
+ if (isWaitingForViewport) {
3141
+ return (
3142
+ <div ref={containerRef} className={className} style={{ width, height, position: 'relative' }} />
3143
+ );
3144
+ }
3145
+
3146
+ return (
3147
+ <div ref={containerRef} className={className} style={{ width, height, position: 'relative' }}>
3148
+ <TooltipPortalContext.Provider value={portalTarget}>
3149
+ <ReactFlowProvider>
3150
+ <GraphRendererInner
3151
+ configuration={configuration}
3152
+ nodes={nodes}
3153
+ edges={edges}
3154
+ violations={violations}
3155
+ configName={configName}
3156
+ showMinimap={showMinimap}
3157
+ showControls={showControls}
3158
+ showBackground={showBackground}
3159
+ backgroundVariant={backgroundVariant}
3160
+ backgroundGap={backgroundGap}
3161
+ showCenterIndicator={showCenterIndicator}
3162
+ showTooltips={showTooltips}
3163
+ fitViewDuration={fitViewDuration}
3164
+ highlightedNodeId={highlightedNodeId}
3165
+ activeNodeIds={activeNodeIds}
3166
+ events={events}
3167
+ onEventProcessed={onEventProcessed}
3168
+ editable={editable}
3169
+ onPendingChangesChange={onPendingChangesChange}
3170
+ editStateRef={editStateRef}
3171
+ resetVisualStateRef={resetVisualStateRef}
3172
+ resetTextChangesVersionRef={resetTextChangesVersionRef}
3173
+ undoRedoFunctionsRef={undoRedoFunctionsRef}
3174
+ pushHistory={pushHistory}
3175
+ clearHistory={clearHistory}
3176
+ undoFromStack={undoFromStack}
3177
+ redoFromStack={redoFromStack}
3178
+ onNodeClick={onNodeClick}
3179
+ fitViewToNodeIds={fitViewToNodeIds}
3180
+ fitViewPadding={fitViewPadding}
3181
+ draggableNodeIds={draggableNodeIds}
3182
+ onNodeDragStop={onNodeDragStop}
3183
+ onCopy={onCopy}
3184
+ initialViewport={initialViewport}
3185
+ elkLayout={elkLayout}
3186
+ scenarioEdges={scenarioEdges}
3187
+ showSequenceLabels={showSequenceLabels}
3188
+ />
3189
+ </ReactFlowProvider>
3190
+ </TooltipPortalContext.Provider>
3191
+ </div>
3192
+ );
3193
+ });
3194
+
3195
+ GraphRenderer.displayName = 'GraphRenderer';