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,1840 +0,0 @@
1
- /**
2
- * NodeCanvas — main graph viewport (facade)
3
- *
4
- * Thin orchestration layer that delegates to:
5
- * - NodeViewManager (node CRUD + group drag)
6
- * - ConnectionRenderer (SVG paths + gradients + flow)
7
- * - PseudoConnection (temp drag line)
8
- * - ViewportActions (context menu + keyboard + fitView)
9
- *
10
- * @module symbiote-node/canvas/NodeCanvas
11
- */
12
-
13
- import Symbiote from '@symbiotejs/symbiote';
14
- import { template } from './NodeCanvas.tpl.js';
15
- import { styles } from './NodeCanvas.css.js';
16
- import { Drag } from '../../interactions/Drag.js';
17
- import { Zoom } from '../../interactions/Zoom.js';
18
- import { ConnectFlow } from '../../interactions/ConnectFlow.js';
19
- import { Selector } from '../../interactions/Selector.js';
20
- import { SnapGrid } from '../../interactions/SnapGrid.js';
21
- import { applyTheme, DARK_DEFAULT } from '../../themes/Theme.js';
22
- import { applyPalette } from '../../themes/Palette.js';
23
- import { applySkin } from '../../themes/Skin.js';
24
- import { NodeViewManager } from '../NodeViewManager.js';
25
- import { ConnectionRenderer } from '../ConnectionRenderer.js';
26
- import { CanvasConnectionRenderer } from '../CanvasConnectionRenderer.js';
27
- import { PseudoConnection } from '../PseudoConnection.js';
28
- import { ViewportActions } from '../ViewportActions.js';
29
- import { SubgraphManager } from '../SubgraphManager.js';
30
- import '../../menu/ContextMenu/ContextMenu.js';
31
- import '../../toolbar/QuickToolbar/QuickToolbar.js';
32
- import '../../node/GraphFrame/GraphFrame.js';
33
- import '../../inspector/InspectorPanel/InspectorPanel.js';
34
- import '../Minimap/Minimap.js';
35
- import '../NodeSearch/NodeSearch.js';
36
- import '../Breadcrumb/Breadcrumb.js';
37
- import { computeAutoLayout } from '../AutoLayout.js';
38
-
39
- export class NodeCanvas extends Symbiote {
40
-
41
- init$ = {
42
- zoom: 1,
43
- panX: 0,
44
- panY: 0,
45
- '+contentTransform': () => `translate(${this.$.panX}px, ${this.$.panY}px) scale(${this.$.zoom})`,
46
- };
47
-
48
- /** @type {import('../core/Editor.js').NodeEditor|null} */
49
- #editor = null;
50
-
51
- /** @type {Drag|null} */
52
- #drag = null;
53
-
54
- /** @type {Zoom|null} */
55
- #zoom = null;
56
-
57
- /** @type {ConnectFlow|null} */
58
- #connectFlow = null;
59
-
60
- /** @type {Selector} */
61
- #selector = new Selector({
62
- onChange: (nodes, connections) => this.#onSelectionChanged(nodes, connections),
63
- });
64
-
65
- /** @type {SnapGrid} */
66
- #snapGrid = new SnapGrid({ size: 16, dynamic: false });
67
-
68
- /** @type {Map<string, HTMLElement>} */
69
- #nodeViews = new Map();
70
-
71
- /** @type {Map<string, HTMLElement>} */
72
- #frameViews = new Map();
73
-
74
- /** @type {boolean} */
75
- #readonly = false;
76
-
77
- /** @type {boolean} */
78
- #snapEnabled = false;
79
-
80
- /** @type {number|null} */
81
- #panAnimFrame = null;
82
-
83
- /** @type {string} */
84
- #themeName = 'dark-default';
85
-
86
- /** @type {NodeViewManager|null} */
87
- #viewManager = null;
88
-
89
- /** @type {ConnectionRenderer|null} */
90
- #connRenderer = null;
91
-
92
- /** @type {PseudoConnection|null} */
93
- #pseudo = null;
94
-
95
- /** @type {ViewportActions|null} */
96
- #actions = null;
97
-
98
- /** @type {number} */
99
- #zCounter = 0;
100
-
101
- /** @type {'bezier'|'orthogonal'|'straight'|'pcb'} saved across setEditor calls */
102
- #pathStyle = 'bezier';
103
-
104
- // ─── Virtualization (Canvas LOD) ───
105
- /** All node data objects from editor — the full set regardless of DOM state */
106
- #allNodes = new Map();
107
- /** Position + size cache for nodes without DOM (phantom rendering on Canvas) */
108
- #phantomData = new Map();
109
- /** Degree (connection count) per node — for dot sizing */
110
- #nodeDegrees = new Map();
111
- /** Debounce timer for promote/demote batch */
112
- #virtTimer = null;
113
- /** Dirty flag — phantom positions changed, needs re-sync to renderer */
114
- #phantomDirty = false;
115
-
116
- // --- Public API ---
117
-
118
- /**
119
- * Clear all existing node, connection, and frame views from the DOM.
120
- * Called before switching to a new editor to ensure clean state.
121
- */
122
- #clearViews() {
123
- // Remove all node views and their preview timers
124
- for (const [id, el] of this.#nodeViews) {
125
- if (el._previewRaf) { clearTimeout(el._previewRaf); el._previewRaf = null; }
126
- if (el._drag) el._drag.destroy();
127
- el._redrawPreview = null;
128
- el.remove();
129
- }
130
- this.#nodeViews.clear();
131
- this.#allNodes.clear();
132
- this.#phantomData.clear();
133
- this.#nodeDegrees.clear();
134
- if (this.#virtTimer) { clearTimeout(this.#virtTimer); this.#virtTimer = null; }
135
-
136
- // Unsubscribe from previous editor events to prevent leaks
137
- if (this.#editor) {
138
- this.#editor.removeAllListeners?.();
139
- }
140
-
141
- // Remove all connection SVG paths
142
- if (this.#connRenderer) {
143
- const conns = [...this.#connRenderer.data.values()];
144
- for (const conn of conns) {
145
- this.#connRenderer.remove(conn);
146
- }
147
- }
148
-
149
- // Remove all frame views
150
- for (const [, el] of this.#frameViews) {
151
- if (el._drag) el._drag.destroy();
152
- if (el._resizeDrag) el._resizeDrag.destroy();
153
- el.remove();
154
- }
155
- this.#frameViews.clear();
156
-
157
- // Clear selection state
158
- if (this.#selector) this.#selector.unselectAll();
159
- }
160
-
161
- /**
162
- * Bind editor to canvas
163
- * @param {import('../core/Editor.js').NodeEditor} editor
164
- */
165
- setEditor(editor) {
166
- // Clear previous views before switching
167
- this.#clearViews();
168
-
169
- this.#editor = editor;
170
-
171
- const engineMode = this.getAttribute('connection-engine') || 'svg';
172
-
173
- if (engineMode === 'canvas') {
174
- this.#connRenderer = new CanvasConnectionRenderer({
175
- canvasLayer: this.ref.connCanvas,
176
- dotLayer: this.ref.pseudoSvg,
177
- nodeViews: this.#nodeViews,
178
- editor,
179
- onConnectionClick: (connId, e) => this.#handleConnectionClick(connId, e),
180
- getZoom: () => this.$.zoom,
181
- getPan: () => ({ x: this.$.panX, y: this.$.panY }),
182
- onDotDrag: (socketData) => {
183
- if (this.#connectFlow && !this.#readonly) {
184
- this.#connectFlow.pickSocket(socketData);
185
- }
186
- },
187
- });
188
- } else {
189
- this.#connRenderer = new ConnectionRenderer({
190
- svgLayer: this.ref.connections,
191
- dotLayer: this.ref.pseudoSvg,
192
- nodeViews: this.#nodeViews,
193
- editor,
194
- onConnectionClick: (connId, e) => this.#handleConnectionClick(connId, e),
195
- getZoom: () => this.$.zoom,
196
- onDotDrag: (socketData) => {
197
- if (this.#connectFlow && !this.#readonly) {
198
- this.#connectFlow.pickSocket(socketData);
199
- }
200
- },
201
- });
202
- }
203
-
204
- // For test automation
205
- this._connRenderer = this.#connRenderer;
206
-
207
- // Re-apply saved pathStyle after creating new renderer
208
- if (this.#pathStyle !== 'bezier') {
209
- this.#connRenderer.setPathStyle(this.#pathStyle);
210
- }
211
-
212
- this.#pseudo = new PseudoConnection(this.ref.pseudoSvg);
213
-
214
- this.#actions = new ViewportActions({
215
- editor,
216
- selector: this.#selector,
217
- nodeViews: this.#nodeViews,
218
- canvas: this,
219
- });
220
-
221
- // Quick Action Toolbar
222
- const toolbar = this.ref.quickToolbar;
223
- if (toolbar) {
224
- toolbar._onAction = (action, nodeId) => {
225
- const nodeEl = this.#nodeViews.get(nodeId);
226
- switch (action) {
227
- case 'delete': this.#actions.deleteNode(nodeId); toolbar.hide(); break;
228
- case 'duplicate': this.#actions.cloneNode(nodeId); break;
229
- case 'enter': this.drillDown(nodeId); toolbar.hide(); break;
230
- case 'mute':
231
- this.#actions.muteNode(nodeId);
232
- if (nodeEl) toolbar.show(nodeId, nodeEl);
233
- break;
234
- default:
235
- // Custom actions — dispatch event for consumer (e.g. dep-graph explore)
236
- this.dispatchEvent(new CustomEvent('toolbar-action', {
237
- detail: { action, nodeId },
238
- bubbles: true,
239
- }));
240
- toolbar.hide();
241
- break;
242
- }
243
- };
244
- }
245
-
246
- this.#viewManager = new NodeViewManager({
247
- nodeViews: this.#nodeViews,
248
- editor,
249
- selector: this.#selector,
250
- snapGrid: this.#snapGrid,
251
- getZoom: () => this.$.zoom,
252
- setNodePosition: (id, x, y) => this.setNodePosition(id, x, y),
253
- animateNodeToPosition: (id, x, y) => this.animateNodeToPosition(id, x, y),
254
- onNodeClick: (id, e) => this.#handleNodeClick(id, e),
255
- nodesLayer: this.ref.nodesLayer,
256
- canvas: this,
257
- onSvgShapeReady: (nodeId) => this.#connRenderer?.renderFreeDots(nodeId),
258
- });
259
-
260
- // ConnectFlow
261
- this.#connectFlow = new ConnectFlow(editor, {
262
- getNodePosition: (id) => {
263
- const el = this.#nodeViews.get(id);
264
- return el?._position || { x: 0, y: 0 };
265
- },
266
- getNodeSize: (id) => {
267
- const el = this.#nodeViews.get(id);
268
- return { width: el?.offsetWidth || 180, height: el?.offsetHeight || 60 };
269
- },
270
- getTransform: () => ({
271
- k: this.$.zoom,
272
- x: this.$.panX,
273
- y: this.$.panY,
274
- rect: this.ref.canvasContainer.getBoundingClientRect(),
275
- }),
276
- onPseudoStart: (sx, sy, socketData) => {
277
- this.#actions.highlightCompatibleSockets(socketData, this.ref.nodesLayer);
278
- },
279
- onPseudoMove: (sx, sy, ex, ey) => {
280
- this.#pseudo.show(sx, sy, ex, ey);
281
- },
282
- onPseudoEnd: () => {
283
- this.#pseudo.hide();
284
- this.#actions.clearSocketHighlights(this.ref.nodesLayer);
285
- this.#actions.clearPortHints();
286
- this.#connRenderer?.clearDotHighlights();
287
- },
288
- onCompatibleMove: (worldX, worldY, socketData) => {
289
- // Highlight compatible SVG dots (no port teleportation)
290
- const compatibleIds = this.#actions.getCompatibleNodeIds(socketData);
291
- this.#connRenderer?.highlightDotsForNodes(compatibleIds);
292
- },
293
-
294
- onDropEmpty: (x, y, socketData) => {
295
- this.#actions.handleDropEmpty(x, y, socketData);
296
- // Show context menu at drop position
297
- const container = this.ref.canvasContainer;
298
- const rect = container.getBoundingClientRect();
299
- const menuX = x * this.$.zoom + this.$.panX;
300
- const menuY = y * this.$.zoom + this.$.panY;
301
- this.ref.contextMenu?.show(menuX, menuY, [
302
- { label: 'Add Node', icon: 'add_box', action: () => this.#editor?.emit('contextadd', { x, y }) },
303
- ]);
304
- },
305
- findNearestDot: (wx, wy) => this.#connRenderer?.findNearestDot(wx, wy),
306
- });
307
-
308
- // Subscribe to editor events
309
- editor.on('nodecreated', (node) => {
310
- this.#allNodes.set(node.id, node);
311
- // If virtualization is active (phantom data initialized), add as phantom; otherwise DOM
312
- if (this.#phantomData.size > 0) {
313
- this.#phantomData.set(node.id, {
314
- id: node.id, x: 0, y: 0, w: 180, h: 60,
315
- degree: 0, color: null, label: node.label || node.id,
316
- });
317
- } else {
318
- this.#viewManager.addView(node);
319
- }
320
- });
321
- editor.on('noderemoved', (node) => {
322
- this.#viewManager.removeView(node);
323
- // Remove connections touching this node
324
- for (const [, conn] of this.#connRenderer.data) {
325
- if (conn.from === node.id || conn.to === node.id) {
326
- this.#connRenderer.remove(conn);
327
- }
328
- }
329
- });
330
- editor.on('connectioncreated', (conn) => this.#connRenderer.add(conn));
331
- editor.on('connectionremoved', (conn) => {
332
- this.#connRenderer.remove(conn);
333
- this.#selector.getSelectedConnections().delete(conn.id);
334
- });
335
-
336
- // Re-render connections after node layout changes (collapse/mute)
337
- const refreshNodeConnections = ({ nodeId }) => {
338
- requestAnimationFrame(() => this.#connRenderer?.updateForNode(nodeId));
339
- };
340
- editor.on('nodecollapse', refreshNodeConnections);
341
- editor.on('nodemute', refreshNodeConnections);
342
-
343
- // ─── Virtualized initialization ───
344
- // Store all nodes as data, compute degrees from connections
345
- this.#allNodes.clear();
346
- this.#phantomData.clear();
347
- this.#nodeDegrees.clear();
348
-
349
- for (const node of editor.getNodes()) {
350
- this.#allNodes.set(node.id, node);
351
- this.#nodeDegrees.set(node.id, 0);
352
- }
353
-
354
- const allConns = editor.getConnections();
355
- for (const conn of allConns) {
356
- this.#nodeDegrees.set(conn.from, (this.#nodeDegrees.get(conn.from) || 0) + 1);
357
- this.#nodeDegrees.set(conn.to, (this.#nodeDegrees.get(conn.to) || 0) + 1);
358
- }
359
-
360
- // Create DOM only for nodes that will be visible (or all if small graph)
361
- const VIRT_THRESHOLD = 200;
362
- if (this.#allNodes.size <= VIRT_THRESHOLD) {
363
- // Small graph — create all DOM nodes immediately
364
- this.#viewManager.addViews(editor.getNodes());
365
- } else {
366
- // Large graph — start all as phantom, promote visible ones after layout
367
- const defaultW = 180, defaultH = 60;
368
- for (const [id, node] of this.#allNodes) {
369
- this.#phantomData.set(id, {
370
- id, x: 0, y: 0, w: defaultW, h: defaultH,
371
- degree: this.#nodeDegrees.get(id) || 0,
372
- color: null,
373
- label: node.label || id,
374
- });
375
- }
376
- }
377
-
378
- // Batch renderer operations to prevent multiple redundant redraws
379
- this.#connRenderer.setBatchMode(true);
380
- this.#connRenderer.addBatch(allConns);
381
- this.#syncPhantomToRenderer();
382
- this.#connRenderer.setBatchMode(false);
383
-
384
- // Subscribe to frame events
385
- editor.on('framecreated', (frame) => this.#addFrameView(frame));
386
- editor.on('frameremoved', (frame) => this.#removeFrameView(frame));
387
-
388
- // Align tools emit nodemovetopos
389
- editor.on('nodemovetopos', ({ nodeId, x, y }) => {
390
- this.setNodePosition(nodeId, x, y);
391
- });
392
-
393
- // Render existing frames
394
- for (const frame of editor.getFrames()) {
395
- this.#addFrameView(frame);
396
- }
397
-
398
- // Initialize subgraph navigation (skip during drill-down/drillUp)
399
- if (!this.#navigating) {
400
- this.#subgraphManager.initialize(this, editor);
401
- const breadcrumb = this.ref.breadcrumb;
402
- if (breadcrumb) {
403
- this.#subgraphManager.onNavigate((path) => {
404
- breadcrumb.setPath(path);
405
- });
406
- breadcrumb.onNavigate((level) => {
407
- this.drillUp(level);
408
- });
409
- }
410
- }
411
- }
412
-
413
- /** @returns {ConnectFlow|null} */
414
- getConnectFlow() { return this.#connectFlow; }
415
-
416
- /**
417
- * Enable/disable snap to grid
418
- * @param {boolean} enabled
419
- * @param {number} [size]
420
- */
421
- setSnapGrid(enabled, size) {
422
- this.#snapEnabled = enabled;
423
- if (size) this.#snapGrid.setSize(size);
424
- this.#viewManager?.setSnapEnabled(enabled);
425
- }
426
-
427
- /**
428
- * Enable/disable readonly mode
429
- * @param {boolean} enabled
430
- */
431
- setReadonly(enabled) {
432
- this.#readonly = enabled;
433
- if (enabled) {
434
- this.setAttribute('data-readonly', '');
435
- } else {
436
- this.removeAttribute('data-readonly');
437
- }
438
- this.#viewManager?.setReadonly(enabled);
439
- this.#actions?.setReadonly(enabled);
440
- }
441
-
442
- /**
443
- * Enable/disable compact mode (hides node body: ports & controls).
444
- * Use this for schematic/PCB views where nodes show only labels.
445
- * This is a structural setting — independent of visual theme.
446
- * @param {boolean} enabled
447
- */
448
- setCompactMode(enabled) {
449
- if (enabled) {
450
- this.setAttribute('data-compact', '');
451
- } else {
452
- this.removeAttribute('data-compact');
453
- }
454
- }
455
-
456
- /**
457
- * Apply a theme to the canvas
458
- * @param {import('../themes/Theme.js').ThemeDefinition} theme
459
- */
460
- setTheme(theme) {
461
- applyTheme(this, theme);
462
- this.#themeName = theme.name;
463
- }
464
-
465
- /**
466
- * Apply only color palette
467
- * @param {import('../themes/Palette.js').PaletteDefinition} palette
468
- */
469
- setPalette(palette) { applyPalette(this, palette); }
470
-
471
- /**
472
- * Apply only geometry skin
473
- * @param {import('../themes/Skin.js').SkinDefinition} skin
474
- */
475
- setSkin(skin) { applySkin(this, skin); }
476
-
477
- /** @returns {string} */
478
- getThemeName() { return this.#themeName; }
479
-
480
- /**
481
- * Set data flow animation on a connection
482
- * @param {string} connId
483
- * @param {boolean} active
484
- */
485
- setFlowing(connId, active) { this.#connRenderer?.setFlowing(connId, active); }
486
-
487
- /**
488
- * Set data flow animation on all connections
489
- * @param {boolean} active
490
- */
491
- setAllFlowing(active) { this.#connRenderer?.setAllFlowing(active); }
492
-
493
- /**
494
- * Set connection path style (persists across setEditor/drill-down)
495
- * @param {'bezier'|'orthogonal'|'straight'|'pcb'} style
496
- */
497
- setPathStyle(style) {
498
- this.#pathStyle = style;
499
- this.#connRenderer?.setPathStyle(style);
500
- }
501
-
502
- /** @returns {'bezier'|'orthogonal'|'straight'|'pcb'} */
503
- getPathStyle() { return this.#pathStyle; }
504
-
505
- /**
506
- * Programmatically select a node by ID
507
- * @param {string} nodeId
508
- */
509
- selectNode(nodeId) {
510
- this.#selector?.selectNode(nodeId);
511
- }
512
-
513
- /**
514
- * Clear all connector caches and re-render.
515
- * Call after initial node positioning to settle SVG connectors.
516
- */
517
- refreshConnections() { this.#connRenderer?.refreshAll(); }
518
-
519
- /**
520
- * Set error state on a node with frame-style error display
521
- * @param {string} nodeId
522
- * @param {string} message - Error message to display
523
- */
524
- setNodeError(nodeId, message) {
525
- const el = this.#nodeViews.get(nodeId);
526
- if (!el) return;
527
-
528
- // Remove existing error frame if any
529
- this.clearNodeError(nodeId);
530
-
531
- el.setAttribute('data-error', '');
532
-
533
- // Build error frame DOM
534
- const frame = document.createElement('div');
535
- frame.className = 'error-frame';
536
-
537
- const header = document.createElement('div');
538
- header.className = 'error-frame-header';
539
- const icon = document.createElement('span');
540
- icon.className = 'material-symbols-outlined';
541
- icon.textContent = 'error';
542
- header.append(icon, ' Error');
543
-
544
- const body = document.createElement('div');
545
- body.className = 'error-frame-body';
546
- body.textContent = message;
547
-
548
- frame.append(header, body);
549
- el.append(frame);
550
- }
551
-
552
- /**
553
- * Clear error state from a node
554
- * @param {string} nodeId
555
- */
556
- clearNodeError(nodeId) {
557
- const el = this.#nodeViews.get(nodeId);
558
- if (!el) return;
559
- el.removeAttribute('data-error');
560
- const frame = el.querySelector('.error-frame');
561
- if (frame) frame.remove();
562
- }
563
-
564
- /**
565
- * Clear all error states
566
- */
567
- clearAllErrors() {
568
- for (const [id] of this.#nodeViews) {
569
- this.clearNodeError(id);
570
- }
571
- }
572
-
573
- /**
574
- * Apply auto layout to all nodes
575
- */
576
- autoLayout() {
577
- if (!this.#editor) return;
578
- const positions = computeAutoLayout(this.#editor);
579
- for (const [nodeId, pos] of Object.entries(positions)) {
580
- this.setNodePosition(nodeId, pos.x, pos.y);
581
- }
582
- }
583
-
584
- /**
585
- * Fit all nodes into the viewport.
586
- * Calculates required zoom/pan based on current node layout,
587
- * accounting for the inspector panel if open.
588
- */
589
- fitView() {
590
- if (this.#nodeViews.size === 0 && this.#phantomData.size === 0) return;
591
-
592
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
593
-
594
- // Include DOM nodes
595
- for (const [, el] of this.#nodeViews) {
596
- if (!el._position) continue;
597
- const x = el._position.x;
598
- const y = el._position.y;
599
- const w = el._cachedW || el.offsetWidth || 150;
600
- const h = el._cachedH || el.offsetHeight || 40;
601
- if (x < minX) minX = x;
602
- if (y < minY) minY = y;
603
- if (x + w > maxX) maxX = x + w;
604
- if (y + h > maxY) maxY = y + h;
605
- }
606
-
607
- // Include phantom nodes (virtualized)
608
- for (const [, pd] of this.#phantomData) {
609
- if (pd.x < minX) minX = pd.x;
610
- if (pd.y < minY) minY = pd.y;
611
- if (pd.x + pd.w > maxX) maxX = pd.x + pd.w;
612
- if (pd.y + pd.h > maxY) maxY = pd.y + pd.h;
613
- }
614
-
615
- if (minX === Infinity) return;
616
-
617
- const graphW = maxX - minX;
618
- const graphH = maxY - minY;
619
- const canvasRect = this.ref.canvasContainer.getBoundingClientRect();
620
-
621
- let visibleWidth = canvasRect.width;
622
- const inspector = this.ref.inspector || this.querySelector('inspector-panel');
623
- if (inspector && !inspector.hasAttribute('hidden')) {
624
- visibleWidth -= inspector.offsetWidth || 280;
625
- }
626
-
627
- const scaleX = (visibleWidth - 80) / graphW;
628
- const scaleY = (canvasRect.height - 80) / graphH;
629
- const scale = Math.max(0.001, Math.min(scaleX, scaleY, 1.5));
630
-
631
- const centerX = (minX + maxX) / 2;
632
- const centerY = (minY + maxY) / 2;
633
-
634
- this.$.zoom = scale;
635
- this.$.panX = (visibleWidth / 2) - centerX * scale;
636
- this.$.panY = canvasRect.height / 2 - centerY * scale;
637
- this.#updateTransform();
638
- }
639
-
640
- /**
641
- * Focus viewport on a specific node by ID.
642
- * Deducts inspector panel width from visibility calculation.
643
- * @param {string} nodeId - Target node ID
644
- * @param {Object} [opts]
645
- * @param {number} [opts.zoom=0.8] - Target zoom level
646
- * @returns {boolean}
647
- */
648
- flyToNode(nodeId, { zoom = 0.8 } = {}) {
649
- let el = this.#nodeViews.get(nodeId);
650
-
651
- // If node is phantom (no DOM), promote it first
652
- if (!el && this.#phantomData.has(nodeId)) {
653
- this.#promoteNode(nodeId);
654
- el = this.#nodeViews.get(nodeId);
655
- }
656
- if (!el) return false;
657
-
658
- // If position not set yet, use phantom data
659
- const pos = el._position || (() => {
660
- const pd = this.#phantomData.get(nodeId);
661
- return pd ? { x: pd.x, y: pd.y } : { x: 0, y: 0 };
662
- })();
663
-
664
- const canvasRect = this.ref.canvasContainer.getBoundingClientRect();
665
- let visibleWidth = canvasRect.width;
666
-
667
- const inspector = this.ref.inspector || this.querySelector('inspector-panel');
668
- if (inspector && !inspector.hasAttribute('hidden') && inspector.offsetWidth > 20) {
669
- visibleWidth -= inspector.offsetWidth;
670
- }
671
-
672
- const elWidth = el._cachedW || el.offsetWidth || 150;
673
- const elHeight = el._cachedH || el.offsetHeight || 40;
674
-
675
- const nodeX = pos.x + (elWidth / 2);
676
- const nodeY = pos.y + (elHeight / 2);
677
-
678
- const newPanX = (visibleWidth / 2) - nodeX * zoom;
679
- const newPanY = canvasRect.height / 2 - nodeY * zoom;
680
-
681
- const dz = Math.abs(this.$.zoom - zoom);
682
- const dx = Math.abs(this.$.panX - newPanX);
683
- const dy = Math.abs(this.$.panY - newPanY);
684
-
685
- if (dz < 0.01 && dx < 2 && dy < 2) {
686
- this.selectNode(nodeId);
687
- if (!this._cullingScheduled) {
688
- this._cullingScheduled = true;
689
- requestAnimationFrame(() => {
690
- this._cullingScheduled = false;
691
- this.#applyCullingAndLOD();
692
- });
693
- }
694
- return true;
695
- }
696
-
697
- this.$.zoom = zoom;
698
- this.$.panX = newPanX;
699
- this.$.panY = newPanY;
700
-
701
- this.selectNode(nodeId);
702
- return true;
703
- }
704
-
705
-
706
- /**
707
- * Measure actual DOM sizes of all rendered nodes.
708
- * Returns a plain object { [nodeId]: { w, h } } suitable for AutoLayout's nodeSizes option.
709
- * Call after nodes are rendered to DOM (after setEditor + requestAnimationFrame).
710
- * @returns {Object<string, { w: number, h: number }>}
711
- */
712
- measureNodeSizes() {
713
- const sizes = {};
714
- for (const [nodeId, el] of this.#nodeViews) {
715
- if (el && el.offsetWidth > 0) {
716
- sizes[nodeId] = { w: el.offsetWidth, h: el.offsetHeight };
717
- }
718
- }
719
- return sizes;
720
- }
721
-
722
- /**
723
- * Set preview content on a node (image URL or text)
724
- * @param {string} nodeId
725
- * @param {string} content - Image URL or text
726
- * @param {'image'|'text'} [type='text']
727
- */
728
- setPreview(nodeId, content, type = 'text') {
729
- const el = this.#nodeViews.get(nodeId);
730
- if (!el) return;
731
- const preview = el.ref?.previewArea;
732
- if (!preview) return;
733
-
734
- preview.hidden = false;
735
- preview.replaceChildren();
736
- if (type === 'image') {
737
- const img = document.createElement('img');
738
- img.src = content;
739
- img.alt = 'Preview';
740
- preview.appendChild(img);
741
- } else {
742
- const div = document.createElement('div');
743
- div.className = 'sn-preview-text';
744
- div.textContent = content;
745
- preview.appendChild(div);
746
- }
747
- }
748
-
749
- /**
750
- * Clear preview from a node
751
- * @param {string} nodeId
752
- */
753
- clearPreview(nodeId) {
754
- const el = this.#nodeViews.get(nodeId);
755
- if (!el) return;
756
- const preview = el.ref?.previewArea;
757
- if (!preview) return;
758
- preview.hidden = true;
759
- preview.replaceChildren();
760
- }
761
-
762
- /**
763
- * Get node view element by ID (used by FlowSimulator)
764
- * @param {string} nodeId
765
- * @returns {HTMLElement|undefined}
766
- */
767
- _getNodeView(nodeId) { return this.#nodeViews.get(nodeId); }
768
-
769
- /** Alias for SubgraphManager */
770
- getNodeView(nodeId) { return this.#nodeViews.get(nodeId); }
771
-
772
- /**
773
- * Highlight nodes sequentially based on execution trace.
774
- * Each node pulses green in order, then fades.
775
- * Uses inline styles to guarantee visibility regardless of CSS cache.
776
- *
777
- * @param {Array<{nodeId: string}>} trace - Execution trace from Fire/Run
778
- * @param {number} [stepDelay=300] - Delay between node highlights (ms)
779
- */
780
- highlightTrace(trace, stepDelay = 300) {
781
- if (!trace || !trace.length) return;
782
-
783
- // Inject keyframe animation once
784
- if (!document.getElementById('sn-fire-keyframes')) {
785
- const style = document.createElement('style');
786
- style.id = 'sn-fire-keyframes';
787
- style.textContent = `
788
- @keyframes sn-fire-pulse {
789
- 0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
790
- 50% { box-shadow: 0 0 20px 6px rgba(76, 175, 80, 0.5); }
791
- 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
792
- }
793
- `;
794
- document.head.appendChild(style);
795
- }
796
-
797
- // Clear any previous fire states
798
- for (const [, el] of this.#nodeViews) {
799
- el.removeAttribute('data-fire-state');
800
- el.style.opacity = '';
801
- el.style.borderColor = '';
802
- el.style.animation = '';
803
- el.style.zIndex = '';
804
- el.style.transition = '';
805
- }
806
-
807
- // Set all traced nodes to pending (dimmed)
808
- for (const step of trace) {
809
- const el = this.#nodeViews.get(step.nodeId);
810
- if (el) {
811
- el.style.opacity = '0.4';
812
- el.style.transition = 'opacity 0.15s';
813
- }
814
- }
815
-
816
- // Sequentially activate each node
817
- trace.forEach((step, i) => {
818
- setTimeout(() => {
819
- const el = this.#nodeViews.get(step.nodeId);
820
- if (!el) return;
821
-
822
- // Active: green pulse
823
- el.style.opacity = '1';
824
- el.style.borderColor = '#4caf50';
825
- el.style.animation = 'sn-fire-pulse 0.6s ease-out';
826
- el.style.zIndex = '50';
827
-
828
- // Done: fade border
829
- setTimeout(() => {
830
- el.style.animation = '';
831
- el.style.borderColor = 'rgba(76, 175, 80, 0.4)';
832
- el.style.transition = 'border-color 2s ease-out';
833
- }, 600);
834
- }, i * stepDelay);
835
- });
836
-
837
- // Clear all states after animation completes
838
- const totalDuration = trace.length * stepDelay + 3500;
839
- setTimeout(() => {
840
- for (const [, el] of this.#nodeViews) {
841
- el.style.opacity = '';
842
- el.style.borderColor = '';
843
- el.style.animation = '';
844
- el.style.zIndex = '';
845
- el.style.transition = '';
846
- }
847
- }, totalDuration);
848
- }
849
-
850
- // --- Subgraph Navigation ---
851
-
852
- /** @type {SubgraphManager} */
853
- #subgraphManager = new SubgraphManager();
854
-
855
- /** @type {boolean} - guard to prevent setEditor re-init during navigation */
856
- #navigating = false;
857
-
858
- /**
859
- * Drill down into a subgraph node
860
- * @param {string} nodeId - SubgraphNode ID
861
- */
862
- drillDown(nodeId) {
863
- if (!this.#editor) return;
864
- const node = this.#editor.getNode(nodeId);
865
- if (!node?._isSubgraph) return;
866
- this.#navigating = true;
867
- this.#subgraphManager.drillDown(node);
868
- this.#navigating = false;
869
- this.dispatchEvent(new CustomEvent('subgraph-enter', {
870
- detail: { node, nodeId },
871
- bubbles: true,
872
- }));
873
- }
874
-
875
- /**
876
- * Navigate up to a breadcrumb level
877
- * @param {number} level - 0 = root
878
- */
879
- drillUp(level) {
880
- this.#navigating = true;
881
- this.#subgraphManager.drillUp(level);
882
- this.#navigating = false;
883
- this.dispatchEvent(new CustomEvent('subgraph-exit', {
884
- detail: { level },
885
- bubbles: true,
886
- }));
887
- }
888
-
889
- /**
890
- * Get current subgraph depth (0 = root)
891
- * @returns {number}
892
- */
893
- getSubgraphDepth() {
894
- return this.#subgraphManager.depth;
895
- }
896
-
897
- /**
898
- * Get subgraph breadcrumb path
899
- * @returns {Array<{ label: string, level: number }>}
900
- */
901
- getSubgraphPath() {
902
- return this.#subgraphManager.getPath();
903
- }
904
-
905
- /**
906
- * Enable/disable batch positioning mode.
907
- * When true, setNodePosition skips connection updates.
908
- * Call refreshConnections() after batch is done.
909
- * @param {boolean} active
910
- */
911
- setBatchMode(active) {
912
- this._batchMode = !!active;
913
- if (!this._batchMode && !this._cullingScheduled) {
914
- this._cullingScheduled = true;
915
- requestAnimationFrame(() => {
916
- this._cullingScheduled = false;
917
- this.#applyCullingAndLOD();
918
- });
919
- }
920
- }
921
-
922
- /**
923
- * Set node position
924
- * @param {string} nodeId
925
- * @param {number} x
926
- * @param {number} y
927
- */
928
- setNodePosition(nodeId, x, y) {
929
- const el = this.#nodeViews.get(nodeId);
930
- if (!el) {
931
- // Update phantom data if node is virtualized (no DOM)
932
- const pd = this.#phantomData.get(nodeId);
933
- if (pd) { pd.x = x; pd.y = y; this.#phantomDirty = true; }
934
- return;
935
- }
936
- el.style.transform = `translate(${x}px, ${y}px)`;
937
- el._position = { x, y };
938
-
939
- // Skip connection updates during batch positioning
940
- if (this._batchMode) return;
941
-
942
- this.#connRenderer?.updateForNode(nodeId);
943
- // Render or refresh free dots for SVG nodes
944
- if (el.hasAttribute('data-svg-shape')) {
945
- this.#connRenderer?.refreshFreeDots(nodeId);
946
- }
947
-
948
- // Keep toolbar in sync during drag
949
- const toolbar = this.ref.quickToolbar;
950
- if (toolbar && toolbar._nodeId === nodeId) {
951
- toolbar.updatePosition(el);
952
- }
953
- }
954
-
955
- /**
956
- * Animate node to position with wires synced via RAF
957
- * @param {string} nodeId
958
- * @param {number} targetX
959
- * @param {number} targetY
960
- * @param {number} [duration=200] - Animation duration in ms
961
- */
962
- animateNodeToPosition(nodeId, targetX, targetY, duration = 200) {
963
- const el = this.#nodeViews.get(nodeId);
964
- if (!el) return;
965
-
966
- const startX = el._position.x;
967
- const startY = el._position.y;
968
- const dx = targetX - startX;
969
- const dy = targetY - startY;
970
-
971
- // Skip animation if position hasn't changed
972
- if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
973
-
974
- const startTime = performance.now();
975
-
976
- const animate = (now) => {
977
- const t = Math.min((now - startTime) / duration, 1);
978
- // Ease-out cubic
979
- const ease = 1 - (1 - t) ** 3;
980
- const x = startX + dx * ease;
981
- const y = startY + dy * ease;
982
-
983
- el.style.transform = `translate(${x}px, ${y}px)`;
984
- el._position = { x, y };
985
- this.#connRenderer?.updateForNode(nodeId);
986
- this.#connRenderer?.refreshFreeDots(nodeId);
987
-
988
- const toolbar = this.ref.quickToolbar;
989
- if (toolbar && toolbar._nodeId === nodeId) {
990
- toolbar.updatePosition(el);
991
- }
992
-
993
- if (t < 1) {
994
- requestAnimationFrame(animate);
995
- } else {
996
- // Ensure final position is exact
997
- el._position = { x: targetX, y: targetY };
998
- el.style.transform = `translate(${targetX}px, ${targetY}px)`;
999
- this.#connRenderer?.updateForNode(nodeId);
1000
- this.#connRenderer?.refreshFreeDots(nodeId);
1001
- }
1002
- };
1003
-
1004
- requestAnimationFrame(animate);
1005
- }
1006
-
1007
- /**
1008
- * Get all node positions — includes both DOM nodes and phantom (virtualized) nodes.
1009
- * SubgraphRouter relies on this to decide whether a node is on the current canvas layer;
1010
- * phantom nodes DO have layout positions but no DOM element, so they must be included here.
1011
- * @returns {Object<string, number[]>}
1012
- */
1013
- getPositions() {
1014
- const positions = {};
1015
- // DOM nodes (promoted / small graph)
1016
- for (const [id, el] of this.#nodeViews) {
1017
- if (el._position) {
1018
- positions[id] = [el._position.x, el._position.y];
1019
- }
1020
- }
1021
- // Phantom nodes (virtualized, laid out but no DOM yet).
1022
- // Include even at (0,0) — position existence means node IS on this layer.
1023
- for (const [id, pd] of this.#phantomData) {
1024
- if (!positions[id]) {
1025
- positions[id] = [pd.x, pd.y];
1026
- }
1027
- }
1028
- return positions;
1029
- }
1030
-
1031
- /**
1032
- * Check whether a node exists on the current canvas layer (DOM or phantom).
1033
- * SubgraphRouter uses this to avoid spurious drillDown when layout is still in progress.
1034
- * @param {string} nodeId
1035
- * @returns {boolean}
1036
- */
1037
- hasNode(nodeId) {
1038
- return this.#nodeViews.has(nodeId) || this.#phantomData.has(nodeId);
1039
- }
1040
-
1041
- // --- Frame API ---
1042
-
1043
- /**
1044
- * Add a frame to the canvas
1045
- * @param {import('../core/Frame.js').Frame} frame
1046
- */
1047
- addFrame(frame) {
1048
- this.#editor?.addFrame(frame);
1049
- }
1050
-
1051
- /**
1052
- * Set frame position
1053
- * @param {string} frameId
1054
- * @param {number} x
1055
- * @param {number} y
1056
- */
1057
- setFramePosition(frameId, x, y) {
1058
- const el = this.#frameViews.get(frameId);
1059
- if (!el) return;
1060
- el.style.transform = `translate(${x}px, ${y}px)`;
1061
- el._position = { x, y };
1062
- const frame = this.#editor?.getFrame(frameId);
1063
- if (frame) { frame.x = x; frame.y = y; }
1064
- }
1065
-
1066
- /**
1067
- * Set frame size
1068
- * @param {string} frameId
1069
- * @param {number} w
1070
- * @param {number} h
1071
- */
1072
- setFrameSize(frameId, w, h) {
1073
- const el = this.#frameViews.get(frameId);
1074
- if (!el) return;
1075
- el.style.width = `${w}px`;
1076
- el.style.height = `${h}px`;
1077
- const frame = this.#editor?.getFrame(frameId);
1078
- if (frame) { frame.width = w; frame.height = h; }
1079
- }
1080
-
1081
- /**
1082
- * Get node IDs that are spatially inside a frame
1083
- * @param {string} frameId
1084
- * @returns {string[]}
1085
- */
1086
- #getNodesInFrame(frameId) {
1087
- const el = this.#frameViews.get(frameId);
1088
- if (!el) return [];
1089
- const fp = el._position;
1090
- const fw = parseFloat(el.style.width) || el._frameData?.width || 400;
1091
- const fh = parseFloat(el.style.height) || el._frameData?.height || 300;
1092
- const ids = [];
1093
- for (const [nodeId, nodeEl] of this.#nodeViews) {
1094
- const np = nodeEl._position;
1095
- if (np.x >= fp.x && np.y >= fp.y && np.x <= fp.x + fw && np.y <= fp.y + fh) {
1096
- ids.push(nodeId);
1097
- }
1098
- }
1099
- return ids;
1100
- }
1101
-
1102
- /**
1103
- * Create frame DOM element with drag and resize
1104
- * @param {import('../core/Frame.js').Frame} frame
1105
- */
1106
- #addFrameView(frame) {
1107
- const el = document.createElement('graph-frame');
1108
- el.style.position = 'absolute';
1109
- el.style.width = `${frame.width}px`;
1110
- el.style.height = `${frame.height}px`;
1111
- el.style.transform = `translate(${frame.x}px, ${frame.y}px)`;
1112
- el._position = { x: frame.x, y: frame.y };
1113
- el._frameData = frame;
1114
- el.setAttribute('frame-id', frame.id);
1115
-
1116
- // Set frame color directly as CSS variable (reliable, no dependency on Symbiote state)
1117
- el.style.setProperty('--frame-color', frame.color);
1118
-
1119
- // Wait for Symbiote render, then set state
1120
- requestAnimationFrame(() => {
1121
- if (el.$) {
1122
- el.$.label = frame.label;
1123
- el.$.color = frame.color;
1124
- } else {
1125
- // Fallback: set label text directly
1126
- const labelEl = el.querySelector('.sn-frame-label');
1127
- if (labelEl) labelEl.textContent = frame.label;
1128
- }
1129
- });
1130
-
1131
- // Frame drag — moves child nodes too
1132
- const drag = new Drag();
1133
- let childStartPositions = null;
1134
- let frameStartPos = null;
1135
-
1136
- drag.initialize(
1137
- el,
1138
- {
1139
- getPosition: () => el._position,
1140
- getZoom: () => this.$.zoom,
1141
- },
1142
- {
1143
- onStart: () => {
1144
- frameStartPos = { ...el._position };
1145
- // Capture positions of nodes that are inside this frame
1146
- const nodeIds = this.#getNodesInFrame(frame.id);
1147
- childStartPositions = new Map();
1148
- for (const nid of nodeIds) {
1149
- const nel = this.#nodeViews.get(nid);
1150
- if (nel) childStartPositions.set(nid, { ...nel._position });
1151
- }
1152
- },
1153
- onTranslate: (x, y) => {
1154
- // Move child nodes by delta from frame start
1155
- if (childStartPositions && frameStartPos) {
1156
- const dx = x - frameStartPos.x;
1157
- const dy = y - frameStartPos.y;
1158
- for (const [nid, startPos] of childStartPositions) {
1159
- this.setNodePosition(nid, startPos.x + dx, startPos.y + dy);
1160
- }
1161
- }
1162
- this.setFramePosition(frame.id, x, y);
1163
- },
1164
- onDrop: () => {
1165
- childStartPositions = null;
1166
- frameStartPos = null;
1167
- },
1168
- }
1169
- );
1170
- el._drag = drag;
1171
-
1172
- // Resize handle
1173
- requestAnimationFrame(() => {
1174
- const handle = el.ref?.resizeHandle;
1175
- if (handle) {
1176
- const resizeDrag = new Drag();
1177
- let startSize = null;
1178
- resizeDrag.initialize(
1179
- handle,
1180
- {
1181
- getPosition: () => ({ x: frame.width, y: frame.height }),
1182
- getZoom: () => this.$.zoom,
1183
- },
1184
- {
1185
- onStart: () => {
1186
- startSize = { w: frame.width, h: frame.height };
1187
- },
1188
- onTranslate: (x, y) => {
1189
- const w = Math.max(120, x);
1190
- const h = Math.max(80, y);
1191
- this.setFrameSize(frame.id, w, h);
1192
- },
1193
- onDrop: () => { startSize = null; },
1194
- }
1195
- );
1196
- el._resizeDrag = resizeDrag;
1197
- }
1198
- });
1199
-
1200
- this.ref.framesLayer.appendChild(el);
1201
- this.#frameViews.set(frame.id, el);
1202
- }
1203
-
1204
- /**
1205
- * Remove frame DOM element
1206
- * @param {import('../core/Frame.js').Frame} frame
1207
- */
1208
- #removeFrameView(frame) {
1209
- const el = this.#frameViews.get(frame.id);
1210
- if (!el) return;
1211
- if (el._drag) el._drag.destroy();
1212
- if (el._resizeDrag) el._resizeDrag.destroy();
1213
- el.remove();
1214
- this.#frameViews.delete(frame.id);
1215
- }
1216
-
1217
- // --- Selection ---
1218
-
1219
- #onSelectionChanged(selectedNodes, selectedConnections) {
1220
- this.#zCounter++;
1221
-
1222
- // 1. Identify neighbors of currently selected nodes for "Focus Mode" label visibility
1223
- const neighbors = new Set();
1224
- if (this.#editor && selectedNodes.size > 0) {
1225
- for (const conn of this.#editor.getConnections()) {
1226
- if (selectedNodes.has(conn.from)) neighbors.add(conn.to);
1227
- if (selectedNodes.has(conn.to)) neighbors.add(conn.from);
1228
- }
1229
- }
1230
-
1231
- // Update node attributes — guard to avoid redundant DOM mutations
1232
- for (const [id, el] of this.#nodeViews) {
1233
- const shouldSelect = selectedNodes.has(id);
1234
- const isSelected = el.hasAttribute('data-selected');
1235
- if (shouldSelect && !isSelected) {
1236
- el.setAttribute('data-selected', '');
1237
- el.style.zIndex = this.#zCounter;
1238
- } else if (!shouldSelect && isSelected) {
1239
- el.removeAttribute('data-selected');
1240
- }
1241
-
1242
- const shouldNeighbor = neighbors.has(id) && !shouldSelect;
1243
- const isNeighbor = el.hasAttribute('data-neighbor-focused');
1244
- if (shouldNeighbor && !isNeighbor) {
1245
- el.setAttribute('data-neighbor-focused', '');
1246
- } else if (!shouldNeighbor && isNeighbor) {
1247
- el.removeAttribute('data-neighbor-focused');
1248
- }
1249
- }
1250
-
1251
- // 2. Mark connections touching selected nodes
1252
- const activeConnIds = new Set();
1253
- if (this.#editor && selectedNodes.size > 0) {
1254
- for (const conn of this.#editor.getConnections()) {
1255
- if (selectedNodes.has(conn.from) || selectedNodes.has(conn.to)) {
1256
- activeConnIds.add(conn.id);
1257
- }
1258
- }
1259
- }
1260
-
1261
- // Use cached path map instead of querySelector per connection
1262
- const connSvg = this.ref.connections;
1263
- if (!this._connPathCache) this._connPathCache = new Map();
1264
- for (const [id] of this.#connRenderer?.data || []) {
1265
- let path = this._connPathCache.get(id);
1266
- if (!path || !path.isConnected) {
1267
- path = connSvg.querySelector(`[data-conn-id="${id}"]`);
1268
- if (path) this._connPathCache.set(id, path);
1269
- }
1270
- if (!path) continue;
1271
-
1272
- // Selection state
1273
- const shouldSelectConn = selectedConnections.has(id);
1274
- if (shouldSelectConn !== path.hasAttribute('data-selected')) {
1275
- shouldSelectConn ? path.setAttribute('data-selected', '') : path.removeAttribute('data-selected');
1276
- }
1277
-
1278
- // Active connection: touches a selected node
1279
- const isActive = activeConnIds.has(id);
1280
- if (isActive !== path.hasAttribute('data-active-conn')) {
1281
- isActive ? path.setAttribute('data-active-conn', '') : path.removeAttribute('data-active-conn');
1282
- }
1283
-
1284
- // Dimming
1285
- const shouldDim = !isActive && selectedNodes.size > 0;
1286
- if (shouldDim !== path.hasAttribute('data-dimmed')) {
1287
- shouldDim ? path.setAttribute('data-dimmed', '') : path.removeAttribute('data-dimmed');
1288
- }
1289
- }
1290
-
1291
- // Pass selection state to Canvas renderer for dimming implementation
1292
- if (this.#connRenderer && typeof this.#connRenderer.setSelectionState === 'function') {
1293
- this.#connRenderer.setSelectionState(selectedNodes.size > 0, activeConnIds);
1294
- }
1295
-
1296
- // Quick Action Toolbar — show for single node selection
1297
- const toolbar = this.ref.quickToolbar;
1298
- if (toolbar) {
1299
- if (selectedNodes.size === 1) {
1300
- const nodeId = [...selectedNodes][0];
1301
- const nodeEl = this.#nodeViews.get(nodeId);
1302
- if (nodeEl) toolbar.show(nodeId, nodeEl);
1303
- } else {
1304
- toolbar.hide();
1305
- }
1306
- }
1307
-
1308
- // Inspector — show selected node details, auto-hide on deselect
1309
- const inspector = this.ref.inspector;
1310
- if (inspector) {
1311
- inspector._canvas = this;
1312
- if (selectedNodes.size === 1) {
1313
- const nodeId = [...selectedNodes][0];
1314
- const node = this.#editor?.getNode(nodeId);
1315
- if (node) {
1316
- inspector.inspect(node);
1317
- inspector.hidden = false;
1318
- }
1319
- } else {
1320
- inspector.clear();
1321
- inspector.hidden = true;
1322
- }
1323
- }
1324
-
1325
- // Dispatch event so consumers can react to selection changes (including deselect)
1326
- this.dispatchEvent(new CustomEvent('selection-changed', {
1327
- detail: { nodes: [...selectedNodes], connections: [...selectedConnections] },
1328
- }));
1329
- }
1330
-
1331
- /** @type {number} */
1332
- #lastClickTime = 0;
1333
- /** @type {string|null} */
1334
- #lastClickNodeId = null;
1335
-
1336
- #handleNodeClick(nodeId, e) {
1337
- const accumulate = e.ctrlKey || e.metaKey;
1338
- this.#selector.selectNode(nodeId, accumulate);
1339
-
1340
- // Double-click detection for subgraph drill-down
1341
- const now = Date.now();
1342
- if (this.#lastClickNodeId === nodeId && now - this.#lastClickTime < 400) {
1343
- this.drillDown(nodeId);
1344
- this.#lastClickTime = 0;
1345
- this.#lastClickNodeId = null;
1346
- } else {
1347
- this.#lastClickTime = now;
1348
- this.#lastClickNodeId = nodeId;
1349
- }
1350
- }
1351
-
1352
-
1353
-
1354
- #handleConnectionClick(connId, e) {
1355
- const accumulate = e.ctrlKey || e.metaKey;
1356
- this.#selector.selectConnection(connId, accumulate);
1357
- }
1358
-
1359
- // --- Transform ---
1360
-
1361
- #updateTransform() {
1362
-
1363
- // Sync grid dots with pan/zoom (cached to avoid forced reflow via getComputedStyle)
1364
- if (this._gridBase === undefined) {
1365
- this._gridBase = parseInt(getComputedStyle(this).getPropertyValue('--sn-grid-size')) || 20;
1366
- }
1367
- const gridBase = this._gridBase;
1368
- const zoom = this.$.zoom;
1369
- const multiplier = zoom < 0.5 ? 2 : 1;
1370
- const gridSize = gridBase * multiplier * zoom;
1371
- this.style.backgroundSize = `${gridSize}px ${gridSize}px`;
1372
- this.style.backgroundPosition = `${this.$.panX}px ${this.$.panY}px`;
1373
-
1374
- // Sync toolbar position with zoom/pan
1375
- const toolbar = this.ref.quickToolbar;
1376
- if (toolbar) {
1377
- toolbar._transform = { zoom, panX: this.$.panX, panY: this.$.panY };
1378
- if (toolbar._nodeEl) toolbar.updatePosition(toolbar._nodeEl);
1379
- }
1380
-
1381
- // Viewport culling + LOD (throttled via rAF to prevent re-render cycles)
1382
- if (!this._cullingScheduled) {
1383
- this._cullingScheduled = true;
1384
- requestAnimationFrame(() => {
1385
- this._cullingScheduled = false;
1386
- this.#applyCullingAndLOD();
1387
- });
1388
- }
1389
- }
1390
-
1391
- /** Apply viewport culling and LOD based on current transform */
1392
- #applyCullingAndLOD() {
1393
- if (!this.ref.canvasContainer) return;
1394
- // Allow running even with 0 DOM nodes (phantom-only mode)
1395
- if (this.#nodeViews.size === 0 && this.#phantomData.size === 0) return;
1396
-
1397
- const cw = this.ref.canvasContainer.clientWidth;
1398
- const ch = this.ref.canvasContainer.clientHeight;
1399
- const zoom = this.$.zoom;
1400
- const panX = this.$.panX;
1401
- const panY = this.$.panY;
1402
- const margin = 300;
1403
-
1404
- const lod = zoom < 0.5 ? 'medium' : 'full';
1405
- this._currentLod = lod;
1406
-
1407
- if (this.ref.connections) {
1408
- const isDimmed = this.ref.connections.hasAttribute('data-lod-dimmed');
1409
- if (lod !== 'full') {
1410
- if (!isDimmed) this.ref.connections.setAttribute('data-lod-dimmed', '');
1411
- } else {
1412
- if (isDimmed) this.ref.connections.removeAttribute('data-lod-dimmed');
1413
- }
1414
- }
1415
-
1416
- // ─── Virtualization: promote/demote ───
1417
- const isVirtualized = this.#phantomData.size > 0 || this.#allNodes.size > 200;
1418
- const FAR_ZOOM = zoom < 0.25;
1419
- const toPromote = [];
1420
- const toDemote = [];
1421
-
1422
- // Check existing DOM nodes for visibility
1423
- for (const [id, el] of this.#nodeViews) {
1424
- const pos = el._position;
1425
- if (!pos) continue;
1426
-
1427
- const screenX = pos.x * zoom + panX;
1428
- const screenY = pos.y * zoom + panY;
1429
- if (!el._cachedW) {
1430
- el._cachedW = el.offsetWidth || 180;
1431
- el._cachedH = el.offsetHeight || 60;
1432
- }
1433
- const w = el._cachedW * zoom;
1434
- const h = el._cachedH * zoom;
1435
-
1436
- const visible = (screenX + w > -margin) && (screenX < cw + margin) &&
1437
- (screenY + h > -margin) && (screenY < ch + margin);
1438
-
1439
- if (isVirtualized && FAR_ZOOM) {
1440
- toDemote.push(id);
1441
- } else if (visible) {
1442
- if (el.style.visibility !== '') el.style.visibility = '';
1443
- if (el.getAttribute('data-lod') !== lod) el.setAttribute('data-lod', lod);
1444
- } else if (isVirtualized) {
1445
- // Demote to phantom (only in virtualized mode)
1446
- toDemote.push(id);
1447
- } else {
1448
- if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
1449
- }
1450
- }
1451
-
1452
- // Check phantom nodes for visibility — promote if in viewport
1453
- // Skip promotion if ALL nodes are still phantom (initial load before fitView)
1454
- const allPhantom = this.#nodeViews.size === 0 && this.#phantomData.size > 0;
1455
- if (isVirtualized && !FAR_ZOOM) {
1456
- for (const [id, pd] of this.#phantomData) {
1457
- const screenX = pd.x * zoom + panX;
1458
- const screenY = pd.y * zoom + panY;
1459
- const w = (pd.w || 180) * zoom;
1460
- const h = (pd.h || 60) * zoom;
1461
-
1462
- const visible = (screenX + w > -margin) && (screenX < cw + margin) &&
1463
- (screenY + h > -margin) && (screenY < ch + margin);
1464
-
1465
- if (visible) toPromote.push(id);
1466
- }
1467
- }
1468
-
1469
- // FAR_ZOOM: synchronous demote — no debounce to prevent flicker
1470
- if (FAR_ZOOM && toDemote.length > 0) {
1471
- if (this.#virtTimer) { clearTimeout(this.#virtTimer); this.#virtTimer = null; }
1472
- for (const id of toDemote) this.#demoteNode(id);
1473
- this.#syncPhantomToRenderer();
1474
- } else if (toPromote.length > 0 || toDemote.length > 0) {
1475
- // Normal viewport culling: debounce to avoid thrashing during pan/scroll
1476
- if (this.#virtTimer) clearTimeout(this.#virtTimer);
1477
- this.#virtTimer = setTimeout(() => {
1478
- this.#virtTimer = null;
1479
- for (const id of toDemote) this.#demoteNode(id);
1480
- for (const id of toPromote) this.#promoteNode(id);
1481
- this.#syncPhantomToRenderer();
1482
- }, 100);
1483
- } else if (this.#phantomDirty || allPhantom) {
1484
- // Phantom positions changed or initial phantom-only state — sync to renderer
1485
- this.#phantomDirty = false;
1486
- this.#syncPhantomToRenderer();
1487
- }
1488
- }
1489
-
1490
- // ─── Virtualization helpers ───
1491
-
1492
- /** Promote a phantom node to full DOM */
1493
- #promoteNode(nodeId) {
1494
- if (this.#nodeViews.has(nodeId)) return; // already DOM
1495
- const node = this.#allNodes.get(nodeId);
1496
- if (!node) return;
1497
-
1498
- const pd = this.#phantomData.get(nodeId);
1499
- this.#viewManager.addView(node);
1500
- const el = this.#nodeViews.get(nodeId);
1501
- if (el && pd) {
1502
- el._position = { x: pd.x, y: pd.y };
1503
- el._cachedW = pd.w;
1504
- el._cachedH = pd.h;
1505
- el.style.transform = `translate(${pd.x}px, ${pd.y}px)`;
1506
- }
1507
- this.#phantomData.delete(nodeId);
1508
- }
1509
-
1510
- /** Demote a DOM node to phantom (Canvas dot) */
1511
- #demoteNode(nodeId) {
1512
- if (!this.#nodeViews.has(nodeId)) return; // already phantom
1513
- const el = this.#nodeViews.get(nodeId);
1514
- // Capture color from DOM before removal (use cached or inline style — avoid getComputedStyle reflow)
1515
- let color = null;
1516
- if (el) {
1517
- color = el._cachedBgColor || el.style.backgroundColor || null;
1518
- }
1519
- const dims = this.#viewManager.removeViewInstant(nodeId);
1520
- if (dims) {
1521
- const node = this.#allNodes.get(nodeId);
1522
- this.#phantomData.set(nodeId, {
1523
- id: nodeId,
1524
- x: dims.x, y: dims.y,
1525
- w: dims.w, h: dims.h,
1526
- degree: this.#nodeDegrees.get(nodeId) || 0,
1527
- color,
1528
- label: node?.label || nodeId,
1529
- });
1530
- }
1531
- }
1532
-
1533
- /** Push current phantom data to the Canvas renderer */
1534
- #syncPhantomToRenderer() {
1535
- if (this.#connRenderer && typeof this.#connRenderer.setPhantomNodes === 'function') {
1536
- this.#connRenderer.setPhantomNodes([...this.#phantomData.values()]);
1537
- }
1538
- }
1539
-
1540
- /** Public: force sync phantom data to renderer (for use after batch setNodePosition) */
1541
- syncPhantom() {
1542
- this.#phantomDirty = false;
1543
- this.#syncPhantomToRenderer();
1544
- }
1545
-
1546
- // --- Lifecycle ---
1547
-
1548
- renderCallback() {
1549
- const container = this.ref.canvasContainer;
1550
- const content = this.ref.content;
1551
-
1552
- // Canvas pan
1553
- this.#drag = new Drag();
1554
- this.#drag.initialize(
1555
- container,
1556
- {
1557
- getPosition: () => ({ x: this.$.panX, y: this.$.panY }),
1558
- getZoom: () => 1,
1559
- },
1560
- {
1561
- onStart: (e) => {
1562
- // Track start position — only unselect on click (not drag)
1563
- this._panStart = e ? { x: e.pageX, y: e.pageY, target: e.target } : null;
1564
- },
1565
- onTranslate: (x, y) => {
1566
- if (this.#zoom?.isTranslating()) return;
1567
- if (this.#connectFlow?.isPicking()) return;
1568
- this.$.panX = x;
1569
- this.$.panY = y;
1570
- this.#updateTransform();
1571
- this.dispatchEvent(new CustomEvent('manualviewport'));
1572
-
1573
- // Suppress CSS :hover on paths during active pan
1574
- if (!this.hasAttribute('data-interacting')) {
1575
- this.setAttribute('data-interacting', '');
1576
- }
1577
- },
1578
- onDrop: (e) => {
1579
- // Unselect only on click (minimal movement), not after panning
1580
- if (this._panStart && e) {
1581
- const dx = Math.abs(e.pageX - this._panStart.x);
1582
- const dy = Math.abs(e.pageY - this._panStart.y);
1583
- const t = this._panStart.target;
1584
- const isNode = t?.closest?.('graph-node, quick-toolbar, context-menu, inspector-panel');
1585
- if (dx < 5 && dy < 5 && !isNode) {
1586
- this.#selector.unselectAll();
1587
- }
1588
- }
1589
- this._panStart = null;
1590
- this.removeAttribute('data-interacting');
1591
- },
1592
- }
1593
- );
1594
-
1595
- // Zoom
1596
- this.#zoom = new Zoom(0.1);
1597
- let interactingTimer = null;
1598
- this.#zoom.initialize(container, content, (delta, ox, oy) => {
1599
- const k = this.$.zoom;
1600
- const newK = k * (1 + delta);
1601
- if (newK < 0.001 || newK > 5) return;
1602
- this.$.zoom = newK;
1603
- this.$.panX += ox;
1604
- this.$.panY += oy;
1605
- this.#updateTransform();
1606
- this.dispatchEvent(new CustomEvent('manualviewport'));
1607
-
1608
- // Suppress CSS :hover on paths during active zoom
1609
- if (!this.hasAttribute('data-interacting')) {
1610
- this.setAttribute('data-interacting', '');
1611
- }
1612
- clearTimeout(interactingTimer);
1613
- interactingTimer = setTimeout(() => {
1614
- this.removeAttribute('data-interacting');
1615
- }, 150);
1616
- }, () => ({ x: this.$.panX, y: this.$.panY }));
1617
-
1618
- // Context menu + keyboard
1619
- container.addEventListener('contextmenu', (e) => {
1620
- this.#actions?.showContextMenu(e, this.ref.contextMenu, container, {
1621
- panX: this.$.panX,
1622
- panY: this.$.panY,
1623
- zoom: this.$.zoom,
1624
- });
1625
- });
1626
- container.addEventListener('keydown', (e) => this.#actions?.handleKeydown(e));
1627
-
1628
- // Ctrl+F to open node search
1629
- container.addEventListener('keydown', (e) => {
1630
- if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
1631
- e.preventDefault();
1632
- this.ref.nodeSearch?.toggle();
1633
- }
1634
- });
1635
-
1636
- // Computed transform — auto-tracks panX, panY, zoom
1637
- // --- Loop detector for contentTransform ---
1638
- this.sub('+contentTransform', (val) => {
1639
- if (this.ref.content) {
1640
- this.ref.content.style.transform = val;
1641
- }
1642
- this.#connRenderer?.refreshAll();
1643
- });
1644
-
1645
- this.#updateTransform();
1646
-
1647
-
1648
- // Minimap — auto-show on viewport change, toggle button
1649
- const minimap = this.ref.minimap;
1650
- const minimapToggle = this.ref.minimapToggle;
1651
- const MINIMAP_KEY = 'sn-minimap-enabled';
1652
- let minimapEnabled = localStorage.getItem(MINIMAP_KEY) === 'true';
1653
- let fadeTimer = null;
1654
- const FADE_DELAY = 2000;
1655
-
1656
- const showMinimap = () => {
1657
- if (!minimapEnabled || !minimap) return;
1658
- minimap.hidden = false;
1659
- minimap.removeAttribute('data-fading');
1660
- minimap.update?.();
1661
- clearTimeout(fadeTimer);
1662
- fadeTimer = setTimeout(() => {
1663
- minimap.setAttribute('data-fading', '');
1664
- // After transition ends, hide completely
1665
- setTimeout(() => {
1666
- if (minimap.hasAttribute('data-fading')) {
1667
- minimap.hidden = true;
1668
- minimap.removeAttribute('data-fading');
1669
- }
1670
- }, 400);
1671
- }, FADE_DELAY);
1672
- };
1673
-
1674
- const updateToggleState = () => {
1675
- if (minimapToggle) {
1676
- minimapToggle.toggleAttribute('data-active', minimapEnabled);
1677
- }
1678
- if (!minimapEnabled && minimap) {
1679
- minimap.hidden = true;
1680
- minimap.removeAttribute('data-fading');
1681
- clearTimeout(fadeTimer);
1682
- }
1683
- };
1684
-
1685
- updateToggleState();
1686
-
1687
- if (minimapToggle) {
1688
- minimapToggle.addEventListener('click', (e) => {
1689
- e.stopPropagation();
1690
- minimapEnabled = !minimapEnabled;
1691
- localStorage.setItem(MINIMAP_KEY, minimapEnabled);
1692
- updateToggleState();
1693
- if (minimapEnabled) showMinimap();
1694
- });
1695
- }
1696
-
1697
- // Minimap only shown via toggle button (auto-show disabled)
1698
-
1699
- if (minimap) {
1700
- minimap.setStateGetter(() => {
1701
- const nodes = [];
1702
- for (const [id, el] of this.#nodeViews) {
1703
- const pos = el._position || { x: 0, y: 0 };
1704
- if (!el._cachedW) {
1705
- el._cachedW = el.offsetWidth || 180;
1706
- el._cachedH = el.offsetHeight || 60;
1707
- }
1708
- nodes.push({
1709
- x: pos.x,
1710
- y: pos.y,
1711
- width: el._cachedW,
1712
- height: el._cachedH,
1713
- bypassed: el.hasAttribute('data-bypassed'),
1714
- });
1715
- }
1716
- return {
1717
- nodes,
1718
- transform: { x: this.$.panX, y: this.$.panY, zoom: this.$.zoom },
1719
- containerSize: {
1720
- width: container.clientWidth,
1721
- height: container.clientHeight,
1722
- },
1723
- };
1724
- });
1725
-
1726
- // Handle minimap viewport drag
1727
- minimap.addEventListener('minimap-navigate', (e) => {
1728
- this.$.panX = e.detail.x;
1729
- this.$.panY = e.detail.y;
1730
- this.#updateTransform();
1731
- });
1732
- }
1733
-
1734
- // Node search
1735
- const nodeSearch = this.ref.nodeSearch;
1736
- if (nodeSearch) {
1737
- nodeSearch.configure({
1738
- getNodes: () => {
1739
- const result = [];
1740
- if (this.#editor) {
1741
- for (const node of this.#editor.getNodes()) {
1742
- result.push({ id: node.id, label: node.label, type: node.type, category: node.category });
1743
- }
1744
- }
1745
- return result;
1746
- },
1747
- onSelect: (nodeId) => {
1748
- // Select node
1749
- this.#selector.selectNode(nodeId);
1750
- // Center viewport on node
1751
- const el = this.#nodeViews.get(nodeId);
1752
- if (el?._position) {
1753
- const cx = container.clientWidth / 2;
1754
- const cy = container.clientHeight / 2;
1755
- this.$.panX = -el._position.x * this.$.zoom + cx;
1756
- this.$.panY = -el._position.y * this.$.zoom + cy;
1757
- this.#updateTransform();
1758
- }
1759
- },
1760
- });
1761
- }
1762
- }
1763
-
1764
- /**
1765
- * Smoothly pan viewport to center on a node
1766
- * @param {string} nodeId
1767
- * @param {number} [duration=400] - Animation duration in ms
1768
- */
1769
- panToNode(nodeId, duration = 400) {
1770
- const el = this.#nodeViews.get(nodeId);
1771
- if (!el?._position) return;
1772
- const container = this.ref.canvasContainer;
1773
- if (!container) return;
1774
-
1775
- const cx = container.clientWidth / 2;
1776
- const cy = container.clientHeight / 2;
1777
- const nodeW = (el.offsetWidth || 180) / 2;
1778
- const nodeH = (el.offsetHeight || 60) / 2;
1779
- const targetX = -(el._position.x + nodeW) * this.$.zoom + cx;
1780
- const targetY = -(el._position.y + nodeH) * this.$.zoom + cy;
1781
-
1782
- this.#animatePan(targetX, targetY, duration);
1783
- }
1784
-
1785
- /**
1786
- * RAF-based smooth pan animation with easeOutCubic
1787
- * @param {number} targetX
1788
- * @param {number} targetY
1789
- * @param {number} duration
1790
- */
1791
- #animatePan(targetX, targetY, duration) {
1792
- if (this.#panAnimFrame) {
1793
- cancelAnimationFrame(this.#panAnimFrame);
1794
- this.#panAnimFrame = null;
1795
- }
1796
-
1797
- const startX = this.$.panX;
1798
- const startY = this.$.panY;
1799
- const dx = targetX - startX;
1800
- const dy = targetY - startY;
1801
-
1802
- // Skip if already close enough
1803
- if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
1804
-
1805
- const startTime = performance.now();
1806
-
1807
- const step = (now) => {
1808
- const elapsed = now - startTime;
1809
- const t = Math.min(elapsed / duration, 1);
1810
- // easeOutCubic
1811
- const ease = 1 - Math.pow(1 - t, 3);
1812
-
1813
- this.$.panX = startX + dx * ease;
1814
- this.$.panY = startY + dy * ease;
1815
- this.#updateTransform();
1816
-
1817
- if (t < 1) {
1818
- this.#panAnimFrame = requestAnimationFrame(step);
1819
- } else {
1820
- this.#panAnimFrame = null;
1821
- }
1822
- };
1823
-
1824
- this.#panAnimFrame = requestAnimationFrame(step);
1825
- }
1826
-
1827
- destroyCallback() {
1828
- if (this.#panAnimFrame) cancelAnimationFrame(this.#panAnimFrame);
1829
- if (this.#drag) this.#drag.destroy();
1830
- if (this.#zoom) this.#zoom.destroy();
1831
- if (this.#connectFlow) this.#connectFlow.destroy();
1832
- for (const [, el] of this.#nodeViews) {
1833
- if (el._drag) el._drag.destroy();
1834
- }
1835
- }
1836
- }
1837
-
1838
- NodeCanvas.template = template;
1839
- NodeCanvas.rootStyles = styles;
1840
- NodeCanvas.reg('node-canvas');