project-graph-mcp 2.3.1 → 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 (226) 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/index.js +0 -103
  148. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +0 -361
  149. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +0 -332
  150. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +0 -96
  151. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +0 -104
  152. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +0 -133
  153. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +0 -33
  154. package/vendor/symbiote-node/interactions/ConnectFlow.js +0 -307
  155. package/vendor/symbiote-node/interactions/Drag.js +0 -102
  156. package/vendor/symbiote-node/interactions/Selector.js +0 -132
  157. package/vendor/symbiote-node/interactions/SnapGrid.js +0 -65
  158. package/vendor/symbiote-node/interactions/Zoom.js +0 -140
  159. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +0 -88
  160. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +0 -254
  161. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +0 -11
  162. package/vendor/symbiote-node/layout/Layout/Layout.css.js +0 -88
  163. package/vendor/symbiote-node/layout/Layout/Layout.js +0 -622
  164. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +0 -25
  165. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +0 -293
  166. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +0 -467
  167. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +0 -33
  168. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +0 -46
  169. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +0 -102
  170. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +0 -6
  171. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +0 -156
  172. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +0 -250
  173. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +0 -379
  174. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +0 -263
  175. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +0 -20
  176. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +0 -183
  177. package/vendor/symbiote-node/layout/LayoutTree.js +0 -246
  178. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +0 -43
  179. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +0 -89
  180. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +0 -14
  181. package/vendor/symbiote-node/layout/index.js +0 -16
  182. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +0 -61
  183. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +0 -79
  184. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +0 -19
  185. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +0 -41
  186. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +0 -24
  187. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +0 -16
  188. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +0 -65
  189. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +0 -29
  190. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +0 -13
  191. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +0 -683
  192. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +0 -92
  193. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +0 -17
  194. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +0 -25
  195. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +0 -7
  196. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +0 -90
  197. package/vendor/symbiote-node/node/PortItem/PortItem.js +0 -87
  198. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +0 -10
  199. package/vendor/symbiote-node/package.json +0 -59
  200. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +0 -143
  201. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +0 -131
  202. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +0 -16
  203. package/vendor/symbiote-node/plugins/History.js +0 -384
  204. package/vendor/symbiote-node/plugins/Readonly.js +0 -59
  205. package/vendor/symbiote-node/shapes/CircleShape.js +0 -80
  206. package/vendor/symbiote-node/shapes/CommentShape.js +0 -35
  207. package/vendor/symbiote-node/shapes/DiamondShape.js +0 -115
  208. package/vendor/symbiote-node/shapes/NodeShape.js +0 -80
  209. package/vendor/symbiote-node/shapes/PillShape.js +0 -91
  210. package/vendor/symbiote-node/shapes/RectShape.js +0 -72
  211. package/vendor/symbiote-node/shapes/SVGShape.js +0 -494
  212. package/vendor/symbiote-node/shapes/index.js +0 -53
  213. package/vendor/symbiote-node/themes/Palette.js +0 -32
  214. package/vendor/symbiote-node/themes/Skin.js +0 -113
  215. package/vendor/symbiote-node/themes/Theme.js +0 -84
  216. package/vendor/symbiote-node/themes/carbon.js +0 -137
  217. package/vendor/symbiote-node/themes/dark.js +0 -137
  218. package/vendor/symbiote-node/themes/ebook.js +0 -138
  219. package/vendor/symbiote-node/themes/grey.js +0 -137
  220. package/vendor/symbiote-node/themes/light.js +0 -137
  221. package/vendor/symbiote-node/themes/neon.js +0 -138
  222. package/vendor/symbiote-node/themes/pcb.js +0 -273
  223. package/vendor/symbiote-node/themes/synthwave.js +0 -137
  224. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +0 -86
  225. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +0 -128
  226. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +0 -29
