project-graph-mcp 2.3.2 → 2.4.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 (279) hide show
  1. package/package.json +3 -2
  2. package/src/analysis/analysis-cache.ctx +9 -0
  3. package/src/analysis/analysis-cache.js +1 -1
  4. package/src/analysis/complexity.ctx +6 -0
  5. package/src/analysis/complexity.js +1 -1
  6. package/src/analysis/custom-rules.ctx +14 -0
  7. package/src/analysis/custom-rules.js +1 -1
  8. package/src/analysis/db-analysis.ctx +7 -0
  9. package/src/analysis/db-analysis.js +1 -1
  10. package/src/analysis/dead-code.ctx +6 -0
  11. package/src/analysis/dead-code.js +1 -1
  12. package/src/analysis/full-analysis.ctx +9 -0
  13. package/src/analysis/full-analysis.js +1 -1
  14. package/src/analysis/jsdoc-checker.ctx +10 -0
  15. package/src/analysis/jsdoc-checker.js +1 -1
  16. package/src/analysis/jsdoc-generator.ctx +9 -0
  17. package/src/analysis/jsdoc-generator.js +1 -1
  18. package/src/analysis/large-files.ctx +6 -0
  19. package/src/analysis/large-files.js +1 -1
  20. package/src/analysis/outdated-patterns.ctx +7 -0
  21. package/src/analysis/outdated-patterns.js +1 -1
  22. package/src/analysis/similar-functions.ctx +6 -0
  23. package/src/analysis/similar-functions.js +1 -1
  24. package/src/analysis/test-annotations.ctx +11 -0
  25. package/src/analysis/test-annotations.js +1 -1
  26. package/src/analysis/type-checker.ctx +6 -0
  27. package/src/analysis/type-checker.js +1 -1
  28. package/src/analysis/undocumented.ctx +8 -0
  29. package/src/analysis/undocumented.js +1 -1
  30. package/src/cli/cli-handlers.ctx +7 -0
  31. package/src/cli/cli-handlers.js +1 -1
  32. package/src/cli/cli.ctx +6 -0
  33. package/src/cli/cli.js +1 -1
  34. package/src/compact/ai-context.ctx +6 -0
  35. package/src/compact/ai-context.js +1 -1
  36. package/src/compact/compact-migrate.ctx +8 -0
  37. package/src/compact/compact-migrate.js +1 -1
  38. package/src/compact/compact.ctx +11 -0
  39. package/src/compact/compact.js +1 -1
  40. package/src/compact/compress.ctx +7 -0
  41. package/src/compact/compress.js +1 -1
  42. package/src/compact/ctx-resolver.ctx +2 -0
  43. package/src/compact/ctx-resolver.js +1 -1
  44. package/src/compact/ctx-to-jsdoc.ctx +11 -0
  45. package/src/compact/ctx-to-jsdoc.js +1 -1
  46. package/src/compact/doc-dialect.ctx +11 -0
  47. package/src/compact/doc-dialect.js +2 -2
  48. package/src/compact/expand.ctx +14 -0
  49. package/src/compact/expand.js +1 -1
  50. package/src/compact/framework-references.ctx +7 -0
  51. package/src/compact/framework-references.js +1 -1
  52. package/src/compact/instructions.ctx +6 -0
  53. package/src/compact/instructions.js +1 -1
  54. package/src/compact/jsdoc-builder.ctx +4 -0
  55. package/src/compact/jsdoc-builder.js +1 -1
  56. package/src/compact/mode-config.ctx +8 -0
  57. package/src/compact/mode-config.js +1 -1
  58. package/src/compact/split-declarations.ctx +6 -0
  59. package/src/compact/split-declarations.js +1 -1
  60. package/src/compact/validate-pipeline.ctx +12 -0
  61. package/src/compact/validate-pipeline.js +1 -1
  62. package/src/core/event-bus.ctx +9 -0
  63. package/src/core/event-bus.js +1 -1
  64. package/src/core/file-walker.ctx +1 -0
  65. package/src/core/file-walker.js +1 -1
  66. package/src/core/filters.ctx +12 -0
  67. package/src/core/filters.js +1 -1
  68. package/src/core/graph-builder.ctx +7 -0
  69. package/src/core/graph-builder.js +1 -1
  70. package/src/core/parser.ctx +12 -0
  71. package/src/core/parser.js +1 -1
  72. package/src/core/utils.ctx +1 -0
  73. package/src/core/utils.js +1 -1
  74. package/src/core/workspace.ctx +7 -0
  75. package/src/core/workspace.js +1 -1
  76. package/src/lang/lang-go.ctx +8 -0
  77. package/src/lang/lang-go.js +1 -1
  78. package/src/lang/lang-python.ctx +5 -0
  79. package/src/lang/lang-python.js +1 -1
  80. package/src/lang/lang-sql.ctx +10 -0
  81. package/src/lang/lang-sql.js +1 -1
  82. package/src/lang/lang-typescript.ctx +6 -0
  83. package/src/lang/lang-typescript.js +1 -1
  84. package/src/lang/lang-utils.ctx +5 -0
  85. package/src/lang/lang-utils.js +1 -1
  86. package/src/mcp/mcp-server.ctx +6 -0
  87. package/src/mcp/mcp-server.js +1 -1
  88. package/src/mcp/tool-defs.ctx +2 -0
  89. package/src/mcp/tool-defs.js +1 -1
  90. package/src/mcp/tools.ctx +13 -0
  91. package/src/mcp/tools.js +1 -1
  92. package/src/network/backend-lifecycle.ctx +10 -0
  93. package/src/network/backend-lifecycle.js +1 -1
  94. package/src/network/backend.ctx +5 -0
  95. package/src/network/backend.js +1 -1
  96. package/src/network/local-gateway.ctx +9 -0
  97. package/src/network/local-gateway.js +1 -1
  98. package/src/network/mdns.ctx +6 -0
  99. package/src/network/mdns.js +1 -1
  100. package/src/network/server.ctx +2 -0
  101. package/src/network/server.js +2 -2
  102. package/src/network/web-server.ctx +17 -0
  103. package/src/network/web-server.js +2 -2
  104. package/web/follow-controller.js +94 -25
  105. package/web/panels/dep-graph.js +207 -21
  106. package/project-graph-mcp-2.3.0.tgz +0 -0
  107. package/vendor/symbiote-node/CHANGELOG.md +0 -31
  108. package/vendor/symbiote-node/LICENSE +0 -21
  109. package/vendor/symbiote-node/README.md +0 -206
  110. package/vendor/symbiote-node/canvas/AutoLayout.js +0 -725
  111. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +0 -73
  112. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +0 -93
  113. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +0 -9
  114. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +0 -962
  115. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +0 -1468
  116. package/vendor/symbiote-node/canvas/FlowSimulator.js +0 -323
  117. package/vendor/symbiote-node/canvas/ForceLayout.js +0 -189
  118. package/vendor/symbiote-node/canvas/ForceWorker.js +0 -1325
  119. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +0 -97
  120. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +0 -176
  121. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +0 -12
  122. package/vendor/symbiote-node/canvas/LODManager.js +0 -88
  123. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +0 -71
  124. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +0 -207
  125. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +0 -9
  126. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +0 -261
  127. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +0 -1840
  128. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +0 -22
  129. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +0 -97
  130. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +0 -132
  131. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +0 -21
  132. package/vendor/symbiote-node/canvas/NodeViewManager.js +0 -584
  133. package/vendor/symbiote-node/canvas/PinExpansion.js +0 -131
  134. package/vendor/symbiote-node/canvas/PseudoConnection.js +0 -80
  135. package/vendor/symbiote-node/canvas/SubgraphManager.js +0 -201
  136. package/vendor/symbiote-node/canvas/SubgraphRouter.js +0 -443
  137. package/vendor/symbiote-node/canvas/ViewportActions.js +0 -446
  138. package/vendor/symbiote-node/core/Connection.js +0 -45
  139. package/vendor/symbiote-node/core/Editor.js +0 -451
  140. package/vendor/symbiote-node/core/Frame.js +0 -31
  141. package/vendor/symbiote-node/core/GraphMermaid.js +0 -348
  142. package/vendor/symbiote-node/core/GraphText.js +0 -210
  143. package/vendor/symbiote-node/core/Node.js +0 -143
  144. package/vendor/symbiote-node/core/Portal.js +0 -104
  145. package/vendor/symbiote-node/core/Socket.js +0 -185
  146. package/vendor/symbiote-node/core/SubgraphNode.js +0 -125
  147. package/vendor/symbiote-node/engine/AgentUICommands.js +0 -100
  148. package/vendor/symbiote-node/engine/Executor.js +0 -371
  149. package/vendor/symbiote-node/engine/Graph.js +0 -314
  150. package/vendor/symbiote-node/engine/GraphServer.js +0 -353
  151. package/vendor/symbiote-node/engine/HandlerLoader.js +0 -145
  152. package/vendor/symbiote-node/engine/History.js +0 -83
  153. package/vendor/symbiote-node/engine/Lifecycle.js +0 -118
  154. package/vendor/symbiote-node/engine/Persistence.js +0 -84
  155. package/vendor/symbiote-node/engine/Registry.js +0 -264
  156. package/vendor/symbiote-node/engine/SocketTypes.js +0 -79
  157. package/vendor/symbiote-node/engine/cli.js +0 -404
  158. package/vendor/symbiote-node/engine/index.js +0 -56
  159. package/vendor/symbiote-node/engine/nanoid.js +0 -28
  160. package/vendor/symbiote-node/engine/package.json +0 -26
  161. package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +0 -215
  162. package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +0 -238
  163. package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +0 -287
  164. package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +0 -565
  165. package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +0 -414
  166. package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +0 -343
  167. package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +0 -164
  168. package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +0 -341
  169. package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +0 -241
  170. package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +0 -191
  171. package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +0 -67
  172. package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +0 -281
  173. package/vendor/symbiote-node/engine/packs/data/personas.handler.js +0 -160
  174. package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +0 -193
  175. package/vendor/symbiote-node/engine/packs/data/roles.handler.js +0 -216
  176. package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +0 -244
  177. package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +0 -52
  178. package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +0 -73
  179. package/vendor/symbiote-node/engine/packs/flow/if.handler.js +0 -107
  180. package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +0 -58
  181. package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +0 -60
  182. package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +0 -65
  183. package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +0 -64
  184. package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +0 -39
  185. package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +0 -82
  186. package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +0 -60
  187. package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +0 -63
  188. package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +0 -494
  189. package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +0 -417
  190. package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +0 -43
  191. package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +0 -339
  192. package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +0 -432
  193. package/vendor/symbiote-node/engine/packs/transform/set.handler.js +0 -57
  194. package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +0 -134
  195. package/vendor/symbiote-node/engine/packs/transform/template.handler.js +0 -79
  196. package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +0 -399
  197. package/vendor/symbiote-node/engine/packs/util/delay.handler.js +0 -39
  198. package/vendor/symbiote-node/engine/packs/util/log.handler.js +0 -44
  199. package/vendor/symbiote-node/engine/packs/video-pack.js +0 -323
  200. package/vendor/symbiote-node/index.js +0 -103
  201. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +0 -361
  202. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +0 -332
  203. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +0 -96
  204. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +0 -104
  205. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +0 -133
  206. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +0 -33
  207. package/vendor/symbiote-node/interactions/ConnectFlow.js +0 -307
  208. package/vendor/symbiote-node/interactions/Drag.js +0 -102
  209. package/vendor/symbiote-node/interactions/Selector.js +0 -132
  210. package/vendor/symbiote-node/interactions/SnapGrid.js +0 -65
  211. package/vendor/symbiote-node/interactions/Zoom.js +0 -140
  212. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +0 -88
  213. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +0 -254
  214. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +0 -11
  215. package/vendor/symbiote-node/layout/Layout/Layout.css.js +0 -88
  216. package/vendor/symbiote-node/layout/Layout/Layout.js +0 -622
  217. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +0 -25
  218. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +0 -293
  219. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +0 -467
  220. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +0 -33
  221. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +0 -46
  222. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +0 -102
  223. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +0 -6
  224. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +0 -156
  225. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +0 -250
  226. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +0 -379
  227. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +0 -263
  228. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +0 -20
  229. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +0 -183
  230. package/vendor/symbiote-node/layout/LayoutTree.js +0 -246
  231. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +0 -43
  232. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +0 -89
  233. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +0 -14
  234. package/vendor/symbiote-node/layout/index.js +0 -16
  235. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +0 -61
  236. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +0 -79
  237. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +0 -19
  238. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +0 -41
  239. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +0 -24
  240. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +0 -16
  241. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +0 -65
  242. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +0 -29
  243. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +0 -13
  244. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +0 -683
  245. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +0 -92
  246. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +0 -17
  247. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +0 -25
  248. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +0 -7
  249. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +0 -90
  250. package/vendor/symbiote-node/node/PortItem/PortItem.js +0 -87
  251. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +0 -10
  252. package/vendor/symbiote-node/package.json +0 -59
  253. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +0 -143
  254. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +0 -131
  255. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +0 -16
  256. package/vendor/symbiote-node/plugins/History.js +0 -384
  257. package/vendor/symbiote-node/plugins/Readonly.js +0 -59
  258. package/vendor/symbiote-node/shapes/CircleShape.js +0 -80
  259. package/vendor/symbiote-node/shapes/CommentShape.js +0 -35
  260. package/vendor/symbiote-node/shapes/DiamondShape.js +0 -115
  261. package/vendor/symbiote-node/shapes/NodeShape.js +0 -80
  262. package/vendor/symbiote-node/shapes/PillShape.js +0 -91
  263. package/vendor/symbiote-node/shapes/RectShape.js +0 -72
  264. package/vendor/symbiote-node/shapes/SVGShape.js +0 -494
  265. package/vendor/symbiote-node/shapes/index.js +0 -53
  266. package/vendor/symbiote-node/themes/Palette.js +0 -32
  267. package/vendor/symbiote-node/themes/Skin.js +0 -113
  268. package/vendor/symbiote-node/themes/Theme.js +0 -84
  269. package/vendor/symbiote-node/themes/carbon.js +0 -137
  270. package/vendor/symbiote-node/themes/dark.js +0 -137
  271. package/vendor/symbiote-node/themes/ebook.js +0 -138
  272. package/vendor/symbiote-node/themes/grey.js +0 -137
  273. package/vendor/symbiote-node/themes/light.js +0 -137
  274. package/vendor/symbiote-node/themes/neon.js +0 -138
  275. package/vendor/symbiote-node/themes/pcb.js +0 -273
  276. package/vendor/symbiote-node/themes/synthwave.js +0 -137
  277. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +0 -86
  278. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +0 -128
  279. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +0 -29
