project-graph-mcp 2.2.4 → 2.3.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 (151) hide show
  1. package/ARCHITECTURE.md +81 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +9 -4
  4. package/package.json +6 -13
  5. package/src/compact/expand.js +2 -4
  6. package/src/core/graph-builder.js +2 -2
  7. package/src/core/parser.js +2 -2
  8. package/src/network/server.js +1 -2
  9. package/src/network/web-server.js +4 -1
  10. package/vendor/symbiote-node/CHANGELOG.md +31 -0
  11. package/vendor/symbiote-node/LICENSE +21 -0
  12. package/vendor/symbiote-node/README.md +206 -0
  13. package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
  14. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
  15. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
  16. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
  17. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
  18. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
  19. package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
  20. package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
  21. package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
  22. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
  23. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
  24. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  25. package/vendor/symbiote-node/canvas/LODManager.js +88 -0
  26. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
  27. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
  28. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
  29. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
  30. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
  31. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  32. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
  33. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
  34. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
  35. package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
  36. package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
  37. package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
  38. package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
  39. package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
  40. package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
  41. package/vendor/symbiote-node/core/Connection.js +45 -0
  42. package/vendor/symbiote-node/core/Editor.js +451 -0
  43. package/vendor/symbiote-node/core/Frame.js +31 -0
  44. package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
  45. package/vendor/symbiote-node/core/GraphText.js +210 -0
  46. package/vendor/symbiote-node/core/Node.js +143 -0
  47. package/vendor/symbiote-node/core/Portal.js +104 -0
  48. package/vendor/symbiote-node/core/Socket.js +185 -0
  49. package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
  50. package/vendor/symbiote-node/index.js +103 -0
  51. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
  52. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
  53. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  54. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  55. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
  56. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  57. package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
  58. package/vendor/symbiote-node/interactions/Drag.js +102 -0
  59. package/vendor/symbiote-node/interactions/Selector.js +132 -0
  60. package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
  61. package/vendor/symbiote-node/interactions/Zoom.js +140 -0
  62. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
  63. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
  64. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
  65. package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
  66. package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
  67. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
  68. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
  69. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
  70. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
  71. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  72. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
  73. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  74. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
  75. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
  76. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
  77. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
  78. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
  79. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
  80. package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
  81. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
  82. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
  83. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
  84. package/vendor/symbiote-node/layout/index.js +16 -0
  85. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
  86. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
  87. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  88. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
  89. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
  90. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
  91. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
  92. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
  93. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
  94. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
  95. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
  96. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
  97. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
  98. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
  99. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
  100. package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
  101. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
  102. package/vendor/symbiote-node/package.json +59 -0
  103. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  104. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
  105. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
  106. package/vendor/symbiote-node/plugins/History.js +384 -0
  107. package/vendor/symbiote-node/plugins/Readonly.js +59 -0
  108. package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
  109. package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
  110. package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
  111. package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
  112. package/vendor/symbiote-node/shapes/PillShape.js +91 -0
  113. package/vendor/symbiote-node/shapes/RectShape.js +72 -0
  114. package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
  115. package/vendor/symbiote-node/shapes/index.js +53 -0
  116. package/vendor/symbiote-node/themes/Palette.js +32 -0
  117. package/vendor/symbiote-node/themes/Skin.js +113 -0
  118. package/vendor/symbiote-node/themes/Theme.js +84 -0
  119. package/vendor/symbiote-node/themes/carbon.js +137 -0
  120. package/vendor/symbiote-node/themes/dark.js +137 -0
  121. package/vendor/symbiote-node/themes/ebook.js +138 -0
  122. package/vendor/symbiote-node/themes/grey.js +137 -0
  123. package/vendor/symbiote-node/themes/light.js +137 -0
  124. package/vendor/symbiote-node/themes/neon.js +138 -0
  125. package/vendor/symbiote-node/themes/pcb.js +273 -0
  126. package/vendor/symbiote-node/themes/synthwave.js +137 -0
  127. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
  128. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
  129. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
  130. package/web/app.js +6 -5
  131. package/web/components/canvas-graph.js +1666 -0
  132. package/web/components/event-feed/CodeWidget.js +32 -0
  133. package/web/components/event-feed/EventWidget.js +97 -0
  134. package/web/components/event-feed/ListWidget.js +57 -0
  135. package/web/components/event-feed/MiniGraphWidget.js +69 -0
  136. package/web/dashboard.js +1 -1
  137. package/web/index.html +4 -0
  138. package/web/panels/ActionBoard/ActionBoard.js +1 -1
  139. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
  140. package/web/panels/code-viewer.js +50 -15
  141. package/web/panels/dep-graph.js +2712 -7
  142. package/web/panels/file-tree.js +5 -2
  143. package/web/panels/live-monitor.js +75 -3
  144. package/web/style.css +33 -0
  145. package/docs/img/explorer-compact.jpg +0 -0
  146. package/docs/img/explorer-expanded.jpg +0 -0
  147. package/src/.contextignore +0 -22
  148. package/src/.project-graph-cache.json +0 -1
  149. package/src/compact/.project-graph-cache.json +0 -1
  150. package/web/.project-graph-cache.json +0 -1
  151. package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
