symbiote-ui 0.3.0-alpha.4

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 (322) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/canvas/AutoLayout.js +731 -0
  5. package/canvas/Breadcrumb/Breadcrumb.css.js +75 -0
  6. package/canvas/Breadcrumb/Breadcrumb.js +96 -0
  7. package/canvas/Breadcrumb/Breadcrumb.tpl.js +7 -0
  8. package/canvas/CanvasConnectionRenderer.js +971 -0
  9. package/canvas/CanvasGraph/CanvasGraph.css.js +29 -0
  10. package/canvas/CanvasGraph/CanvasGraph.js +1697 -0
  11. package/canvas/CanvasGraph/CanvasGraphDrawState.js +280 -0
  12. package/canvas/CanvasGraph/CanvasGraphGeometry.js +194 -0
  13. package/canvas/CanvasViewport.js +550 -0
  14. package/canvas/ConnectionRenderer.js +1283 -0
  15. package/canvas/FlowSimulator.js +326 -0
  16. package/canvas/ForceLayout.js +226 -0
  17. package/canvas/ForceWorker.js +1303 -0
  18. package/canvas/FrameManager.js +223 -0
  19. package/canvas/GraphExplorerShell/GraphExplorerShell.css.js +136 -0
  20. package/canvas/GraphExplorerShell/GraphExplorerShell.js +129 -0
  21. package/canvas/GraphExplorerShell/GraphExplorerShell.tpl.js +12 -0
  22. package/canvas/GraphTabs/GraphTabs.css.js +101 -0
  23. package/canvas/GraphTabs/GraphTabs.js +189 -0
  24. package/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  25. package/canvas/LODManager.js +88 -0
  26. package/canvas/Minimap/Minimap.css.js +73 -0
  27. package/canvas/Minimap/Minimap.js +210 -0
  28. package/canvas/Minimap/Minimap.tpl.js +7 -0
  29. package/canvas/NodeCanvas/NodeCanvas.css.js +398 -0
  30. package/canvas/NodeCanvas/NodeCanvas.js +1499 -0
  31. package/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  32. package/canvas/NodeSearch/NodeSearch.css.js +97 -0
  33. package/canvas/NodeSearch/NodeSearch.js +140 -0
  34. package/canvas/NodeSearch/NodeSearch.tpl.js +25 -0
  35. package/canvas/NodeViewManager.js +748 -0
  36. package/canvas/PcbRouteDiagnostics.js +463 -0
  37. package/canvas/PcbRouter.js +1127 -0
  38. package/canvas/PinExpansion.js +134 -0
  39. package/canvas/PseudoConnection.js +84 -0
  40. package/canvas/SelectionSync.js +163 -0
  41. package/canvas/SubgraphManager.js +203 -0
  42. package/canvas/SubgraphRouter.js +452 -0
  43. package/canvas/ViewportActions.js +473 -0
  44. package/canvas/graph-explorer.js +339 -0
  45. package/canvas/graph-layout.js +148 -0
  46. package/canvas/graph-model.js +68 -0
  47. package/canvas/html-in-canvas.js +202 -0
  48. package/canvas/project-graph-builder.js +440 -0
  49. package/canvas/project-graph-model.js +183 -0
  50. package/chat/ChatComposer/ChatComposer.css.js +652 -0
  51. package/chat/ChatComposer/ChatComposer.js +304 -0
  52. package/chat/ChatList/ChatList.css.js +102 -0
  53. package/chat/ChatList/ChatList.js +99 -0
  54. package/chat/ChatList/ChatList.tpl.js +20 -0
  55. package/chat/ChatListItem/ChatListItem.css.js +117 -0
  56. package/chat/ChatListItem/ChatListItem.js +32 -0
  57. package/chat/ChatListItem/ChatListItem.tpl.js +17 -0
  58. package/chat/ChatMessageItem/ChatMessageItem.css.js +628 -0
  59. package/chat/ChatMessageItem/ChatMessageItem.js +156 -0
  60. package/chat/ChatSidebar/ChatSidebar.css.js +150 -0
  61. package/chat/ChatSidebar/ChatSidebar.js +230 -0
  62. package/chat/ChatSidebar/ChatSidebar.tpl.js +18 -0
  63. package/chat/ChatSidebar/constants.js +11 -0
  64. package/chat/ChatSidebarItem/ChatSidebarItem.css.js +445 -0
  65. package/chat/ChatSidebarItem/ChatSidebarItem.js +304 -0
  66. package/chat/ChatTranscript/ChatTranscript.css.js +90 -0
  67. package/chat/ChatTranscript/ChatTranscript.js +244 -0
  68. package/chat/chat-context.js +123 -0
  69. package/chat/message-model.js +156 -0
  70. package/cli.js +20 -0
  71. package/control/Button/Button.css.js +93 -0
  72. package/control/Button/Button.js +78 -0
  73. package/control/Button/Button.tpl.js +3 -0
  74. package/control/Field/Field.css.js +91 -0
  75. package/control/Field/Field.js +17 -0
  76. package/control/Field/Field.tpl.js +3 -0
  77. package/core/Connection.js +47 -0
  78. package/core/Editor.js +449 -0
  79. package/core/Frame.js +33 -0
  80. package/core/GraphMermaid.js +348 -0
  81. package/core/GraphText.js +228 -0
  82. package/core/Node.js +145 -0
  83. package/core/Portal.js +106 -0
  84. package/core/Socket.js +187 -0
  85. package/core/SubgraphNode.js +121 -0
  86. package/core/base-path.js +55 -0
  87. package/core/dom-utils.js +14 -0
  88. package/core/index.js +18 -0
  89. package/core/local-cache.js +26 -0
  90. package/core/state-sync.js +227 -0
  91. package/custom-elements.json +6380 -0
  92. package/discover.js +240 -0
  93. package/display/Badge/Badge.css.js +44 -0
  94. package/display/Badge/Badge.js +17 -0
  95. package/display/Badge/Badge.tpl.js +3 -0
  96. package/display/Banner/Banner.css.js +61 -0
  97. package/display/Banner/Banner.js +17 -0
  98. package/display/Banner/Banner.tpl.js +3 -0
  99. package/display/CodeBlock/CodeBlock.css.js +194 -0
  100. package/display/CodeBlock/CodeBlock.js +220 -0
  101. package/display/CodeBlock/CodeBlock.tpl.js +11 -0
  102. package/display/DataTable/DataTable.css.js +101 -0
  103. package/display/DataTable/DataTable.js +136 -0
  104. package/display/DataTable/DataTable.tpl.js +13 -0
  105. package/display/EmptyState/EmptyState.css.js +33 -0
  106. package/display/EmptyState/EmptyState.js +17 -0
  107. package/display/EmptyState/EmptyState.tpl.js +3 -0
  108. package/display/EventFeed/EventFeed.css.js +145 -0
  109. package/display/EventFeed/EventFeed.js +64 -0
  110. package/display/EventFeed/EventFeed.tpl.js +14 -0
  111. package/display/EventFeed/EventFeedItem.js +116 -0
  112. package/display/EventFeed/EventFeedItem.tpl.js +22 -0
  113. package/display/LoadingOverlay/LoadingOverlay.css.js +91 -0
  114. package/display/LoadingOverlay/LoadingOverlay.js +48 -0
  115. package/display/LoadingOverlay/LoadingOverlay.tpl.js +12 -0
  116. package/display/Metric/Metric.css.js +60 -0
  117. package/display/Metric/Metric.js +17 -0
  118. package/display/Metric/Metric.tpl.js +6 -0
  119. package/display/OutputGraphPreview/OutputGraphPreview.css.js +122 -0
  120. package/display/OutputGraphPreview/OutputGraphPreview.js +89 -0
  121. package/display/OutputGraphPreview/OutputGraphPreview.tpl.js +13 -0
  122. package/display/OutputListPreview/OutputListPreview.css.js +109 -0
  123. package/display/OutputListPreview/OutputListPreview.js +77 -0
  124. package/display/OutputListPreview/OutputListPreview.tpl.js +13 -0
  125. package/display/SourceEditor/SourceEditor.css.js +39 -0
  126. package/display/SourceEditor/SourceEditor.js +129 -0
  127. package/display/SourceEditor/SourceEditor.tpl.js +10 -0
  128. package/display/SourceViewer/SourceViewer.css.js +80 -0
  129. package/display/SourceViewer/SourceViewer.js +418 -0
  130. package/display/SourceViewer/SourceViewer.tpl.js +17 -0
  131. package/display/StatusRibbon/StatusRibbon.css.js +73 -0
  132. package/display/StatusRibbon/StatusRibbon.js +87 -0
  133. package/display/StatusRibbon/StatusRibbon.tpl.js +7 -0
  134. package/display/event-feed-adapter.js +72 -0
  135. package/display/format-utils.js +29 -0
  136. package/display/highlight.js +659 -0
  137. package/display/icons.js +37 -0
  138. package/display/markdown-formatter.js +60 -0
  139. package/display/network-approval-page.js +487 -0
  140. package/display/output-preview.js +261 -0
  141. package/effects/CellBg/CellBg.css.js +33 -0
  142. package/effects/CellBg/CellBg.js +410 -0
  143. package/effects/CellBg/CellBg.tpl.js +5 -0
  144. package/graph/canvas-adapter.js +223 -0
  145. package/graph/graph-algorithms.js +31 -0
  146. package/graph/index.js +46 -0
  147. package/graph/model.js +176 -0
  148. package/graph/project-graph-build.js +66 -0
  149. package/graph/project-graph-metadata.js +253 -0
  150. package/graph/project-package.js +128 -0
  151. package/graph/project-runtime.js +116 -0
  152. package/graph/project-transaction.js +284 -0
  153. package/graph/skeleton-utils.js +84 -0
  154. package/graph/theme-contract.js +36 -0
  155. package/graph/transaction-parser.js +56 -0
  156. package/icons/MaterialSymbols.js +69 -0
  157. package/icons/material-symbols-outlined-400.ttf +0 -0
  158. package/icons/material-symbols.css +24 -0
  159. package/index.js +95 -0
  160. package/inspector/InspectorPanel/InspectorPanel.css.js +375 -0
  161. package/inspector/InspectorPanel/InspectorPanel.js +368 -0
  162. package/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  163. package/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  164. package/inspector/TemplatePreview/TemplatePreview.js +145 -0
  165. package/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  166. package/interactions/ConnectFlow.js +304 -0
  167. package/interactions/Drag.js +104 -0
  168. package/interactions/Selector.js +133 -0
  169. package/interactions/SnapGrid.js +66 -0
  170. package/interactions/Zoom.js +139 -0
  171. package/layout/ActionZone/ActionZone.css.js +88 -0
  172. package/layout/ActionZone/ActionZone.js +261 -0
  173. package/layout/ActionZone/ActionZone.tpl.js +11 -0
  174. package/layout/CrossLayoutPortalBridge/CrossLayoutPortalBridge.js +255 -0
  175. package/layout/Layout/Layout.css.js +91 -0
  176. package/layout/Layout/Layout.js +637 -0
  177. package/layout/Layout/Layout.tpl.js +27 -0
  178. package/layout/LayoutNode/LayoutNode.css.js +302 -0
  179. package/layout/LayoutNode/LayoutNode.js +509 -0
  180. package/layout/LayoutNode/LayoutNode.tpl.js +39 -0
  181. package/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  182. package/layout/LayoutPreview/LayoutPreview.js +102 -0
  183. package/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  184. package/layout/LayoutRouter/LayoutRouter.js +274 -0
  185. package/layout/LayoutRouter/SectionRegistry.js +135 -0
  186. package/layout/LayoutRouter/routerSync.js +250 -0
  187. package/layout/LayoutSidebar/LayoutSidebar.css.js +411 -0
  188. package/layout/LayoutSidebar/LayoutSidebar.js +368 -0
  189. package/layout/LayoutSidebar/LayoutSidebar.tpl.js +26 -0
  190. package/layout/LayoutSidebar/SidebarSection.css.js +20 -0
  191. package/layout/LayoutSidebar/SidebarSection.js +184 -0
  192. package/layout/LayoutSidebar/SidebarSection.tpl.js +22 -0
  193. package/layout/LayoutTree.js +373 -0
  194. package/layout/PanelMenu/PanelMenu.css.js +43 -0
  195. package/layout/PanelMenu/PanelMenu.js +95 -0
  196. package/layout/PanelMenu/PanelMenu.tpl.js +17 -0
  197. package/layout/ProjectTabs/ProjectTabs.css.js +188 -0
  198. package/layout/ProjectTabs/ProjectTabs.js +77 -0
  199. package/layout/ProjectTabs/ProjectTabs.tpl.js +15 -0
  200. package/layout/index.js +40 -0
  201. package/list/ListDetailShell/ListDetailShell.css.js +128 -0
  202. package/list/ListDetailShell/ListDetailShell.js +72 -0
  203. package/list/ListDetailShell/ListDetailShell.tpl.js +36 -0
  204. package/list/ListItem/ListItem.css.js +111 -0
  205. package/list/ListItem/ListItem.js +66 -0
  206. package/list/ListItem/ListItem.tpl.js +18 -0
  207. package/locale/index.js +503 -0
  208. package/manifest/component-registry.js +2446 -0
  209. package/manifest/graph-schema.js +285 -0
  210. package/manifest/index.js +6 -0
  211. package/manifest/project-schema-catalog.js +246 -0
  212. package/manifest/rule-catalog.js +201 -0
  213. package/manifest/theme-catalog.js +2149 -0
  214. package/manifest/ui-schema-catalog.js +334 -0
  215. package/menu/ContextMenu/ContextMenu.css.js +61 -0
  216. package/menu/ContextMenu/ContextMenu.js +82 -0
  217. package/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  218. package/navigation/QuickOpen/QuickOpen.css.js +92 -0
  219. package/navigation/QuickOpen/QuickOpen.js +185 -0
  220. package/navigation/QuickOpen/QuickOpen.tpl.js +15 -0
  221. package/navigation/quick-open-utils.js +101 -0
  222. package/node/CtrlItem/CtrlItem.css.js +41 -0
  223. package/node/CtrlItem/CtrlItem.js +24 -0
  224. package/node/CtrlItem/CtrlItem.tpl.js +17 -0
  225. package/node/GraphFrame/GraphFrame.css.js +66 -0
  226. package/node/GraphFrame/GraphFrame.js +32 -0
  227. package/node/GraphFrame/GraphFrame.tpl.js +13 -0
  228. package/node/GraphNode/GraphNode.css.js +815 -0
  229. package/node/GraphNode/GraphNode.js +173 -0
  230. package/node/GraphNode/GraphNode.tpl.js +33 -0
  231. package/node/NodeCallout/NodeCallout.css.js +91 -0
  232. package/node/NodeCallout/NodeCallout.js +281 -0
  233. package/node/NodeCallout/NodeCallout.tpl.js +8 -0
  234. package/node/NodeSocket/NodeSocket.css.js +68 -0
  235. package/node/NodeSocket/NodeSocket.js +26 -0
  236. package/node/NodeSocket/NodeSocket.tpl.js +7 -0
  237. package/node/PortItem/PortItem.css.js +93 -0
  238. package/node/PortItem/PortItem.js +87 -0
  239. package/node/PortItem/PortItem.tpl.js +10 -0
  240. package/package.json +165 -0
  241. package/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  242. package/palette/PaletteBrowser/PaletteBrowser.js +152 -0
  243. package/palette/PaletteBrowser/PaletteBrowser.tpl.js +23 -0
  244. package/plugins/History.js +408 -0
  245. package/plugins/Readonly.js +60 -0
  246. package/rules/symbiote-3x.json +170 -0
  247. package/schemas/component-descriptor-v1.json +91 -0
  248. package/schemas/component-descriptor-v2.json +145 -0
  249. package/schemas/graph-model-v1.json +179 -0
  250. package/schemas/graph-v1.json +91 -0
  251. package/schemas/project-package-v1.json +102 -0
  252. package/schemas/project-transaction-v1.json +114 -0
  253. package/schemas/runtime-ui-v1.json +80 -0
  254. package/schemas/theme-rule-block-v1.json +73 -0
  255. package/shapes/CircleShape.js +79 -0
  256. package/shapes/CommentShape.js +35 -0
  257. package/shapes/DiamondShape.js +130 -0
  258. package/shapes/NodeShape.js +79 -0
  259. package/shapes/PillShape.js +91 -0
  260. package/shapes/RectShape.js +84 -0
  261. package/shapes/SVGShape.js +525 -0
  262. package/shapes/index.js +63 -0
  263. package/surface/Card/Card.css.js +57 -0
  264. package/surface/Card/Card.js +17 -0
  265. package/surface/Card/Card.tpl.js +3 -0
  266. package/themes/Palette.js +30 -0
  267. package/themes/Skin.js +113 -0
  268. package/themes/Theme.js +82 -0
  269. package/themes/carbon.js +135 -0
  270. package/themes/dark.js +140 -0
  271. package/themes/default-dark.js +714 -0
  272. package/themes/default-provider.css +635 -0
  273. package/themes/default-provider.js +718 -0
  274. package/themes/ebook.js +136 -0
  275. package/themes/grey.js +137 -0
  276. package/themes/light.js +139 -0
  277. package/themes/neon.js +138 -0
  278. package/themes/pcb.js +273 -0
  279. package/themes/synthwave.js +138 -0
  280. package/tokens/base.json +29 -0
  281. package/tokens/themes/carbon.json +11 -0
  282. package/tokens/themes/dark.json +12 -0
  283. package/tokens/themes/default-dark.json +1543 -0
  284. package/tokens/themes/default-provider.json +1543 -0
  285. package/tokens/themes/ebook.json +11 -0
  286. package/tokens/themes/grey.json +11 -0
  287. package/tokens/themes/light.json +12 -0
  288. package/tokens/themes/neon.json +11 -0
  289. package/tokens/themes/pcb.json +11 -0
  290. package/tokens/themes/synthwave.json +11 -0
  291. package/toolbar/QuickToolbar/QuickToolbar.css.js +152 -0
  292. package/toolbar/QuickToolbar/QuickToolbar.js +529 -0
  293. package/toolbar/QuickToolbar/QuickToolbar.tpl.js +34 -0
  294. package/tree/TreePanel/TreePanel.css.js +112 -0
  295. package/tree/TreePanel/TreePanel.js +147 -0
  296. package/tree/TreePanel/TreePanel.tpl.js +18 -0
  297. package/tree/TreeView/TreeView.css.js +122 -0
  298. package/tree/TreeView/TreeView.js +365 -0
  299. package/tree/TreeView/TreeView.tpl.js +10 -0
  300. package/ui/dialogs.js +221 -0
  301. package/ui/host-adapters.js +114 -0
  302. package/ui/index.js +660 -0
  303. package/ui/locale.js +50 -0
  304. package/ui/overlay-stack.js +89 -0
  305. package/ui/shared-styles.js +26 -0
  306. package/webmcp.js +37 -0
  307. package/xr/deep-graph.js +646 -0
  308. package/xr/emulation.js +198 -0
  309. package/xr/gesture.js +228 -0
  310. package/xr/html-canvas-renderer.js +472 -0
  311. package/xr/index.js +15 -0
  312. package/xr/layout-projection.js +1046 -0
  313. package/xr/panel-frame.js +128 -0
  314. package/xr/panel-host.js +267 -0
  315. package/xr/pointer.js +258 -0
  316. package/xr/scene-controller.js +242 -0
  317. package/xr/spatial-scene.js +212 -0
  318. package/xr/theme-bridge.js +105 -0
  319. package/xr/three-webxr-adapter.js +3439 -0
  320. package/xr/webgl-layer-renderer.js +419 -0
  321. package/xr/webxr.js +679 -0
  322. package/xr/workbench.js +516 -0