@@ -1,1468 +0,0 @@
1
- /**
2
- * ConnectionRenderer — SVG connection path manager
3
- *
4
- * Handles rendering Bézier curves between sockets,
5
- * gradient coloring, flow animation, socket offset calculation.
6
- * Extracted from NodeCanvas to reduce complexity.
7
- *
8
- * @module symbiote-node/canvas/ConnectionRenderer
9
- */
10
-
11
- import { getShape } from '../shapes/index.js';
12
-
13
- export class ConnectionRenderer {
14
-
15
- /** @type {Map<string, import('../core/Connection.js').Connection>} */
16
- #connectionData = new Map();
17
-
18
- /** @type {SVGElement} */
19
- #svgLayer;
20
-
21
- /** @type {SVGElement} - overlay layer for dots (z-index above nodes) */
22
- #dotLayer;
23
-
24
- /** @type {Map<string, HTMLElement>} */
25
- #nodeViews;
26
-
27
- /** @type {import('../core/Editor.js').NodeEditor} */
28
- #editor;
29
-
30
- /** @type {function} */
31
- #onConnectionClick;
32
-
33
- /** @type {function} */
34
- #getZoom;
35
-
36
- /** @type {function|null} - callback when dot is dragged: (socketData) => void */
37
- #onDotDrag = null;
38
-
39
- /** @type {'bezier'|'orthogonal'|'straight'|'pcb'} */
40
- #pathStyle = 'bezier';
41
-
42
- /**
43
- * @param {object} config
44
- * @param {SVGElement} config.svgLayer
45
- * @param {Map<string, HTMLElement>} config.nodeViews
46
- * @param {import('../core/Editor.js').NodeEditor} config.editor
47
- * @param {function} config.onConnectionClick - (connId, event)
48
- * @param {function} config.getZoom - Returns current zoom level
49
- */
50
- constructor({ svgLayer, dotLayer, nodeViews, editor, onConnectionClick, getZoom, onDotDrag }) {
51
- this.#svgLayer = svgLayer;
52
- this.#dotLayer = dotLayer || svgLayer;
53
- this.#nodeViews = nodeViews;
54
- this.#editor = editor;
55
- this.#onConnectionClick = onConnectionClick;
56
- this.#getZoom = getZoom || (() => 1);
57
- this.#onDotDrag = onDotDrag || null;
58
- }
59
-
60
- /** @returns {Map<string, import('../core/Connection.js').Connection>} */
61
- get data() {
62
- return this.#connectionData;
63
- }
64
-
65
- /**
66
- * Add a connection and render its SVG path
67
- * @param {import('../core/Connection.js').Connection} conn
68
- */
69
- add(conn) {
70
- this.#connectionData.set(conn.id, conn);
71
- // Remove free dots for now-connected ports
72
- this.removeFreeDot(conn.from, conn.out, 'output');
73
- this.removeFreeDot(conn.to, conn.in, 'input');
74
- // Full re-render for affected nodes (slot pool needs full context)
75
- this.#fullRerenderForNodes(new Set([conn.from, conn.to]));
76
- }
77
-
78
- /**
79
- * Bulk add connections and render them in a single batch.
80
- * Greatly improves performance when inflating large graphs.
81
- * @param {import('../core/Connection.js').Connection[]} conns
82
- */
83
- addBatch(conns) {
84
- if (!conns || conns.length === 0) return;
85
- for (const conn of conns) {
86
- this.#connectionData.set(conn.id, conn);
87
- }
88
- this.refreshAll();
89
- }
90
-
91
- /**
92
- * Clear slot registries and caches for all known nodes
93
- */
94
- #clearAllSlots() {
95
- for (const [, el] of this.#nodeViews) {
96
- el._usedCoords = [];
97
- el._slotCache = new Map();
98
- }
99
- }
100
-
101
- /**
102
- * Full re-render: clear all slots for affected nodes, recalculate everything
103
- * @param {Set<string>} nodeIds
104
- */
105
- #fullRerenderForNodes(nodeIds) {
106
- const allNodes = new Set(nodeIds);
107
- const conns = [];
108
- for (const [, conn] of this.#connectionData) {
109
- if (nodeIds.has(conn.from) || nodeIds.has(conn.to)) {
110
- allNodes.add(conn.from);
111
- allNodes.add(conn.to);
112
- conns.push(conn);
113
- }
114
- }
115
- for (const nid of allNodes) {
116
- const el = this.#nodeViews.get(nid);
117
- if (el) {
118
- el._usedCoords = [];
119
- el._slotCache = new Map();
120
- }
121
- }
122
- for (const conn of conns) {
123
- this.#render(conn);
124
- }
125
- }
126
-
127
- /**
128
- * Remove a connection path
129
- * @param {import('../core/Connection.js').Connection} conn
130
- */
131
- remove(conn) {
132
- const fromId = conn.from;
133
- const toId = conn.to;
134
- this.#connectionData.delete(conn.id);
135
- const path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
136
- if (path) {
137
- // Fade out using existing CSS opacity transition
138
- path.style.opacity = '0';
139
- path.addEventListener('transitionend', () => path.remove(), { once: true });
140
- // Fallback removal if transition doesn't fire
141
- setTimeout(() => { if (path.parentNode) path.remove(); }, 200);
142
- }
143
- // Remove endpoint dots and arrow
144
- for (const end of ['start', 'end']) {
145
- const dot = this.#dotLayer.querySelector(`[data-conn-dot="${conn.id}-${end}"]`);
146
- if (dot) dot.remove();
147
- }
148
- const arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${conn.id}"]`);
149
- if (arrow) arrow.remove();
150
-
151
- // Re-render free dots for freed ports
152
- this.renderFreeDots(fromId);
153
- this.renderFreeDots(toId);
154
- }
155
-
156
- /**
157
- * Highlight overlay dots belonging to compatible nodes during drag
158
- * @param {Set<string>} compatibleNodeIds - set of node IDs that have compatible ports
159
- */
160
- highlightDotsForNodes(compatibleNodeIds) {
161
- // Highlight connected dots
162
- const connDots = this.#dotLayer.querySelectorAll('.sn-conn-dot');
163
- for (const dot of connDots) {
164
- const dotId = dot.getAttribute('data-conn-dot') || '';
165
- const connId = dotId.replace(/-(?:start|end)$/, '');
166
- const conn = this.#connectionData.get(connId);
167
- if (!conn) continue;
168
- const end = dotId.endsWith('-start') ? 'start' : 'end';
169
- const nodeId = end === 'start' ? conn.from : conn.to;
170
- if (compatibleNodeIds.has(nodeId)) {
171
- dot.classList.add('sn-dot-hint');
172
- } else {
173
- dot.classList.remove('sn-dot-hint');
174
- }
175
- }
176
-
177
- // Highlight free dots
178
- const freeDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
179
- for (const dot of freeDots) {
180
- const nodeId = dot.getAttribute('data-node-id');
181
- if (compatibleNodeIds.has(nodeId)) {
182
- dot.classList.add('sn-dot-hint');
183
- } else {
184
- dot.classList.remove('sn-dot-hint');
185
- }
186
- }
187
- }
188
-
189
- /**
190
- * Clear all dot highlights
191
- */
192
- clearDotHighlights() {
193
- const dots = this.#dotLayer.querySelectorAll('.sn-dot-hint');
194
- for (const dot of dots) {
195
- dot.classList.remove('sn-dot-hint');
196
- }
197
- }
198
-
199
- /**
200
- * Update all connections touching a node
201
- * @param {string} nodeId
202
- */
203
- updateForNode(nodeId) {
204
- // Only clear and recalculate the DRAGGED node.
205
- // Non-dragged nodes use cached slot assignments (no jitter).
206
- const draggedEl = this.#nodeViews.get(nodeId);
207
- if (draggedEl) {
208
- draggedEl._usedCoords = [];
209
- draggedEl._slotCache = new Map();
210
- }
211
-
212
- // Collect touched connections for re-render
213
- const touchedConns = [];
214
- for (const [, conn] of this.#connectionData) {
215
- if (conn.from === nodeId || conn.to === nodeId) {
216
- touchedConns.push(conn);
217
-
218
- }
219
- }
220
-
221
- for (const conn of touchedConns) {
222
- this.#render(conn, nodeId);
223
- }
224
- }
225
-
226
- static _refreshCycleCount = 0;
227
- static _lastRefreshTime = 0;
228
-
229
- /**
230
- * Clear all caches and re-render every connection + free dots.
231
- * Call after initial node positioning to let SVG connectors settle.
232
- */
233
- refreshAll() {
234
- const t0 = performance.now();
235
- ConnectionRenderer._refreshCycleCount = (ConnectionRenderer._refreshCycleCount || 0) + 1;
236
-
237
- this.#clearAllSlots();
238
-
239
- // Clear stale free dots from previous render
240
- const staleDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
241
- for (const dot of staleDots) dot.remove();
242
-
243
- // Detach layers from layout tree to prevent O(N²) thrashing during Read/Write mix
244
- const originalSvgDisplay = this.#svgLayer.style.display;
245
- const originalDotDisplay = this.#dotLayer.style.display;
246
- this.#svgLayer.style.display = 'none';
247
- this.#dotLayer.style.display = 'none';
248
-
249
- // Pre-cache node rects for routing (prevents O(N^2) Layout Thrashing)
250
- this._nodeRectCache = new Map();
251
- for (const [nid, el] of this.#nodeViews) {
252
- if (el) {
253
- this._nodeRectCache.set(nid, {
254
- id: nid,
255
- x: el._position?.x || 0,
256
- y: el._position?.y || 0,
257
- w: el.offsetWidth || 180,
258
- h: el.offsetHeight || 100,
259
- });
260
- }
261
- }
262
-
263
- // ─── Three-Pass Pipeline: Side-Based Pin Assignment ───
264
- // Pass 1: Assign sides and distribute pins
265
- // Pass 2: Render connections (pins from _slotCache)
266
- // Pass 3: Render free dots on remaining edges
267
-
268
- const conns = Array.from(this.#connectionData.values());
269
-
270
- // ─── Pass 1: Side-Based Pin Assignment ───
271
- // Group all connection endpoints by node → then by side
272
- /** @type {Map<string, Array<{portKey: string, portSide: string, targetPos: {x:number, y:number}}>>} */
273
- const nodeJobs = new Map();
274
-
275
- for (const conn of conns) {
276
- const fromEl = this.#nodeViews.get(conn.from);
277
- const toEl = this.#nodeViews.get(conn.to);
278
- if (!fromEl || !toEl) continue;
279
-
280
- const fromPos = fromEl._position;
281
- const toPos = toEl._position;
282
- if (!fromPos || !toPos) continue;
283
-
284
- const toCenter = {
285
- x: toPos.x + (toEl._cachedW || 180) / 2,
286
- y: toPos.y + (toEl._cachedH || 100) / 2,
287
- };
288
- const fromCenter = {
289
- x: fromPos.x + (fromEl._cachedW || 180) / 2,
290
- y: fromPos.y + (fromEl._cachedH || 100) / 2,
291
- };
292
-
293
- if (!nodeJobs.has(conn.from)) nodeJobs.set(conn.from, []);
294
- nodeJobs.get(conn.from).push({ portKey: conn.out, portSide: 'output', targetPos: toCenter });
295
-
296
- if (!nodeJobs.has(conn.to)) nodeJobs.set(conn.to, []);
297
- nodeJobs.get(conn.to).push({ portKey: conn.in, portSide: 'input', targetPos: fromCenter });
298
- }
299
-
300
- // For each node: determine side per connection, group by side, distribute pins
301
- for (const [nodeId, jobs] of nodeJobs) {
302
- const el = this.#nodeViews.get(nodeId);
303
- if (!el?._position) continue;
304
-
305
- const shape = getShape(el.getAttribute('node-shape'));
306
- if (!shape?.getSidePosition) continue;
307
-
308
- const size = { width: el._cachedW || 180, height: el._cachedH || 100 };
309
- const cx = el._position.x + size.width / 2;
310
- const cy = el._position.y + size.height / 2;
311
-
312
- if (!el._slotCache) el._slotCache = new Map();
313
-
314
- // Step 1: Determine side for each connection
315
- /** @type {Map<string, Array<{portKey: string, portSide: string, angle: number}>>} */
316
- const sideBuckets = new Map();
317
- for (const job of jobs) {
318
- const dx = job.targetPos.x - cx;
319
- const dy = job.targetPos.y - cy;
320
- const angle = Math.atan2(dy, dx);
321
-
322
- // Determine side from angle quadrant
323
- let nodeSide;
324
- if (Math.abs(dx) > Math.abs(dy)) {
325
- nodeSide = dx > 0 ? 'right' : 'left';
326
- } else {
327
- nodeSide = dy > 0 ? 'bottom' : 'top';
328
- }
329
-
330
- if (!sideBuckets.has(nodeSide)) sideBuckets.set(nodeSide, []);
331
- sideBuckets.get(nodeSide).push({ portKey: job.portKey, portSide: job.portSide, angle });
332
- }
333
-
334
- // Step 2: Within each side, sort by perpendicular angle and distribute
335
- for (const [nodeSide, bucket] of sideBuckets) {
336
- // Sort by perpendicular component for natural spacing
337
- // For left/right sides: sort by Y (angle's vertical component)
338
- // For top/bottom sides: sort by X (angle's horizontal component)
339
- if (nodeSide === 'left' || nodeSide === 'right') {
340
- bucket.sort((a, b) => Math.sin(a.angle) - Math.sin(b.angle));
341
- } else {
342
- bucket.sort((a, b) => Math.cos(a.angle) - Math.cos(b.angle));
343
- }
344
-
345
- // Distribute pins evenly along the side edge
346
- const total = bucket.length;
347
- bucket.forEach((item, index) => {
348
- const t = total === 1 ? 0.5 : index / (total - 1);
349
- const pos = shape.getSidePosition(nodeSide, t, size);
350
- const cacheKey = `${item.portKey}:${item.portSide}`;
351
- el._slotCache.set(cacheKey, { x: pos.x, y: pos.y, angle: pos.angle });
352
-
353
- if (ConnectionRenderer.debug) {
354
- const label = el._nodeData?.label || nodeId;
355
- console.log(`[PIN] ${label} | ${item.portSide}:${item.portKey} → side=${nodeSide} t=${t.toFixed(2)} pos=(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}) angle=${pos.angle}°`);
356
- }
357
- });
358
- }
359
- }
360
-
361
- // ─── Pass 2: Render connections (pins pre-assigned from _slotCache) ───
362
- for (const conn of conns) {
363
- this.#render(conn);
364
- }
365
-
366
- // ─── Pass 3: Render free dots for SVG nodes ───
367
- for (const [nodeId, el] of this.#nodeViews) {
368
- if (el.getAttribute('data-svg-shape')) {
369
- this.renderFreeDots(nodeId);
370
- }
371
- }
372
-
373
- // ─── Final Debug Pass: Inter-Trace Overlaps ───
374
- if (ConnectionRenderer.debug && this._allSegments) {
375
- let overlaps = 0;
376
- for (let i = 0; i < this._allSegments.length; i++) {
377
- for (let j = i + 1; j < this._allSegments.length; j++) {
378
- const s1 = this._allSegments[i];
379
- const s2 = this._allSegments[j];
380
- if (s1.connId === s2.connId) continue;
381
-
382
- // Check if both are horizontal
383
- if (s1.p1.y === s1.p2.y && s2.p1.y === s2.p2.y && s1.p1.y === s2.p1.y) {
384
- const minX1 = Math.min(s1.p1.x, s1.p2.x), maxX1 = Math.max(s1.p1.x, s1.p2.x);
385
- const minX2 = Math.min(s2.p1.x, s2.p2.x), maxX2 = Math.max(s2.p1.x, s2.p2.x);
386
- if (Math.max(minX1, minX2) + 5 < Math.min(maxX1, maxX2)) {
387
- console.warn(`[PCB DEBUG] Trace Overlap (Horizontal) Y=${s1.p1.y}: conn[${s1.connId}] overlaps conn[${s2.connId}]`);
388
- overlaps++;
389
- }
390
- }
391
- // Check if both are vertical
392
- if (s1.p1.x === s1.p2.x && s2.p1.x === s2.p2.x && s1.p1.x === s2.p1.x) {
393
- const minY1 = Math.min(s1.p1.y, s1.p2.y), maxY1 = Math.max(s1.p1.y, s1.p2.y);
394
- const minY2 = Math.min(s2.p1.y, s2.p2.y), maxY2 = Math.max(s2.p1.y, s2.p2.y);
395
- if (Math.max(minY1, minY2) + 5 < Math.min(maxY1, maxY2)) {
396
- console.warn(`[PCB DEBUG] Trace Overlap (Vertical) X=${s1.p1.x}: conn[${s1.connId}] overlaps conn[${s2.connId}]`);
397
- overlaps++;
398
- }
399
- }
400
- }
401
- }
402
- if (overlaps > 0) console.warn(`[PCB DEBUG] Found ${overlaps} inter-trace overlaps.`);
403
- }
404
- this._allSegments = null;
405
- this._nodeRectCache = null;
406
-
407
- // Restore layers
408
- this.#svgLayer.style.display = originalSvgDisplay;
409
- this.#dotLayer.style.display = originalDotDisplay;
410
-
411
- // ─── Performance Monitoring ───
412
- if (ConnectionRenderer.debug) {
413
- const t1 = performance.now();
414
- const mem = (performance?.memory?.usedJSHeapSize)
415
- ? (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + 'MB'
416
- : 'N/A';
417
- console.log(`[PCB PERF] refreshAll cycle #${ConnectionRenderer._refreshCycleCount} took ${(t1 - t0).toFixed(2)}ms | Mem: ${mem}`);
418
-
419
- const dt = t0 - (ConnectionRenderer._lastRefreshTime || 0);
420
- if (ConnectionRenderer._lastRefreshTime > 0 && dt < 16) {
421
- console.warn(`[PCB PERF] High refresh rate detected! dt=${dt.toFixed(2)}ms (possible rendering loop or layout oscillation)`);
422
- }
423
- ConnectionRenderer._lastRefreshTime = t0;
424
- }
425
- }
426
-
427
- /**
428
- * Set data flow animation on a connection
429
- * @param {string} connId
430
- * @param {boolean} active
431
- */
432
- setFlowing(connId, active) {
433
- const path = this.#svgLayer.querySelector(`[data-conn-id="${connId}"]`);
434
- if (!path) return;
435
- if (active) {
436
- path.setAttribute('data-flowing', '');
437
- } else {
438
- path.removeAttribute('data-flowing');
439
- }
440
- }
441
-
442
- /**
443
- * Set data flow animation on all connections
444
- * @param {boolean} active
445
- */
446
- setAllFlowing(active) {
447
- for (const [connId] of this.#connectionData) {
448
- this.setFlowing(connId, active);
449
- }
450
- }
451
-
452
- /**
453
- * Set connection path style
454
- * @param {'bezier'|'orthogonal'|'straight'} style
455
- */
456
- setPathStyle(style) {
457
- this.#pathStyle = style;
458
- this.#clearAllSlots();
459
- for (const [, conn] of this.#connectionData) {
460
- this.#render(conn);
461
- }
462
- }
463
-
464
-
465
- /** @returns {'bezier'|'orthogonal'|'straight'} */
466
- get pathStyle() { return this.#pathStyle; }
467
-
468
- /**
469
- * Get socket offset relative to graph-node.
470
- * For SVG shapes with a target position, computes dynamic edge point
471
- * in the direction of the connected node (connector slides along perimeter).
472
- *
473
- * @param {HTMLElement} nodeEl
474
- * @param {string} portKey
475
- * @param {'input'|'output'} side
476
- * @param {{ x: number, y: number }} [targetPos] - center of the connected node (for dynamic edge)
477
- * @returns {{ x: number, y: number }}
478
- */
479
- getSocketOffset(nodeEl, portKey, side, targetPos) {
480
- // SVG shapes: compute edge position mathematically
481
- const shape = getShape(nodeEl.getAttribute('node-shape'));
482
- const nodeData = nodeEl._nodeData;
483
- if (shape && shape.pathData && nodeData) {
484
- const size = { width: nodeEl._cachedW || 180, height: nodeEl._cachedH || 100 };
485
-
486
- // Dynamic mode — side-based pin placement
487
- if (targetPos && shape.getSidePosition) {
488
- // Check cache first (set by refreshAll two-pass pipeline)
489
- if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
490
- const cacheKey = `${portKey}:${side}`;
491
- if (nodeEl._slotCache.has(cacheKey)) {
492
- return nodeEl._slotCache.get(cacheKey);
493
- }
494
-
495
- // Fallback: immediate side-based calculation (for single-connection render)
496
- const nodePos = nodeEl._position;
497
- const cx = nodePos.x + size.width / 2;
498
- const cy = nodePos.y + size.height / 2;
499
- const dx = targetPos.x - cx;
500
- const dy = targetPos.y - cy;
501
-
502
- // Determine side from angle to target
503
- const nodeSide = Math.abs(dx) > Math.abs(dy)
504
- ? (dx > 0 ? 'right' : 'left')
505
- : (dy > 0 ? 'bottom' : 'top');
506
-
507
- const pos = shape.getSidePosition(nodeSide, 0.5, size);
508
- const result = { x: pos.x, y: pos.y, angle: pos.angle };
509
- nodeEl._slotCache.set(cacheKey, result);
510
- return result;
511
- }
512
-
513
- // Also support getEdgePoint fallback with smart routing
514
- if (targetPos && shape.getEdgePoint) {
515
- const ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
516
- const keys = ports ? Object.keys(ports) : [portKey];
517
- const index = keys.indexOf(portKey);
518
- const total = keys.length;
519
-
520
- const nodePos = nodeEl._position;
521
- const cx = nodePos.x + size.width / 2;
522
- const cy = nodePos.y + size.height / 2;
523
- const baseAngle = Math.atan2(targetPos.y - cy, targetPos.x - cx);
524
-
525
- // 1. Separate input/output zones: gap between types
526
- const sideGap = Math.PI / 6; // 30° gap = one full slot between input/output
527
- const adjustedBase = baseAngle + (side === 'output' ? -sideGap : sideGap);
528
-
529
- // 2. Anti-crossing: reverse port order based on perpendicular direction
530
- const dx = targetPos.x - cx;
531
- const dy = targetPos.y - cy;
532
- const shouldReverse = (side === 'output') ? (dy < 0) : (dy > 0);
533
- const effectiveIndex = shouldReverse ? (total - 1 - index) : index;
534
-
535
- // 3. Spread ports around adjusted base angle
536
- let angle = adjustedBase;
537
- if (total > 1) {
538
- const segment = (2 * Math.PI) / (total * 2);
539
- const offset = (effectiveIndex - (total - 1) / 2) * segment;
540
- angle = adjustedBase + offset;
541
- }
542
-
543
- // 4. Quantize to 15° grid for stable discrete movement
544
- const step = Math.PI / 12; // 15° grid
545
- angle = Math.round(angle / step) * step;
546
-
547
- // Check cache first
548
- if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
549
- const cacheKey = `${portKey}:${side}`;
550
- if (nodeEl._slotCache.has(cacheKey)) {
551
- return nodeEl._slotCache.get(cacheKey);
552
- }
553
-
554
- // 5. Collision avoidance by PIXEL COORDINATES
555
- if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
556
- const MIN_PIX = 5;
557
- let nudged = angle;
558
- let attempts = 0;
559
- while (attempts < 24) {
560
- const testPos = shape.getEdgePoint(nudged, size);
561
- const tooClose = nodeEl._usedCoords.some(
562
- c => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
563
- );
564
- if (!tooClose) break;
565
- nudged += step;
566
- attempts++;
567
- }
568
-
569
- const pos = shape.getEdgePoint(nudged, size);
570
- nodeEl._usedCoords.push({ x: pos.x, y: pos.y });
571
- const result = { x: pos.x, y: pos.y, angle: pos.angle };
572
- nodeEl._slotCache.set(cacheKey, result);
573
- return result;
574
- }
575
- // Fixed mode: distribute ports at preset angles
576
- const ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
577
- if (ports) {
578
- const keys = Object.keys(ports);
579
- const index = keys.indexOf(portKey);
580
- const total = keys.length;
581
- if (index >= 0) {
582
- const pos = shape.getSocketPosition(side, index, total, size);
583
- return { x: pos.x, y: pos.y };
584
- }
585
- }
586
- }
587
-
588
- // Fast path: if node is culled, skip forced layout resolution
589
- if (nodeEl.style.contentVisibility === 'hidden') {
590
- return {
591
- x: side === 'output' ? (nodeEl._cachedW || 180) : 0,
592
- y: (nodeEl._cachedH || 100) / 2,
593
- };
594
- }
595
-
596
- // Standard shapes: read from DOM socket elements
597
- const container = side === 'output'
598
- ? nodeEl.querySelector('.outputs')
599
- : nodeEl.querySelector('.inputs');
600
-
601
- if (container) {
602
- const portItems = container.querySelectorAll('port-item');
603
- for (const portItem of portItems) {
604
- if (portItem.$.key === portKey) {
605
- const socket = portItem.querySelector('.sn-socket');
606
- if (socket) {
607
- const nodeRect = nodeEl.getBoundingClientRect();
608
- const socketRect = socket.getBoundingClientRect();
609
- const z = this.#getZoom();
610
- return {
611
- x: (socketRect.left - nodeRect.left + socketRect.width / 2) / z,
612
- y: (socketRect.top - nodeRect.top + socketRect.height / 2) / z,
613
- };
614
- }
615
- }
616
- }
617
- }
618
-
619
- return {
620
- x: side === 'output' ? (nodeEl._cachedW || nodeEl.offsetWidth || 180) : 0,
621
- y: (nodeEl._cachedH || nodeEl.offsetHeight || 100) / 2,
622
- };
623
- }
624
-
625
- /**
626
- * Render a single connection SVG path with tangent-aware Bézier and gradient coloring
627
- * @param {import('../core/Connection.js').Connection} conn
628
- */
629
- #render(conn, draggedNodeId = null) {
630
- const fromEl = this.#nodeViews.get(conn.from);
631
- const toEl = this.#nodeViews.get(conn.to);
632
- if (!fromEl || !toEl) return;
633
-
634
- const fromPos = fromEl._position;
635
- const toPos = toEl._position;
636
-
637
- // Compute centers for dynamic edge positioning on SVG shapes
638
- const fromW = fromEl._cachedW || fromEl.offsetWidth || 180;
639
- const fromH = fromEl._cachedH || fromEl.offsetHeight || 100;
640
- const toW = toEl._cachedW || toEl.offsetWidth || 180;
641
- const toH = toEl._cachedH || toEl.offsetHeight || 100;
642
- const fromCenter = {
643
- x: fromPos.x + fromW / 2,
644
- y: fromPos.y + fromH / 2,
645
- };
646
- const toCenter = {
647
- x: toPos.x + toW / 2,
648
- y: toPos.y + toH / 2,
649
- };
650
-
651
- // Always recalculate both sides (slot pool makes this cheap and deterministic)
652
- const fromOffset = this.getSocketOffset(fromEl, conn.out, 'output', toCenter);
653
- const toOffset = this.getSocketOffset(toEl, conn.in, 'input', fromCenter);
654
-
655
- const startX = fromPos.x + fromOffset.x;
656
- const startY = fromPos.y + fromOffset.y;
657
- const endX = toPos.x + toOffset.x;
658
- const endY = toPos.y + toOffset.y;
659
-
660
- // Tangent-aware Bézier using shape angles
661
- const fromNode = this.#editor.getNode(conn.from);
662
- const toNode = this.#editor.getNode(conn.to);
663
- const fromShape = getShape(fromNode?.shape);
664
- const toShape = getShape(toNode?.shape);
665
-
666
- const fromSize = { width: fromW, height: fromH };
667
- const toSize = { width: toW, height: toH };
668
-
669
- // Generate path based on style
670
- let d;
671
- if (this.#pathStyle === 'straight') {
672
- d = `M ${startX} ${startY} L ${endX} ${endY}`;
673
- } else if (this.#pathStyle === 'orthogonal') {
674
- const connKeys = Array.from(this.#connectionData.keys());
675
- const connIndex = connKeys.indexOf(conn.id);
676
- const traceOffset = (connIndex > -1 ? connIndex % 10 : 0) * 4;
677
-
678
- const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
679
- const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
680
-
681
- const stubLen = 20;
682
- const getDxDy = (deg) => ({
683
- dx: Math.round(Math.cos(deg * Math.PI / 180)),
684
- dy: Math.round(Math.sin(deg * Math.PI / 180))
685
- });
686
-
687
- const fDir = getDxDy(fromAngle);
688
- const tDir = getDxDy(toAngle);
689
-
690
- const p1x = startX + fDir.dx * stubLen;
691
- const p1y = startY + fDir.dy * stubLen;
692
- const p2x = endX + tDir.dx * stubLen;
693
- const p2y = endY + tDir.dy * stubLen;
694
-
695
- const fromH = fromEl._cachedH || 60;
696
- const toH = toEl._cachedH || 60;
697
-
698
- let pts = [{x: startX, y: startY}, {x: p1x, y: p1y}];
699
-
700
- if (endX < startX) {
701
- const bottomY = Math.max(fromPos.y + fromH, toPos.y + toH) + 30 + traceOffset;
702
- pts.push({x: p1x, y: bottomY});
703
- pts.push({x: p2x, y: bottomY});
704
- } else {
705
- const maxH = Math.max(fromH, toH);
706
- if (Math.abs(p1y - p2y) < maxH) {
707
- let nodeBetween = false;
708
- for (const [, node] of this.#nodeViews) {
709
- if (!node._position) continue;
710
- const nx = node._position.x;
711
- const ny = node._position.y;
712
- const nw = node._cachedW || 180;
713
- const nh = node._cachedH || 60;
714
- if (nx > p1x && nx + nw < p2x) {
715
- if (Math.min(p1y, p2y) <= ny + nh && Math.max(p1y, p2y) >= ny) {
716
- nodeBetween = true; break;
717
- }
718
- }
719
- }
720
-
721
- if (nodeBetween) {
722
- const detourY = Math.min(fromPos.y, toPos.y) - 30 - traceOffset;
723
- pts.push({x: p1x, y: detourY});
724
- pts.push({x: p2x, y: detourY});
725
- } else {
726
- const midX = (p1x + p2x) / 2 + traceOffset;
727
- pts.push({x: midX, y: p1y});
728
- pts.push({x: midX, y: p2y});
729
- }
730
- } else {
731
- let midX = (p1x + p2x) / 2 + traceOffset;
732
- let obstacleNode = null;
733
- const minY = Math.min(p1y, p2y);
734
- const maxY = Math.max(p1y, p2y);
735
-
736
- for (const [, node] of this.#nodeViews) {
737
- if (!node._position) continue;
738
- const nx = node._position.x;
739
- const ny = node._position.y;
740
- const nw = node._cachedW || 180;
741
- const nh = node._cachedH || 60;
742
- if (midX >= nx && midX <= nx + nw) {
743
- if (ny <= maxY && ny + nh >= minY) {
744
- obstacleNode = {x: nx, w: nw};
745
- break;
746
- }
747
- }
748
- }
749
-
750
- if (obstacleNode) {
751
- const leftDist = Math.abs(midX - obstacleNode.x);
752
- const rightDist = Math.abs(midX - (obstacleNode.x + obstacleNode.w));
753
- if (leftDist < rightDist) {
754
- midX = obstacleNode.x - 30 - traceOffset;
755
- } else {
756
- midX = obstacleNode.x + obstacleNode.w + 30 + traceOffset;
757
- }
758
- }
759
-
760
- pts.push({x: midX, y: p1y});
761
- pts.push({x: midX, y: p2y});
762
- }
763
- }
764
-
765
- pts.push({x: p2x, y: p2y});
766
- pts.push({x: endX, y: endY});
767
-
768
- let path = `M ${pts[0].x} ${pts[0].y}`;
769
- for (let i = 1; i < pts.length; i++) {
770
- const prev = pts[i-1];
771
- const curr = pts[i];
772
- if (curr.x === prev.x && curr.y === prev.y) continue;
773
- if (curr.x !== prev.x && curr.y !== prev.y) {
774
- path += ` H ${curr.x} V ${curr.y}`;
775
- } else if (curr.x !== prev.x) {
776
- path += ` H ${curr.x}`;
777
- } else if (curr.y !== prev.y) {
778
- path += ` V ${curr.y}`;
779
- }
780
- }
781
- d = path;
782
- } else if (this.#pathStyle === 'pcb') {
783
- // ─── PCB Grid-Based Trace Routing ───
784
- // All waypoints snap to a grid. Stubs exit perpendicular to node surface
785
- // with a minimum length, then route on grid channels with chamfered corners.
786
-
787
- const TRACE_GRID = 5; // Dense trace grid (5px)
788
- const STUB_MIN = 20; // minimum perpendicular stub from node edge
789
- const CHAMFER = 8; // 45° chamfer radius (px)
790
-
791
- // Snap a coordinate to the trace grid
792
- const snapGrid = (v) => Math.round(v / TRACE_GRID) * TRACE_GRID;
793
-
794
- // Connection channel index for parallel trace separation
795
- const connKeys = Array.from(this.#connectionData.keys());
796
- const connIndex = connKeys.indexOf(conn.id);
797
-
798
- // Determine unique channel shift to prevent parallel traces overlapping
799
- // Alternates: 0, +5, -5, +10, -10...
800
- const shiftIndex = (connIndex > -1 ? connIndex % 12 : 0);
801
- const channelShift = (shiftIndex % 2 === 0 ? 1 : -1) * Math.ceil(shiftIndex / 2) * TRACE_GRID;
802
-
803
- // Compute perpendicular stub directions from surface normals
804
- const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
805
- const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
806
-
807
- // Snap angle to cardinal direction (→ ↓ ← ↑)
808
- const snapDir = (deg) => {
809
- const r = ((deg % 360) + 360) % 360;
810
- if (r < 45 || r >= 315) return { dx: 1, dy: 0 }; // right
811
- if (r >= 45 && r < 135) return { dx: 0, dy: 1 }; // down
812
- if (r >= 135 && r < 225) return { dx: -1, dy: 0 }; // left
813
- return { dx: 0, dy: -1 }; // up
814
- };
815
-
816
- const fDir = snapDir(fromAngle);
817
- const tDir = snapDir(toAngle);
818
-
819
- // Stub endpoints: extend strictly perpedicular, no grid snapping on the orthogonal axis
820
- // to avoid diagonal stubs from pins that are floating (not grid aligned).
821
- const stubFromX = fDir.dx === 0 ? startX : startX + fDir.dx * STUB_MIN;
822
- const stubFromY = fDir.dy === 0 ? startY : startY + fDir.dy * STUB_MIN;
823
- const stubToX = tDir.dx === 0 ? endX : endX + tDir.dx * STUB_MIN;
824
- const stubToY = tDir.dy === 0 ? endY : endY + tDir.dy * STUB_MIN;
825
-
826
- const fromH = fromEl.offsetHeight || 60;
827
- const toH = toEl.offsetHeight || 60;
828
-
829
- // Build orthogonal waypoints on grid
830
- let pts = [
831
- { x: startX, y: startY },
832
- { x: stubFromX, y: stubFromY },
833
- ];
834
-
835
- // Very simple heuristic orthogonal router
836
- if (endX < startX - 20) {
837
- // Backwards routing: U-turn below obstacles in the path
838
- const minXForObstacle = Math.min(stubFromX, stubToX);
839
- const maxXForObstacle = Math.max(stubFromX, stubToX);
840
- let maxObstacleY = Math.max(fromPos.y + fromH, toPos.y + toH);
841
-
842
- const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
843
- for (const rect of iter) {
844
- const nx = rect.x;
845
- const ny = rect.y;
846
- const nw = rect.w;
847
- const nh = rect.h;
848
- // Check if node is in the horizontal path of the detour
849
- const pad = TRACE_GRID * 2;
850
- if (nx + nw + pad >= minXForObstacle && nx - pad <= maxXForObstacle) {
851
- if (ny + nh > maxObstacleY) {
852
- maxObstacleY = ny + nh;
853
- }
854
- }
855
- }
856
-
857
- // Detour deeply below all nodes in the path to avoid overlaps
858
- // We use absolute channelShift so tracks stack neatly downward
859
- const bottomY = snapGrid(maxObstacleY + 30) + Math.abs(channelShift);
860
- pts.push({ x: stubFromX, y: bottomY });
861
- pts.push({ x: stubToX, y: bottomY });
862
- } else {
863
- // Forward routing: mid-X channel
864
- let midX = snapGrid((stubFromX + stubToX) / 2) + channelShift;
865
-
866
- // Same-height shortcut: if stubs are roughly aligned (in same track cell), connect via single horizontal
867
- if (Math.abs(stubFromY - stubToY) < TRACE_GRID * 2) {
868
- // Keep strictly horizontal
869
- pts.push({ x: stubToX, y: stubFromY });
870
- } else {
871
- // Obstacle check for mid-X vertical segment
872
- const minY = Math.min(stubFromY, stubToY);
873
- const maxY = Math.max(stubFromY, stubToY);
874
- const pad = TRACE_GRID * 4;
875
-
876
- const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
877
- for (const rect of iter) {
878
- if (rect.id === conn.from || rect.id === conn.to) continue;
879
- const nx = rect.x, ny = rect.y;
880
- const nw = rect.w, nh = rect.h;
881
-
882
- if (midX >= nx - pad && midX <= nx + nw + pad) {
883
- if (ny - pad <= maxY && ny + nh + pad >= minY) {
884
- // Detour around obstacle
885
- const leftX = snapGrid(nx - pad) + channelShift;
886
- const rightX = snapGrid(nx + nw + pad) + channelShift;
887
- midX = Math.abs(midX - leftX) < Math.abs(midX - rightX) ? leftX : rightX;
888
- break;
889
- }
890
- }
891
- }
892
-
893
- pts.push({ x: midX, y: stubFromY });
894
- pts.push({ x: midX, y: stubToY });
895
- }
896
- }
897
-
898
- pts.push({ x: stubToX, y: stubToY });
899
- pts.push({ x: endX, y: endY });
900
-
901
- // Path building and Chamfering
902
- let debugCollisions = [];
903
-
904
- // 1. Check if line segments intersect any nodes
905
- for (let i = 0; i < pts.length - 1; i++) {
906
- const segX1 = Math.min(pts[i].x, pts[i + 1].x);
907
- const segY1 = Math.min(pts[i].y, pts[i + 1].y);
908
- const segX2 = Math.max(pts[i].x, pts[i + 1].x);
909
- const segY2 = Math.max(pts[i].y, pts[i + 1].y);
910
-
911
- const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
912
- for (const rect of iter) {
913
- if (rect.id === conn.from || rect.id === conn.to) continue;
914
-
915
- const nx = rect.x, ny = rect.y;
916
- const nw = rect.w, nh = rect.h;
917
-
918
- if (segX1 < nx + nw && segX2 > nx && segY1 < ny + nh && segY2 > ny) {
919
- debugCollisions.push(`Node Collision: (${pts[i].x},${pts[i].y})->(${pts[i+1].x},${pts[i+1].y}) intersects Node[${rect.id}]`);
920
- }
921
- }
922
- }
923
-
924
- // 2. Self-overlap (180 degree turn)
925
- for (let i = 0; i < pts.length - 2; i++) {
926
- const p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2];
927
- const v1x = p2.x - p1.x, v1y = p2.y - p1.y;
928
- const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
929
- if (v1x * v2x < 0 || v1y * v2y < 0) {
930
- debugCollisions.push(`180° Fold: at (${p2.x},${p2.y}) turning back toward (${p3.x},${p3.y})`);
931
- }
932
- }
933
-
934
- // Store generated segments for global overlap checks
935
- if (!this._allSegments) this._allSegments = [];
936
- const segments = [];
937
- for (let i = 0; i < pts.length - 1; i++) {
938
- segments.push({
939
- p1: pts[i], p2: pts[i+1],
940
- connId: conn.id,
941
- channel: connIndex
942
- });
943
- }
944
- this._allSegments.push(...segments);
945
-
946
- // Log route stats
947
- if (ConnectionRenderer.debug) {
948
- const fromLabel = fromEl._nodeData?.label || conn.from;
949
- const toLabel = toEl._nodeData?.label || conn.to;
950
- let msg = `[PCB] ${fromLabel} → ${toLabel} | waypoints=${pts.length}`;
951
- if (debugCollisions.length > 0) {
952
- msg += ` | ERRS: ` + debugCollisions.join(' | ');
953
- }
954
- console.log(msg);
955
- }
956
-
957
- // Build SVG path with 45° chamfered corners
958
- let path = `M ${pts[0].x} ${pts[0].y}`;
959
- for (let i = 1; i < pts.length; i++) {
960
- const prev = pts[i - 1];
961
- const curr = pts[i];
962
- if (Math.abs(curr.x - prev.x) < 0.5 && Math.abs(curr.y - prev.y) < 0.5) continue;
963
-
964
- const next = pts[i + 1];
965
- if (next) {
966
- // Determine if there's a turn at curr → need chamfer
967
- const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
968
- const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
969
- const isH1 = Math.abs(dx1) > Math.abs(dy1);
970
- const isH2 = Math.abs(dx2) > Math.abs(dy2);
971
-
972
- if (isH1 !== isH2) {
973
- // Corner turn — apply 45° chamfer
974
- const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
975
- const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
976
- if (len1 < 1 || len2 < 1) {
977
- // Degenerate segment — skip chamfer, go straight
978
- path += ` L ${curr.x} ${curr.y}`;
979
- continue;
980
- }
981
- const c = Math.min(CHAMFER, len1 / 2, len2 / 2);
982
-
983
- // Pre-corner point
984
- const nx1 = dx1 / len1, ny1 = dy1 / len1;
985
- const preX = curr.x - nx1 * c;
986
- const preY = curr.y - ny1 * c;
987
- // Post-corner point
988
- const nx2 = dx2 / len2, ny2 = dy2 / len2;
989
- const postX = curr.x + nx2 * c;
990
- const postY = curr.y + ny2 * c;
991
-
992
- path += ` L ${preX} ${preY} L ${postX} ${postY}`;
993
- continue;
994
- }
995
- }
996
-
997
- // Straight segment — use H/V for axis-aligned, L for diagonal stubs
998
- if (Math.abs(curr.y - prev.y) < 0.5) {
999
- path += ` H ${curr.x}`;
1000
- } else if (Math.abs(curr.x - prev.x) < 0.5) {
1001
- path += ` V ${curr.y}`;
1002
- } else {
1003
- path += ` L ${curr.x} ${curr.y}`;
1004
- }
1005
- }
1006
- d = path;
1007
- } else {
1008
- // Tangent direction: use dynamic edge angle if available, else fixed socket angle
1009
- let fromAngleDeg, toAngleDeg;
1010
-
1011
- if (fromOffset.angle !== undefined) {
1012
- fromAngleDeg = fromOffset.angle;
1013
- } else {
1014
- const fromPortIndex = fromNode ? Object.keys(fromNode.outputs).indexOf(conn.out) : 0;
1015
- const fromPortTotal = fromNode ? Object.keys(fromNode.outputs).length : 1;
1016
- const pos = fromShape?.getSocketPosition?.('output', fromPortIndex, fromPortTotal, fromSize);
1017
- fromAngleDeg = pos?.angle ?? 0;
1018
- }
1019
-
1020
- if (toOffset.angle !== undefined) {
1021
- toAngleDeg = toOffset.angle;
1022
- } else {
1023
- const toPortIndex = toNode ? Object.keys(toNode.inputs).indexOf(conn.in) : 0;
1024
- const toPortTotal = toNode ? Object.keys(toNode.inputs).length : 1;
1025
- const pos = toShape?.getSocketPosition?.('input', toPortIndex, toPortTotal, toSize);
1026
- toAngleDeg = pos?.angle ?? 180;
1027
- }
1028
-
1029
- const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
1030
- const cpLen = Math.max(50, dist * 0.4);
1031
- const fromRad = (fromAngleDeg * Math.PI) / 180;
1032
- const toRad = (toAngleDeg * Math.PI) / 180;
1033
-
1034
- const cp1x = startX + Math.cos(fromRad) * cpLen;
1035
- const cp1y = startY + Math.sin(fromRad) * cpLen;
1036
- const cp2x = endX + Math.cos(toRad) * cpLen;
1037
- const cp2y = endY + Math.sin(toRad) * cpLen;
1038
-
1039
- d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`;
1040
- }
1041
-
1042
- let path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
1043
- if (!path) {
1044
- path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1045
- path.setAttribute('class', 'sn-conn-path');
1046
- path.setAttribute('data-conn-id', conn.id);
1047
- path.addEventListener('click', (e) => {
1048
- e.stopPropagation();
1049
- this.#onConnectionClick(conn.id, e);
1050
- });
1051
- this.#svgLayer.appendChild(path);
1052
- }
1053
- path.setAttribute('d', d);
1054
-
1055
- // Wire type styling — thicker for exec, normal for data
1056
- const fromSocketName = fromNode?.outputs[conn.out]?.socket?.name || 'data';
1057
- if (fromSocketName === 'exec' || fromSocketName === 'execution' || fromSocketName === 'trigger') {
1058
- path.setAttribute('data-wire-type', 'exec');
1059
- path.style.strokeWidth = '3';
1060
- path.style.strokeDasharray = '8 4';
1061
- } else if (fromSocketName === 'array' || fromSocketName === 'object' || fromSocketName === 'json') {
1062
- path.setAttribute('data-wire-type', 'data-heavy');
1063
- path.style.strokeWidth = '2.5';
1064
- path.style.strokeDasharray = '';
1065
- } else {
1066
- path.removeAttribute('data-wire-type');
1067
- path.style.strokeWidth = '';
1068
- path.style.strokeDasharray = '';
1069
- }
1070
-
1071
- // Gradient connection coloring
1072
- this.#applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY);
1073
-
1074
- // Determine socket type for visual dot styling
1075
- const outSocketName = fromNode?.outputs?.[conn.out]?.socket?.name || 'data';
1076
- const inSocketName = toNode?.inputs?.[conn.in]?.socket?.name || outSocketName;
1077
-
1078
- // Endpoint dots with side and type coloring
1079
- this.#updateDot(conn.id, 'start', startX, startY, 'output', outSocketName);
1080
- this.#updateDot(conn.id, 'end', endX, endY, 'input', inSocketName);
1081
-
1082
- // Direction arrow at wire midpoint
1083
- this.#updateArrow(conn.id, d);
1084
- }
1085
-
1086
- /**
1087
- * Create or update a small circle dot at a connector endpoint
1088
- * @param {string} connId
1089
- * @param {'start'|'end'} end
1090
- * @param {number} x
1091
- * @param {number} y
1092
- * @param {'input'|'output'} side
1093
- * @param {string} socketType
1094
- */
1095
- #updateDot(connId, end, x, y, side = 'output', socketType = 'data') {
1096
- const dotId = `${connId}-${end}`;
1097
- let dot = this.#dotLayer.querySelector(`[data-conn-dot="${dotId}"]`);
1098
- if (!dot) {
1099
- dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1100
- dot.setAttribute('data-conn-dot', dotId);
1101
- dot.setAttribute('r', '5');
1102
- this.#dotLayer.appendChild(dot);
1103
- }
1104
- dot.setAttribute('cx', x);
1105
- dot.setAttribute('cy', y);
1106
-
1107
- // Dots are hidden by default (CSS). Only show for SVG nodes.
1108
- // Runs on every update to handle timing — NodeViewManager may set
1109
- // data-svg-shape after initial connection render
1110
- if (!dot.hasAttribute('data-svg-wired')) {
1111
- const conn = this.#connectionData.get(connId);
1112
- if (conn) {
1113
- const nodeId = end === 'start' ? conn.from : conn.to;
1114
- const nodeEl = this.#nodeViews.get(nodeId);
1115
- if (nodeEl?.hasAttribute('data-svg-shape')) {
1116
- dot.setAttribute('data-svg-wired', '');
1117
- dot.style.display = '';
1118
- if (this.#onDotDrag) {
1119
- dot.style.pointerEvents = 'auto';
1120
- dot.style.cursor = 'crosshair';
1121
- dot.addEventListener('pointerdown', (e) => {
1122
- e.stopPropagation();
1123
- e.preventDefault();
1124
- const dotX = parseFloat(dot.getAttribute('cx')) || 0;
1125
- const dotY = parseFloat(dot.getAttribute('cy')) || 0;
1126
- const socketData = end === 'start'
1127
- ? { nodeId: conn.from, key: conn.out, side: 'output', worldX: dotX, worldY: dotY }
1128
- : { nodeId: conn.to, key: conn.in, side: 'input', worldX: dotX, worldY: dotY };
1129
- this.#onDotDrag(socketData);
1130
- });
1131
- }
1132
- }
1133
- }
1134
- }
1135
-
1136
- // Classify socket type
1137
- let typeClass = 'sn-dot-data';
1138
- if (socketType === 'exec' || socketType === 'execution' || socketType === 'trigger') {
1139
- typeClass = 'sn-dot-exec';
1140
- } else if (socketType === 'ctrl' || socketType === 'control' || socketType === 'signal') {
1141
- typeClass = 'sn-dot-ctrl';
1142
- }
1143
- const sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
1144
- dot.setAttribute('class', `sn-conn-dot ${sideClass} ${typeClass}`);
1145
- }
1146
-
1147
- /**
1148
- * Create or update a direction arrow at the midpoint of a bezier path
1149
- * @param {string} connId
1150
- * @param {string} pathD - SVG path d attribute
1151
- */
1152
- #updateArrow(connId, pathD) {
1153
- // Universal midpoint calculation using SVG path API (works for all path styles)
1154
- const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1155
- tempPath.setAttribute('d', pathD);
1156
-
1157
- // Need to briefly attach to DOM for getPointAtLength to work
1158
- this.#svgLayer.appendChild(tempPath);
1159
- const totalLen = tempPath.getTotalLength();
1160
- if (totalLen < 1) {
1161
- tempPath.remove();
1162
- return;
1163
- }
1164
-
1165
- // Midpoint at 50% of path length
1166
- const mid = tempPath.getPointAtLength(totalLen * 0.5);
1167
-
1168
- // Tangent: sample two close points (0.5% before/after midpoint)
1169
- const delta = Math.max(0.5, totalLen * 0.005);
1170
- const p1 = tempPath.getPointAtLength(Math.max(0, totalLen * 0.5 - delta));
1171
- const p2 = tempPath.getPointAtLength(Math.min(totalLen, totalLen * 0.5 + delta));
1172
- tempPath.remove();
1173
-
1174
- const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
1175
-
1176
- let arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${connId}"]`);
1177
- if (!arrow) {
1178
- arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
1179
- arrow.setAttribute('data-conn-arrow', connId);
1180
- arrow.setAttribute('class', 'sn-conn-arrow');
1181
- arrow.setAttribute('points', '-5,-3.5 5,0 -5,3.5');
1182
- this.#svgLayer.appendChild(arrow);
1183
- }
1184
- arrow.setAttribute('transform', `translate(${mid.x},${mid.y}) rotate(${angle})`);
1185
- }
1186
-
1187
- /**
1188
- * Apply socket-color gradient to connection path
1189
- * @param {SVGPathElement} path
1190
- * @param {import('../core/Connection.js').Connection} conn
1191
- * @param {import('../core/Node.js').Node|undefined} fromNode
1192
- * @param {import('../core/Node.js').Node|undefined} toNode
1193
- * @param {number} startX
1194
- * @param {number} startY
1195
- * @param {number} endX
1196
- * @param {number} endY
1197
- */
1198
- #applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY) {
1199
- const fromColor = fromNode?.outputs[conn.out]?.socket?.color;
1200
- const toColor = toNode?.inputs[conn.in]?.socket?.color;
1201
-
1202
- if (fromColor && toColor && fromColor !== toColor) {
1203
- const gradId = `grad-${conn.id}`;
1204
- let defs = this.#svgLayer.querySelector('defs');
1205
- if (!defs) {
1206
- defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1207
- this.#svgLayer.prepend(defs);
1208
- }
1209
- let grad = defs.querySelector(`#${gradId}`);
1210
- if (!grad) {
1211
- grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
1212
- grad.setAttribute('id', gradId);
1213
- const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1214
- stop1.setAttribute('offset', '0%');
1215
- const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1216
- stop2.setAttribute('offset', '100%');
1217
- grad.appendChild(stop1);
1218
- grad.appendChild(stop2);
1219
- defs.appendChild(grad);
1220
- }
1221
- grad.setAttribute('gradientUnits', 'userSpaceOnUse');
1222
- grad.setAttribute('x1', String(startX));
1223
- grad.setAttribute('y1', String(startY));
1224
- grad.setAttribute('x2', String(endX));
1225
- grad.setAttribute('y2', String(endY));
1226
- grad.children[0].setAttribute('stop-color', fromColor);
1227
- grad.children[1].setAttribute('stop-color', toColor);
1228
- path.setAttribute('stroke', `url(#${gradId})`);
1229
- } else if (fromColor) {
1230
- path.setAttribute('stroke', fromColor);
1231
- }
1232
- }
1233
- /**
1234
- * Render persistent free dots for all ports of an SVG node.
1235
- * Called after SVG shape setup. Free dots are interactive drag sources.
1236
- * @param {string} nodeId
1237
- */
1238
- renderFreeDots(nodeId) {
1239
- const nodeEl = this.#nodeViews.get(nodeId);
1240
- const node = this.#editor.getNode(nodeId);
1241
- if (!nodeEl || !node) return;
1242
-
1243
- const shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
1244
- const shape = getShape(shapeName);
1245
- if (!shape?.pathData || !shape.getEdgePoint) return;
1246
-
1247
- const size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
1248
- const pos = nodeEl._position;
1249
- if (!pos) return;
1250
-
1251
- // Collect already-connected port keys
1252
- const connectedPorts = new Set();
1253
- for (const [, conn] of this.#connectionData) {
1254
- if (conn.from === nodeId) connectedPorts.add(`output:${conn.out}`);
1255
- if (conn.to === nodeId) connectedPorts.add(`input:${conn.in}`);
1256
- }
1257
-
1258
- // Ensure collision tracking exists
1259
- if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
1260
- const MIN_PIX = 12;
1261
- const step = Math.PI / 12; // 15° grid
1262
-
1263
- // Place free dots using edge-point system (same as connections)
1264
- const placeDot = (key, side, baseAngle, portData) => {
1265
- // Find a free position using collision avoidance
1266
- let angle = Math.round(baseAngle / step) * step;
1267
- let nudged = angle;
1268
- let attempts = 0;
1269
-
1270
- while (attempts < 24) {
1271
- const testPos = shape.getEdgePoint(nudged, size);
1272
- const tooClose = nodeEl._usedCoords.some(
1273
- c => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
1274
- );
1275
- if (!tooClose) break;
1276
- attempts++;
1277
- const offset = Math.ceil(attempts / 2) * step;
1278
- const dir = (attempts % 2 === 1) ? 1 : -1;
1279
- nudged = angle + dir * offset;
1280
- }
1281
-
1282
- const ep = shape.getEdgePoint(nudged, size);
1283
- nodeEl._usedCoords.push({ x: ep.x, y: ep.y });
1284
-
1285
- this.#createFreeDot(nodeId, key, side, pos.x + ep.x, pos.y + ep.y, portData);
1286
- };
1287
-
1288
- // Render dots for unconnected inputs (left side baseline angle = π)
1289
- const inputKeys = Object.keys(node.inputs);
1290
- inputKeys.forEach((key, i) => {
1291
- if (connectedPorts.has(`input:${key}`)) return;
1292
- const spread = Math.PI * 0.4;
1293
- const baseAngle = Math.PI + (inputKeys.length > 1
1294
- ? (i / (inputKeys.length - 1) - 0.5) * spread
1295
- : 0);
1296
- placeDot(key, 'input', baseAngle, node.inputs[key]);
1297
- });
1298
-
1299
- // Render dots for unconnected outputs (right side baseline angle = 0)
1300
- const outputKeys = Object.keys(node.outputs);
1301
- outputKeys.forEach((key, i) => {
1302
- if (connectedPorts.has(`output:${key}`)) return;
1303
- const spread = Math.PI * 0.4;
1304
- const baseAngle = 0 + (outputKeys.length > 1
1305
- ? (i / (outputKeys.length - 1) - 0.5) * spread
1306
- : 0);
1307
- placeDot(key, 'output', baseAngle, node.outputs[key]);
1308
- });
1309
- }
1310
-
1311
- /**
1312
- * Create a single free dot element
1313
- * @param {string} nodeId
1314
- * @param {string} key
1315
- * @param {'input'|'output'} side
1316
- * @param {number} wx - world X
1317
- * @param {number} wy - world Y
1318
- * @param {object} portData - Input/Output instance
1319
- */
1320
- #createFreeDot(nodeId, key, side, wx, wy, portData) {
1321
- const dotId = `free-${nodeId}-${side}-${key}`;
1322
- // Skip if already exists
1323
- if (this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`)) return;
1324
-
1325
- const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1326
- dot.setAttribute('data-free-dot', dotId);
1327
- dot.setAttribute('data-node-id', nodeId);
1328
- dot.setAttribute('data-port-key', key);
1329
- dot.setAttribute('data-port-side', side);
1330
- dot.setAttribute('r', '4');
1331
- dot.setAttribute('cx', wx);
1332
- dot.setAttribute('cy', wy);
1333
- dot.style.pointerEvents = 'auto';
1334
- dot.style.cursor = 'crosshair';
1335
-
1336
- const socketName = portData?.socket?.name || 'data';
1337
- let typeClass = 'sn-dot-data';
1338
- if (socketName === 'exec' || socketName === 'execution' || socketName === 'trigger') {
1339
- typeClass = 'sn-dot-exec';
1340
- } else if (socketName === 'ctrl' || socketName === 'control' || socketName === 'signal') {
1341
- typeClass = 'sn-dot-ctrl';
1342
- }
1343
- const sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
1344
- dot.setAttribute('class', `sn-free-dot ${sideClass} ${typeClass}`);
1345
-
1346
- if (this.#onDotDrag) {
1347
- dot.addEventListener('pointerdown', (e) => {
1348
- e.stopPropagation();
1349
- e.preventDefault();
1350
- this.#onDotDrag({
1351
- nodeId,
1352
- key,
1353
- side,
1354
- worldX: parseFloat(dot.getAttribute('cx')) || 0,
1355
- worldY: parseFloat(dot.getAttribute('cy')) || 0,
1356
- });
1357
- });
1358
- }
1359
-
1360
- this.#dotLayer.appendChild(dot);
1361
- }
1362
-
1363
- /**
1364
- * Remove free dot when a connection fills this port
1365
- * @param {string} nodeId
1366
- * @param {string} key
1367
- * @param {'input'|'output'} side
1368
- */
1369
- removeFreeDot(nodeId, key, side) {
1370
- const dotId = `free-${nodeId}-${side}-${key}`;
1371
- const dot = this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`);
1372
- if (dot) dot.remove();
1373
- }
1374
-
1375
- /**
1376
- * Refresh free dot positions after node move (updates coords without recreating)
1377
- * @param {string} nodeId
1378
- */
1379
- refreshFreeDots(nodeId) {
1380
- const dots = this.#dotLayer.querySelectorAll(`[data-node-id="${nodeId}"][data-free-dot]`);
1381
- if (!dots.length) {
1382
- // No dots yet — initial render (position was likely missing at shape setup time)
1383
- this.renderFreeDots(nodeId);
1384
- return;
1385
- }
1386
-
1387
- const nodeEl = this.#nodeViews.get(nodeId);
1388
- const node = this.#editor.getNode(nodeId);
1389
- if (!nodeEl || !node) return;
1390
-
1391
- const shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
1392
- const shape = getShape(shapeName);
1393
- if (!shape?.pathData) return;
1394
-
1395
- const size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
1396
- const pos = nodeEl._position;
1397
- if (!pos) return;
1398
-
1399
- for (const dot of dots) {
1400
- const key = dot.getAttribute('data-port-key');
1401
- const side = dot.getAttribute('data-port-side');
1402
- const ports = side === 'output' ? node.outputs : node.inputs;
1403
- const keys = Object.keys(ports);
1404
- const index = keys.indexOf(key);
1405
- if (index < 0) continue;
1406
- const sp = shape.getSocketPosition(side, index, keys.length, size);
1407
- dot.setAttribute('cx', pos.x + sp.x);
1408
- dot.setAttribute('cy', pos.y + sp.y);
1409
- }
1410
- }
1411
-
1412
- /**
1413
- * Find nearest SVG dot (free or connected) to world position within radius.
1414
- * Used as drop target for connections.
1415
- * @param {number} wx - world X
1416
- * @param {number} wy - world Y
1417
- * @param {number} [radius=20] - search radius in world units
1418
- * @returns {{ nodeId: string, key: string, side: string }|null}
1419
- */
1420
- findNearestDot(wx, wy, radius = 20) {
1421
- let bestDist = radius;
1422
- let best = null;
1423
-
1424
- // Search free dots
1425
- const freeDots = this.#dotLayer.querySelectorAll('[data-free-dot]');
1426
- for (const dot of freeDots) {
1427
- const cx = parseFloat(dot.getAttribute('cx')) || 0;
1428
- const cy = parseFloat(dot.getAttribute('cy')) || 0;
1429
- const dist = Math.hypot(cx - wx, cy - wy);
1430
- if (dist < bestDist) {
1431
- bestDist = dist;
1432
- best = {
1433
- nodeId: dot.getAttribute('data-node-id'),
1434
- key: dot.getAttribute('data-port-key'),
1435
- side: dot.getAttribute('data-port-side'),
1436
- };
1437
- }
1438
- }
1439
-
1440
- // Search connected SVG dots
1441
- const wiredDots = this.#dotLayer.querySelectorAll('[data-svg-wired=""]');
1442
- for (const dot of wiredDots) {
1443
- const cx = parseFloat(dot.getAttribute('cx')) || 0;
1444
- const cy = parseFloat(dot.getAttribute('cy')) || 0;
1445
- const dist = Math.hypot(cx - wx, cy - wy);
1446
- if (dist < bestDist) {
1447
- bestDist = dist;
1448
- const connDotId = dot.getAttribute('data-conn-dot');
1449
- // Parse connDotId: "connId-start" or "connId-end"
1450
- const isStart = connDotId.endsWith('-start');
1451
- const connId = connDotId.replace(/-(?:start|end)$/, '');
1452
- const conn = this.#connectionData.get(connId);
1453
- if (conn) {
1454
- best = {
1455
- nodeId: isStart ? conn.from : conn.to,
1456
- key: isStart ? conn.out : conn.in,
1457
- side: isStart ? 'output' : 'input',
1458
- };
1459
- }
1460
- }
1461
- }
1462
-
1463
- return best;
1464
- }
1465
- }
1466
-
1467
- /** @type {boolean} Set to true to enable debug logging for pin placement and routing */
1468
- ConnectionRenderer.debug = false;