@@ -0,0 +1,443 @@
1
+ export class SubgraphRouter {
2
+ #canvas = null;
3
+ #config = {};
4
+ #isAutoRouting = false;
5
+ #canvasDepth = 0;
6
+ #listeners = [];
7
+ #destroyed = false;
8
+
9
+ /**
10
+ * @param {HTMLElement} canvas - NodeCanvas instance
11
+ * @param {Object} config - Configuration options
12
+ * @param {String} [config.hashPrefix='graph'] - URL hash routing prefix
13
+ * @param {Map} [config.fileMap] - Map of file paths to node IDs
14
+ * @param {Map} [config.dirNodeMap] - Map of directory paths to node IDs
15
+ * @param {Map} [config.symbolMap] - Map of symbol IDs to { name, file }
16
+ * @param {Set} [config.drillableFiles] - Set of file paths that contain symbol subgraphs
17
+ * @param {Function} [config.onNavigate] - Callback after successful navigation
18
+ */
19
+ constructor(canvas, config = {}) {
20
+ this.#canvas = canvas;
21
+ this.updateConfig(config);
22
+ this.#bindListeners();
23
+ }
24
+
25
+ updateConfig(config = {}) {
26
+ this.#config = {
27
+ hashPrefix: 'graph',
28
+ fileMap: new Map(),
29
+ dirNodeMap: new Map(),
30
+ symbolMap: new Map(),
31
+ drillableFiles: new Set(),
32
+ onNavigate: () => {},
33
+ ...this.#config,
34
+ ...config
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Internal router depth tracker
40
+ * @returns {number}
41
+ */
42
+ get depth() {
43
+ return this.#canvasDepth;
44
+ }
45
+
46
+ /**
47
+ * Prevent hash rewriting during automatic routing across layers
48
+ */
49
+ #runAutoRouting(fn) {
50
+ this.#isAutoRouting = true;
51
+ fn();
52
+ this.#isAutoRouting = false;
53
+ }
54
+
55
+ #bindListeners() {
56
+ const handleEnter = (e) => {
57
+ this.#canvasDepth++;
58
+ if (this.#isAutoRouting) return;
59
+
60
+ const nodeId = e.detail?.nodeId;
61
+ if (!nodeId) return;
62
+
63
+ // Find the path string for this node ID
64
+ let path = null;
65
+ for (const [key, id] of this.#config.dirNodeMap.entries()) {
66
+ if (id === nodeId) { path = key; break; }
67
+ }
68
+ if (!path) {
69
+ for (const [key, id] of this.#config.fileMap.entries()) {
70
+ if (id === nodeId) { path = key; break; }
71
+ }
72
+ }
73
+
74
+ if (path) {
75
+ const hash = window.location.hash;
76
+ const [base, queryStr] = hash.split('?');
77
+ const params = new URLSearchParams(queryStr || '');
78
+
79
+ params.set('in', '1');
80
+
81
+ // Preserve symbol if it exists and path is drillable
82
+ if (!params.has('symbol') || !this.#config.drillableFiles.has(path)) {
83
+ params.delete('symbol');
84
+ }
85
+
86
+ const newQuery = params.toString();
87
+ history.replaceState(null, '', `#${this.#config.hashPrefix}/${path}?${newQuery}`);
88
+ }
89
+ };
90
+
91
+ const handleExit = (e) => {
92
+ const level = e.detail?.level;
93
+ this.#canvasDepth = (typeof level === 'number') ? level : Math.max(0, this.#canvasDepth - 1);
94
+ if (this.#isAutoRouting) return; // Prevent erasing URL when popping out to find hidden nested paths
95
+
96
+ // Extract the path we were drilled into BEFORE modifying the URL
97
+ const hashPath = window.location.hash.replace(`#${this.#config.hashPrefix}/`, '').split('?')[0].split('&')[0];
98
+
99
+ // Find the directory path we just exited from (to focus on it)
100
+ let exitedDirPath = hashPath;
101
+ if (this.#config.fileMap?.has(hashPath)) {
102
+ const parts = hashPath.split('/');
103
+ parts.pop();
104
+ exitedDirPath = parts.join('/') + '/';
105
+ }
106
+ // Walk up to find the nearest known directory
107
+ if (exitedDirPath && !this.#config.dirNodeMap?.has(exitedDirPath)) {
108
+ const segments = exitedDirPath.replace(/\/$/, '').split('/');
109
+ while (segments.length > 0) {
110
+ const candidate = segments.join('/') + '/';
111
+ if (this.#config.dirNodeMap?.has(candidate)) {
112
+ exitedDirPath = candidate;
113
+ break;
114
+ }
115
+ segments.pop();
116
+ }
117
+ }
118
+
119
+ const updateUrl = (newPath, setIn = false, setFocus = null) => {
120
+ const hash = window.location.hash;
121
+ const [base, queryStr] = hash.split('?');
122
+ const params = new URLSearchParams(queryStr || '');
123
+
124
+ let newBase = `#${this.#config.hashPrefix}`;
125
+ if (newPath) newBase += `/${newPath}`;
126
+
127
+ if (setIn) params.set('in', '1');
128
+ else params.delete('in');
129
+
130
+ if (setFocus) params.set('focus', setFocus);
131
+ else params.delete('focus');
132
+
133
+ params.delete('symbol'); // always clear symbol on exit
134
+
135
+ const newQuery = params.toString();
136
+ const newHash = newQuery ? `${newBase}?${newQuery}` : newBase;
137
+ history.replaceState(null, '', newHash);
138
+ };
139
+
140
+ if (this.#canvasDepth > 0) {
141
+ // Still inside a subgraph — update URL to parent context
142
+ if (this.#config.dirNodeMap?.has(exitedDirPath)) {
143
+ updateUrl(exitedDirPath, true, null);
144
+ } else if (exitedDirPath) {
145
+ updateUrl(exitedDirPath, false, null);
146
+ }
147
+ } else {
148
+ // Back at root
149
+ if (exitedDirPath) {
150
+ updateUrl(null, false, exitedDirPath);
151
+ } else {
152
+ updateUrl(null, false, null);
153
+ }
154
+ }
155
+
156
+ // Fly to the exited group node at ANY level
157
+ if (exitedDirPath) {
158
+ requestAnimationFrame(() => {
159
+ const nodeId = this.#config.dirNodeMap?.get(exitedDirPath) ||
160
+ this.#config.fileMap?.get(exitedDirPath);
161
+ if (nodeId && this.#canvas.flyToNode) {
162
+ this.#canvas.flyToNode(nodeId, { zoom: 0.8 });
163
+ } else if (this.#canvas.fitView) {
164
+ this.#canvas.fitView();
165
+ }
166
+ });
167
+ } else if (this.#canvas.fitView) {
168
+ requestAnimationFrame(() => this.#canvas.fitView());
169
+ }
170
+ };
171
+
172
+ this.#canvas.addEventListener('subgraph-enter', handleEnter);
173
+ this.#canvas.addEventListener('subgraph-exit', handleExit);
174
+
175
+ this.#listeners.push(
176
+ { name: 'subgraph-enter', fn: handleEnter },
177
+ { name: 'subgraph-exit', fn: handleExit }
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Reads URL hash and triggers initial drill down + focus sequence.
183
+ *
184
+ * Universal URL semantics:
185
+ * - `#graph` → root, fit view
186
+ * - `#graph?focus=src/analysis/` → root, fly to analysis node
187
+ * - `#graph/src/analysis/?in=1` → drill into analysis
188
+ * - `#graph/src/analysis/?in=1&focus=file.js` → drill into analysis, focus file.js
189
+ * - `#graph/src/analysis/file.js?in=1` → drill into analysis, drill into file
190
+ * - `#graph/src/analysis/file.js?in=1&symbol=name` → drill into file, focus symbol
191
+ * - `#graph/src/analysis/` → (legacy) focus analysis at root
192
+ *
193
+ * @param {NodeEditor} editor
194
+ */
195
+ restoreFromHash(editor) {
196
+ if (this.#destroyed || !this.#canvas) return;
197
+
198
+ const hash = window.location.hash;
199
+ const prefix = `#${this.#config.hashPrefix}`;
200
+ if (!hash.startsWith(prefix)) return;
201
+
202
+ const afterPrefix = hash.slice(prefix.length); // e.g. '/src/analysis/?in=1&focus=file.js' or '?focus=src/analysis/'
203
+
204
+ // Parse query parameters from the hash
205
+ const qIdx = afterPrefix.indexOf('?');
206
+ const pathPart = qIdx >= 0 ? afterPrefix.slice(0, qIdx) : afterPrefix; // '/src/analysis/' or ''
207
+ const queryStr = qIdx >= 0 ? afterPrefix.slice(qIdx + 1) : '';
208
+ const params = new URLSearchParams(queryStr);
209
+
210
+ const drillPath = pathPart.replace(/^\//, ''); // strip leading /
211
+ const hasDrillFlag = params.get('in') === '1';
212
+ const focusParam = params.get('focus');
213
+ const symbolParam = params.get('symbol');
214
+
215
+ // Case 0: bare #graph — pop all subgraph layers and reset to root view
216
+ if (!drillPath && !focusParam && !hasDrillFlag && !symbolParam) {
217
+ // Event-driven pop: wait for each subgraph-exit before the next drillUp.
218
+ // rAF-polling was unreliable because canvasDepth updates only AFTER the exit
219
+ // event fires — which happens asynchronously during the canvas animation.
220
+ this.#isAutoRouting = true;
221
+ let safetyCounter = 10;
222
+ const doPopStep = () => {
223
+ if (this.#canvasDepth <= 0 || safetyCounter-- <= 0) {
224
+ this.#isAutoRouting = false;
225
+ this.#canvas.fitView?.();
226
+ return;
227
+ }
228
+ // Register exit listener FIRST, then trigger drillUp
229
+ const onExit = () => {
230
+ this.#canvas.removeEventListener('subgraph-exit', onExit);
231
+ // canvasDepth is now decremented by the main handler; recurse
232
+ requestAnimationFrame(doPopStep);
233
+ };
234
+ this.#canvas.addEventListener('subgraph-exit', onExit);
235
+ this.#canvas.drillUp?.();
236
+ };
237
+ doPopStep();
238
+ return;
239
+ }
240
+
241
+ // Case 1: #graph?focus=src/analysis/ (root-level focus, no path)
242
+ if (!drillPath && focusParam) {
243
+ this.navigateTo(decodeURIComponent(focusParam), 0, false);
244
+ return;
245
+ }
246
+
247
+ // Case 2: #graph/path?in=1 (drill into path)
248
+ if (drillPath && hasDrillFlag) {
249
+ const drilled = this.#restoreDrillDown(drillPath, editor, true);
250
+
251
+ // After drilling, handle &focus= (select node inside group)
252
+ if (drilled && focusParam) {
253
+ const fullFocusPath = drillPath + decodeURIComponent(focusParam);
254
+ requestAnimationFrame(() => {
255
+ this.navigateTo(fullFocusPath, 0, false);
256
+ });
257
+ }
258
+
259
+ // Handle &symbol= (focus symbol inside file subgraph)
260
+ if (drilled && symbolParam) {
261
+ requestAnimationFrame(() => {
262
+ this.restoreSymbolFocus(drillPath);
263
+ });
264
+ }
265
+ return;
266
+ }
267
+
268
+ // Case 3: #graph/path (legacy — just focus the node at root)
269
+ if (drillPath) {
270
+ this.navigateTo(drillPath, 0, false);
271
+ }
272
+ }
273
+
274
+ #restoreDrillDown(targetPath, editor, autoDrill = false) {
275
+ if (!this.#canvas) return false;
276
+
277
+ // Try to find a directory SubgraphNode matching the path
278
+ for (const node of editor.getNodes()) {
279
+ if (!node._isSubgraph) continue;
280
+ const nodePath = node.params?.path;
281
+ if (!nodePath) continue;
282
+
283
+ // Exact directory match (e.g. 'src/core/')
284
+ if (nodePath === targetPath) {
285
+ if (autoDrill) {
286
+ this.#runAutoRouting(() => {
287
+ this.#canvas.drillDown(node.id);
288
+ });
289
+ if (this.#canvas.fitView) {
290
+ requestAnimationFrame(() => this.#canvas.fitView());
291
+ }
292
+ } else {
293
+ // Just focus on the directory node without drilling in
294
+ this.#canvas.flyToNode?.(node.id, { zoom: 0.8 }) ||
295
+ this.#canvas.selectNode?.(node.id);
296
+ }
297
+ return true;
298
+ }
299
+
300
+ // File inside this directory — drill into dir, then focus file
301
+ if (targetPath.startsWith(nodePath)) {
302
+ this.#runAutoRouting(() => {
303
+ this.#canvas.drillDown(node.id);
304
+ });
305
+ // After transition, specifically focus the exact nested file node
306
+ requestAnimationFrame(() => {
307
+ this.navigateTo(targetPath, 0, autoDrill);
308
+ });
309
+ return true;
310
+ }
311
+ }
312
+
313
+ return false;
314
+ }
315
+
316
+ /**
317
+ * Restore visual symbol focus from &symbol= URL parameter.
318
+ * Called after autoDrill into a file subgraph to select the target function/class node.
319
+ * @param {string} filePath - the file we drilled into
320
+ */
321
+ restoreSymbolFocus(filePath) {
322
+ const hashParts = window.location.hash.split('&symbol=');
323
+ if (hashParts.length < 2) return;
324
+ const symbolName = decodeURIComponent(hashParts[1].split('&')[0]);
325
+ if (!symbolName || !this.#config.symbolMap) return;
326
+
327
+ for (const [nodeId, params] of this.#config.symbolMap) {
328
+ if (params.name === symbolName && params.file === filePath) {
329
+ this.#canvas?.selectNode(nodeId);
330
+ return;
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Focus viewport on a specific node by path
337
+ * @param {string} targetPath - e.g. 'src/core/event-bus.js'
338
+ * @param {number} depth - Internal recursion depth limit
339
+ * @param {boolean} autoDrill - Attempt to drill into target if it is a Subgraph
340
+ * @returns {boolean} true if node found and focused
341
+ */
342
+ navigateTo(targetPath, depth = 0, autoDrill = false) {
343
+ if (this.#destroyed || !this.#canvas || !this.#config.fileMap || depth > 5) return false;
344
+
345
+ // Find node ID by file path string or directory path string
346
+ let targetId = null;
347
+ let isFile = true;
348
+ if (this.#config.fileMap.has(targetPath)) {
349
+ targetId = this.#config.fileMap.get(targetPath);
350
+ } else if (this.#config.dirNodeMap && this.#config.dirNodeMap.has(targetPath)) {
351
+ targetId = this.#config.dirNodeMap.get(targetPath);
352
+ isFile = false;
353
+ }
354
+
355
+ if (!targetId) return false;
356
+
357
+ const positions = typeof this.#canvas.getPositions === 'function' ? this.#canvas.getPositions() : {};
358
+ const pos = positions[targetId];
359
+
360
+ // Auto-traversal engine: if target is not visible on current canvas layer
361
+ if (!pos && typeof this.#canvas.drillDown === 'function') {
362
+ if (this.#config.dirNodeMap) {
363
+ // Case 1: Target is a file or directory hidden inside a deeper subgraph.
364
+ // Walk up the path hierarchy to find the nearest visible ancestor directory.
365
+ let searchPath = targetPath;
366
+ if (isFile) {
367
+ // Start from the file's parent directory
368
+ const parts = targetPath.split('/');
369
+ parts.pop();
370
+ searchPath = parts.join('/') + '/';
371
+ if (searchPath === '/') searchPath = './';
372
+ }
373
+
374
+ // Walk UP the directory tree to find the nearest visible ancestor
375
+ let segments = searchPath.replace(/\/$/, '').split('/');
376
+ while (segments.length > 0) {
377
+ const candidateDir = segments.join('/') + '/';
378
+ const dirId = this.#config.dirNodeMap.get(candidateDir);
379
+ if (dirId && positions[dirId]) {
380
+ // Found a visible ancestor — drill into it
381
+ this.#runAutoRouting(() => {
382
+ this.#canvas.drillDown(dirId);
383
+ });
384
+ requestAnimationFrame(() => this.navigateTo(targetPath, depth + 1, autoDrill));
385
+ return true;
386
+ }
387
+ segments.pop();
388
+ }
389
+ // Also try "./" as root
390
+ const rootId = this.#config.dirNodeMap.get('./');
391
+ if (rootId && positions[rootId]) {
392
+ this.#runAutoRouting(() => {
393
+ this.#canvas.drillDown(rootId);
394
+ });
395
+ requestAnimationFrame(() => this.navigateTo(targetPath, depth + 1, autoDrill));
396
+ return true;
397
+ }
398
+
399
+ // Case 2: Target is completely off-scope (we are inside wrong group). Drill UP to root.
400
+ if (this.#canvasDepth > 0) {
401
+ this.#runAutoRouting(() => {
402
+ this.#canvas.drillUp?.();
403
+ });
404
+ requestAnimationFrame(() => this.navigateTo(targetPath, depth + 1, autoDrill));
405
+ return true;
406
+ }
407
+ }
408
+ return false; // Unable to locate on any layer
409
+ }
410
+
411
+ // We found the node on the current layer. If target is a subgraph file and we are commanded to drill into it, do it.
412
+ if (autoDrill && isFile && this.#config.drillableFiles?.has(targetPath)) {
413
+ this.#runAutoRouting(() => {
414
+ this.#canvas.drillDown?.(targetId);
415
+ });
416
+ requestAnimationFrame(() => {
417
+ if (this.#canvas.fitView) this.#canvas.fitView();
418
+ // Restore &symbol= focus from deep-link URL
419
+ this.restoreSymbolFocus(targetPath);
420
+ });
421
+ return true;
422
+ }
423
+
424
+ // SubgraphRouter delegates raw center/fly animations up to Canvas if possible
425
+ if (this.#canvas.flyToNode) {
426
+ this.#canvas.flyToNode(targetId, { zoom: 0.8 });
427
+ } else {
428
+ // Safe fallback just in case
429
+ this.#canvas.selectNode?.(targetId);
430
+ }
431
+
432
+ this.#config.onNavigate(targetPath);
433
+ return true;
434
+ }
435
+
436
+ destroy() {
437
+ this.#destroyed = true;
438
+ for (const listener of this.#listeners) {
439
+ this.#canvas.removeEventListener(listener.name, listener.fn);
440
+ }
441
+ this.#listeners = [];
442
+ }
443
+ }