@@ -0,0 +1,1283 @@
1
+ /* global document */
2
+ /**
3
+ * ConnectionRenderer — SVG connection path manager
4
+ *
5
+ * Handles rendering Bézier curves between sockets,
6
+ * gradient coloring, flow animation, socket offset calculation.
7
+ * Extracted from NodeCanvas to reduce complexity.
8
+ *
9
+ * @module symbiote-node/canvas/ConnectionRenderer
10
+ */
11
+
12
+ import { getShape } from '../shapes/index.js';
13
+ import { routePcbTrace } from './PcbRouter.js';
14
+
15
+ export class ConnectionRenderer {
16
+ /** @type {Map<string, import('../core/Connection.js').Connection>} */
17
+ #connectionData = new Map();
18
+
19
+ /** @type {SVGElement} */
20
+ #svgLayer;
21
+
22
+ /** @type {SVGElement} - overlay layer for dots (z-index above nodes) */
23
+ #dotLayer;
24
+
25
+ /** @type {Map<string, HTMLElement>} */
26
+ #nodeViews;
27
+
28
+ /** @type {import('../core/Editor.js').NodeEditor} */
29
+ #editor;
30
+
31
+ /** @type {function} */
32
+ #onConnectionClick;
33
+
34
+ /** @type {function} */
35
+ #getZoom;
36
+
37
+ /** @type {function|null} - callback when dot is dragged: (socketData) => void */
38
+ #onDotDrag = null;
39
+
40
+ /** @type {'bezier'|'orthogonal'|'straight'|'pcb'} */
41
+ #pathStyle = 'bezier';
42
+
43
+ /**
44
+ * @param {object} config
45
+ * @param {SVGElement} config.svgLayer
46
+ * @param {SVGElement} [config.dotLayer]
47
+ * @param {Map<string, HTMLElement>} config.nodeViews
48
+ * @param {import('../core/Editor.js').NodeEditor} config.editor
49
+ * @param {function} config.onConnectionClick - (connId, event)
50
+ * @param {function} config.getZoom - Returns current zoom level
51
+ * @param {function} [config.onDotDrag] - Callback for dot dragging
52
+ */
53
+ constructor({ svgLayer, dotLayer, nodeViews, editor, onConnectionClick, getZoom, onDotDrag }) {
54
+ this.#svgLayer = svgLayer;
55
+ this.#dotLayer = dotLayer || svgLayer;
56
+ this.#nodeViews = nodeViews;
57
+ this.#editor = editor;
58
+ this.#onConnectionClick = onConnectionClick;
59
+ this.#getZoom = getZoom || (() => 1);
60
+ this.#onDotDrag = onDotDrag || null;
61
+ }
62
+
63
+ /** @returns {Map<string, import('../core/Connection.js').Connection>} */
64
+ get data() {
65
+ return this.#connectionData;
66
+ }
67
+
68
+ /**
69
+ * Keep the SVG renderer compatible with NodeCanvas batch initialization.
70
+ * @param {boolean} _on
71
+ */
72
+ setBatchMode(_on) {}
73
+
74
+ /**
75
+ * Read the current rendered node size. SVG nodes can resize after creation
76
+ * through params, so offset dimensions must take precedence over stale cache.
77
+ * @param {HTMLElement} nodeEl
78
+ * @param {number} fallbackWidth
79
+ * @param {number} fallbackHeight
80
+ * @returns {{ width: number, height: number }}
81
+ */
82
+ #getNodeSize(nodeEl, fallbackWidth, fallbackHeight) {
83
+ return {
84
+ width: nodeEl.offsetWidth || nodeEl._cachedW || fallbackWidth,
85
+ height: nodeEl.offsetHeight || nodeEl._cachedH || fallbackHeight,
86
+ };
87
+ }
88
+
89
+ #buildNodeRectCache() {
90
+ const rects = new Map();
91
+ for (const [nid, el] of this.#nodeViews) {
92
+ if (!el) continue;
93
+ const size = this.#getNodeSize(el, 180, 100);
94
+ rects.set(nid, {
95
+ id: nid,
96
+ x: el._position?.x || 0,
97
+ y: el._position?.y || 0,
98
+ w: size.width,
99
+ h: size.height,
100
+ });
101
+ }
102
+ return rects;
103
+ }
104
+
105
+ #nodeRectsForRouting() {
106
+ return [...(this._nodeRectCache || this.#buildNodeRectCache()).values()];
107
+ }
108
+
109
+ /**
110
+ * Add a connection and render its SVG path
111
+ * @param {import('../core/Connection.js').Connection} conn
112
+ */
113
+ add(conn) {
114
+ this.#connectionData.set(conn.id, conn);
115
+
116
+ this.removeFreeDot(conn.from, conn.out, 'output');
117
+ this.removeFreeDot(conn.to, conn.in, 'input');
118
+
119
+ this.#fullRerenderForNodes(new Set([conn.from, conn.to]));
120
+ }
121
+
122
+ /**
123
+ * Bulk add connections and render them in a single batch.
124
+ * Greatly improves performance when inflating large graphs.
125
+ * @param {import('../core/Connection.js').Connection[]} conns
126
+ */
127
+ addBatch(conns) {
128
+ if (!conns || conns.length === 0) return;
129
+ for (const conn of conns) {
130
+ this.#connectionData.set(conn.id, conn);
131
+ }
132
+ this.refreshAll();
133
+ }
134
+
135
+ /**
136
+ * Clear slot registries and caches for all known nodes
137
+ */
138
+ #clearAllSlots() {
139
+ for (const [, el] of this.#nodeViews) {
140
+ el._usedCoords = [];
141
+ el._slotCache = new Map();
142
+ }
143
+ }
144
+
145
+ #nodeRects() {
146
+ const rects = [];
147
+ for (const [nid, el] of this.#nodeViews) {
148
+ if (!el?._position) continue;
149
+ rects.push({
150
+ id: nid,
151
+ x: el._position.x,
152
+ y: el._position.y,
153
+ w: el.offsetWidth || el._cachedW || 180,
154
+ h: el.offsetHeight || el._cachedH || 100,
155
+ });
156
+ }
157
+ return rects;
158
+ }
159
+
160
+ /**
161
+ * Full re-render: clear all slots for affected nodes, recalculate everything
162
+ * @param {Set<string>} nodeIds
163
+ */
164
+ #fullRerenderForNodes(nodeIds) {
165
+ let allNodes = new Set(nodeIds);
166
+ let conns = [];
167
+ for (const [, conn] of this.#connectionData) {
168
+ if (nodeIds.has(conn.from) || nodeIds.has(conn.to)) {
169
+ allNodes.add(conn.from);
170
+ allNodes.add(conn.to);
171
+ conns.push(conn);
172
+ }
173
+ }
174
+ for (const nid of allNodes) {
175
+ let el = this.#nodeViews.get(nid);
176
+ if (el) {
177
+ el._usedCoords = [];
178
+ el._slotCache = new Map();
179
+ }
180
+ }
181
+ const previousRectCache = this._nodeRectCache;
182
+ this._nodeRectCache = this.#buildNodeRectCache();
183
+ try {
184
+ for (const conn of conns) {
185
+ this.#render(conn);
186
+ }
187
+ } finally {
188
+ this._nodeRectCache = previousRectCache || null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Remove a connection path
194
+ * @param {import('../core/Connection.js').Connection} conn
195
+ */
196
+ remove(conn) {
197
+ let fromId = conn.from;
198
+ let toId = conn.to;
199
+ this.#connectionData.delete(conn.id);
200
+ let path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
201
+ if (path) {
202
+
203
+ path.style.opacity = '0';
204
+ path.addEventListener('transitionend', () => path.remove(), { once: true });
205
+
206
+ setTimeout(() => {
207
+ if (path.parentNode) path.remove();
208
+ }, 200);
209
+ }
210
+
211
+ for (const end of ['start', 'end']) {
212
+ let dot = this.#dotLayer.querySelector(`[data-conn-dot="${conn.id}-${end}"]`);
213
+ if (dot) dot.remove();
214
+ }
215
+ let arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${conn.id}"]`);
216
+ if (arrow) arrow.remove();
217
+
218
+
219
+ this.renderFreeDots(fromId);
220
+ this.renderFreeDots(toId);
221
+ }
222
+
223
+ /**
224
+ * Highlight overlay dots belonging to compatible nodes during drag
225
+ * @param {Set<string>} compatibleNodeIds - set of node IDs that have compatible ports
226
+ */
227
+ highlightDotsForNodes(compatibleNodeIds) {
228
+
229
+ let connDots = this.#dotLayer.querySelectorAll('.sn-conn-dot');
230
+ for (const dot of connDots) {
231
+ let dotId = dot.getAttribute('data-conn-dot') || '';
232
+ let connId = dotId.replace(/-(?:start|end)$/, '');
233
+ let conn = this.#connectionData.get(connId);
234
+ if (!conn) continue;
235
+ let end = dotId.endsWith('-start') ? 'start' : 'end';
236
+ let nodeId = end === 'start' ? conn.from : conn.to;
237
+ if (compatibleNodeIds.has(nodeId)) {
238
+ dot.classList.add('sn-dot-hint');
239
+ } else {
240
+ dot.classList.remove('sn-dot-hint');
241
+ }
242
+ }
243
+
244
+
245
+ let freeDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
246
+ for (const dot of freeDots) {
247
+ let nodeId = dot.getAttribute('data-node-id');
248
+ if (compatibleNodeIds.has(nodeId)) {
249
+ dot.classList.add('sn-dot-hint');
250
+ } else {
251
+ dot.classList.remove('sn-dot-hint');
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Clear all dot highlights
258
+ */
259
+ clearDotHighlights() {
260
+ let dots = this.#dotLayer.querySelectorAll('.sn-dot-hint');
261
+ for (const dot of dots) {
262
+ dot.classList.remove('sn-dot-hint');
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Update all connections touching a node
268
+ * @param {string} nodeId
269
+ */
270
+ updateForNode(nodeId) {
271
+
272
+
273
+ let draggedEl = this.#nodeViews.get(nodeId);
274
+ if (draggedEl) {
275
+ draggedEl._usedCoords = [];
276
+ draggedEl._slotCache = new Map();
277
+ }
278
+
279
+
280
+ let touchedConns = [];
281
+ for (const [, conn] of this.#connectionData) {
282
+ if (conn.from === nodeId || conn.to === nodeId) {
283
+ touchedConns.push(conn);
284
+ }
285
+ }
286
+
287
+ const previousRectCache = this._nodeRectCache;
288
+ this._nodeRectCache = this.#buildNodeRectCache();
289
+ try {
290
+ for (const conn of touchedConns) {
291
+ this.#render(conn, nodeId);
292
+ }
293
+ } finally {
294
+ this._nodeRectCache = previousRectCache || null;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Clear all caches and re-render every connection + free dots.
300
+ * Call after initial node positioning to let SVG connectors settle.
301
+ */
302
+ refreshAll() {
303
+ this.#clearAllSlots();
304
+
305
+
306
+ let staleDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
307
+ for (const dot of staleDots) dot.remove();
308
+
309
+
310
+ let originalSvgDisplay = this.#svgLayer.style.display;
311
+ let originalDotDisplay = this.#dotLayer.style.display;
312
+ this.#svgLayer.style.display = 'none';
313
+ this.#dotLayer.style.display = 'none';
314
+
315
+
316
+ this._nodeRectCache = new Map();
317
+ for (const [nid, el] of this.#nodeViews) {
318
+ if (el) {
319
+ this._nodeRectCache.set(nid, {
320
+ id: nid,
321
+ x: el._position?.x || 0,
322
+ y: el._position?.y || 0,
323
+ w: el.offsetWidth || el._cachedW || 180,
324
+ h: el.offsetHeight || el._cachedH || 100,
325
+ });
326
+ }
327
+ }
328
+
329
+
330
+ let conns = Array.from(this.#connectionData.values());
331
+
332
+
333
+ /** @type {Map<string, Array<{portKey: string, portSide: string, targetPos: {x:number, y:number}}>>} */
334
+ let nodeJobs = new Map();
335
+
336
+ for (const conn of conns) {
337
+ let fromEl = this.#nodeViews.get(conn.from);
338
+ let toEl = this.#nodeViews.get(conn.to);
339
+ if (!fromEl || !toEl) continue;
340
+
341
+ let fromPos = fromEl._position;
342
+ let toPos = toEl._position;
343
+ if (!fromPos || !toPos) continue;
344
+ const toSize = this.#getNodeSize(toEl, 180, 100);
345
+ const fromSize = this.#getNodeSize(fromEl, 180, 100);
346
+
347
+ let toCenter = {
348
+ x: toPos.x + (toEl._cachedW || 180) / 2,
349
+ y: toPos.y + (toEl._cachedH || 100) / 2,
350
+ };
351
+ let fromCenter = {
352
+ x: fromPos.x + (fromEl._cachedW || 180) / 2,
353
+ y: fromPos.y + (fromEl._cachedH || 100) / 2,
354
+ };
355
+
356
+ if (!nodeJobs.has(conn.from)) nodeJobs.set(conn.from, []);
357
+ nodeJobs.get(conn.from).push({ portKey: conn.out, portSide: 'output', targetPos: toCenter });
358
+
359
+ if (!nodeJobs.has(conn.to)) nodeJobs.set(conn.to, []);
360
+ nodeJobs.get(conn.to).push({ portKey: conn.in, portSide: 'input', targetPos: fromCenter });
361
+ }
362
+
363
+
364
+ for (const [nodeId, jobs] of nodeJobs) {
365
+ let el = this.#nodeViews.get(nodeId);
366
+ if (!el?._position) continue;
367
+
368
+ let shape = getShape(el.getAttribute('node-shape'));
369
+ if (shape?.pathData && shape.getEdgePoint) continue;
370
+ if (!shape?.getSidePosition) continue;
371
+
372
+ let size = { width: el._cachedW || 180, height: el._cachedH || 100 };
373
+ let cx = el._position.x + size.width / 2;
374
+ let cy = el._position.y + size.height / 2;
375
+
376
+ if (!el._slotCache) el._slotCache = new Map();
377
+
378
+
379
+ /** @type {Map<string, Array<{portKey: string, portSide: string, angle: number}>>} */
380
+ let sideBuckets = new Map();
381
+ for (const job of jobs) {
382
+ let dx = job.targetPos.x - cx;
383
+ let dy = job.targetPos.y - cy;
384
+ let angle = Math.atan2(dy, dx);
385
+
386
+
387
+ let nodeSide;
388
+ if (Math.abs(dx) > Math.abs(dy)) {
389
+ nodeSide = dx > 0 ? 'right' : 'left';
390
+ } else {
391
+ nodeSide = dy > 0 ? 'bottom' : 'top';
392
+ }
393
+
394
+ if (!sideBuckets.has(nodeSide)) sideBuckets.set(nodeSide, []);
395
+ sideBuckets.get(nodeSide).push({ portKey: job.portKey, portSide: job.portSide, angle });
396
+ }
397
+
398
+
399
+ for (const [nodeSide, bucket] of sideBuckets) {
400
+
401
+
402
+ if (nodeSide === 'left' || nodeSide === 'right') {
403
+ bucket.sort((a, b) => Math.sin(a.angle) - Math.sin(b.angle));
404
+ } else {
405
+ bucket.sort((a, b) => Math.cos(a.angle) - Math.cos(b.angle));
406
+ }
407
+
408
+
409
+ let total = bucket.length;
410
+ bucket.forEach((item, index) => {
411
+ let t = total === 1 ? 0.5 : index / (total - 1);
412
+ let pos = shape.getSidePosition(nodeSide, t, size);
413
+ let cacheKey = `${item.portKey}:${item.portSide}`;
414
+ el._slotCache.set(cacheKey, { x: pos.x, y: pos.y, angle: pos.angle });
415
+ });
416
+ }
417
+ }
418
+
419
+
420
+ for (const conn of conns) {
421
+ this.#render(conn);
422
+ }
423
+
424
+
425
+ for (const [nodeId, el] of this.#nodeViews) {
426
+ if (el.getAttribute('data-svg-shape')) {
427
+ this.renderFreeDots(nodeId);
428
+ }
429
+ }
430
+
431
+ this._allSegments = null;
432
+ this._nodeRectCache = null;
433
+
434
+
435
+ this.#svgLayer.style.display = originalSvgDisplay;
436
+ this.#dotLayer.style.display = originalDotDisplay;
437
+
438
+ }
439
+
440
+ /**
441
+ * SVG connections live inside the transformed content layer, so viewport
442
+ * pan/zoom does not require path geometry recalculation.
443
+ */
444
+ refreshViewportTransform() {}
445
+
446
+ /**
447
+ * Set data flow animation on a connection
448
+ * @param {string} connId
449
+ * @param {boolean} active
450
+ */
451
+ setFlowing(connId, active) {
452
+ let path = this.#svgLayer.querySelector(`[data-conn-id="${connId}"]`);
453
+ if (!path) return;
454
+ if (active) {
455
+ path.setAttribute('data-flowing', '');
456
+ } else {
457
+ path.removeAttribute('data-flowing');
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Set data flow animation on all connections
463
+ * @param {boolean} active
464
+ */
465
+ setAllFlowing(active) {
466
+ for (const [connId] of this.#connectionData) {
467
+ this.setFlowing(connId, active);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Set connection path style
473
+ * @param {'bezier'|'orthogonal'|'straight'|'pcb'} style
474
+ */
475
+ setPathStyle(style) {
476
+ this.#pathStyle = style;
477
+ this.#clearAllSlots();
478
+ for (const [, conn] of this.#connectionData) {
479
+ this.#render(conn);
480
+ }
481
+ }
482
+
483
+ /** @returns {'bezier'|'orthogonal'|'straight'} */
484
+ get pathStyle() {
485
+ return this.#pathStyle;
486
+ }
487
+
488
+ /**
489
+ * Get socket offset relative to graph-node.
490
+ * For SVG shapes with a target position, computes dynamic edge point
491
+ * in the direction of the connected node (connector slides along perimeter).
492
+ *
493
+ * @param {HTMLElement} nodeEl
494
+ * @param {string} portKey
495
+ * @param {'input'|'output'} side
496
+ * @param {{ x: number, y: number }} [targetPos] - center of the connected node (for dynamic edge)
497
+ * @returns {{ x: number, y: number }}
498
+ */
499
+ getSocketOffset(nodeEl, portKey, side, targetPos) {
500
+
501
+ let shape = getShape(nodeEl.getAttribute('node-shape'));
502
+ let nodeData = nodeEl._nodeData;
503
+ if (shape && shape.pathData && nodeData) {
504
+ let size = { width: nodeEl._cachedW || 180, height: nodeEl._cachedH || 100 };
505
+
506
+
507
+ if (targetPos && shape.getEdgePoint) {
508
+ let ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
509
+ let keys = ports ? Object.keys(ports) : [portKey];
510
+ let index = keys.indexOf(portKey);
511
+ let total = keys.length;
512
+
513
+ let nodePos = nodeEl._position;
514
+ let cx = nodePos.x + size.width / 2;
515
+ let cy = nodePos.y + size.height / 2;
516
+ let baseAngle = Math.atan2(targetPos.y - cy, targetPos.x - cx);
517
+
518
+
519
+ let sideGap = Math.PI / 6;
520
+ let adjustedBase = baseAngle + (side === 'output' ? -sideGap : sideGap);
521
+
522
+
523
+ let dy = targetPos.y - cy;
524
+ let shouldReverse = side === 'output' ? dy < 0 : dy > 0;
525
+ let effectiveIndex = shouldReverse ? total - 1 - index : index;
526
+
527
+
528
+ let angle = adjustedBase;
529
+ if (total > 1) {
530
+ let segment = (2 * Math.PI) / (total * 2);
531
+ let offset = (effectiveIndex - (total - 1) / 2) * segment;
532
+ angle = adjustedBase + offset;
533
+ }
534
+
535
+
536
+ let step = Math.PI / 12;
537
+ angle = Math.round(angle / step) * step;
538
+
539
+
540
+ if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
541
+ let cacheKey = `${portKey}:${side}:${Math.round(angle * 1000)}`;
542
+ if (nodeEl._slotCache.has(cacheKey)) {
543
+ return nodeEl._slotCache.get(cacheKey);
544
+ }
545
+
546
+
547
+ if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
548
+ const MIN_PIX = 5;
549
+ let nudged = angle;
550
+ let attempts = 0;
551
+ while (attempts < 24) {
552
+ let testPos = shape.getEdgePoint(nudged, size);
553
+ let tooClose = nodeEl._usedCoords.some(
554
+ (c) => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
555
+ );
556
+ if (!tooClose) break;
557
+ nudged += step;
558
+ attempts++;
559
+ }
560
+
561
+ let pos = shape.getEdgePoint(nudged, size);
562
+ nodeEl._usedCoords.push({ x: pos.x, y: pos.y });
563
+ let result = { x: pos.x, y: pos.y, angle: pos.angle };
564
+ nodeEl._slotCache.set(cacheKey, result);
565
+ return result;
566
+ }
567
+
568
+
569
+ if (targetPos && shape.getSidePosition) {
570
+
571
+ if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
572
+ let cacheKey = `${portKey}:${side}`;
573
+ if (nodeEl._slotCache.has(cacheKey)) {
574
+ return nodeEl._slotCache.get(cacheKey);
575
+ }
576
+
577
+
578
+ let nodePos = nodeEl._position;
579
+ let cx = nodePos.x + size.width / 2;
580
+ let cy = nodePos.y + size.height / 2;
581
+ let dx = targetPos.x - cx;
582
+ let dy = targetPos.y - cy;
583
+
584
+
585
+ let nodeSide =
586
+ Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top';
587
+
588
+ let pos = shape.getSidePosition(nodeSide, 0.5, size);
589
+ let result = { x: pos.x, y: pos.y, angle: pos.angle };
590
+ nodeEl._slotCache.set(cacheKey, result);
591
+ return result;
592
+ }
593
+
594
+ let ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
595
+ if (ports) {
596
+ let keys = Object.keys(ports);
597
+ let index = keys.indexOf(portKey);
598
+ let total = keys.length;
599
+ if (index >= 0) {
600
+ let pos = shape.getSocketPosition(side, index, total, size);
601
+ return { x: pos.x, y: pos.y };
602
+ }
603
+ }
604
+ }
605
+
606
+
607
+ if (nodeEl.style.contentVisibility === 'hidden') {
608
+ return {
609
+ x: side === 'output' ? nodeEl._cachedW || 180 : 0,
610
+ y: (nodeEl._cachedH || 100) / 2,
611
+ };
612
+ }
613
+
614
+
615
+ let container =
616
+ side === 'output' ? nodeEl.querySelector('.outputs') : nodeEl.querySelector('.inputs');
617
+
618
+ if (container) {
619
+ let portItems = container.querySelectorAll('port-item');
620
+ for (const portItem of portItems) {
621
+ if (portItem.$.key === portKey) {
622
+ let socket = portItem.querySelector('.sn-socket');
623
+ if (socket) {
624
+ let nodeRect = nodeEl.getBoundingClientRect();
625
+ let socketRect = socket.getBoundingClientRect();
626
+ let z = this.#getZoom();
627
+ return {
628
+ x: (socketRect.left - nodeRect.left + socketRect.width / 2) / z,
629
+ y: (socketRect.top - nodeRect.top + socketRect.height / 2) / z,
630
+ };
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ return {
637
+ x: side === 'output' ? nodeEl._cachedW || nodeEl.offsetWidth || 180 : 0,
638
+ y: (nodeEl._cachedH || nodeEl.offsetHeight || 100) / 2,
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Render a single connection SVG path with tangent-aware Bézier and gradient coloring
644
+ * @param {import('../core/Connection.js').Connection} conn
645
+ * @returns {void}
646
+ */
647
+ #render(conn) {
648
+ let fromEl = this.#nodeViews.get(conn.from);
649
+ let toEl = this.#nodeViews.get(conn.to);
650
+ if (!fromEl || !toEl) return;
651
+
652
+ let fromPos = fromEl._position;
653
+ let toPos = toEl._position;
654
+
655
+
656
+ let fromW = fromEl._cachedW || fromEl.offsetWidth || 180;
657
+ let fromH = fromEl._cachedH || fromEl.offsetHeight || 100;
658
+ let toW = toEl._cachedW || toEl.offsetWidth || 180;
659
+ let toH = toEl._cachedH || toEl.offsetHeight || 100;
660
+ let fromCenter = {
661
+ x: fromPos.x + fromW / 2,
662
+ y: fromPos.y + fromH / 2,
663
+ };
664
+ let toCenter = {
665
+ x: toPos.x + toW / 2,
666
+ y: toPos.y + toH / 2,
667
+ };
668
+
669
+
670
+ let fromOffset = this.getSocketOffset(fromEl, conn.out, 'output', toCenter);
671
+ let toOffset = this.getSocketOffset(toEl, conn.in, 'input', fromCenter);
672
+
673
+ let startX = fromPos.x + fromOffset.x;
674
+ let startY = fromPos.y + fromOffset.y;
675
+ let endX = toPos.x + toOffset.x;
676
+ let endY = toPos.y + toOffset.y;
677
+
678
+
679
+ let fromNode = this.#editor.getNode(conn.from);
680
+ let toNode = this.#editor.getNode(conn.to);
681
+ let fromShape = getShape(fromNode?.shape);
682
+ let toShape = getShape(toNode?.shape);
683
+
684
+ let fromSize = { width: fromW, height: fromH };
685
+ let toSize = { width: toW, height: toH };
686
+
687
+
688
+ let d;
689
+ if (this.#pathStyle === 'straight') {
690
+ d = `M ${startX} ${startY} L ${endX} ${endY}`;
691
+ } else if (this.#pathStyle === 'orthogonal') {
692
+ let connKeys = Array.from(this.#connectionData.keys());
693
+ let connIndex = connKeys.indexOf(conn.id);
694
+ let traceOffset = (connIndex > -1 ? connIndex % 10 : 0) * 4;
695
+
696
+ let fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
697
+ let toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
698
+
699
+ let stubLen = 20;
700
+ let getDxDy = (deg) => ({
701
+ dx: Math.round(Math.cos((deg * Math.PI) / 180)),
702
+ dy: Math.round(Math.sin((deg * Math.PI) / 180)),
703
+ });
704
+
705
+ let fDir = getDxDy(fromAngle);
706
+ let tDir = getDxDy(toAngle);
707
+
708
+ let p1x = startX + fDir.dx * stubLen;
709
+ let p1y = startY + fDir.dy * stubLen;
710
+ let p2x = endX + tDir.dx * stubLen;
711
+ let p2y = endY + tDir.dy * stubLen;
712
+
713
+ let fromH = fromEl._cachedH || 60;
714
+ let toH = toEl._cachedH || 60;
715
+
716
+ let pts = [
717
+ { x: startX, y: startY },
718
+ { x: p1x, y: p1y },
719
+ ];
720
+
721
+ if (endX < startX) {
722
+ let bottomY = Math.max(fromPos.y + fromH, toPos.y + toH) + 30 + traceOffset;
723
+ pts.push({ x: p1x, y: bottomY });
724
+ pts.push({ x: p2x, y: bottomY });
725
+ } else {
726
+ let maxH = Math.max(fromH, toH);
727
+ if (Math.abs(p1y - p2y) < maxH) {
728
+ let nodeBetween = false;
729
+ for (const [, node] of this.#nodeViews) {
730
+ if (!node._position) continue;
731
+ let nx = node._position.x;
732
+ let ny = node._position.y;
733
+ let nw = node._cachedW || 180;
734
+ let nh = node._cachedH || 60;
735
+ if (nx > p1x && nx + nw < p2x) {
736
+ if (Math.min(p1y, p2y) <= ny + nh && Math.max(p1y, p2y) >= ny) {
737
+ nodeBetween = true;
738
+ break;
739
+ }
740
+ }
741
+ }
742
+
743
+ if (nodeBetween) {
744
+ let detourY = Math.min(fromPos.y, toPos.y) - 30 - traceOffset;
745
+ pts.push({ x: p1x, y: detourY });
746
+ pts.push({ x: p2x, y: detourY });
747
+ } else {
748
+ let midX = (p1x + p2x) / 2 + traceOffset;
749
+ pts.push({ x: midX, y: p1y });
750
+ pts.push({ x: midX, y: p2y });
751
+ }
752
+ } else {
753
+ let midX = (p1x + p2x) / 2 + traceOffset;
754
+ let obstacleNode = null;
755
+ let minY = Math.min(p1y, p2y);
756
+ let maxY = Math.max(p1y, p2y);
757
+
758
+ for (const [, node] of this.#nodeViews) {
759
+ if (!node._position) continue;
760
+ let nx = node._position.x;
761
+ let ny = node._position.y;
762
+ let nw = node._cachedW || 180;
763
+ let nh = node._cachedH || 60;
764
+ if (midX >= nx && midX <= nx + nw) {
765
+ if (ny <= maxY && ny + nh >= minY) {
766
+ obstacleNode = { x: nx, w: nw };
767
+ break;
768
+ }
769
+ }
770
+ }
771
+
772
+ if (obstacleNode) {
773
+ let leftDist = Math.abs(midX - obstacleNode.x);
774
+ let rightDist = Math.abs(midX - (obstacleNode.x + obstacleNode.w));
775
+ if (leftDist < rightDist) {
776
+ midX = obstacleNode.x - 30 - traceOffset;
777
+ } else {
778
+ midX = obstacleNode.x + obstacleNode.w + 30 + traceOffset;
779
+ }
780
+ }
781
+
782
+ pts.push({ x: midX, y: p1y });
783
+ pts.push({ x: midX, y: p2y });
784
+ }
785
+ }
786
+
787
+ pts.push({ x: p2x, y: p2y });
788
+ pts.push({ x: endX, y: endY });
789
+
790
+ let path = `M ${pts[0].x} ${pts[0].y}`;
791
+ for (let i = 1; i < pts.length; i++) {
792
+ let prev = pts[i - 1];
793
+ let curr = pts[i];
794
+ if (curr.x === prev.x && curr.y === prev.y) continue;
795
+ if (curr.x !== prev.x && curr.y !== prev.y) {
796
+ path += ` H ${curr.x} V ${curr.y}`;
797
+ } else if (curr.x !== prev.x) {
798
+ path += ` H ${curr.x}`;
799
+ } else if (curr.y !== prev.y) {
800
+ path += ` V ${curr.y}`;
801
+ }
802
+ }
803
+ d = path;
804
+ } else if (this.#pathStyle === 'pcb') {
805
+ let routed = routePcbTrace({
806
+ start: { x: startX, y: startY },
807
+ end: { x: endX, y: endY },
808
+ fromRect: { id: conn.from, x: fromPos.x, y: fromPos.y, w: fromW, h: fromH },
809
+ toRect: { id: conn.to, x: toPos.x, y: toPos.y, w: toW, h: toH },
810
+ fromAngle: fromOffset.angle ?? 0,
811
+ toAngle: toOffset.angle ?? 180,
812
+ rects: this._nodeRectCache ? [...this._nodeRectCache.values()] : this.#nodeRects(),
813
+ connections: [...this.#connectionData.values()],
814
+ conn,
815
+ });
816
+ d = routed.path;
817
+ } else {
818
+
819
+ let fromAngleDeg, toAngleDeg;
820
+
821
+ if (fromOffset.angle !== undefined) {
822
+ fromAngleDeg = fromOffset.angle;
823
+ } else {
824
+ let fromPortIndex = fromNode ? Object.keys(fromNode.outputs).indexOf(conn.out) : 0;
825
+ let fromPortTotal = fromNode ? Object.keys(fromNode.outputs).length : 1;
826
+ let pos = fromShape?.getSocketPosition?.('output', fromPortIndex, fromPortTotal, fromSize);
827
+ fromAngleDeg = pos?.angle ?? 0;
828
+ }
829
+
830
+ if (toOffset.angle !== undefined) {
831
+ toAngleDeg = toOffset.angle;
832
+ } else {
833
+ let toPortIndex = toNode ? Object.keys(toNode.inputs).indexOf(conn.in) : 0;
834
+ let toPortTotal = toNode ? Object.keys(toNode.inputs).length : 1;
835
+ let pos = toShape?.getSocketPosition?.('input', toPortIndex, toPortTotal, toSize);
836
+ toAngleDeg = pos?.angle ?? 180;
837
+ }
838
+
839
+ let dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
840
+ let cpLen = Math.max(50, dist * 0.4);
841
+ let fromRad = (fromAngleDeg * Math.PI) / 180;
842
+ let toRad = (toAngleDeg * Math.PI) / 180;
843
+
844
+ let cp1x = startX + Math.cos(fromRad) * cpLen;
845
+ let cp1y = startY + Math.sin(fromRad) * cpLen;
846
+ let cp2x = endX + Math.cos(toRad) * cpLen;
847
+ let cp2y = endY + Math.sin(toRad) * cpLen;
848
+
849
+ d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`;
850
+ }
851
+
852
+ let path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
853
+ if (!path) {
854
+ path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
855
+ path.setAttribute('class', 'sn-conn-path');
856
+ path.setAttribute('data-conn-id', conn.id);
857
+ path.addEventListener('click', (e) => {
858
+ e.stopPropagation();
859
+ this.#onConnectionClick(conn.id, e);
860
+ });
861
+ this.#svgLayer.appendChild(path);
862
+ }
863
+ path.setAttribute('d', d);
864
+
865
+
866
+ let fromSocketName = fromNode?.outputs[conn.out]?.socket?.name || 'data';
867
+ if (
868
+ fromSocketName === 'exec' ||
869
+ fromSocketName === 'execution' ||
870
+ fromSocketName === 'trigger'
871
+ ) {
872
+ path.setAttribute('data-wire-type', 'exec');
873
+ path.style.strokeWidth = '3';
874
+ path.style.strokeDasharray = '8 4';
875
+ } else if (
876
+ fromSocketName === 'array' ||
877
+ fromSocketName === 'object' ||
878
+ fromSocketName === 'json'
879
+ ) {
880
+ path.setAttribute('data-wire-type', 'data-heavy');
881
+ path.style.strokeWidth = '2.5';
882
+ path.style.strokeDasharray = '';
883
+ } else {
884
+ path.removeAttribute('data-wire-type');
885
+ path.style.strokeWidth = '';
886
+ path.style.strokeDasharray = '';
887
+ }
888
+
889
+
890
+ this.#applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY);
891
+
892
+
893
+ let outSocketName = fromNode?.outputs?.[conn.out]?.socket?.name || 'data';
894
+ let inSocketName = toNode?.inputs?.[conn.in]?.socket?.name || outSocketName;
895
+
896
+
897
+ this.#updateDot(conn.id, 'start', startX, startY, 'output', outSocketName);
898
+ this.#updateDot(conn.id, 'end', endX, endY, 'input', inSocketName);
899
+
900
+
901
+ this.#updateArrow(conn.id, d);
902
+ }
903
+
904
+ /**
905
+ * Create or update a small circle dot at a connector endpoint
906
+ * @param {string} connId
907
+ * @param {'start'|'end'} end
908
+ * @param {number} x
909
+ * @param {number} y
910
+ * @param {'input'|'output'} side
911
+ * @param {string} socketType
912
+ */
913
+ #updateDot(connId, end, x, y, side = 'output', socketType = 'data') {
914
+ let dotId = `${connId}-${end}`;
915
+ let dot = this.#dotLayer.querySelector(`[data-conn-dot="${dotId}"]`);
916
+ if (!dot) {
917
+ dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
918
+ dot.setAttribute('data-conn-dot', dotId);
919
+ dot.setAttribute('r', '7');
920
+ this.#dotLayer.appendChild(dot);
921
+ }
922
+ dot.setAttribute('cx', x);
923
+ dot.setAttribute('cy', y);
924
+
925
+
926
+ if (!dot.hasAttribute('data-svg-wired')) {
927
+ let conn = this.#connectionData.get(connId);
928
+ if (conn) {
929
+ let nodeId = end === 'start' ? conn.from : conn.to;
930
+ let nodeEl = this.#nodeViews.get(nodeId);
931
+ if (nodeEl?.hasAttribute('data-svg-shape')) {
932
+ dot.setAttribute('data-svg-wired', '');
933
+ dot.style.display = '';
934
+ if (this.#onDotDrag) {
935
+ dot.style.pointerEvents = 'auto';
936
+ dot.style.cursor = 'crosshair';
937
+ dot.addEventListener('pointerdown', (e) => {
938
+ e.stopPropagation();
939
+ e.preventDefault();
940
+ let dotX = parseFloat(dot.getAttribute('cx')) || 0;
941
+ let dotY = parseFloat(dot.getAttribute('cy')) || 0;
942
+ let socketData =
943
+ end === 'start'
944
+ ? { nodeId: conn.from, key: conn.out, side: 'output', worldX: dotX, worldY: dotY }
945
+ : { nodeId: conn.to, key: conn.in, side: 'input', worldX: dotX, worldY: dotY };
946
+ this.#onDotDrag(socketData);
947
+ });
948
+ }
949
+ }
950
+ }
951
+ }
952
+
953
+
954
+ let typeClass = 'sn-dot-data';
955
+ if (socketType === 'exec' || socketType === 'execution' || socketType === 'trigger') {
956
+ typeClass = 'sn-dot-exec';
957
+ } else if (socketType === 'ctrl' || socketType === 'control' || socketType === 'signal') {
958
+ typeClass = 'sn-dot-ctrl';
959
+ }
960
+ let sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
961
+ dot.setAttribute('class', `sn-conn-dot ${sideClass} ${typeClass}`);
962
+ }
963
+
964
+ /**
965
+ * Create or update a direction arrow at the midpoint of a bezier path
966
+ * @param {string} connId
967
+ * @param {string} pathD - SVG path d attribute
968
+ */
969
+ #updateArrow(connId, pathD) {
970
+
971
+ let tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
972
+ tempPath.setAttribute('d', pathD);
973
+
974
+
975
+ this.#svgLayer.appendChild(tempPath);
976
+ let totalLen = tempPath.getTotalLength();
977
+ if (totalLen < 1) {
978
+ tempPath.remove();
979
+ return;
980
+ }
981
+
982
+
983
+ let mid = tempPath.getPointAtLength(totalLen * 0.5);
984
+
985
+
986
+ let delta = Math.max(0.5, totalLen * 0.005);
987
+ let p1 = tempPath.getPointAtLength(Math.max(0, totalLen * 0.5 - delta));
988
+ let p2 = tempPath.getPointAtLength(Math.min(totalLen, totalLen * 0.5 + delta));
989
+ tempPath.remove();
990
+
991
+ let angle = (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
992
+
993
+ let arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${connId}"]`);
994
+ if (!arrow) {
995
+ arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
996
+ arrow.setAttribute('data-conn-arrow', connId);
997
+ arrow.setAttribute('class', 'sn-conn-arrow');
998
+ arrow.setAttribute('points', '-5,-3.5 5,0 -5,3.5');
999
+ this.#svgLayer.appendChild(arrow);
1000
+ }
1001
+ arrow.setAttribute('transform', `translate(${mid.x},${mid.y}) rotate(${angle})`);
1002
+ }
1003
+
1004
+ /**
1005
+ * Apply socket-color gradient to connection path
1006
+ * @param {SVGPathElement} path
1007
+ * @param {import('../core/Connection.js').Connection} conn
1008
+ * @param {import('../core/Node.js').Node|undefined} fromNode
1009
+ * @param {import('../core/Node.js').Node|undefined} toNode
1010
+ * @param {number} startX
1011
+ * @param {number} startY
1012
+ * @param {number} endX
1013
+ * @param {number} endY
1014
+ */
1015
+ #applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY) {
1016
+ let fromColor = fromNode?.outputs[conn.out]?.socket?.color;
1017
+ let toColor = toNode?.inputs[conn.in]?.socket?.color;
1018
+
1019
+ if (fromColor && toColor && fromColor !== toColor) {
1020
+ let gradId = `grad-${conn.id}`;
1021
+ let defs = this.#svgLayer.querySelector('defs');
1022
+ if (!defs) {
1023
+ defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1024
+ this.#svgLayer.prepend(defs);
1025
+ }
1026
+ let grad = defs.querySelector(`#${gradId}`);
1027
+ if (!grad) {
1028
+ grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
1029
+ grad.setAttribute('id', gradId);
1030
+ let stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1031
+ stop1.setAttribute('offset', '0%');
1032
+ let stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1033
+ stop2.setAttribute('offset', '100%');
1034
+ grad.appendChild(stop1);
1035
+ grad.appendChild(stop2);
1036
+ defs.appendChild(grad);
1037
+ }
1038
+ grad.setAttribute('gradientUnits', 'userSpaceOnUse');
1039
+ grad.setAttribute('x1', String(startX));
1040
+ grad.setAttribute('y1', String(startY));
1041
+ grad.setAttribute('x2', String(endX));
1042
+ grad.setAttribute('y2', String(endY));
1043
+ grad.children[0].setAttribute('stop-color', fromColor);
1044
+ grad.children[1].setAttribute('stop-color', toColor);
1045
+ path.setAttribute('stroke', `url(#${gradId})`);
1046
+ } else if (fromColor) {
1047
+ path.setAttribute('stroke', fromColor);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Render persistent free dots for all ports of an SVG node.
1052
+ * Called after SVG shape setup. Free dots are interactive drag sources.
1053
+ * @param {string} nodeId
1054
+ */
1055
+ renderFreeDots(nodeId) {
1056
+ let nodeEl = this.#nodeViews.get(nodeId);
1057
+ let node = this.#editor.getNode(nodeId);
1058
+ if (!nodeEl || !node) return;
1059
+
1060
+ let shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
1061
+ let shape = getShape(shapeName);
1062
+ if (!shape?.pathData || !shape.getEdgePoint) return;
1063
+
1064
+ let size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
1065
+ let pos = nodeEl._position;
1066
+ if (!pos) return;
1067
+
1068
+
1069
+ let connectedPorts = new Set();
1070
+ for (const [, conn] of this.#connectionData) {
1071
+ if (conn.from === nodeId) connectedPorts.add(`output:${conn.out}`);
1072
+ if (conn.to === nodeId) connectedPorts.add(`input:${conn.in}`);
1073
+ }
1074
+
1075
+
1076
+ if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
1077
+ const MIN_PIX = 12;
1078
+ let step = Math.PI / 12;
1079
+
1080
+
1081
+ let placeDot = (key, side, baseAngle, portData) => {
1082
+
1083
+ let angle = Math.round(baseAngle / step) * step;
1084
+ let nudged = angle;
1085
+ let attempts = 0;
1086
+
1087
+ while (attempts < 24) {
1088
+ let testPos = shape.getEdgePoint(nudged, size);
1089
+ let tooClose = nodeEl._usedCoords.some(
1090
+ (c) => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
1091
+ );
1092
+ if (!tooClose) break;
1093
+ attempts++;
1094
+ let offset = Math.ceil(attempts / 2) * step;
1095
+ let dir = attempts % 2 === 1 ? 1 : -1;
1096
+ nudged = angle + dir * offset;
1097
+ }
1098
+
1099
+ let ep = shape.getEdgePoint(nudged, size);
1100
+ nodeEl._usedCoords.push({ x: ep.x, y: ep.y });
1101
+
1102
+ this.#createFreeDot(nodeId, key, side, pos.x + ep.x, pos.y + ep.y, portData);
1103
+ };
1104
+
1105
+
1106
+ let inputKeys = Object.keys(node.inputs);
1107
+ inputKeys.forEach((key, i) => {
1108
+ if (connectedPorts.has(`input:${key}`)) return;
1109
+ let spread = Math.PI * 0.4;
1110
+ let baseAngle =
1111
+ Math.PI + (inputKeys.length > 1 ? (i / (inputKeys.length - 1) - 0.5) * spread : 0);
1112
+ placeDot(key, 'input', baseAngle, node.inputs[key]);
1113
+ });
1114
+
1115
+
1116
+ let outputKeys = Object.keys(node.outputs);
1117
+ outputKeys.forEach((key, i) => {
1118
+ if (connectedPorts.has(`output:${key}`)) return;
1119
+ let spread = Math.PI * 0.4;
1120
+ let baseAngle =
1121
+ 0 + (outputKeys.length > 1 ? (i / (outputKeys.length - 1) - 0.5) * spread : 0);
1122
+ placeDot(key, 'output', baseAngle, node.outputs[key]);
1123
+ });
1124
+ }
1125
+
1126
+ /**
1127
+ * Create a single free dot element
1128
+ * @param {string} nodeId
1129
+ * @param {string} key
1130
+ * @param {'input'|'output'} side
1131
+ * @param {number} wx - world X
1132
+ * @param {number} wy - world Y
1133
+ * @param {object} portData - Input/Output instance
1134
+ */
1135
+ #createFreeDot(nodeId, key, side, wx, wy, portData) {
1136
+ let dotId = `free-${nodeId}-${side}-${key}`;
1137
+
1138
+ if (this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`)) return;
1139
+
1140
+ let dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1141
+ dot.setAttribute('data-free-dot', dotId);
1142
+ dot.setAttribute('data-node-id', nodeId);
1143
+ dot.setAttribute('data-port-key', key);
1144
+ dot.setAttribute('data-port-side', side);
1145
+ dot.setAttribute('r', '7');
1146
+ dot.setAttribute('cx', wx);
1147
+ dot.setAttribute('cy', wy);
1148
+ dot.style.pointerEvents = 'auto';
1149
+ dot.style.cursor = 'crosshair';
1150
+
1151
+ let socketName = portData?.socket?.name || 'data';
1152
+ let typeClass = 'sn-dot-data';
1153
+ if (socketName === 'exec' || socketName === 'execution' || socketName === 'trigger') {
1154
+ typeClass = 'sn-dot-exec';
1155
+ } else if (socketName === 'ctrl' || socketName === 'control' || socketName === 'signal') {
1156
+ typeClass = 'sn-dot-ctrl';
1157
+ }
1158
+ let sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
1159
+ dot.setAttribute('class', `sn-free-dot ${sideClass} ${typeClass}`);
1160
+
1161
+ if (this.#onDotDrag) {
1162
+ dot.addEventListener('pointerdown', (e) => {
1163
+ e.stopPropagation();
1164
+ e.preventDefault();
1165
+ this.#onDotDrag({
1166
+ nodeId,
1167
+ key,
1168
+ side,
1169
+ worldX: parseFloat(dot.getAttribute('cx')) || 0,
1170
+ worldY: parseFloat(dot.getAttribute('cy')) || 0,
1171
+ });
1172
+ });
1173
+ }
1174
+
1175
+ this.#dotLayer.appendChild(dot);
1176
+ }
1177
+
1178
+ /**
1179
+ * Remove free dot when a connection fills this port
1180
+ * @param {string} nodeId
1181
+ * @param {string} key
1182
+ * @param {'input'|'output'} side
1183
+ */
1184
+ removeFreeDot(nodeId, key, side) {
1185
+ let dotId = `free-${nodeId}-${side}-${key}`;
1186
+ let dot = this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`);
1187
+ if (dot) dot.remove();
1188
+ }
1189
+
1190
+ /**
1191
+ * Refresh free dot positions after node move (updates coords without recreating)
1192
+ * @param {string} nodeId
1193
+ */
1194
+ refreshFreeDots(nodeId) {
1195
+ let dots = this.#dotLayer.querySelectorAll(`[data-node-id="${nodeId}"][data-free-dot]`);
1196
+ if (!dots.length) {
1197
+
1198
+ this.renderFreeDots(nodeId);
1199
+ return;
1200
+ }
1201
+
1202
+ let nodeEl = this.#nodeViews.get(nodeId);
1203
+ let node = this.#editor.getNode(nodeId);
1204
+ if (!nodeEl || !node) return;
1205
+
1206
+ let shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
1207
+ let shape = getShape(shapeName);
1208
+ if (!shape?.pathData) return;
1209
+
1210
+ let size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
1211
+ let pos = nodeEl._position;
1212
+ if (!pos) return;
1213
+
1214
+ for (const dot of dots) {
1215
+ let key = dot.getAttribute('data-port-key');
1216
+ let side = dot.getAttribute('data-port-side');
1217
+ let ports = side === 'output' ? node.outputs : node.inputs;
1218
+ let keys = Object.keys(ports);
1219
+ let index = keys.indexOf(key);
1220
+ if (index < 0) continue;
1221
+ let sp = shape.getSocketPosition(side, index, keys.length, size);
1222
+ dot.setAttribute('cx', pos.x + sp.x);
1223
+ dot.setAttribute('cy', pos.y + sp.y);
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Find nearest SVG dot (free or connected) to world position within radius.
1229
+ * Used as drop target for connections.
1230
+ * @param {number} wx - world X
1231
+ * @param {number} wy - world Y
1232
+ * @param {number} [radius=20] - search radius in world units
1233
+ * @returns {{ nodeId: string, key: string, side: string }|null}
1234
+ */
1235
+ findNearestDot(wx, wy, radius = 20) {
1236
+ let bestDist = radius;
1237
+ let best = null;
1238
+
1239
+
1240
+ let freeDots = this.#dotLayer.querySelectorAll('[data-free-dot]');
1241
+ for (const dot of freeDots) {
1242
+ let cx = parseFloat(dot.getAttribute('cx')) || 0;
1243
+ let cy = parseFloat(dot.getAttribute('cy')) || 0;
1244
+ let dist = Math.hypot(cx - wx, cy - wy);
1245
+ if (dist < bestDist) {
1246
+ bestDist = dist;
1247
+ best = {
1248
+ nodeId: dot.getAttribute('data-node-id'),
1249
+ key: dot.getAttribute('data-port-key'),
1250
+ side: dot.getAttribute('data-port-side'),
1251
+ };
1252
+ }
1253
+ }
1254
+
1255
+
1256
+ let wiredDots = this.#dotLayer.querySelectorAll('[data-svg-wired=""]');
1257
+ for (const dot of wiredDots) {
1258
+ let cx = parseFloat(dot.getAttribute('cx')) || 0;
1259
+ let cy = parseFloat(dot.getAttribute('cy')) || 0;
1260
+ let dist = Math.hypot(cx - wx, cy - wy);
1261
+ if (dist < bestDist) {
1262
+ bestDist = dist;
1263
+ let connDotId = dot.getAttribute('data-conn-dot');
1264
+
1265
+ let isStart = connDotId.endsWith('-start');
1266
+ let connId = connDotId.replace(/-(?:start|end)$/, '');
1267
+ let conn = this.#connectionData.get(connId);
1268
+ if (conn) {
1269
+ best = {
1270
+ nodeId: isStart ? conn.from : conn.to,
1271
+ key: isStart ? conn.out : conn.in,
1272
+ side: isStart ? 'output' : 'input',
1273
+ };
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ return best;
1279
+ }
1280
+ }
1281
+
1282
+ /** @type {boolean} Set to true to enable debug logging for pin placement and routing */
1283
+ ConnectionRenderer.debug = false;