@@ -1,962 +0,0 @@
1
- import { getShape } from '../shapes/index.js';
2
-
3
- /**
4
- * Parallel support for connection rendering via HTML5 Canvas API.
5
- * This is used to test performance against the DOM-bound SVG renderer.
6
- */
7
- export class CanvasConnectionRenderer {
8
- #canvasLayer;
9
- #dotLayer;
10
- #nodeViews;
11
- #editor;
12
- #onConnectionClick;
13
- #getZoom;
14
- #getPan;
15
- #onDotDrag;
16
-
17
- #pathStyle = 'bezier';
18
- #connectionData = new Map();
19
- #ctx;
20
- #resizeObserver;
21
- #animationFrameId;
22
- #batchMode = false;
23
- #batchDirty = false;
24
-
25
- /** @type {Array<{id:string, x:number, y:number, w:number, h:number, degree:number, color:string, label:string}>} */
26
- #phantomNodes = [];
27
- /** @type {Map<string, Object>} Fast lookup for phantom proxy by nodeId */
28
- #phantomMap = new Map();
29
-
30
- // Computed styles matching the theme
31
- #colorParams = {
32
- normal: '#4a9eff',
33
- selected: '#ff6b6b',
34
- width: 2,
35
- flowingColor: '#4a9eff', // We use --sn-conn-color directly
36
- };
37
-
38
- /**
39
- * @param {Object} config
40
- * @param {HTMLCanvasElement} config.canvasLayer
41
- * @param {HTMLElement} config.dotLayer
42
- * @param {Map<string, HTMLElement>} config.nodeViews
43
- * @param {import('../core/GraphEditor.js').GraphEditor} config.editor
44
- * @param {function(string, MouseEvent)} config.onConnectionClick
45
- * @param {function(): number} config.getZoom
46
- * @param {function(): {x: number, y: number}} config.getPan
47
- * @param {function(Object)} config.onDotDrag
48
- */
49
- constructor(config = {}) {
50
- this.#canvasLayer = config.canvasLayer || document.createElement('canvas');
51
- this.#dotLayer = config.dotLayer;
52
- this.#nodeViews = config.nodeViews;
53
- this.#editor = config.editor;
54
- this.#onConnectionClick = config.onConnectionClick;
55
- this.#getZoom = config.getZoom || (() => 1);
56
- this.#getPan = config.getPan || (() => ({ x: 0, y: 0 }));
57
- this.#onDotDrag = config.onDotDrag;
58
-
59
- this.#ctx = this.#canvasLayer.getContext('2d', { alpha: true, desynchronized: false });
60
- this.#initResizeObserver();
61
- this.#updateStyles();
62
-
63
- // Start render loop for flow animations (if flowing exists)
64
- this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
65
- }
66
-
67
- /**
68
- * Resize observer to keep the canvas 1:1 with device pixels
69
- */
70
- #initResizeObserver() {
71
- const parent = this.#canvasLayer.parentElement;
72
- if (!parent) return;
73
-
74
- this.#resizeObserver = new ResizeObserver((entries) => {
75
- const rect = entries[0].contentRect;
76
- const dpr = window.devicePixelRatio || 1;
77
-
78
- this.#canvasLayer.width = rect.width * dpr;
79
- this.#canvasLayer.height = rect.height * dpr;
80
-
81
- this.redraw();
82
- });
83
-
84
- this.#resizeObserver.observe(parent);
85
- }
86
-
87
- #updateStyles() {
88
- const computed = getComputedStyle(document.body);
89
- this.#colorParams.normal = computed.getPropertyValue('--sn-conn-color').trim() || '#4a9eff';
90
- this.#colorParams.selected = computed.getPropertyValue('--sn-conn-selected').trim() || '#ff6b6b';
91
- this.#colorParams.outline = computed.getPropertyValue('--sn-port-outline').trim() || '#16213e';
92
- this.#colorParams.bg = computed.getPropertyValue('--sn-bg').trim() || '#1a1a2e';
93
- this.#colorParams.width = parseFloat(computed.getPropertyValue('--sn-conn-width')) || 2;
94
- }
95
-
96
- /** @param {'bezier'|'orthogonal'|'straight'|'pcb'} style */
97
- setPathStyle(style) {
98
- this.#pathStyle = style;
99
- this.redraw();
100
- }
101
-
102
- get data() {
103
- return this.#connectionData;
104
- }
105
-
106
- addBatch(conns) {
107
- for (const conn of conns) {
108
- this.#connectionData.set(conn.id, conn);
109
- }
110
- this.redraw();
111
- }
112
-
113
- refreshAll() {
114
- this.redraw();
115
- }
116
-
117
- add(conn) {
118
- this.#connectionData.set(conn.id, conn);
119
- this.redraw();
120
- }
121
-
122
- remove(conn) {
123
- this.#connectionData.delete(conn.id);
124
- this.redraw();
125
- }
126
-
127
- updateForNode(nodeId) {
128
- this.redraw();
129
- }
130
-
131
- setFlowing(connId, active) {
132
- const conn = this.#connectionData.get(connId);
133
- if (conn) conn.flowing = active;
134
- }
135
-
136
- setAllFlowing(active) {
137
- for (const conn of this.#connectionData.values()) {
138
- conn.flowing = active;
139
- }
140
- }
141
-
142
- setPathStyle(style) {
143
- this.#pathStyle = style;
144
- this.redraw();
145
- }
146
-
147
- highlightDotsForNodes(compatibleNodeIds) { }
148
- clearDotHighlights() { }
149
- renderFreeDots(nodeId) { }
150
- removeFreeDot(nodeId, key, side) { }
151
- refreshFreeDots(nodeId) { }
152
- findNearestDot(wx, wy, radius = 20) { return null; }
153
-
154
- clear() {
155
- this.#connectionData.clear();
156
- this.#phantomNodes = [];
157
- this.#phantomMap.clear();
158
- this.redraw();
159
- }
160
-
161
- // #phantomMap moved to class field declarations (line 25)
162
-
163
- /**
164
- * Set phantom nodes — nodes without DOM that are rendered as Canvas dots.
165
- * @param {Array<{id:string, x:number, y:number, w:number, h:number, degree:number, color:string, label:string}>} nodes
166
- */
167
- setPhantomNodes(nodes) {
168
- this.#phantomNodes = nodes || [];
169
- this.#phantomMap.clear();
170
- for (const n of this.#phantomNodes) {
171
- this.#phantomMap.set(n.id, n);
172
- }
173
- this.redraw();
174
- }
175
-
176
- /** Retrieve actual connector coordinate relative to the origin */
177
- getSocketOffset(nodeEl, portKey, side, targetPos) {
178
- if (!nodeEl) return { x: 0, y: 0 };
179
- const w = nodeEl._cachedW || nodeEl.offsetWidth || 180;
180
- const h = nodeEl._cachedH || nodeEl.offsetHeight || 100;
181
-
182
- let basePortX = side === 'output' ? w : 0;
183
-
184
- // Fast path: cached layout coords for the node
185
- if (nodeEl._slotCache && nodeEl._slotCache.has(portKey)) {
186
- const cached = nodeEl._slotCache.get(portKey);
187
- return {
188
- x: cached.x,
189
- y: cached.y,
190
- angle: cached.angle
191
- };
192
- }
193
-
194
- const nodeModel = this.#editor?.getNode(nodeEl.id);
195
- const isParamNode = nodeModel?.type === 'param';
196
- let portIndex = 0;
197
- let totalPorts = 1;
198
-
199
- if (nodeModel && nodeModel.type !== 'param') {
200
- const portsData = side === 'output' ? nodeModel.outputs : nodeModel.inputs;
201
- if (portsData) {
202
- const keys = Object.keys(portsData);
203
- totalPorts = keys.length || 1;
204
- const idx = keys.indexOf(portKey);
205
- if (idx !== -1) portIndex = idx;
206
- }
207
- }
208
-
209
- // Delegate to UniversalSvgShape if defined and handles geometric coordinates (SVGShape)
210
- const shapeConfig = getShape(nodeModel?.shape);
211
- if (shapeConfig && shapeConfig.pathData && shapeConfig.getSocketPosition) {
212
- const pos = shapeConfig.getSocketPosition(side, portIndex, totalPorts, { width: w, height: h }, targetPos);
213
- if (pos) return pos;
214
- }
215
-
216
- // Standard shapes: read from DOM socket elements
217
- const container = side === 'output'
218
- ? nodeEl.querySelector('.outputs')
219
- : nodeEl.querySelector('.inputs');
220
-
221
- if (container) {
222
- const portItems = container.querySelectorAll('port-item');
223
- for (const portItem of portItems) {
224
- if (String(portItem.$.key) === String(portKey)) {
225
- const socket = portItem.querySelector('.sn-socket');
226
- if (socket) {
227
- const nodeRect = nodeEl.getBoundingClientRect();
228
- const socketRect = socket.getBoundingClientRect();
229
- const z = this.#getZoom();
230
- return {
231
- x: (socketRect.left - nodeRect.left + socketRect.width / 2) / z,
232
- y: (socketRect.top - nodeRect.top + socketRect.height / 2) / z,
233
- };
234
- }
235
- }
236
- }
237
- }
238
-
239
- return {
240
- x: side === 'output' ? (nodeEl._cachedW || nodeEl.offsetWidth || 180) : 0,
241
- y: (nodeEl._cachedH || nodeEl.offsetHeight || 100) / 2,
242
- };
243
- }
244
-
245
- #hasSelection = false;
246
- #activeConnIds = new Set();
247
-
248
- setSelectionState(hasSelection, activeConnIds) {
249
- this.#hasSelection = hasSelection;
250
- this.#activeConnIds = activeConnIds;
251
- this.redraw();
252
- }
253
-
254
- /** Suppress redraws during batch operations (e.g. setEditor initialization) */
255
- setBatchMode(on) {
256
- this.#batchMode = on;
257
- if (!on && this.#batchDirty) {
258
- this.#batchDirty = false;
259
- this.redraw();
260
- }
261
- }
262
-
263
- /** Perform full synchronous redraw of all connections */
264
- redraw() {
265
- if (this.#batchMode) { this.#batchDirty = true; return; }
266
- const ctx = this.#ctx;
267
- if (!ctx) return;
268
-
269
- // Reset and clear with devicePixelRatio
270
- const dpr = window.devicePixelRatio || 1;
271
- const zoom = this.#getZoom();
272
- this._frameZoom = zoom; // cache for #plotPath LOD
273
- const pan = this.#getPan();
274
-
275
- // Reset transform to identity to clear the raw screen buffer
276
- ctx.setTransform(1, 0, 0, 1, 0, 0);
277
- ctx.clearRect(0, 0, this.#canvasLayer.width, this.#canvasLayer.height);
278
-
279
- // Set view transform: Map World coordinates -> Screen coordinates
280
- ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, dpr * pan.x, dpr * pan.y);
281
-
282
- // Update theme vars
283
- this.#updateStyles();
284
-
285
- const time = Date.now();
286
- let hasFlowing = false;
287
-
288
-
289
- // Cache node layout geometry once per frame for the router (Map for O(1) lookup)
290
- this._nodeRectMap = new Map();
291
- for (const [nid, el] of this.#nodeViews) {
292
- if (el && el._position) {
293
- this._nodeRectMap.set(nid, {
294
- id: nid,
295
- x: el._position.x,
296
- y: el._position.y,
297
- w: el._cachedW || 180,
298
- h: el._cachedH || 60,
299
- el: el
300
- });
301
- }
302
- }
303
- // Include phantom nodes in geometry cache for FAR_ZOOM routing
304
- for (const node of this.#phantomNodes) {
305
- if (node && !this._nodeRectMap.has(node.id)) {
306
- this._nodeRectMap.set(node.id, {
307
- id: node.id,
308
- x: node.x || 0,
309
- y: node.y || 0,
310
- w: node.w || 180,
311
- h: node.h || 60,
312
- el: null
313
- });
314
- }
315
- }
316
-
317
- // Pre-compute connection index once per frame (avoids O(N²) Array.from+indexOf in routing)
318
- const connIndexMap = new Map();
319
- let ci = 0;
320
- for (const key of this.#connectionData.keys()) {
321
- connIndexMap.set(key, ci++);
322
- }
323
- this._connIndexMap = connIndexMap;
324
-
325
- // Collect connected sockets to draw caps over them (fixes DOM/Canvas sub-pixel drift seams)
326
- const socketsToDraw = new Map();
327
-
328
- const drawConnection = (id, connection) => {
329
- // Draw connection
330
- const isFlowing = connection.flowing;
331
- const isActive = this.#activeConnIds ? this.#activeConnIds.has(connection.id) : false;
332
- const isSelected = isActive;
333
- const isDimmed = !isActive && this.#hasSelection;
334
-
335
- const fromNode = this.#editor?.getNode(connection.from);
336
- const toNode = this.#editor?.getNode(connection.to);
337
- const fromColor = fromNode?.outputs?.[connection.out]?.socket?.color;
338
- const toColor = toNode?.inputs?.[connection.in]?.socket?.color;
339
-
340
- // Scale lineWidth inversely with zoom to maintain screen visibility
341
- // At zoom 0.003, a 2px world line = 0.006 screen px (invisible)
342
- // minScreenWidth = 1.5 screen px → in world coords = 1.5 / zoom
343
- const baseWidth = this.#colorParams.width;
344
- ctx.lineWidth = Math.max(baseWidth, 1.5 / zoom);
345
- ctx.lineCap = 'round';
346
- ctx.lineJoin = 'round';
347
- ctx.globalAlpha = 1.0;
348
-
349
- ctx.beginPath();
350
- let coords = null;
351
- try {
352
- coords = this.#plotPath(ctx, connection);
353
- } catch (err) {
354
- console.warn('Path failed:', err);
355
- }
356
- if (!coords) return;
357
-
358
- // Save caps for later drawing (ensures they are on top of all paths)
359
- socketsToDraw.set(`${connection.from}:${connection.out}`, { x: coords.startX, y: coords.startY, color: fromColor || this.#colorParams.normal });
360
- socketsToDraw.set(`${connection.to}:${connection.in}`, { x: coords.endX, y: coords.endY, color: toColor || this.#colorParams.normal });
361
-
362
- let finalColor;
363
- if (fromColor && toColor && fromColor !== toColor) {
364
- const grad = ctx.createLinearGradient(coords.startX, coords.startY, coords.endX, coords.endY);
365
- grad.addColorStop(0, fromColor);
366
- grad.addColorStop(1, toColor);
367
- finalColor = grad;
368
- } else {
369
- finalColor = fromColor || this.#colorParams.normal;
370
- }
371
-
372
- if (isDimmed) {
373
- // Actually, color-mix doesn't work on CanvasGradient. If it's a gradient, fallback to solid fromColor.
374
- let baseColor = fromColor || this.#colorParams.normal;
375
- finalColor = `color-mix(in srgb, ${baseColor} 15%, ${this.#colorParams.bg})`;
376
- }
377
-
378
- ctx.strokeStyle = finalColor;
379
- ctx.fillStyle = finalColor;
380
-
381
- if (isFlowing) {
382
- ctx.setLineDash([10, 10]);
383
- ctx.lineDashOffset = -(time / 20) % 20;
384
- hasFlowing = true;
385
- } else {
386
- ctx.setLineDash([]);
387
- }
388
-
389
- // Apply drop shadow for selected lines to make them pop
390
- if (isSelected && !isDimmed) {
391
- ctx.shadowColor = ctx.strokeStyle;
392
- ctx.shadowBlur = 8;
393
- } else {
394
- ctx.shadowBlur = 0;
395
- }
396
-
397
- ctx.stroke(coords.path2D);
398
-
399
- // draw direction arrow
400
- if (coords.arrow) {
401
- ctx.save();
402
- ctx.translate(coords.arrow.x, coords.arrow.y);
403
- // Transform from typical left-to-right arrow drawn along X axis
404
- ctx.rotate(coords.arrow.angle);
405
- ctx.beginPath();
406
- ctx.moveTo(-5, -3.5);
407
- ctx.lineTo(5, 0);
408
- ctx.lineTo(-5, 3.5);
409
- ctx.closePath();
410
- ctx.fillStyle = ctx.strokeStyle; // inherited from path
411
- ctx.fill();
412
- ctx.restore();
413
- }
414
- };
415
-
416
- if (this.#hasSelection) {
417
- for (const [id, connection] of this.#connectionData) {
418
- if (!this.#activeConnIds.has(connection.id)) drawConnection(id, connection);
419
- }
420
- for (const [id, connection] of this.#connectionData) {
421
- if (this.#activeConnIds.has(connection.id)) drawConnection(id, connection);
422
- }
423
- } else {
424
- for (const [id, connection] of this.#connectionData) {
425
- drawConnection(id, connection);
426
- }
427
- }
428
-
429
- // Draw caps for connected sockets to hide DOM subpixel drift
430
- ctx.setLineDash([]);
431
-
432
- for (const [, pos] of socketsToDraw) {
433
- ctx.beginPath();
434
- // HTML ::after is 12x12 (content) + 2px border = 16px total.
435
- // Canvas r=7 (dia 14) + lineWidth 2 = 14+-1 = 12px inner fill, 16px total outer bound.
436
- ctx.arc(pos.x, pos.y, 7, 0, Math.PI * 2);
437
- ctx.fillStyle = pos.color;
438
- ctx.fill();
439
- ctx.lineWidth = 2; // Match the 2px solid CSS outline exactly
440
- ctx.strokeStyle = this.#colorParams.outline; // Match var(--sn-port-outline) / var(--sn-node-bg)
441
- ctx.stroke();
442
- }
443
-
444
- // ─── Draw phantom node dots (Obsidian-style) ───
445
- this.#drawPhantomDots(ctx, zoom);
446
-
447
- // Stop flow animation if none flowing to save CPU
448
- if (!hasFlowing && this.#animationFrameId) {
449
- cancelAnimationFrame(this.#animationFrameId);
450
- this.#animationFrameId = null;
451
- } else if (hasFlowing && !this.#animationFrameId) {
452
- this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
453
- }
454
- }
455
-
456
- /**
457
- * Draw phantom nodes as colored dots with size proportional to degree.
458
- * @param {CanvasRenderingContext2D} ctx
459
- * @param {number} zoom
460
- */
461
- #drawPhantomDots(ctx, zoom) {
462
- if (this.#phantomNodes.length === 0) return;
463
-
464
- ctx.shadowBlur = 0;
465
- ctx.setLineDash([]);
466
-
467
- // At very low zoom, scale dot size to maintain minimum screen visibility
468
- // minScreen = 8px → in world coords = 8 / zoom
469
- const minWorldW = Math.max(180, 8 / zoom);
470
- const minWorldH = Math.max(60, 4 / zoom);
471
- const showLabels = zoom > 0.15;
472
- const labelFontSize = Math.max(9, Math.min(14, 12 / zoom));
473
-
474
- for (const node of this.#phantomNodes) {
475
- if (!node || node.w === undefined || node.h === undefined) continue;
476
-
477
- const w = Math.max(minWorldW, node.w);
478
- const h = Math.max(minWorldH, node.h);
479
- // Center the enlarged dot on the original position
480
- const x = (node.x || 0) - (w - node.w) / 2;
481
- const y = (node.y || 0) - (h - node.h) / 2;
482
-
483
- ctx.beginPath();
484
- try {
485
- const r = Math.min(6, w * 0.1, h * 0.1);
486
- if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
487
- else ctx.rect(x, y, w, h);
488
- } catch (e) {
489
- ctx.rect(x, y, w, h);
490
- }
491
-
492
- ctx.fillStyle = node.color || this.#colorParams.normal;
493
- ctx.globalAlpha = 0.85;
494
- ctx.fill();
495
-
496
- // Stroke — scale lineWidth for visibility
497
- ctx.lineWidth = Math.max(1.5, 1 / zoom);
498
- ctx.strokeStyle = this.#colorParams.outline;
499
- ctx.stroke();
500
- ctx.globalAlpha = 1.0;
501
-
502
- // Label at medium+ zoom
503
- if (showLabels && node.label) {
504
- ctx.fillStyle = '#fff';
505
- ctx.globalAlpha = 1;
506
- ctx.font = `${labelFontSize}px sans-serif`;
507
- ctx.textAlign = 'center';
508
- ctx.textBaseline = 'middle';
509
-
510
- ctx.save();
511
- ctx.beginPath();
512
- ctx.rect(x + 4, y, w - 8, h);
513
- ctx.clip();
514
- ctx.fillText(node.label, x + w / 2, y + h / 2);
515
- ctx.restore();
516
- }
517
- }
518
- }
519
-
520
- /**
521
- * Create a minimal proxy object for a phantom node so #plotPath can work.
522
- * Mimics the shape of a DOM nodeView element with _position and _cachedW/H.
523
- */
524
- #getPhantomProxy(nodeId) {
525
- const phantom = this.#phantomMap.get(nodeId);
526
- if (!phantom) return null;
527
- return {
528
- id: phantom.id,
529
- _position: { x: phantom.x, y: phantom.y },
530
- _cachedW: phantom.w,
531
- _cachedH: phantom.h,
532
- offsetWidth: phantom.w,
533
- offsetHeight: phantom.h,
534
- getAttribute: () => null,
535
- querySelector: () => null,
536
- querySelectorAll: () => [],
537
- style: {},
538
- };
539
- }
540
-
541
- #renderLoop = () => {
542
- this.redraw();
543
- this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
544
- };
545
-
546
- #plotPath(ctx, conn) {
547
- let fromElNodeView = this.#nodeViews.get(conn.from);
548
- let toElNodeView = this.#nodeViews.get(conn.to);
549
-
550
- // Fallback to phantom proxy for nodes without DOM
551
- if (!fromElNodeView) fromElNodeView = this.#getPhantomProxy(conn.from);
552
- if (!toElNodeView) toElNodeView = this.#getPhantomProxy(conn.to);
553
- if (!fromElNodeView || !toElNodeView) return;
554
-
555
- let fromPos = fromElNodeView._position || { x: 0, y: 0 };
556
- let toPos = toElNodeView._position || { x: 0, y: 0 };
557
-
558
- let fromEl = fromElNodeView;
559
- let toEl = toElNodeView;
560
-
561
- if (this._nodeRectMap) {
562
- const c1 = this._nodeRectMap.get(conn.from);
563
- if (c1) { fromPos = { x: c1.x, y: c1.y }; if (c1.el) fromEl = c1.el; }
564
- const c2 = this._nodeRectMap.get(conn.to);
565
- if (c2) { toPos = { x: c2.x, y: c2.y }; if (c2.el) toEl = c2.el; }
566
- }
567
-
568
- const fromW = fromEl._cachedW || fromEl.offsetWidth || 180;
569
- const fromH = fromEl._cachedH || fromEl.offsetHeight || 100;
570
- const toW = toEl._cachedW || toEl.offsetWidth || 180;
571
- const toH = toEl._cachedH || toEl.offsetHeight || 100;
572
-
573
- const fromSize = { width: fromW, height: fromH };
574
- const toSize = { width: toW, height: toH };
575
- const fromNode = this.#editor?.getNode(conn.from);
576
- const toNode = this.#editor?.getNode(conn.to);
577
- const fromShape = getShape(fromNode?.shape);
578
- const toShape = getShape(toNode?.shape);
579
-
580
- const fromCenter = { x: fromPos.x + fromW / 2, y: fromPos.y + fromH / 2 };
581
- const toCenter = { x: toPos.x + toW / 2, y: toPos.y + toH / 2 };
582
-
583
- const fromOffset = this.getSocketOffset(fromEl, conn.out, 'output', toCenter);
584
- const toOffset = this.getSocketOffset(toEl, conn.in, 'input', fromCenter);
585
-
586
- const startX = fromPos.x + fromOffset.x;
587
- const startY = fromPos.y + fromOffset.y;
588
- const endX = toPos.x + toOffset.x;
589
- const endY = toPos.y + toOffset.y;
590
-
591
-
592
- let d;
593
- let arrow = { x: endX, y: endY, angle: 0 };
594
- const effectiveStyle = this.#pathStyle;
595
- if (effectiveStyle === 'straight') {
596
- d = `M ${startX} ${startY} L ${endX} ${endY}`;
597
- arrow.x = (startX + endX) / 2;
598
- arrow.y = (startY + endY) / 2;
599
- arrow.angle = Math.atan2(endY - startY, endX - startX);
600
- } else if (effectiveStyle === 'orthogonal') {
601
- const connIndex = this._connIndexMap ? (this._connIndexMap.get(conn.id) ?? 0) : 0;
602
- const traceOffset = (connIndex > -1 ? connIndex % 10 : 0) * 4;
603
-
604
- const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
605
- const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
606
-
607
- const stubLen = 20;
608
- const getDxDy = (deg) => ({
609
- dx: Math.round(Math.cos(deg * Math.PI / 180)),
610
- dy: Math.round(Math.sin(deg * Math.PI / 180))
611
- });
612
-
613
- const fDir = getDxDy(fromAngle);
614
- const tDir = getDxDy(toAngle);
615
-
616
- const p1x = startX + fDir.dx * stubLen;
617
- const p1y = startY + fDir.dy * stubLen;
618
- const p2x = endX + tDir.dx * stubLen;
619
- const p2y = endY + tDir.dy * stubLen;
620
-
621
- const fromH = fromEl._cachedH || 60;
622
- const toH = toEl._cachedH || 60;
623
-
624
- let pts = [{ x: startX, y: startY }, { x: p1x, y: p1y }];
625
- const skipObstacles = this._nodeRectMap && this._nodeRectMap.size > 200;
626
-
627
- if (endX < startX) {
628
- const bottomY = Math.max(fromPos.y + fromH, toPos.y + toH) + 30 + traceOffset;
629
- pts.push({ x: p1x, y: bottomY });
630
- pts.push({ x: p2x, y: bottomY });
631
- } else if (skipObstacles) {
632
- // Large graph: simple mid-X routing without obstacle checks
633
- const midX = (p1x + p2x) / 2 + traceOffset;
634
- pts.push({ x: midX, y: p1y });
635
- pts.push({ x: midX, y: p2y });
636
- } else {
637
- const maxH = Math.max(fromH, toH);
638
- if (Math.abs(p1y - p2y) < maxH) {
639
- let nodeBetween = false;
640
- const obstacleIter = this._nodeRectMap ? this._nodeRectMap.values() : [];
641
- for (const rect of obstacleIter) {
642
- const nx = rect.x;
643
- const ny = rect.y;
644
- const nw = rect.w || 180;
645
- const nh = rect.h || 60;
646
- if (nx > p1x && nx + nw < p2x) {
647
- if (Math.min(p1y, p2y) <= ny + nh && Math.max(p1y, p2y) >= ny) {
648
- nodeBetween = true; break;
649
- }
650
- }
651
- }
652
-
653
- if (nodeBetween) {
654
- const detourY = Math.min(fromPos.y, toPos.y) - 30 - traceOffset;
655
- pts.push({ x: p1x, y: detourY });
656
- pts.push({ x: p2x, y: detourY });
657
- } else {
658
- const midX = (p1x + p2x) / 2 + traceOffset;
659
- pts.push({ x: midX, y: p1y });
660
- pts.push({ x: midX, y: p2y });
661
- }
662
- } else {
663
- let midX = (p1x + p2x) / 2 + traceOffset;
664
- let obstacleNode = null;
665
- const minY = Math.min(p1y, p2y);
666
- const maxY = Math.max(p1y, p2y);
667
-
668
- const obstIter = this._nodeRectMap ? this._nodeRectMap.values() : [];
669
- for (const rect of obstIter) {
670
- const nx = rect.x;
671
- const ny = rect.y;
672
- const nw = rect.w || 180;
673
- const nh = rect.h || 60;
674
- if (midX >= nx && midX <= nx + nw) {
675
- if (ny <= maxY && ny + nh >= minY) {
676
- obstacleNode = { x: nx, w: nw };
677
- break;
678
- }
679
- }
680
- }
681
-
682
- if (obstacleNode) {
683
- const leftDist = Math.abs(midX - obstacleNode.x);
684
- const rightDist = Math.abs(midX - (obstacleNode.x + obstacleNode.w));
685
- if (leftDist < rightDist) {
686
- midX = obstacleNode.x - 30 - traceOffset;
687
- } else {
688
- midX = obstacleNode.x + obstacleNode.w + 30 + traceOffset;
689
- }
690
- }
691
-
692
- pts.push({ x: midX, y: p1y });
693
- pts.push({ x: midX, y: p2y });
694
- }
695
- }
696
-
697
- pts.push({ x: p2x, y: p2y });
698
- pts.push({ x: endX, y: endY });
699
-
700
- let path = `M ${pts[0].x} ${pts[0].y}`;
701
- for (let i = 1; i < pts.length; i++) {
702
- const prev = pts[i - 1];
703
- const curr = pts[i];
704
- if (curr.x === prev.x && curr.y === prev.y) continue;
705
- if (curr.x !== prev.x && curr.y !== prev.y) {
706
- path += ` H ${curr.x} V ${curr.y}`;
707
- } else if (curr.x !== prev.x) {
708
- path += ` H ${curr.x}`;
709
- } else if (curr.y !== prev.y) {
710
- path += ` V ${curr.y}`;
711
- }
712
- }
713
- if (pts.length >= 2) {
714
- const midIndex = Math.floor(pts.length / 2);
715
- const p1 = pts[midIndex - 1];
716
- const p2 = pts[midIndex];
717
- if (p1 && p2) {
718
- arrow.x = (p1.x + p2.x) / 2;
719
- arrow.y = (p1.y + p2.y) / 2;
720
- arrow.angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
721
- }
722
- }
723
- d = path;
724
- } else if (effectiveStyle === 'pcb') {
725
- // ─── PCB Grid-Based Trace Routing ───
726
- // All waypoints snap to a grid. Stubs exit perpendicular to node surface
727
- // with a minimum length, then route on grid channels with chamfered corners.
728
-
729
- const TRACE_GRID = 5; // Dense trace grid (5px)
730
- const STUB_MIN = 20; // minimum perpendicular stub from node edge
731
- const CHAMFER = 8; // 45° chamfer radius (px)
732
-
733
- // Snap a coordinate to the trace grid
734
- const snapGrid = (v) => Math.round(v / TRACE_GRID) * TRACE_GRID;
735
-
736
- // Connection channel index for parallel trace separation
737
- const connIndex = this._connIndexMap ? (this._connIndexMap.get(conn.id) ?? 0) : 0;
738
-
739
- // Determine unique channel shift to prevent parallel traces overlapping
740
- // Alternates: 0, +5, -5, +10, -10...
741
- const shiftIndex = (connIndex > -1 ? connIndex % 12 : 0);
742
- const channelShift = (shiftIndex % 2 === 0 ? 1 : -1) * Math.ceil(shiftIndex / 2) * TRACE_GRID;
743
-
744
- // Compute perpendicular stub directions from surface normals
745
- const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
746
- const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
747
-
748
- // Snap angle to cardinal direction (→ ↓ ← ↑)
749
- const snapDir = (deg) => {
750
- const r = ((deg % 360) + 360) % 360;
751
- if (r < 45 || r >= 315) return { dx: 1, dy: 0 }; // right
752
- if (r >= 45 && r < 135) return { dx: 0, dy: 1 }; // down
753
- if (r >= 135 && r < 225) return { dx: -1, dy: 0 }; // left
754
- return { dx: 0, dy: -1 }; // up
755
- };
756
-
757
- const fDir = snapDir(fromAngle);
758
- const tDir = snapDir(toAngle);
759
-
760
- // Stub endpoints: extend strictly perpedicular, no grid snapping on the orthogonal axis
761
- // to avoid diagonal stubs from pins that are floating (not grid aligned).
762
- const stubFromX = fDir.dx === 0 ? startX : startX + fDir.dx * STUB_MIN;
763
- const stubFromY = fDir.dy === 0 ? startY : startY + fDir.dy * STUB_MIN;
764
- const stubToX = tDir.dx === 0 ? endX : endX + tDir.dx * STUB_MIN;
765
- const stubToY = tDir.dy === 0 ? endY : endY + tDir.dy * STUB_MIN;
766
-
767
- const fromH = fromEl.offsetHeight || 60;
768
- const toH = toEl.offsetHeight || 60;
769
-
770
- // Build orthogonal waypoints on grid
771
- let pts = [
772
- { x: startX, y: startY },
773
- { x: stubFromX, y: stubFromY },
774
- ];
775
- // Skip obstacle avoidance on large graphs — O(N) per connection is too expensive
776
- // and produces worse visual results at high density anyway
777
- const skipObstacles = this._nodeRectMap && this._nodeRectMap.size > 200;
778
-
779
- // Very simple heuristic orthogonal router
780
- if (endX < startX - 20) {
781
- // Backwards routing: U-turn below obstacles in the path
782
- let maxObstacleY = Math.max(fromPos.y + fromH, toPos.y + toH);
783
-
784
- if (!skipObstacles) {
785
- const minXForObstacle = Math.min(stubFromX, stubToX);
786
- const maxXForObstacle = Math.max(stubFromX, stubToX);
787
- const iter = this._nodeRectMap ? this._nodeRectMap.values() : [];
788
- for (const rect of iter) {
789
- const nx = rect.x;
790
- const ny = rect.y;
791
- const nw = rect.w;
792
- const nh = rect.h;
793
- const pad = TRACE_GRID * 2;
794
- if (nx + nw + pad >= minXForObstacle && nx - pad <= maxXForObstacle) {
795
- if (ny + nh > maxObstacleY) {
796
- maxObstacleY = ny + nh;
797
- }
798
- }
799
- }
800
- }
801
-
802
- const bottomY = snapGrid(maxObstacleY + 30) + Math.abs(channelShift);
803
- pts.push({ x: stubFromX, y: bottomY });
804
- pts.push({ x: stubToX, y: bottomY });
805
- } else {
806
- // Forward routing: mid-X channel
807
- let midX = snapGrid((stubFromX + stubToX) / 2) + channelShift;
808
-
809
- // Same-height shortcut
810
- if (Math.abs(stubFromY - stubToY) < TRACE_GRID * 2) {
811
- pts.push({ x: stubToX, y: stubFromY });
812
- } else {
813
- if (!skipObstacles) {
814
- // Obstacle check for mid-X vertical segment
815
- const minY = Math.min(stubFromY, stubToY);
816
- const maxY = Math.max(stubFromY, stubToY);
817
- const pad = TRACE_GRID * 4;
818
-
819
- const iter = this._nodeRectMap ? this._nodeRectMap.values() : [];
820
- for (const rect of iter) {
821
- if (rect.id === conn.from || rect.id === conn.to) continue;
822
- const nx = rect.x, ny = rect.y;
823
- const nw = rect.w, nh = rect.h;
824
-
825
- if (midX >= nx - pad && midX <= nx + nw + pad) {
826
- if (ny - pad <= maxY && ny + nh + pad >= minY) {
827
- const leftX = snapGrid(nx - pad) + channelShift;
828
- const rightX = snapGrid(nx + nw + pad) + channelShift;
829
- midX = Math.abs(midX - leftX) < Math.abs(midX - rightX) ? leftX : rightX;
830
- break;
831
- }
832
- }
833
- }
834
- }
835
-
836
- pts.push({ x: midX, y: stubFromY });
837
- pts.push({ x: midX, y: stubToY });
838
- }
839
- }
840
-
841
- pts.push({ x: stubToX, y: stubToY });
842
- pts.push({ x: endX, y: endY });
843
-
844
- // Path building and Chamfering
845
-
846
- // Log route stats (debug only)
847
- if (CanvasConnectionRenderer.debug) {
848
- const fromLabel = fromEl._nodeData?.label || conn.from;
849
- const toLabel = toEl._nodeData?.label || conn.to;
850
- console.log(`[PCB] ${fromLabel} → ${toLabel} | waypoints=${pts.length}`);
851
- }
852
-
853
- // Build SVG path with 45° chamfered corners
854
- let path = `M ${pts[0].x} ${pts[0].y}`;
855
- for (let i = 1; i < pts.length; i++) {
856
- const prev = pts[i - 1];
857
- const curr = pts[i];
858
- if (Math.abs(curr.x - prev.x) < 0.5 && Math.abs(curr.y - prev.y) < 0.5) continue;
859
-
860
- const next = pts[i + 1];
861
- if (next) {
862
- // Determine if there's a turn at curr → need chamfer
863
- const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
864
- const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
865
- const isH1 = Math.abs(dx1) > Math.abs(dy1);
866
- const isH2 = Math.abs(dx2) > Math.abs(dy2);
867
-
868
- if (isH1 !== isH2) {
869
- // Corner turn — apply 45° chamfer
870
- const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
871
- const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
872
- if (len1 < 1 || len2 < 1) {
873
- // Degenerate segment — skip chamfer, go straight
874
- path += ` L ${curr.x} ${curr.y}`;
875
- continue;
876
- }
877
- const c = Math.min(CHAMFER, len1 / 2, len2 / 2);
878
-
879
- // Pre-corner point
880
- const nx1 = dx1 / len1, ny1 = dy1 / len1;
881
- const preX = curr.x - nx1 * c;
882
- const preY = curr.y - ny1 * c;
883
- // Post-corner point
884
- const nx2 = dx2 / len2, ny2 = dy2 / len2;
885
- const postX = curr.x + nx2 * c;
886
- const postY = curr.y + ny2 * c;
887
-
888
- path += ` L ${preX} ${preY} L ${postX} ${postY}`;
889
- continue;
890
- }
891
- }
892
-
893
- // Straight segment — use H/V for axis-aligned, L for diagonal stubs
894
- if (Math.abs(curr.y - prev.y) < 0.5) {
895
- path += ` H ${curr.x}`;
896
- } else if (Math.abs(curr.x - prev.x) < 0.5) {
897
- path += ` V ${curr.y}`;
898
- } else {
899
- path += ` L ${curr.x} ${curr.y}`;
900
- }
901
- }
902
- if (pts.length >= 2) {
903
- const midIndex = Math.floor(pts.length / 2);
904
- const p1 = pts[midIndex - 1];
905
- const p2 = pts[midIndex];
906
- if (p1 && p2) {
907
- arrow.x = (p1.x + p2.x) / 2;
908
- arrow.y = (p1.y + p2.y) / 2;
909
- arrow.angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
910
- }
911
- }
912
- d = path;
913
- } else {
914
- // Tangent direction: use dynamic edge angle if available, else fixed socket angle
915
- let fromAngleDeg, toAngleDeg;
916
-
917
- if (fromOffset.angle !== undefined) {
918
- fromAngleDeg = fromOffset.angle;
919
- } else {
920
- const fromPortIndex = fromNode ? Object.keys(fromNode.outputs).indexOf(conn.out) : 0;
921
- const fromPortTotal = fromNode ? Object.keys(fromNode.outputs).length : 1;
922
- const pos = fromShape?.getSocketPosition?.('output', fromPortIndex, fromPortTotal, fromSize);
923
- fromAngleDeg = pos?.angle ?? 0;
924
- }
925
-
926
- if (toOffset.angle !== undefined) {
927
- toAngleDeg = toOffset.angle;
928
- } else {
929
- const toPortIndex = toNode ? Object.keys(toNode.inputs).indexOf(conn.in) : 0;
930
- const toPortTotal = toNode ? Object.keys(toNode.inputs).length : 1;
931
- const pos = toShape?.getSocketPosition?.('input', toPortIndex, toPortTotal, toSize);
932
- toAngleDeg = pos?.angle ?? 180;
933
- }
934
-
935
- const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
936
- const cpLen = Math.max(50, dist * 0.4);
937
- const fromRad = (fromAngleDeg * Math.PI) / 180;
938
- const toRad = (toAngleDeg * Math.PI) / 180;
939
-
940
- const cp1x = startX + Math.cos(fromRad) * cpLen;
941
- const cp1y = startY + Math.sin(fromRad) * cpLen;
942
- const cp2x = endX + Math.cos(toRad) * cpLen;
943
- const cp2y = endY + Math.sin(toRad) * cpLen;
944
-
945
- d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`;
946
-
947
- arrow.x = (startX + 3 * cp1x + 3 * cp2x + endX) / 8;
948
- arrow.y = (startY + 3 * cp1y + 3 * cp2y + endY) / 8;
949
- arrow.angle = Math.atan2(endY + cp2y - cp1y - startY, endX + cp2x - cp1x - startX);
950
- }
951
-
952
-
953
- const p = new Path2D(d);
954
- return { startX, startY, endX, endY, path2D: p, arrow, pathStyle: effectiveStyle };
955
- }
956
-
957
- destroy() {
958
- if (this.#resizeObserver) this.#resizeObserver.disconnect();
959
- if (this.#animationFrameId) cancelAnimationFrame(this.#animationFrameId);
960
- this.#connectionData.clear();
961
- }
962
- }