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.
- package/ARCHITECTURE.md +81 -0
- package/CHANGELOG.md +57 -0
- package/README.md +9 -4
- package/package.json +6 -13
- package/src/compact/expand.js +2 -4
- package/src/core/graph-builder.js +2 -2
- package/src/core/parser.js +2 -2
- package/src/network/server.js +1 -2
- package/src/network/web-server.js +4 -1
- package/vendor/symbiote-node/CHANGELOG.md +31 -0
- package/vendor/symbiote-node/LICENSE +21 -0
- package/vendor/symbiote-node/README.md +206 -0
- package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
- package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
- package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
- package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
- package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
- package/vendor/symbiote-node/canvas/LODManager.js +88 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
- package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
- package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
- package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
- package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
- package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
- package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
- package/vendor/symbiote-node/core/Connection.js +45 -0
- package/vendor/symbiote-node/core/Editor.js +451 -0
- package/vendor/symbiote-node/core/Frame.js +31 -0
- package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
- package/vendor/symbiote-node/core/GraphText.js +210 -0
- package/vendor/symbiote-node/core/Node.js +143 -0
- package/vendor/symbiote-node/core/Portal.js +104 -0
- package/vendor/symbiote-node/core/Socket.js +185 -0
- package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
- package/vendor/symbiote-node/index.js +103 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
- package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
- package/vendor/symbiote-node/interactions/Drag.js +102 -0
- package/vendor/symbiote-node/interactions/Selector.js +132 -0
- package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
- package/vendor/symbiote-node/interactions/Zoom.js +140 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
- package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
- package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
- package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
- package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
- package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
- package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
- package/vendor/symbiote-node/layout/index.js +16 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
- package/vendor/symbiote-node/package.json +59 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
- package/vendor/symbiote-node/plugins/History.js +384 -0
- package/vendor/symbiote-node/plugins/Readonly.js +59 -0
- package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
- package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
- package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
- package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
- package/vendor/symbiote-node/shapes/PillShape.js +91 -0
- package/vendor/symbiote-node/shapes/RectShape.js +72 -0
- package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
- package/vendor/symbiote-node/shapes/index.js +53 -0
- package/vendor/symbiote-node/themes/Palette.js +32 -0
- package/vendor/symbiote-node/themes/Skin.js +113 -0
- package/vendor/symbiote-node/themes/Theme.js +84 -0
- package/vendor/symbiote-node/themes/carbon.js +137 -0
- package/vendor/symbiote-node/themes/dark.js +137 -0
- package/vendor/symbiote-node/themes/ebook.js +138 -0
- package/vendor/symbiote-node/themes/grey.js +137 -0
- package/vendor/symbiote-node/themes/light.js +137 -0
- package/vendor/symbiote-node/themes/neon.js +138 -0
- package/vendor/symbiote-node/themes/pcb.js +273 -0
- package/vendor/symbiote-node/themes/synthwave.js +137 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
- package/web/app.js +6 -5
- package/web/components/canvas-graph.js +1666 -0
- package/web/components/event-feed/CodeWidget.js +32 -0
- package/web/components/event-feed/EventWidget.js +97 -0
- package/web/components/event-feed/ListWidget.js +57 -0
- package/web/components/event-feed/MiniGraphWidget.js +69 -0
- package/web/dashboard.js +1 -1
- package/web/index.html +4 -0
- package/web/panels/ActionBoard/ActionBoard.js +1 -1
- package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
- package/web/panels/code-viewer.js +50 -15
- package/web/panels/dep-graph.js +2712 -7
- package/web/panels/file-tree.js +5 -2
- package/web/panels/live-monitor.js +75 -3
- package/web/style.css +33 -0
- package/docs/img/explorer-compact.jpg +0 -0
- package/docs/img/explorer-expanded.jpg +0 -0
- package/src/.contextignore +0 -22
- package/src/.project-graph-cache.json +0 -1
- package/src/compact/.project-graph-cache.json +0 -1
- package/web/.project-graph-cache.json +0 -1
- package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
package/web/panels/dep-graph.js
CHANGED
|
@@ -1,7 +1,2712 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dep-graph.js — Visual Project Graph (PCB Board Style)
|
|
3
|
+
*
|
|
4
|
+
* Renders the project dependency graph as an interactive
|
|
5
|
+
* node-canvas visualization styled like a printed circuit board.
|
|
6
|
+
* Uses symbiote-node's NodeCanvas with orthogonal routing,
|
|
7
|
+
* readonly mode, and auto-layout.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1: File-level graph (each file = node, imports = traces).
|
|
10
|
+
*/
|
|
11
|
+
import Symbiote from '@symbiotejs/symbiote';
|
|
12
|
+
import {
|
|
13
|
+
NodeEditor,
|
|
14
|
+
Node,
|
|
15
|
+
SubgraphNode,
|
|
16
|
+
Connection,
|
|
17
|
+
Socket,
|
|
18
|
+
Input,
|
|
19
|
+
Output,
|
|
20
|
+
NodeCanvas,
|
|
21
|
+
Frame,
|
|
22
|
+
computeAutoLayout,
|
|
23
|
+
computeTreeLayout,
|
|
24
|
+
applyTheme,
|
|
25
|
+
SubgraphRouter,
|
|
26
|
+
LODManager,
|
|
27
|
+
PinExpansion,
|
|
28
|
+
ForceLayout,
|
|
29
|
+
PCB_DARK,
|
|
30
|
+
} from 'symbiote-node';
|
|
31
|
+
import { api, state, events, emit } from '../app.js';
|
|
32
|
+
|
|
33
|
+
// ── Socket types (for wire coloring) ──
|
|
34
|
+
const S_IMPORT = new Socket('import');
|
|
35
|
+
S_IMPORT.color = '#c87533'; // copper
|
|
36
|
+
const S_EXPORT = new Socket('export');
|
|
37
|
+
S_EXPORT.color = '#d4a04a'; // gold
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract directory from file path
|
|
41
|
+
* @param {string} filePath
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function dirOf(filePath) {
|
|
45
|
+
if (!filePath) return './';
|
|
46
|
+
const idx = filePath.lastIndexOf('/');
|
|
47
|
+
return idx >= 0 ? filePath.slice(0, idx + 1) : './';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Short filename for node label
|
|
52
|
+
* @param {string} filePath
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function baseName(filePath) {
|
|
56
|
+
if (!filePath) return '?';
|
|
57
|
+
const idx = filePath.lastIndexOf('/');
|
|
58
|
+
return idx >= 0 ? filePath.slice(idx + 1) : filePath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve import path to a known file
|
|
65
|
+
* @param {string} importPath
|
|
66
|
+
* @param {string} fromFile
|
|
67
|
+
* @param {Set<string>} knownFiles
|
|
68
|
+
* @returns {string|null}
|
|
69
|
+
*/
|
|
70
|
+
function resolveImport(importPath, fromFile, knownFiles) {
|
|
71
|
+
// Direct match
|
|
72
|
+
if (knownFiles.has(importPath)) return importPath;
|
|
73
|
+
|
|
74
|
+
// Try with .js extension
|
|
75
|
+
if (knownFiles.has(importPath + '.js')) return importPath + '.js';
|
|
76
|
+
|
|
77
|
+
// Relative resolution
|
|
78
|
+
if (importPath.startsWith('.')) {
|
|
79
|
+
const dir = dirOf(fromFile);
|
|
80
|
+
let resolved = dir + importPath.replace(/^\.\//, '');
|
|
81
|
+
// Normalize ../ segments
|
|
82
|
+
const parts = resolved.split('/');
|
|
83
|
+
const normalized = [];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (part === '..') normalized.pop();
|
|
86
|
+
else if (part !== '.') normalized.push(part);
|
|
87
|
+
}
|
|
88
|
+
resolved = normalized.join('/');
|
|
89
|
+
|
|
90
|
+
if (knownFiles.has(resolved)) return resolved;
|
|
91
|
+
if (knownFiles.has(resolved + '.js')) return resolved + '.js';
|
|
92
|
+
// Try index
|
|
93
|
+
if (knownFiles.has(resolved + '/index.js')) return resolved + '/index.js';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Module name match via pre-built index — O(1) instead of O(N)
|
|
97
|
+
const base = importPath.split('/').pop();
|
|
98
|
+
const idx = buildBasenameIndex(knownFiles);
|
|
99
|
+
return idx.get(base) || idx.get(base.replace(/\.js$/, '')) || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let _basenameIndex = null;
|
|
103
|
+
let _indexedSet = null;
|
|
104
|
+
function buildBasenameIndex(knownFiles) {
|
|
105
|
+
if (_indexedSet === knownFiles) return _basenameIndex;
|
|
106
|
+
_indexedSet = knownFiles;
|
|
107
|
+
_basenameIndex = new Map();
|
|
108
|
+
for (const file of knownFiles) {
|
|
109
|
+
const base = file.split('/').pop();
|
|
110
|
+
_basenameIndex.set(base, file);
|
|
111
|
+
if (!base.endsWith('.js')) {
|
|
112
|
+
_basenameIndex.set(base + '.js', file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return _basenameIndex;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a hierarchical SubgraphNode graph:
|
|
120
|
+
* Level 0: directories (SubgraphNode)
|
|
121
|
+
* Level 1: files inside directories (SubgraphNode or Node)
|
|
122
|
+
* Level 2: functions/exports inside files (Node)
|
|
123
|
+
*
|
|
124
|
+
* @param {object} skeleton
|
|
125
|
+
* @returns {{ editor: NodeEditor, fileMap: Map<string, string> }}
|
|
126
|
+
*/
|
|
127
|
+
function buildStructuredGraph(skeleton) {
|
|
128
|
+
const editor = new NodeEditor();
|
|
129
|
+
const fileMap = new Map();
|
|
130
|
+
const symbolMap = new Map();
|
|
131
|
+
const L = skeleton.L || {}; // legend: abbreviation → full name
|
|
132
|
+
const N = skeleton.n || {}; // classes: className → { f, m, ... }
|
|
133
|
+
|
|
134
|
+
// Build class-name set for classification
|
|
135
|
+
const classNames = new Set(Object.keys(N));
|
|
136
|
+
// Map file → set of class names defined in it
|
|
137
|
+
const fileClasses = new Map();
|
|
138
|
+
for (const [className, data] of Object.entries(N)) {
|
|
139
|
+
if (data.f) {
|
|
140
|
+
if (!fileClasses.has(data.f)) fileClasses.set(data.f, new Set());
|
|
141
|
+
fileClasses.get(data.f).add(className);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Collect all files
|
|
146
|
+
const files = new Set();
|
|
147
|
+
const assetFiles = new Set();
|
|
148
|
+
for (const data of Object.values(N)) {
|
|
149
|
+
if (data.f) files.add(data.f);
|
|
150
|
+
}
|
|
151
|
+
for (const file of Object.keys(skeleton.X || {})) {
|
|
152
|
+
files.add(file);
|
|
153
|
+
}
|
|
154
|
+
for (const [dir, names] of Object.entries(skeleton.f || {})) {
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
files.add(dir === './' ? name : dir + name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Non-source/asset files (.css, .html, .json, .md, etc.)
|
|
160
|
+
for (const [dir, names] of Object.entries(skeleton.a || {})) {
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
const fullPath = dir === './' ? name : dir + name;
|
|
163
|
+
files.add(fullPath);
|
|
164
|
+
assetFiles.add(fullPath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (files.size === 0) return { editor, fileMap };
|
|
169
|
+
|
|
170
|
+
// Group files by directory
|
|
171
|
+
const dirFiles = new Map();
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
const dir = dirOf(file);
|
|
174
|
+
if (!dirFiles.has(dir)) dirFiles.set(dir, []);
|
|
175
|
+
dirFiles.get(dir).push(file);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Classify a file based on its content and name
|
|
180
|
+
* @param {string} file
|
|
181
|
+
* @returns {string} category
|
|
182
|
+
*/
|
|
183
|
+
function classifyFile(file) {
|
|
184
|
+
if (assetFiles.has(file)) return 'asset';
|
|
185
|
+
const name = baseName(file).toLowerCase();
|
|
186
|
+
const classes = fileClasses.get(file);
|
|
187
|
+
if (classes && classes.size > 0) return 'class';
|
|
188
|
+
if (name === 'index.js' || name === 'index.mjs') return 'module';
|
|
189
|
+
if (name.includes('test') || name.includes('spec')) return 'control';
|
|
190
|
+
if (name.includes('config') || name.includes('.json')) return 'data';
|
|
191
|
+
return 'file';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve export abbreviation to full name
|
|
196
|
+
* @param {string} abbr
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function resolveName(abbr) {
|
|
200
|
+
return L[abbr] || abbr;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Phase 1: Create all Directory SubgraphNodes (without nesting yet) ──
|
|
204
|
+
const dirNodeMap = new Map(); // dirPath → nodeId
|
|
205
|
+
const dirSubgraphs = new Map(); // dirPath → SubgraphNode instance
|
|
206
|
+
|
|
207
|
+
// Sort directories by depth (shortest first) so parents are created before children
|
|
208
|
+
const sortedDirs = [...dirFiles.keys()].sort((a, b) => {
|
|
209
|
+
const dA = a.split('/').filter(Boolean).length;
|
|
210
|
+
const dB = b.split('/').filter(Boolean).length;
|
|
211
|
+
return dA - dB || a.localeCompare(b);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
for (const dir of sortedDirs) {
|
|
215
|
+
const dirFileList = dirFiles.get(dir);
|
|
216
|
+
|
|
217
|
+
// Root directory './' is NOT a node — its contents go directly into the root editor
|
|
218
|
+
const isRoot = (dir === './');
|
|
219
|
+
const targetEditor = isRoot ? editor : null;
|
|
220
|
+
|
|
221
|
+
let dirSubgraph = null;
|
|
222
|
+
let innerEditor;
|
|
223
|
+
|
|
224
|
+
if (isRoot) {
|
|
225
|
+
innerEditor = editor;
|
|
226
|
+
} else {
|
|
227
|
+
const dirLabel = dir.replace(/\/$/, '').split('/').pop() || 'root';
|
|
228
|
+
dirSubgraph = new SubgraphNode(dirLabel, {
|
|
229
|
+
category: 'directory',
|
|
230
|
+
});
|
|
231
|
+
dirSubgraph.params = { path: dir, isDirectory: true };
|
|
232
|
+
dirSubgraph.addOutput('out', new Output(S_EXPORT, ''));
|
|
233
|
+
dirSubgraph.addInput('in', new Input(S_IMPORT, ''));
|
|
234
|
+
innerEditor = dirSubgraph.getInnerEditor();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── File nodes inside this directory ──
|
|
238
|
+
for (const file of dirFileList) {
|
|
239
|
+
const fileLabel = baseName(file);
|
|
240
|
+
const exports = skeleton.X?.[file] || [];
|
|
241
|
+
const fileCategory = classifyFile(file);
|
|
242
|
+
const classes = fileClasses.get(file);
|
|
243
|
+
|
|
244
|
+
let fileNode;
|
|
245
|
+
if (exports.length > 0) {
|
|
246
|
+
fileNode = new SubgraphNode(fileLabel, {
|
|
247
|
+
category: fileCategory,
|
|
248
|
+
});
|
|
249
|
+
fileNode.params = { path: file, dir, calculatedHeight: 60 + exports.length * 50 };
|
|
250
|
+
|
|
251
|
+
const fileInnerEditor = fileNode.getInnerEditor();
|
|
252
|
+
for (const abbr of exports) {
|
|
253
|
+
const abbrId = typeof abbr === 'object' ? abbr.id : abbr;
|
|
254
|
+
const fullName = resolveName(abbrId);
|
|
255
|
+
const isClass = classes && classes.has(fullName);
|
|
256
|
+
const fnNode = new Node(fullName, {
|
|
257
|
+
type: isClass ? 'class' : 'function',
|
|
258
|
+
category: isClass ? 'class' : 'function',
|
|
259
|
+
});
|
|
260
|
+
fnNode.params = { name: fullName, file };
|
|
261
|
+
symbolMap.set(fnNode.id, fnNode.params);
|
|
262
|
+
fileInnerEditor.addNode(fnNode);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
fileNode = new Node(fileLabel, {
|
|
266
|
+
type: 'file',
|
|
267
|
+
category: fileCategory,
|
|
268
|
+
});
|
|
269
|
+
fileNode.params = { path: file, dir };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fileNode.addOutput('out', new Output(S_EXPORT, ''));
|
|
273
|
+
fileNode.addInput('in', new Input(S_IMPORT, ''));
|
|
274
|
+
|
|
275
|
+
innerEditor.addNode(fileNode);
|
|
276
|
+
fileMap.set(file, fileNode.id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── File-level import edges within this directory ──
|
|
280
|
+
const edgesAdded = new Set();
|
|
281
|
+
for (const [srcFile, sources] of Object.entries(skeleton.I || {})) {
|
|
282
|
+
const srcId = fileMap.get(srcFile);
|
|
283
|
+
if (!srcId) continue;
|
|
284
|
+
const srcDir = dirOf(srcFile);
|
|
285
|
+
if (srcDir !== dir) continue;
|
|
286
|
+
|
|
287
|
+
for (const impPath of sources) {
|
|
288
|
+
if (impPath.startsWith('node:') || (!impPath.startsWith('.') && !impPath.startsWith('/'))) continue;
|
|
289
|
+
const targetFile = resolveImport(impPath, srcFile, files);
|
|
290
|
+
if (!targetFile) continue;
|
|
291
|
+
|
|
292
|
+
const tgtId = fileMap.get(targetFile);
|
|
293
|
+
if (!tgtId || tgtId === srcId) continue;
|
|
294
|
+
if (dirOf(targetFile) !== dir) continue;
|
|
295
|
+
|
|
296
|
+
const edgeKey = `${srcId}->${tgtId}`;
|
|
297
|
+
if (edgesAdded.has(edgeKey)) continue;
|
|
298
|
+
edgesAdded.add(edgeKey);
|
|
299
|
+
|
|
300
|
+
const srcNode = innerEditor.getNode(srcId);
|
|
301
|
+
const tgtNode = innerEditor.getNode(tgtId);
|
|
302
|
+
if (srcNode && tgtNode) {
|
|
303
|
+
try {
|
|
304
|
+
innerEditor.addConnection(new Connection(srcNode, 'out', tgtNode, 'in'));
|
|
305
|
+
} catch { /* skip */ }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (dirSubgraph) {
|
|
311
|
+
dirSubgraphs.set(dir, dirSubgraph);
|
|
312
|
+
dirNodeMap.set(dir, dirSubgraph.id);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Phase 2: Nest child directories inside parent directories ──
|
|
317
|
+
// Root './' is not a node, so its children go directly into root editor.
|
|
318
|
+
for (const dir of sortedDirs) {
|
|
319
|
+
if (dir === './') continue; // root dir contents already in root editor
|
|
320
|
+
const dirSubgraph = dirSubgraphs.get(dir);
|
|
321
|
+
if (!dirSubgraph) continue;
|
|
322
|
+
|
|
323
|
+
// Find parent directory
|
|
324
|
+
const segments = dir.replace(/\/$/, '').split('/');
|
|
325
|
+
segments.pop();
|
|
326
|
+
|
|
327
|
+
let parentDir = null;
|
|
328
|
+
while (segments.length > 0) {
|
|
329
|
+
const candidate = segments.join('/') + '/';
|
|
330
|
+
if (dirSubgraphs.has(candidate)) {
|
|
331
|
+
parentDir = candidate;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
segments.pop();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (parentDir) {
|
|
338
|
+
// Nest inside parent's inner editor
|
|
339
|
+
const parentSubgraph = dirSubgraphs.get(parentDir);
|
|
340
|
+
parentSubgraph.getInnerEditor().addNode(dirSubgraph);
|
|
341
|
+
} else {
|
|
342
|
+
// No parent (or parent is './') → add to root editor
|
|
343
|
+
editor.addNode(dirSubgraph);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Cross-directory edges ──
|
|
348
|
+
// Edges between directories that share the same parent go into that parent's inner editor.
|
|
349
|
+
// Edges between top-level directories go into the root editor.
|
|
350
|
+
const crossEdges = new Set();
|
|
351
|
+
for (const [srcFile, sources] of Object.entries(skeleton.I || {})) {
|
|
352
|
+
const srcDir = dirOf(srcFile);
|
|
353
|
+
const srcDirId = dirNodeMap.get(srcDir);
|
|
354
|
+
if (!srcDirId) continue;
|
|
355
|
+
|
|
356
|
+
for (const impPath of sources) {
|
|
357
|
+
if (impPath.startsWith('node:') || (!impPath.startsWith('.') && !impPath.startsWith('/'))) continue;
|
|
358
|
+
const targetFile = resolveImport(impPath, srcFile, files);
|
|
359
|
+
if (!targetFile) continue;
|
|
360
|
+
|
|
361
|
+
const tgtDir = dirOf(targetFile);
|
|
362
|
+
if (tgtDir === srcDir) continue;
|
|
363
|
+
|
|
364
|
+
const tgtDirId = dirNodeMap.get(tgtDir);
|
|
365
|
+
if (!tgtDirId || tgtDirId === srcDirId) continue;
|
|
366
|
+
|
|
367
|
+
const edgeKey = `${srcDirId}->${tgtDirId}`;
|
|
368
|
+
if (crossEdges.has(edgeKey)) continue;
|
|
369
|
+
crossEdges.add(edgeKey);
|
|
370
|
+
|
|
371
|
+
// Find the common parent editor that contains BOTH directory nodes
|
|
372
|
+
// Walk up both paths to find shared ancestor
|
|
373
|
+
const srcSegments = srcDir.replace(/\/$/, '').split('/');
|
|
374
|
+
const tgtSegments = tgtDir.replace(/\/$/, '').split('/');
|
|
375
|
+
|
|
376
|
+
// Find common prefix
|
|
377
|
+
let commonLen = 0;
|
|
378
|
+
while (commonLen < srcSegments.length && commonLen < tgtSegments.length &&
|
|
379
|
+
srcSegments[commonLen] === tgtSegments[commonLen]) {
|
|
380
|
+
commonLen++;
|
|
381
|
+
}
|
|
382
|
+
const commonPath = commonLen > 0 ? srcSegments.slice(0, commonLen).join('/') + '/' : null;
|
|
383
|
+
|
|
384
|
+
// The editor that holds both nodes is the common parent's inner editor,
|
|
385
|
+
// or the root editor if they share no parent.
|
|
386
|
+
let targetEditor = editor; // default: root editor
|
|
387
|
+
if (commonPath && dirSubgraphs.has(commonPath)) {
|
|
388
|
+
targetEditor = dirSubgraphs.get(commonPath).getInnerEditor();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const srcNode = targetEditor.getNode(srcDirId);
|
|
392
|
+
const tgtNode = targetEditor.getNode(tgtDirId);
|
|
393
|
+
if (srcNode && tgtNode) {
|
|
394
|
+
try {
|
|
395
|
+
targetEditor.addConnection(new Connection(srcNode, 'out', tgtNode, 'in'));
|
|
396
|
+
} catch { /* skip */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Pre-compute inner positions for drill-down (recursive) ──
|
|
402
|
+
const symbolNodes = []; // Track internal symbol nodes for idToPath linking
|
|
403
|
+
|
|
404
|
+
function computeInnerPositions(subgraph) {
|
|
405
|
+
if (!subgraph._isSubgraph) return;
|
|
406
|
+
const inner = subgraph.getInnerEditor();
|
|
407
|
+
const innerPos = computeAutoLayout(inner, { nodeHeight: 80, gapY: 100 });
|
|
408
|
+
subgraph.setInnerPositions(innerPos);
|
|
409
|
+
|
|
410
|
+
let minX = 0, maxX = 260;
|
|
411
|
+
let minY = 0, maxY = 60;
|
|
412
|
+
|
|
413
|
+
for (const pos of Object.values(innerPos)) {
|
|
414
|
+
if (pos.x < minX) minX = pos.x;
|
|
415
|
+
if (pos.x + 260 > maxX) maxX = pos.x + 260;
|
|
416
|
+
if (pos.y < minY) minY = pos.y;
|
|
417
|
+
if (pos.y + 60 > maxY) maxY = pos.y + 60;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
subgraph.params = subgraph.params || {};
|
|
421
|
+
subgraph.params.calculatedWidth = maxX - minX + 60;
|
|
422
|
+
subgraph.params.calculatedHeight = maxY - minY + 100;
|
|
423
|
+
|
|
424
|
+
for (const childNode of inner.getNodes()) {
|
|
425
|
+
if (childNode._isSubgraph) {
|
|
426
|
+
computeInnerPositions(childNode);
|
|
427
|
+
// If it's a file node (has params.path and no isDirectory), collect symbols
|
|
428
|
+
if (childNode.params?.path && !childNode.params?.isDirectory) {
|
|
429
|
+
const fileInner = childNode.getInnerEditor();
|
|
430
|
+
for (const fnNode of fileInner.getNodes()) {
|
|
431
|
+
symbolNodes.push({ id: fnNode.id, file: childNode.params.path });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const rootNode of editor.getNodes()) {
|
|
439
|
+
computeInnerPositions(rootNode);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Build Reverse ID Lookup ──
|
|
443
|
+
const idToPath = new Map();
|
|
444
|
+
for (const [path, id] of fileMap.entries()) idToPath.set(id, path);
|
|
445
|
+
for (const [path, id] of dirNodeMap.entries()) idToPath.set(id, path);
|
|
446
|
+
for (const node of symbolNodes) idToPath.set(node.id, node.file);
|
|
447
|
+
|
|
448
|
+
return { editor, fileMap, dirFiles, dirNodeMap, idToPath, symbolMap };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Consumer-specific CSS (toolbar, stats, pin overlay) ──
|
|
452
|
+
// Node styling, chip decorations, connection strokes, frame styling
|
|
453
|
+
// are all handled by the PCB_DARK theme in the library.
|
|
454
|
+
const PCB_CSS = `
|
|
455
|
+
pg-dep-graph {
|
|
456
|
+
display: block;
|
|
457
|
+
height: 100%;
|
|
458
|
+
position: relative;
|
|
459
|
+
overflow: hidden;
|
|
460
|
+
background: var(--sn-bg, #1a1a1a);
|
|
461
|
+
/* Prevent scrollbar oscillation in parent .panel-content (overflow:auto)
|
|
462
|
+
Canvas manages its own viewport — no scrollbars needed */
|
|
463
|
+
contain: strict;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
pg-dep-graph node-canvas,
|
|
467
|
+
pg-dep-graph pg-canvas-graph {
|
|
468
|
+
width: 100%;
|
|
469
|
+
height: 100%;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* Toolbar */
|
|
473
|
+
.pcb-toolbar {
|
|
474
|
+
position: absolute;
|
|
475
|
+
top: 8px;
|
|
476
|
+
right: 8px;
|
|
477
|
+
display: flex;
|
|
478
|
+
gap: 6px;
|
|
479
|
+
z-index: 200;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.pcb-btn {
|
|
483
|
+
background: var(--sn-node-bg, #222222);
|
|
484
|
+
border: 1px solid var(--sn-node-border, rgba(255,255,255,0.12));
|
|
485
|
+
color: var(--sn-text, #e0e0e0);
|
|
486
|
+
border-radius: 3px;
|
|
487
|
+
padding: 4px 10px;
|
|
488
|
+
font-family: var(--sn-font, 'SF Mono', monospace);
|
|
489
|
+
font-size: 10px;
|
|
490
|
+
cursor: pointer;
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
gap: 4px;
|
|
494
|
+
transition: background 150ms, border-color 150ms;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.pcb-btn:hover {
|
|
498
|
+
background: var(--sn-node-hover, #2d2d2d);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.pcb-btn[data-active] {
|
|
502
|
+
border-color: var(--sn-node-selected, #d4a04a);
|
|
503
|
+
background: rgba(212, 160, 74, 0.1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
.pcb-btn .material-symbols-outlined {
|
|
507
|
+
font-size: 14px;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ── PCB Preloader Overlay ── */
|
|
511
|
+
.pcb-loader {
|
|
512
|
+
position: absolute;
|
|
513
|
+
inset: 0;
|
|
514
|
+
display: flex;
|
|
515
|
+
flex-direction: column;
|
|
516
|
+
align-items: center;
|
|
517
|
+
justify-content: center;
|
|
518
|
+
gap: 16px;
|
|
519
|
+
background: var(--sn-bg, #1a1a1a);
|
|
520
|
+
z-index: 500;
|
|
521
|
+
transition: opacity 0.3s ease-out;
|
|
522
|
+
pointer-events: none;
|
|
523
|
+
}
|
|
524
|
+
.pcb-loader[data-hidden] {
|
|
525
|
+
opacity: 0;
|
|
526
|
+
pointer-events: none;
|
|
527
|
+
}
|
|
528
|
+
.pcb-loader-logo {
|
|
529
|
+
font-family: var(--sn-font, 'SF Mono', monospace);
|
|
530
|
+
font-size: 11px;
|
|
531
|
+
letter-spacing: 0.25em;
|
|
532
|
+
color: var(--sn-text-dim, #888);
|
|
533
|
+
text-transform: uppercase;
|
|
534
|
+
}
|
|
535
|
+
.pcb-loader-phase {
|
|
536
|
+
font-family: var(--sn-font, 'SF Mono', monospace);
|
|
537
|
+
font-size: 10px;
|
|
538
|
+
color: var(--sn-node-selected, #d4a04a);
|
|
539
|
+
letter-spacing: 0.15em;
|
|
540
|
+
text-transform: uppercase;
|
|
541
|
+
min-height: 14px;
|
|
542
|
+
}
|
|
543
|
+
.pcb-loader-track {
|
|
544
|
+
width: 200px;
|
|
545
|
+
height: 2px;
|
|
546
|
+
background: rgba(255,255,255,0.08);
|
|
547
|
+
border-radius: 1px;
|
|
548
|
+
overflow: hidden;
|
|
549
|
+
}
|
|
550
|
+
.pcb-loader-bar {
|
|
551
|
+
height: 100%;
|
|
552
|
+
width: 0%;
|
|
553
|
+
background: linear-gradient(90deg, #c87533, #d4a04a);
|
|
554
|
+
border-radius: 1px;
|
|
555
|
+
transition: width 0.35s ease-out;
|
|
556
|
+
box-shadow: 0 0 8px rgba(212,160,74,0.5);
|
|
557
|
+
}
|
|
558
|
+
.pcb-loader-sub {
|
|
559
|
+
font-family: var(--sn-font, 'SF Mono', monospace);
|
|
560
|
+
font-size: 9px;
|
|
561
|
+
color: var(--sn-text-dim, #666);
|
|
562
|
+
letter-spacing: 0.08em;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.pcb-stats {
|
|
566
|
+
position: absolute;
|
|
567
|
+
bottom: 8px;
|
|
568
|
+
left: 8px;
|
|
569
|
+
display: flex;
|
|
570
|
+
gap: 12px;
|
|
571
|
+
z-index: 10;
|
|
572
|
+
font-family: var(--sn-font, 'SF Mono', monospace);
|
|
573
|
+
font-size: 10px;
|
|
574
|
+
color: var(--sn-text-dim, #888888);
|
|
575
|
+
background: rgba(26, 26, 26, 0.9);
|
|
576
|
+
padding: 4px 10px;
|
|
577
|
+
border-radius: 3px;
|
|
578
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.pcb-stat-val {
|
|
582
|
+
color: var(--sn-text, #e0e0e0);
|
|
583
|
+
font-weight: 600;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* ── Pin Labels (dep-graph-specific feature) ── */
|
|
587
|
+
.pcb-pin-overlay {
|
|
588
|
+
position: absolute;
|
|
589
|
+
top: 0;
|
|
590
|
+
left: 0;
|
|
591
|
+
width: 100%;
|
|
592
|
+
height: 100%;
|
|
593
|
+
pointer-events: none;
|
|
594
|
+
z-index: 2;
|
|
595
|
+
opacity: 0;
|
|
596
|
+
transition: opacity 0.25s ease-in-out;
|
|
597
|
+
}
|
|
598
|
+
.pcb-pin-overlay[data-visible] {
|
|
599
|
+
opacity: 1;
|
|
600
|
+
}
|
|
601
|
+
.pcb-pin {
|
|
602
|
+
position: absolute;
|
|
603
|
+
font-family: var(--sn-font, 'JetBrains Mono', monospace);
|
|
604
|
+
font-size: 8px;
|
|
605
|
+
line-height: 1;
|
|
606
|
+
white-space: nowrap;
|
|
607
|
+
color: var(--sn-text-dim, #888);
|
|
608
|
+
pointer-events: auto;
|
|
609
|
+
cursor: default;
|
|
610
|
+
}
|
|
611
|
+
.pcb-pin::before {
|
|
612
|
+
content: '';
|
|
613
|
+
position: absolute;
|
|
614
|
+
top: 50%;
|
|
615
|
+
width: 4px;
|
|
616
|
+
height: 4px;
|
|
617
|
+
background: var(--sn-conn-color, #c87533);
|
|
618
|
+
border-radius: 50%;
|
|
619
|
+
transform: translateY(-50%);
|
|
620
|
+
}
|
|
621
|
+
.pcb-pin[data-side="left"] {
|
|
622
|
+
left: -4px;
|
|
623
|
+
transform: translateX(-100%);
|
|
624
|
+
text-align: right;
|
|
625
|
+
padding-right: 8px;
|
|
626
|
+
}
|
|
627
|
+
.pcb-pin[data-side="left"]::before {
|
|
628
|
+
right: 0;
|
|
629
|
+
}
|
|
630
|
+
.pcb-pin[data-side="right"] {
|
|
631
|
+
right: -4px;
|
|
632
|
+
transform: translateX(100%);
|
|
633
|
+
text-align: left;
|
|
634
|
+
padding-left: 8px;
|
|
635
|
+
}
|
|
636
|
+
.pcb-pin[data-side="right"]::before {
|
|
637
|
+
left: 0;
|
|
638
|
+
}
|
|
639
|
+
.pcb-pin[data-kind="class"] {
|
|
640
|
+
color: var(--sn-cat-control, #d4a04a);
|
|
641
|
+
font-weight: 600;
|
|
642
|
+
}
|
|
643
|
+
.pcb-pin[data-kind="fn"] {
|
|
644
|
+
color: var(--sn-text, #e0e0e0);
|
|
645
|
+
}
|
|
646
|
+
.pcb-pin:hover {
|
|
647
|
+
color: var(--sn-node-selected, #d4a04a) !important;
|
|
648
|
+
text-shadow: 0 0 4px rgba(212, 160, 74, 0.4);
|
|
649
|
+
}
|
|
650
|
+
.pcb-pin[style*="cursor: pointer"]:hover::after {
|
|
651
|
+
content: '→';
|
|
652
|
+
margin-left: 3px;
|
|
653
|
+
font-size: 7px;
|
|
654
|
+
opacity: 0.6;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* Toolbar separator */
|
|
658
|
+
.pcb-toolbar-sep {
|
|
659
|
+
width: 1px;
|
|
660
|
+
background: rgba(255,255,255,0.1);
|
|
661
|
+
margin: 0 4px;
|
|
662
|
+
align-self: stretch;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/* Layer toggle buttons */
|
|
666
|
+
.pcb-layer-btn {
|
|
667
|
+
font-size: 9px;
|
|
668
|
+
padding: 3px 6px;
|
|
669
|
+
opacity: 0.7;
|
|
670
|
+
}
|
|
671
|
+
.pcb-layer-btn[data-active] {
|
|
672
|
+
opacity: 1;
|
|
673
|
+
}
|
|
674
|
+
.pcb-layer-btn[data-hidden] {
|
|
675
|
+
opacity: 0.3;
|
|
676
|
+
text-decoration: line-through;
|
|
677
|
+
}
|
|
678
|
+
`;
|
|
679
|
+
|
|
680
|
+
export class DepGraph extends Symbiote {
|
|
681
|
+
init$ = {};
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
/** @type {NodeEditor|null} */
|
|
685
|
+
_editor = null;
|
|
686
|
+
/** @type {boolean} Tracks whether a node was dragged (suppresses click-to-focus) */
|
|
687
|
+
_wasDragged = false;
|
|
688
|
+
/** @type {Map<string, string>} */
|
|
689
|
+
_fileMap = new Map();
|
|
690
|
+
/** @type {boolean} */
|
|
691
|
+
_autopilot = false;
|
|
692
|
+
/** @type {HTMLElement|null} */
|
|
693
|
+
_canvas = null;
|
|
694
|
+
/** @type {object|null} Skeleton data for resolving pin names */
|
|
695
|
+
_skeleton = null;
|
|
696
|
+
/** @type {SubgraphRouter} */
|
|
697
|
+
_router = null;
|
|
698
|
+
/** @type {PinExpansion} */
|
|
699
|
+
_pinExpansion = null;
|
|
700
|
+
/** @type {LODManager} */
|
|
701
|
+
_lodManager = null;
|
|
702
|
+
/** @type {boolean} Guard against duplicate graph builds */
|
|
703
|
+
_graphBuilt = false;
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Update the PCB preloader overlay
|
|
707
|
+
* @param {number} pct - 0-100 progress percent
|
|
708
|
+
* @param {string} phase - phase label
|
|
709
|
+
* @param {string} [sub] - optional subtitle
|
|
710
|
+
*/
|
|
711
|
+
_setProgress(pct, phase, sub = '') {
|
|
712
|
+
const loader = this.querySelector('#pcb-loader');
|
|
713
|
+
if (!loader) return;
|
|
714
|
+
const bar = loader.querySelector('#pcb-loader-bar');
|
|
715
|
+
const phaseEl = loader.querySelector('#pcb-loader-phase');
|
|
716
|
+
const subEl = loader.querySelector('#pcb-loader-sub');
|
|
717
|
+
if (bar) bar.style.width = `${pct}%`;
|
|
718
|
+
if (phaseEl) phaseEl.textContent = phase;
|
|
719
|
+
if (subEl) subEl.textContent = sub;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Hide the PCB preloader overlay */
|
|
723
|
+
_hideLoader() {
|
|
724
|
+
const loader = this.querySelector('#pcb-loader');
|
|
725
|
+
if (!loader) return;
|
|
726
|
+
loader.setAttribute('data-hidden', '');
|
|
727
|
+
// Remove from DOM after fade to avoid blocking pointer events
|
|
728
|
+
setTimeout(() => loader.remove(), 350);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Show (or re-show) the PCB preloader overlay — safe to call multiple times */
|
|
732
|
+
_showLoader() {
|
|
733
|
+
// Remove any stale loader first
|
|
734
|
+
this.querySelector('#pcb-loader')?.remove();
|
|
735
|
+
const loader = document.createElement('div');
|
|
736
|
+
loader.className = 'pcb-loader';
|
|
737
|
+
loader.id = 'pcb-loader';
|
|
738
|
+
loader.innerHTML = `
|
|
739
|
+
<div class="pcb-loader-logo">Project Graph</div>
|
|
740
|
+
<div class="pcb-loader-phase" id="pcb-loader-phase">Initializing…</div>
|
|
741
|
+
<div class="pcb-loader-track">
|
|
742
|
+
<div class="pcb-loader-bar" id="pcb-loader-bar"></div>
|
|
743
|
+
</div>
|
|
744
|
+
<div class="pcb-loader-sub" id="pcb-loader-sub"></div>
|
|
745
|
+
`;
|
|
746
|
+
// Insert before node-canvas so it renders on top
|
|
747
|
+
const canvas = this.querySelector('node-canvas');
|
|
748
|
+
if (canvas) {
|
|
749
|
+
this.insertBefore(loader, canvas);
|
|
750
|
+
} else {
|
|
751
|
+
this.appendChild(loader);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
initCallback() {
|
|
756
|
+
// Build DOM
|
|
757
|
+
this.innerHTML = `
|
|
758
|
+
<div class="pcb-toolbar">
|
|
759
|
+
<button class="pcb-btn" data-action="fit" title="Fit view">
|
|
760
|
+
<span class="material-symbols-outlined">fit_screen</span>
|
|
761
|
+
FIT
|
|
762
|
+
</button>
|
|
763
|
+
<div class="pcb-toolbar-sep"></div>
|
|
764
|
+
<button class="pcb-btn label-mode-btn pcb-structured-only" data-mode="always" data-active title="Always show labels">LBL:ALW</button>
|
|
765
|
+
<button class="pcb-btn label-mode-btn pcb-structured-only" data-mode="hover" title="Hover labels">LBL:HOV</button>
|
|
766
|
+
<button class="pcb-btn label-mode-btn pcb-structured-only" data-mode="focus" title="Focus labels">LBL:FOC</button>
|
|
767
|
+
<div class="pcb-toolbar-sep pcb-structured-only"></div>
|
|
768
|
+
<button class="pcb-btn pcb-layer-btn pcb-structured-only" data-layer="zones" data-active title="Toggle directory zones">ZONES</button>
|
|
769
|
+
<button class="pcb-btn pcb-layer-btn pcb-structured-only" data-layer="vias" data-active title="Toggle via markers">VIAS</button>
|
|
770
|
+
<div class="pcb-toolbar-sep"></div>
|
|
771
|
+
<button class="pcb-btn" data-action="view-mode" title="Toggle view: Flat ↔ Structured">
|
|
772
|
+
<span class="material-symbols-outlined">account_tree</span>
|
|
773
|
+
FLAT
|
|
774
|
+
</button>
|
|
775
|
+
<button class="pcb-btn pcb-structured-only" data-action="path-style" title="Toggle lines: PCB ↔ Bezier">
|
|
776
|
+
<span class="material-symbols-outlined">route</span>
|
|
777
|
+
PCB
|
|
778
|
+
</button>
|
|
779
|
+
</div>
|
|
780
|
+
<node-canvas connection-engine="canvas"></node-canvas>
|
|
781
|
+
<pg-canvas-graph></pg-canvas-graph>
|
|
782
|
+
<div class="pcb-stats"></div>
|
|
783
|
+
`;
|
|
784
|
+
|
|
785
|
+
this._canvas = this.querySelector('node-canvas');
|
|
786
|
+
this._pgCanvasGraph = this.querySelector('pg-canvas-graph');
|
|
787
|
+
|
|
788
|
+
// Toolbar handlers
|
|
789
|
+
this.querySelector('[data-action="fit"]').addEventListener('click', () => {
|
|
790
|
+
if (this._viewMode === 'flat') {
|
|
791
|
+
this._pgCanvasGraph?.resetView();
|
|
792
|
+
} else {
|
|
793
|
+
this._canvas?.fitView();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
this._pgCanvasGraph.addEventListener('path-changed', (e) => {
|
|
798
|
+
if (this._viewMode === 'flat' && this._initialViewRestored) {
|
|
799
|
+
const path = e.detail.path;
|
|
800
|
+
const hash = path ? `#graph/${path}` : `#graph`;
|
|
801
|
+
// Preserve mode query param but clear focus= when returning to root
|
|
802
|
+
let searchStr = window.location.hash.includes('?') ? '?' + window.location.hash.split('?')[1] : '';
|
|
803
|
+
if (!path && searchStr) {
|
|
804
|
+
// Remove focus param when exiting to root
|
|
805
|
+
const params = new URLSearchParams(searchStr.slice(1));
|
|
806
|
+
params.delete('focus');
|
|
807
|
+
searchStr = params.toString() ? '?' + params.toString() : '';
|
|
808
|
+
}
|
|
809
|
+
history.replaceState(null, '', hash + searchStr);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
this._pgCanvasGraph.addEventListener('file-selected', (e) => {
|
|
814
|
+
const path = e.detail.path;
|
|
815
|
+
// Update URL with focus= so it's bookmarkable
|
|
816
|
+
this._updateHashParam('focus', path);
|
|
817
|
+
emit('file-selected', { path, source: 'canvas' });
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
this._pgCanvasGraph.addEventListener('group-selected', (e) => {
|
|
821
|
+
const path = e.detail.path;
|
|
822
|
+
// Update URL with focus= so group selection is also bookmarkable in flat mode
|
|
823
|
+
this._updateHashParam('focus', path);
|
|
824
|
+
// Sync: highlight directory in the tree sidebar (add trailing / for dir convention)
|
|
825
|
+
emit('file-selected', { path: path + '/', source: 'canvas' });
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// Deselect in flat mode: clear focus= when clicking empty space
|
|
829
|
+
this._pgCanvasGraph.addEventListener('node-deselected', () => {
|
|
830
|
+
if (!this._initialViewRestored) return;
|
|
831
|
+
if (window.location.hash.includes('focus=')) {
|
|
832
|
+
this._updateHashParam('focus', null);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// Flat mode init: fitView early after a few ticks (not waiting for full convergence)
|
|
837
|
+
// In continuous mode, layout-done can take seconds. Positions are usable after ~10 ticks.
|
|
838
|
+
this._flatTickCount = 0;
|
|
839
|
+
this._pgCanvasGraph.addEventListener('layout-tick', () => {
|
|
840
|
+
if (this._viewMode !== 'flat' || this._initialViewRestored) return;
|
|
841
|
+
this._flatTickCount++;
|
|
842
|
+
if (this._flatTickCount >= 10) {
|
|
843
|
+
this._initialViewRestored = true;
|
|
844
|
+
this._restoreFlatFocus();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
// Fallback: also listen for layout-done in case continuous mode is disabled
|
|
848
|
+
this._pgCanvasGraph.addEventListener('layout-done', () => {
|
|
849
|
+
if (this._viewMode === 'flat' && !this._initialViewRestored) {
|
|
850
|
+
this._initialViewRestored = true;
|
|
851
|
+
this._restoreFlatFocus();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Follow mode: listen for global state (set from topbar)
|
|
856
|
+
events.addEventListener('follow-mode-changed', (e) => {
|
|
857
|
+
this._autopilot = e.detail.enabled;
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Label Mode controls
|
|
861
|
+
const labelBtns = this.querySelectorAll('.label-mode-btn');
|
|
862
|
+
labelBtns.forEach(btn => {
|
|
863
|
+
btn.addEventListener('click', (e) => {
|
|
864
|
+
labelBtns.forEach(b => b.removeAttribute('data-active'));
|
|
865
|
+
btn.setAttribute('data-active', '');
|
|
866
|
+
const mode = btn.getAttribute('data-mode');
|
|
867
|
+
this._canvas.setAttribute('data-label-mode', mode);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Phase 3: Layer toggle controls
|
|
872
|
+
this.querySelectorAll('.pcb-layer-btn').forEach(btn => {
|
|
873
|
+
btn.addEventListener('click', () => {
|
|
874
|
+
const layer = btn.getAttribute('data-layer');
|
|
875
|
+
const isActive = btn.hasAttribute('data-active');
|
|
876
|
+
if (isActive) {
|
|
877
|
+
btn.removeAttribute('data-active');
|
|
878
|
+
btn.setAttribute('data-hidden', '');
|
|
879
|
+
} else {
|
|
880
|
+
btn.setAttribute('data-active', '');
|
|
881
|
+
btn.removeAttribute('data-hidden');
|
|
882
|
+
}
|
|
883
|
+
this._toggleLayer(layer, !isActive);
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const searchStr = window.location.search || (window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '');
|
|
888
|
+
const urlParams = new URLSearchParams(searchStr);
|
|
889
|
+
// Support both ?mode=flat and legacy ?flat=true
|
|
890
|
+
const modeParam = urlParams.get('mode') || (urlParams.get('flat') === 'true' ? 'flat' : null);
|
|
891
|
+
this._viewMode = modeParam === 'flat' ? 'flat' : 'structured';
|
|
892
|
+
const viewModeBtn = this.querySelector('[data-action="view-mode"]');
|
|
893
|
+
if (viewModeBtn) {
|
|
894
|
+
const icon = modeParam === 'flat' ? 'account_tree' : 'grid_view';
|
|
895
|
+
const text = modeParam === 'flat' ? 'FLAT' : 'TREE';
|
|
896
|
+
viewModeBtn.innerHTML = `<span class="material-symbols-outlined">${icon}</span>${text}`;
|
|
897
|
+
if (modeParam === 'flat') {
|
|
898
|
+
viewModeBtn.removeAttribute('data-active');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// Hide structured-only buttons in flat mode
|
|
902
|
+
this._updateStructuredOnlyVisibility(this._viewMode);
|
|
903
|
+
viewModeBtn?.addEventListener('click', () => {
|
|
904
|
+
const wantFlat = this._viewMode !== 'flat';
|
|
905
|
+
this._viewMode = wantFlat ? 'flat' : 'structured';
|
|
906
|
+
const label = this._viewMode === 'flat' ? 'FLAT' : 'TREE';
|
|
907
|
+
const icon = this._viewMode === 'flat' ? 'account_tree' : 'grid_view';
|
|
908
|
+
viewModeBtn.innerHTML = `<span class="material-symbols-outlined">${icon}</span>${label}`;
|
|
909
|
+
if (this._viewMode === 'structured') {
|
|
910
|
+
viewModeBtn.setAttribute('data-active', '');
|
|
911
|
+
} else {
|
|
912
|
+
viewModeBtn.removeAttribute('data-active');
|
|
913
|
+
}
|
|
914
|
+
this._updateStructuredOnlyVisibility(this._viewMode);
|
|
915
|
+
|
|
916
|
+
// Persist mode in URL hash
|
|
917
|
+
this._updateHashParam('mode', this._viewMode === 'flat' ? 'flat' : 'tree');
|
|
918
|
+
|
|
919
|
+
// Drill up to root before rebuilding to prevent rendering
|
|
920
|
+
// the full graph on top of a stale subgraph canvas state
|
|
921
|
+
if (this._router?.depth > 0) {
|
|
922
|
+
this._canvas.drillUp?.(0);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Rebuild graph in new mode
|
|
926
|
+
this._graphBuilt = false;
|
|
927
|
+
this._initialViewRestored = false;
|
|
928
|
+
if (this._failsafeTimer) { clearTimeout(this._failsafeTimer); this._failsafeTimer = null; }
|
|
929
|
+
this._showLoader();
|
|
930
|
+
if (state.skeleton) {
|
|
931
|
+
this._buildGraph(state.skeleton);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// Connection Path Style toggling
|
|
936
|
+
const pathStyleBtn = this.querySelector('[data-action="path-style"]');
|
|
937
|
+
if (pathStyleBtn) {
|
|
938
|
+
let currentStyle = urlParams.get('style') || window.localStorage.getItem('connection-style') || 'pcb';
|
|
939
|
+
const styles = ['pcb', 'bezier', 'orthogonal', 'straight'];
|
|
940
|
+
|
|
941
|
+
const updateStyleUI = () => {
|
|
942
|
+
let icon, text;
|
|
943
|
+
switch(currentStyle) {
|
|
944
|
+
case 'bezier': icon = 'timeline'; text = 'BEZIER'; break;
|
|
945
|
+
case 'orthogonal': icon = 'polyline'; text = 'ORTHO'; break;
|
|
946
|
+
case 'straight': icon = 'horizontal_rule'; text = 'STRAIGHT'; break;
|
|
947
|
+
case 'pcb':
|
|
948
|
+
default:
|
|
949
|
+
icon = 'route'; text = 'PCB'; break;
|
|
950
|
+
}
|
|
951
|
+
pathStyleBtn.innerHTML = `<span class="material-symbols-outlined">${icon}</span>${text}`;
|
|
952
|
+
if (currentStyle === 'pcb') {
|
|
953
|
+
pathStyleBtn.setAttribute('data-active', '');
|
|
954
|
+
} else {
|
|
955
|
+
pathStyleBtn.removeAttribute('data-active');
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
updateStyleUI();
|
|
959
|
+
|
|
960
|
+
pathStyleBtn.addEventListener('click', () => {
|
|
961
|
+
const idx = styles.indexOf(currentStyle);
|
|
962
|
+
currentStyle = styles[(idx + 1) % styles.length] || 'pcb';
|
|
963
|
+
window.localStorage.setItem('connection-style', currentStyle);
|
|
964
|
+
this._canvas.setPathStyle(currentStyle);
|
|
965
|
+
updateStyleUI();
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Apply PCB theme
|
|
970
|
+
applyTheme(this._canvas, PCB_DARK);
|
|
971
|
+
|
|
972
|
+
// Setup ResizeObserver to gracefully handle "Layout preserved" (display: none) hidden panels.
|
|
973
|
+
// Prevents building graphs while they have 0 width/height, dodging layout thrashing & 50,000+ DOM mutations
|
|
974
|
+
const ro = new ResizeObserver((entries) => {
|
|
975
|
+
const rect = entries[0].contentRect;
|
|
976
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
977
|
+
if (!this._graphBuilt && state.skeleton) {
|
|
978
|
+
// Wrap in rAF to prevent loop if ResizeObserver caught mid-render
|
|
979
|
+
requestAnimationFrame(() => this._buildGraph(state.skeleton));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
ro.observe(this);
|
|
984
|
+
this._resizeObserver = ro;
|
|
985
|
+
|
|
986
|
+
// Bind and save global listener functions for clean up
|
|
987
|
+
this._onSkeletonLoaded = (e) => {
|
|
988
|
+
if (this._graphBuilt || this.style.display === 'none' || this.offsetWidth === 0) return;
|
|
989
|
+
requestAnimationFrame(() => this._buildGraph(e.detail));
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
this._onToolEvent = (e) => {
|
|
993
|
+
if (this.style.display === 'none' || this.offsetWidth === 0) return;
|
|
994
|
+
if (this._autopilot) {
|
|
995
|
+
this._handleAutopilot(e.detail);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
this._onFileSelected = (e) => {
|
|
1000
|
+
if (this.style.display === 'none' || this.offsetWidth === 0) return;
|
|
1001
|
+
if (e.detail.source === 'canvas') return; // Prevent echo from our own clicks
|
|
1002
|
+
const file = e.detail.path;
|
|
1003
|
+
if (file) {
|
|
1004
|
+
this._updateHashParam('focus', file);
|
|
1005
|
+
// Strip trailing slash for directory paths — canvas-graph stores dirs without trailing /
|
|
1006
|
+
const nodeId = file.endsWith('/') ? file.replace(/\/$/, '') : file;
|
|
1007
|
+
if (this._viewMode === 'flat' && this._pgCanvasGraph) {
|
|
1008
|
+
this._pgCanvasGraph.flyToNode(nodeId);
|
|
1009
|
+
} else {
|
|
1010
|
+
this._router?.navigateTo(file);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// Wait for canvas to initialize, then listen for data
|
|
1016
|
+
events.addEventListener('skeleton-loaded', this._onSkeletonLoaded);
|
|
1017
|
+
|
|
1018
|
+
// Initial fetch if we don't have it
|
|
1019
|
+
if (!state.skeleton) {
|
|
1020
|
+
// Self-fetch skeleton (graph panel may mount before FileTree)
|
|
1021
|
+
api('/api/skeleton', {}).then((skeleton) => {
|
|
1022
|
+
if (skeleton && !this._graphBuilt) {
|
|
1023
|
+
state.skeleton = skeleton;
|
|
1024
|
+
emit('skeleton-loaded', skeleton);
|
|
1025
|
+
}
|
|
1026
|
+
}).catch(() => {});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Autopilot: listen for agent tool events
|
|
1030
|
+
events.addEventListener('tool-event', this._onToolEvent);
|
|
1031
|
+
|
|
1032
|
+
// Update route within graph section
|
|
1033
|
+
// On node click → save file path (just focusing)
|
|
1034
|
+
this._canvas?.addEventListener('click', (e) => {
|
|
1035
|
+
const nodeEl = e.target.closest('graph-node');
|
|
1036
|
+
if (!nodeEl) return;
|
|
1037
|
+
|
|
1038
|
+
// Skip click-to-focus if this click came from a drag-end
|
|
1039
|
+
if (this._wasDragged) {
|
|
1040
|
+
this._wasDragged = false;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const nodeId = nodeEl.getAttribute('node-id');
|
|
1045
|
+
const path = this._idToPath?.get(nodeId);
|
|
1046
|
+
const isSymbol = this._symbolMap?.has(nodeId);
|
|
1047
|
+
const depth = this._router?.depth || 0;
|
|
1048
|
+
|
|
1049
|
+
if (isSymbol) {
|
|
1050
|
+
// Symbol click: keep current drill URL, append &symbol=
|
|
1051
|
+
const sym = this._symbolMap.get(nodeId);
|
|
1052
|
+
this._updateHashParam('symbol', sym.name);
|
|
1053
|
+
// Highlight the parent file in the tree sidebar
|
|
1054
|
+
if (sym.file) {
|
|
1055
|
+
emit('file-selected', { path: sym.file, source: 'canvas' });
|
|
1056
|
+
}
|
|
1057
|
+
} else if (path) {
|
|
1058
|
+
if (depth === 0) {
|
|
1059
|
+
// Root level: path goes into ?focus= parameter
|
|
1060
|
+
this._updateHashParam('focus', path);
|
|
1061
|
+
this._updateHashParam('in', null);
|
|
1062
|
+
// Pan/zoom to the clicked node — it's already visible, no need to drill
|
|
1063
|
+
if (nodeId && this._canvas?.flyToNode) {
|
|
1064
|
+
this._canvas.flyToNode(nodeId, { zoom: 0.9 });
|
|
1065
|
+
}
|
|
1066
|
+
} else {
|
|
1067
|
+
// Inside a group: preserve drill context URL, set &focus= with relative name
|
|
1068
|
+
const drillBase = window.location.hash.split('?')[0]; // e.g. #graph/src/analysis/
|
|
1069
|
+
const drillPath = drillBase.replace('#graph/', '');
|
|
1070
|
+
// Get relative name inside the drilled group
|
|
1071
|
+
const relativeName = path.startsWith(drillPath) ? path.slice(drillPath.length) : path;
|
|
1072
|
+
// Keep existing parameters except we update focus and ensure in=1 is set
|
|
1073
|
+
this._updateHashParam('focus', relativeName);
|
|
1074
|
+
this._updateHashParam('in', '1');
|
|
1075
|
+
|
|
1076
|
+
// Pan/zoom to the clicked node within current subgraph
|
|
1077
|
+
if (nodeId && this._canvas?.flyToNode) {
|
|
1078
|
+
this._canvas.flyToNode(nodeId, { zoom: 0.9 });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Sync: highlight file in the tree sidebar
|
|
1082
|
+
emit('file-selected', { path, source: 'canvas' });
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
// Deselect: when no nodes selected → clear focus from URL
|
|
1088
|
+
this._canvas?.addEventListener('selection-changed', (e) => {
|
|
1089
|
+
if (e.detail.nodes.length > 0) return; // Still has selection
|
|
1090
|
+
if (!this._initialViewRestored) return; // Don't clear URL during initial load
|
|
1091
|
+
const hash = window.location.hash;
|
|
1092
|
+
if (hash.includes('focus=')) {
|
|
1093
|
+
this._updateHashParam('focus', null);
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Toolbar custom actions (e.g. explore, view-code, enter)
|
|
1098
|
+
this.addEventListener('toolbar-action', (e) => {
|
|
1099
|
+
const { action, nodeId } = e.detail;
|
|
1100
|
+
if (action === 'explore') {
|
|
1101
|
+
if (this._viewMode === 'flat') {
|
|
1102
|
+
this._pgCanvasGraph?.flyToNode(nodeId);
|
|
1103
|
+
} else {
|
|
1104
|
+
this._exploreFromNode(nodeId);
|
|
1105
|
+
}
|
|
1106
|
+
} else if (action === 'view-code') {
|
|
1107
|
+
let file;
|
|
1108
|
+
if (this._viewMode === 'flat') {
|
|
1109
|
+
file = nodeId;
|
|
1110
|
+
} else {
|
|
1111
|
+
const path = this._idToPath?.get(nodeId);
|
|
1112
|
+
const isSymbol = this._symbolMap?.has(nodeId);
|
|
1113
|
+
file = isSymbol ? this._symbolMap.get(nodeId).file : path;
|
|
1114
|
+
}
|
|
1115
|
+
if (file) {
|
|
1116
|
+
window.location.hash = `#explorer/${file}`;
|
|
1117
|
+
}
|
|
1118
|
+
} else if (action === 'enter') {
|
|
1119
|
+
if (this._viewMode === 'flat' && this._pgCanvasGraph) {
|
|
1120
|
+
this._pgCanvasGraph.drill(nodeId);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
events.addEventListener('file-selected', this._onFileSelected);
|
|
1126
|
+
|
|
1127
|
+
// React to hash changes from file-tree, back/forward, or external URL paste
|
|
1128
|
+
this._onHashChange = () => {
|
|
1129
|
+
const hash = window.location.hash;
|
|
1130
|
+
if (!hash.startsWith('#graph')) return;
|
|
1131
|
+
|
|
1132
|
+
if (this._viewMode === 'flat') {
|
|
1133
|
+
const [hashBase, queryStr] = hash.replace('#', '').split('?');
|
|
1134
|
+
const hashParams = hashBase.split('/');
|
|
1135
|
+
if (hashParams[0] === 'graph') hashParams.shift();
|
|
1136
|
+
const pathStr = hashParams.join('/');
|
|
1137
|
+
if (this._pgCanvasGraph) this._pgCanvasGraph.setPath(pathStr);
|
|
1138
|
+
// Parse and apply focus= parameter
|
|
1139
|
+
if (queryStr) {
|
|
1140
|
+
const params = new URLSearchParams(queryStr);
|
|
1141
|
+
const focusParam = params.get('focus');
|
|
1142
|
+
if (focusParam && this._pgCanvasGraph) {
|
|
1143
|
+
this._pgCanvasGraph.flyToNode(decodeURIComponent(focusParam));
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (!this._router || !this._editor || !this._initialViewRestored) return;
|
|
1150
|
+
|
|
1151
|
+
let attempts = 0;
|
|
1152
|
+
const doRestore = () => {
|
|
1153
|
+
if (this.offsetWidth > 0 && this.offsetHeight > 0) {
|
|
1154
|
+
this._router.restoreFromHash(this._editor);
|
|
1155
|
+
} else if (attempts < 20) {
|
|
1156
|
+
attempts++;
|
|
1157
|
+
requestAnimationFrame(doRestore);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
doRestore();
|
|
1161
|
+
};
|
|
1162
|
+
window.addEventListener('hashchange', this._onHashChange);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
disconnectedCallback() {
|
|
1166
|
+
super.disconnectedCallback?.();
|
|
1167
|
+
if (this._onSkeletonLoaded) events.removeEventListener('skeleton-loaded', this._onSkeletonLoaded);
|
|
1168
|
+
if (this._onToolEvent) events.removeEventListener('tool-event', this._onToolEvent);
|
|
1169
|
+
if (this._onFileSelected) events.removeEventListener('file-selected', this._onFileSelected);
|
|
1170
|
+
if (this._onHashChange) window.removeEventListener('hashchange', this._onHashChange);
|
|
1171
|
+
if (this._resizeObserver) {
|
|
1172
|
+
this._resizeObserver.disconnect();
|
|
1173
|
+
this._resizeObserver = null;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Count total files in skeleton (quick, no graph construction)
|
|
1179
|
+
* @param {object} skeleton
|
|
1180
|
+
* @returns {number}
|
|
1181
|
+
*/
|
|
1182
|
+
_countSkeletonFiles(skeleton) {
|
|
1183
|
+
const files = new Set();
|
|
1184
|
+
for (const data of Object.values(skeleton.n || {})) if (data.f) files.add(data.f);
|
|
1185
|
+
for (const file of Object.keys(skeleton.X || {})) files.add(file);
|
|
1186
|
+
for (const [dir, names] of Object.entries(skeleton.f || {}))
|
|
1187
|
+
for (const name of names) files.add(dir === './' ? name : dir + name);
|
|
1188
|
+
for (const [dir, names] of Object.entries(skeleton.a || {}))
|
|
1189
|
+
for (const name of names) files.add(dir === './' ? name : dir + name);
|
|
1190
|
+
return files.size;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Restore focus= from URL in flat mode (where SubgraphRouter is not available).
|
|
1195
|
+
* Parses the hash, extracts path drill + focus param, and calls flyToNode.
|
|
1196
|
+
*/
|
|
1197
|
+
_restoreFlatFocus() {
|
|
1198
|
+
const hash = window.location.hash;
|
|
1199
|
+
const [hashBase, queryStr] = hash.replace('#', '').split('?');
|
|
1200
|
+
|
|
1201
|
+
// Apply drill path if present: #graph/src/core/ → setPath('src/core')
|
|
1202
|
+
const hashParams = hashBase.split('/');
|
|
1203
|
+
if (hashParams[0] === 'graph') hashParams.shift();
|
|
1204
|
+
const pathStr = hashParams.join('/');
|
|
1205
|
+
if (pathStr && this._pgCanvasGraph) {
|
|
1206
|
+
this._pgCanvasGraph.setPath(pathStr);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Apply focus= if present
|
|
1210
|
+
if (queryStr) {
|
|
1211
|
+
const params = new URLSearchParams(queryStr);
|
|
1212
|
+
const focusParam = params.get('focus');
|
|
1213
|
+
if (focusParam && this._pgCanvasGraph) {
|
|
1214
|
+
const decoded = decodeURIComponent(focusParam);
|
|
1215
|
+
// Skip if focus target is the same group we just drilled into
|
|
1216
|
+
const focusClean = decoded.replace(/\/$/, '');
|
|
1217
|
+
if (focusClean === pathStr) {
|
|
1218
|
+
// We're already inside this group — just fit the view
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
requestAnimationFrame(() => {
|
|
1222
|
+
this._pgCanvasGraph.flyToNode(decoded);
|
|
1223
|
+
});
|
|
1224
|
+
// Check if focus target is a group (directory) — tree uses trailing / for dirs
|
|
1225
|
+
const graphNode = this._pgCanvasGraph.graphDB?.nodes.get(decoded);
|
|
1226
|
+
const treePath = (graphNode && graphNode.isGroup) ? decoded + '/' : decoded;
|
|
1227
|
+
// Sync tree sidebar
|
|
1228
|
+
state.activeFile = treePath;
|
|
1229
|
+
emit('file-selected', { path: treePath, source: 'canvas' });
|
|
1230
|
+
setTimeout(() => emit('file-selected', { path: treePath, source: 'canvas' }), 500);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// No focus param — fit the full view
|
|
1236
|
+
if (this._pgCanvasGraph) {
|
|
1237
|
+
this._pgCanvasGraph.fitView?.();
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Update a single URL hash parameter without page reload.
|
|
1243
|
+
* Preserves existing hash path and other params.
|
|
1244
|
+
* @param {string} key - Parameter name (e.g. 'mode', 'focus')
|
|
1245
|
+
* @param {string|null} value - Parameter value, null to remove
|
|
1246
|
+
*/
|
|
1247
|
+
/**
|
|
1248
|
+
* Show/hide toolbar buttons that only apply in structured mode.
|
|
1249
|
+
* @param {string} mode - 'flat' or 'structured'
|
|
1250
|
+
*/
|
|
1251
|
+
_updateStructuredOnlyVisibility(mode) {
|
|
1252
|
+
const hide = mode === 'flat';
|
|
1253
|
+
this.querySelectorAll('.pcb-structured-only').forEach(el => {
|
|
1254
|
+
el.style.display = hide ? 'none' : '';
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
_updateHashParam(key, value) {
|
|
1259
|
+
const hash = window.location.hash;
|
|
1260
|
+
const [basePath, queryStr] = hash.split('?');
|
|
1261
|
+
const params = new URLSearchParams(queryStr || '');
|
|
1262
|
+
if (value === null || value === undefined) {
|
|
1263
|
+
params.delete(key);
|
|
1264
|
+
} else {
|
|
1265
|
+
params.set(key, value);
|
|
1266
|
+
}
|
|
1267
|
+
const newQuery = params.toString();
|
|
1268
|
+
const newHash = newQuery ? `${basePath}?${newQuery}` : basePath;
|
|
1269
|
+
history.replaceState(null, '', newHash);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Detect disconnected components in the graph and stack smaller ones
|
|
1274
|
+
* compactly below the main cluster. Without this, outlier chains
|
|
1275
|
+
* (e.g. android.py) stretch BBox 10x+ beyond the main cluster.
|
|
1276
|
+
* @param {NodeEditor} editor
|
|
1277
|
+
* @param {Object} positions - {nodeId: {x, y}}
|
|
1278
|
+
* @returns {Object} compacted positions
|
|
1279
|
+
*/
|
|
1280
|
+
_compactDisconnectedComponents(editor, positions) {
|
|
1281
|
+
const nodes = editor.getNodes();
|
|
1282
|
+
const conns = editor.getConnections();
|
|
1283
|
+
if (nodes.length < 2) return positions;
|
|
1284
|
+
|
|
1285
|
+
// Build adjacency list (undirected)
|
|
1286
|
+
const adj = new Map();
|
|
1287
|
+
for (const n of nodes) adj.set(n.id, []);
|
|
1288
|
+
for (const c of conns) {
|
|
1289
|
+
if (adj.has(c.from)) adj.get(c.from).push(c.to);
|
|
1290
|
+
if (adj.has(c.to)) adj.get(c.to).push(c.from);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// BFS to find connected components
|
|
1294
|
+
const visited = new Set();
|
|
1295
|
+
const components = [];
|
|
1296
|
+
for (const n of nodes) {
|
|
1297
|
+
if (visited.has(n.id)) continue;
|
|
1298
|
+
const component = [];
|
|
1299
|
+
const queue = [n.id];
|
|
1300
|
+
visited.add(n.id);
|
|
1301
|
+
while (queue.length > 0) {
|
|
1302
|
+
const id = queue.shift();
|
|
1303
|
+
component.push(id);
|
|
1304
|
+
for (const neighbor of (adj.get(id) || [])) {
|
|
1305
|
+
if (!visited.has(neighbor)) {
|
|
1306
|
+
visited.add(neighbor);
|
|
1307
|
+
queue.push(neighbor);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
components.push(component);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Only one component — nothing to compact
|
|
1315
|
+
if (components.length <= 1) return positions;
|
|
1316
|
+
|
|
1317
|
+
// Sort by size desc — largest is the main cluster
|
|
1318
|
+
components.sort((a, b) => b.length - a.length);
|
|
1319
|
+
|
|
1320
|
+
// Compute bounding box for each component
|
|
1321
|
+
const GAP = 200; // gap between stacked components
|
|
1322
|
+
const bboxes = components.map(comp => {
|
|
1323
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1324
|
+
for (const id of comp) {
|
|
1325
|
+
const p = positions[id];
|
|
1326
|
+
if (!p) continue;
|
|
1327
|
+
if (p.x < minX) minX = p.x;
|
|
1328
|
+
if (p.y < minY) minY = p.y;
|
|
1329
|
+
if (p.x + 180 > maxX) maxX = p.x + 180; // approx node width
|
|
1330
|
+
if (p.y + 60 > maxY) maxY = p.y + 60; // approx node height
|
|
1331
|
+
}
|
|
1332
|
+
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
// Main cluster stays in place. Stack others below it.
|
|
1336
|
+
const mainBBox = bboxes[0];
|
|
1337
|
+
let cursorY = mainBBox.maxY + GAP;
|
|
1338
|
+
|
|
1339
|
+
for (let i = 1; i < components.length; i++) {
|
|
1340
|
+
const comp = components[i];
|
|
1341
|
+
const bbox = bboxes[i];
|
|
1342
|
+
if (bbox.minX === Infinity) continue; // no positions
|
|
1343
|
+
|
|
1344
|
+
// Offset: shift to align left with main cluster, below current cursor
|
|
1345
|
+
const dx = mainBBox.minX - bbox.minX;
|
|
1346
|
+
const dy = cursorY - bbox.minY;
|
|
1347
|
+
|
|
1348
|
+
for (const id of comp) {
|
|
1349
|
+
if (positions[id]) {
|
|
1350
|
+
positions[id] = {
|
|
1351
|
+
x: positions[id].x + dx,
|
|
1352
|
+
y: positions[id].y + dy,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
cursorY += bbox.h + GAP;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
return positions;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Start radial exploration from a focus node.
|
|
1364
|
+
* Shows the node at center with imports (left) and dependents (right).
|
|
1365
|
+
* @param {string} nodeId
|
|
1366
|
+
*/
|
|
1367
|
+
_exploreFromNode(nodeId) {
|
|
1368
|
+
const editor = this._editor;
|
|
1369
|
+
if (!editor || !this._canvas) return;
|
|
1370
|
+
|
|
1371
|
+
const conns = editor.getConnections();
|
|
1372
|
+
const nodePath = this._idToPath?.get(nodeId) || nodeId;
|
|
1373
|
+
|
|
1374
|
+
// Find imports (outgoing from this node) and dependents (incoming to this node)
|
|
1375
|
+
const imports = []; // files this node imports
|
|
1376
|
+
const dependents = []; // files that import this node
|
|
1377
|
+
|
|
1378
|
+
for (const c of conns) {
|
|
1379
|
+
if (c.from === nodeId && c.to !== nodeId) imports.push(c.to);
|
|
1380
|
+
if (c.to === nodeId && c.from !== nodeId) dependents.push(c.from);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Dedup
|
|
1384
|
+
const importSet = [...new Set(imports)];
|
|
1385
|
+
const dependentSet = [...new Set(dependents)];
|
|
1386
|
+
const allExplore = new Set([nodeId, ...importSet, ...dependentSet]);
|
|
1387
|
+
|
|
1388
|
+
// Save pre-explore state for back navigation
|
|
1389
|
+
if (!this._exploreStack) this._exploreStack = [];
|
|
1390
|
+
this._exploreStack.push({
|
|
1391
|
+
positions: this._canvas.getPositions(),
|
|
1392
|
+
zoom: this._canvas.$.zoom,
|
|
1393
|
+
panX: this._canvas.$.panX,
|
|
1394
|
+
panY: this._canvas.$.panY,
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// Radial layout: focus at center
|
|
1398
|
+
// Imports on LEFT hemisphere, dependents on RIGHT
|
|
1399
|
+
const RADIUS_INNER = 500;
|
|
1400
|
+
const positions = {};
|
|
1401
|
+
positions[nodeId] = { x: 0, y: 0 };
|
|
1402
|
+
|
|
1403
|
+
// Place imports (left hemisphere: angles from 90° to 270°)
|
|
1404
|
+
importSet.forEach((id, i) => {
|
|
1405
|
+
const t = (i + 1) / (importSet.length + 1); // 0..1 evenly spaced
|
|
1406
|
+
const angle = Math.PI / 2 + Math.PI * t; // 90° → 270° (left)
|
|
1407
|
+
positions[id] = {
|
|
1408
|
+
x: RADIUS_INNER * Math.cos(angle),
|
|
1409
|
+
y: RADIUS_INNER * Math.sin(angle),
|
|
1410
|
+
};
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
// Place dependents (right hemisphere: angles from -90° to 90°)
|
|
1414
|
+
dependentSet.forEach((id, i) => {
|
|
1415
|
+
const t = (i + 1) / (dependentSet.length + 1);
|
|
1416
|
+
const angle = -Math.PI / 2 + Math.PI * t; // -90° → 90° (right)
|
|
1417
|
+
positions[id] = {
|
|
1418
|
+
x: RADIUS_INNER * Math.cos(angle),
|
|
1419
|
+
y: RADIUS_INNER * Math.sin(angle),
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
// Move explore nodes to radial positions, push others far below
|
|
1424
|
+
this._canvas.setBatchMode(true);
|
|
1425
|
+
const allNodes = editor.getNodes();
|
|
1426
|
+
for (const n of allNodes) {
|
|
1427
|
+
if (positions[n.id]) {
|
|
1428
|
+
this._canvas.setNodePosition(n.id, positions[n.id].x, positions[n.id].y);
|
|
1429
|
+
} else {
|
|
1430
|
+
// Move non-explore nodes far offscreen (below)
|
|
1431
|
+
this._canvas.setNodePosition(n.id, 0, 50000 + Math.random() * 1000);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
this._canvas.setBatchMode(false);
|
|
1435
|
+
this._canvas.syncPhantom?.();
|
|
1436
|
+
|
|
1437
|
+
// Highlight explore connections
|
|
1438
|
+
const exploreConnIds = conns
|
|
1439
|
+
.filter(c => c.from === nodeId || c.to === nodeId)
|
|
1440
|
+
.map(c => c.id);
|
|
1441
|
+
this._canvas.setActiveConnections?.(exploreConnIds);
|
|
1442
|
+
|
|
1443
|
+
// Fly to focus node
|
|
1444
|
+
this._canvas.flyToNode(nodeId, { zoom: 0.5 });
|
|
1445
|
+
|
|
1446
|
+
// Update URL to reflect explore mode
|
|
1447
|
+
this._updateHashParam('explore', nodePath);
|
|
1448
|
+
|
|
1449
|
+
// Mark explore mode active
|
|
1450
|
+
this._exploreMode = true;
|
|
1451
|
+
this._exploreNodeId = nodeId;
|
|
1452
|
+
|
|
1453
|
+
// Dispatch event for status bar / UI feedback
|
|
1454
|
+
this.dispatchEvent(new CustomEvent('explore-started', {
|
|
1455
|
+
detail: {
|
|
1456
|
+
nodeId,
|
|
1457
|
+
path: nodePath,
|
|
1458
|
+
imports: importSet.length,
|
|
1459
|
+
dependents: dependentSet.length,
|
|
1460
|
+
},
|
|
1461
|
+
bubbles: true,
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Exit explore mode — restore graph to pre-explore state.
|
|
1467
|
+
*/
|
|
1468
|
+
_exitExploreMode() {
|
|
1469
|
+
if (!this._exploreStack?.length || !this._canvas) return;
|
|
1470
|
+
|
|
1471
|
+
const state = this._exploreStack.pop();
|
|
1472
|
+
|
|
1473
|
+
this._canvas.setBatchMode(true);
|
|
1474
|
+
for (const [nodeId, pos] of Object.entries(state.positions)) {
|
|
1475
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
1476
|
+
}
|
|
1477
|
+
this._canvas.setBatchMode(false);
|
|
1478
|
+
this._canvas.syncPhantom?.();
|
|
1479
|
+
|
|
1480
|
+
// Restore zoom/pan
|
|
1481
|
+
this._canvas.$.zoom = state.zoom;
|
|
1482
|
+
this._canvas.$.panX = state.panX;
|
|
1483
|
+
this._canvas.$.panY = state.panY;
|
|
1484
|
+
this._canvas.refreshConnections();
|
|
1485
|
+
|
|
1486
|
+
// Clear highlight
|
|
1487
|
+
this._canvas.setActiveConnections?.(null);
|
|
1488
|
+
|
|
1489
|
+
this._exploreMode = false;
|
|
1490
|
+
this._exploreNodeId = null;
|
|
1491
|
+
this._updateHashParam('explore', null);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Build and render a complete dependency graph from skeleton data.
|
|
1496
|
+
*/
|
|
1497
|
+
_buildGraph(skeleton) {
|
|
1498
|
+
if (!skeleton || !this._canvas) return;
|
|
1499
|
+
// Guard: both ResizeObserver and skeleton-loaded schedule rAF calls
|
|
1500
|
+
// that check _graphBuilt BEFORE the rAF. If both fire in the same
|
|
1501
|
+
// frame, _buildGraph runs twice → double nodes. Guard here too.
|
|
1502
|
+
if (this._graphBuilt) return;
|
|
1503
|
+
this._graphBuilt = true;
|
|
1504
|
+
|
|
1505
|
+
// ── Tear down previous build state ──
|
|
1506
|
+
// Disconnect stale ResizeObserver to prevent old callbacks firing on new nodes
|
|
1507
|
+
if (this._nodeObserver) {
|
|
1508
|
+
this._nodeObserver.disconnect();
|
|
1509
|
+
this._nodeObserver = null;
|
|
1510
|
+
}
|
|
1511
|
+
// Cancel pending layout timers/rAFs from previous build
|
|
1512
|
+
if (this._layoutPassTimer) { clearTimeout(this._layoutPassTimer); this._layoutPassTimer = null; }
|
|
1513
|
+
if (this._failsafeTimer) { clearTimeout(this._failsafeTimer); this._failsafeTimer = null; }
|
|
1514
|
+
if (this._refreshRaf) { cancelAnimationFrame(this._refreshRaf); this._refreshRaf = null; }
|
|
1515
|
+
// Reset the view-restored flag so the new build can do its own initial stabilization
|
|
1516
|
+
this._initialViewRestored = false;
|
|
1517
|
+
this._runRelayoutPass = null;
|
|
1518
|
+
|
|
1519
|
+
// Hide canvas during build to prevent visible flicker (pass 1 → pass 2 jump)
|
|
1520
|
+
if (this._canvas) {
|
|
1521
|
+
this._canvas.style.opacity = '0';
|
|
1522
|
+
this._canvas.style.transition = 'none';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Show (or re-show) the preloader overlay — safe to call multiple times
|
|
1526
|
+
this._showLoader();
|
|
1527
|
+
|
|
1528
|
+
// Phase 0: Parsing
|
|
1529
|
+
this._setProgress(10, 'Parsing graph…', '');
|
|
1530
|
+
|
|
1531
|
+
const isStructured = this._viewMode === 'structured';
|
|
1532
|
+
|
|
1533
|
+
if (!isStructured) {
|
|
1534
|
+
if (this._canvas) this._canvas.style.display = 'none';
|
|
1535
|
+
if (this._pgCanvasGraph) {
|
|
1536
|
+
this._pgCanvasGraph.style.display = 'block';
|
|
1537
|
+
this._pgCanvasGraph.setSkeleton(skeleton);
|
|
1538
|
+
|
|
1539
|
+
// Restore path from URL
|
|
1540
|
+
const hashData = location.hash.replace('#', '').split('?')[0];
|
|
1541
|
+
const hashParams = hashData.split('/');
|
|
1542
|
+
if (hashParams[0] === 'graph') {
|
|
1543
|
+
hashParams.shift(); // remove "graph"
|
|
1544
|
+
}
|
|
1545
|
+
const pathStr = hashParams.join('/');
|
|
1546
|
+
this._pgCanvasGraph.setPath(pathStr);
|
|
1547
|
+
}
|
|
1548
|
+
this._hideLoader();
|
|
1549
|
+
this._graphBuilt = true;
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (this._pgCanvasGraph) this._pgCanvasGraph.style.display = 'none';
|
|
1554
|
+
if (this._canvas) this._canvas.style.display = '';
|
|
1555
|
+
|
|
1556
|
+
// Cache key: reuse previously built graph for same skeleton+mode
|
|
1557
|
+
const cacheKey = isStructured ? 'structured' : 'flat';
|
|
1558
|
+
if (!this._graphCache) this._graphCache = {};
|
|
1559
|
+
|
|
1560
|
+
let editor, fileMap, dirFiles, dirNodeMap, idToPath, symbolMap;
|
|
1561
|
+
|
|
1562
|
+
if (this._graphCache[cacheKey] && this._graphCache[cacheKey].skeleton === skeleton) {
|
|
1563
|
+
// Reuse cached build result — avoids 5+ second rebuild on mode toggle
|
|
1564
|
+
({ editor, fileMap, dirFiles, dirNodeMap, idToPath, symbolMap } = this._graphCache[cacheKey]);
|
|
1565
|
+
this._setProgress(40, 'Building nodes…', `${editor.getNodes().length} nodes (cached)`);
|
|
1566
|
+
} else {
|
|
1567
|
+
|
|
1568
|
+
this._setProgress(15, 'Parsing graph…', isStructured ? 'structured mode' : 'flat mode');
|
|
1569
|
+
if (isStructured) {
|
|
1570
|
+
({ editor, fileMap, dirFiles, dirNodeMap, idToPath, symbolMap } = buildStructuredGraph(skeleton));
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
this._setProgress(40, 'Building nodes…', `${editor.getNodes().length} nodes`);
|
|
1574
|
+
this._graphCache[cacheKey] = { skeleton, editor, fileMap, dirFiles, dirNodeMap, idToPath, symbolMap };
|
|
1575
|
+
}
|
|
1576
|
+
this._editor = editor;
|
|
1577
|
+
this._fileMap = fileMap;
|
|
1578
|
+
this._dirNodeMap = dirNodeMap;
|
|
1579
|
+
this._idToPath = idToPath;
|
|
1580
|
+
this._symbolMap = symbolMap;
|
|
1581
|
+
this._drillableFiles = new Set([...symbolMap.values()].map(s => s.file));
|
|
1582
|
+
|
|
1583
|
+
if (this._router) this._router.destroy();
|
|
1584
|
+
this._router = new SubgraphRouter(this._canvas, {
|
|
1585
|
+
hashPrefix: 'graph',
|
|
1586
|
+
fileMap,
|
|
1587
|
+
dirNodeMap,
|
|
1588
|
+
symbolMap,
|
|
1589
|
+
drillableFiles: this._drillableFiles,
|
|
1590
|
+
onNavigate: (path) => {
|
|
1591
|
+
// Optional hook: focus/pulse upon non-visual navigation
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
// Set editor on canvas
|
|
1596
|
+
|
|
1597
|
+
this._setProgress(55, 'Building nodes…', 'rendering DOM');
|
|
1598
|
+
this._canvas.setEditor(editor);
|
|
1599
|
+
|
|
1600
|
+
this._setProgress(70, 'Placing nodes…', '');
|
|
1601
|
+
|
|
1602
|
+
// Apply settings
|
|
1603
|
+
this._canvas.setReadonly(true);
|
|
1604
|
+
const searchStr = window.location.search || (window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '');
|
|
1605
|
+
const urlParams = new URLSearchParams(searchStr);
|
|
1606
|
+
this._canvas.setPathStyle(urlParams.get('style') || window.localStorage.getItem('connection-style') || 'pcb');
|
|
1607
|
+
|
|
1608
|
+
// Auto-layout
|
|
1609
|
+
const rawPos = this._canvas.getPositions() || {};
|
|
1610
|
+
const existingPositions = {};
|
|
1611
|
+
for (const [id, coords] of Object.entries(rawPos)) {
|
|
1612
|
+
if (typeof coords[0] === 'number' && !isNaN(coords[0]) &&
|
|
1613
|
+
typeof coords[1] === 'number' && !isNaN(coords[1])) {
|
|
1614
|
+
existingPositions[id] = { x: coords[0], y: coords[1] };
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Groups for layout clustering (flat mode only — structured has fewer top-level nodes)
|
|
1619
|
+
const groups = {};
|
|
1620
|
+
if (!isStructured && dirFiles) {
|
|
1621
|
+
for (const [dir, files] of dirFiles.entries()) {
|
|
1622
|
+
const nodeIds = [];
|
|
1623
|
+
for (const f of files) {
|
|
1624
|
+
if (fileMap.has(f)) nodeIds.push(fileMap.get(f));
|
|
1625
|
+
}
|
|
1626
|
+
if (nodeIds.length > 0) groups[dir] = nodeIds;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// --- Layout strategy depends on mode ---
|
|
1631
|
+
let positions;
|
|
1632
|
+
|
|
1633
|
+
if (isStructured && dirFiles) {
|
|
1634
|
+
// TREE mode: directory tree layout (like file explorer)
|
|
1635
|
+
// Build dirPaths map ONLY for nodes that are in the root editor
|
|
1636
|
+
const dirPaths = {};
|
|
1637
|
+
const rootNodeIds = new Set(editor.getNodes().map(n => n.id));
|
|
1638
|
+
for (const [dir, nodeId] of dirNodeMap.entries()) {
|
|
1639
|
+
if (rootNodeIds.has(nodeId)) {
|
|
1640
|
+
dirPaths[nodeId] = dir;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
positions = computeTreeLayout(editor, {
|
|
1645
|
+
dirPaths,
|
|
1646
|
+
nodeWidth: 250,
|
|
1647
|
+
nodeHeight: 100,
|
|
1648
|
+
gapX: 40,
|
|
1649
|
+
gapY: 60,
|
|
1650
|
+
startX: 60,
|
|
1651
|
+
startY: 60,
|
|
1652
|
+
});
|
|
1653
|
+
} else {
|
|
1654
|
+
// FLAT mode: group-aware circular initial positions for force simulation.
|
|
1655
|
+
// AutoLayout (Sugiyama) creates a vertical line that ForceWorker cannot fix,
|
|
1656
|
+
// so we start with a balanced 2D circular layout.
|
|
1657
|
+
const allNodes = [...editor.getNodes()];
|
|
1658
|
+
const totalNodes = allNodes.length;
|
|
1659
|
+
const groupEntries = Object.entries(groups);
|
|
1660
|
+
positions = {};
|
|
1661
|
+
|
|
1662
|
+
if (groupEntries.length > 1) {
|
|
1663
|
+
// Place each group's centroid on a spiral, fan members around it
|
|
1664
|
+
const globalRadius = Math.sqrt(totalNodes) * 80;
|
|
1665
|
+
let groupIdx = 0;
|
|
1666
|
+
for (const [, memberIds] of groupEntries) {
|
|
1667
|
+
const angle = (2 * Math.PI * groupIdx) / groupEntries.length;
|
|
1668
|
+
const r = globalRadius * (0.3 + 0.7 * (groupIdx / groupEntries.length));
|
|
1669
|
+
const cx = Math.cos(angle) * r;
|
|
1670
|
+
const cy = Math.sin(angle) * r;
|
|
1671
|
+
const memberRadius = Math.sqrt(memberIds.length) * 60;
|
|
1672
|
+
for (let mi = 0; mi < memberIds.length; mi++) {
|
|
1673
|
+
const mAngle = (2 * Math.PI * mi) / memberIds.length;
|
|
1674
|
+
positions[memberIds[mi]] = {
|
|
1675
|
+
x: cx + Math.cos(mAngle) * memberRadius + (Math.random() - 0.5) * 20,
|
|
1676
|
+
y: cy + Math.sin(mAngle) * memberRadius + (Math.random() - 0.5) * 20,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
groupIdx++;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Fill ungrouped nodes in a ring
|
|
1684
|
+
for (const n of allNodes) {
|
|
1685
|
+
if (!positions[n.id]) {
|
|
1686
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
1687
|
+
const r = Math.sqrt(totalNodes) * 50 + Math.random() * 200;
|
|
1688
|
+
positions[n.id] = { x: Math.cos(angle) * r, y: Math.sin(angle) * r };
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
this._canvas.setBatchMode(true);
|
|
1694
|
+
for (const [nodeId, pos] of Object.entries(positions)) {
|
|
1695
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
1696
|
+
}
|
|
1697
|
+
this._canvas.setBatchMode(false);
|
|
1698
|
+
|
|
1699
|
+
// Force sync updated phantom positions to renderer immediately
|
|
1700
|
+
// Without this, subsequent fitView/redraw uses stale (0,0) phantom data
|
|
1701
|
+
this._canvas.syncPhantom?.();
|
|
1702
|
+
|
|
1703
|
+
// For large graphs (>200 nodes) the ResizeObserver in Pass 2 will never fire
|
|
1704
|
+
// because phantom-mode nodes have no real DOM elements to observe.
|
|
1705
|
+
// We must start ForceLayout HERE (Pass 1) with the circular seed positions.
|
|
1706
|
+
// The canvas stays hidden; onDone reveals it.
|
|
1707
|
+
const nodeCount = editor.getNodes().length;
|
|
1708
|
+
if (!isStructured && nodeCount > 50) {
|
|
1709
|
+
if (!this._forceLayout) {
|
|
1710
|
+
const workerUrl = new URL('../vendor/symbiote-node/canvas/ForceWorker.js', import.meta.url).href;
|
|
1711
|
+
this._forceLayout = new ForceLayout(workerUrl);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const editorNodes = [...editor.getNodes()];
|
|
1715
|
+
const editorConns = [...editor.getConnections()];
|
|
1716
|
+
const forceNodes = editorNodes.map(n => ({
|
|
1717
|
+
id: n.id,
|
|
1718
|
+
x: positions[n.id]?.x ?? 0,
|
|
1719
|
+
y: positions[n.id]?.y ?? 0,
|
|
1720
|
+
group: groups ? Object.entries(groups).find(([, ids]) => ids.includes(n.id))?.[0] : null,
|
|
1721
|
+
w: n.params?.calculatedWidth || 260,
|
|
1722
|
+
h: n.params?.calculatedHeight || 60,
|
|
1723
|
+
}));
|
|
1724
|
+
const forceEdges = editorConns.map(c => ({ from: c.from, to: c.to }));
|
|
1725
|
+
|
|
1726
|
+
// Live tick updates so user sees nodes moving rather than a freeze
|
|
1727
|
+
this._forceLayout.onTick = (tickPositions) => {
|
|
1728
|
+
if (!this._canvas) return;
|
|
1729
|
+
this._canvas.setBatchMode(true);
|
|
1730
|
+
for (const [nodeId, pos] of Object.entries(tickPositions)) {
|
|
1731
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
1732
|
+
}
|
|
1733
|
+
this._canvas.setBatchMode(false);
|
|
1734
|
+
this._canvas.syncPhantom?.();
|
|
1735
|
+
// Throttle refreshConnections — expensive for 2000+ nodes
|
|
1736
|
+
if (!this._forceTickRaf) {
|
|
1737
|
+
this._forceTickRaf = requestAnimationFrame(() => {
|
|
1738
|
+
this._forceTickRaf = null;
|
|
1739
|
+
this._canvas?.refreshConnections();
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
// Show canvas and hide loader on first tick so user gets live feedback
|
|
1743
|
+
if (!this._initialViewRestored) {
|
|
1744
|
+
this._initialViewRestored = true;
|
|
1745
|
+
this._hideLoader();
|
|
1746
|
+
this._canvas.style.transition = 'opacity 0.2s ease-in';
|
|
1747
|
+
this._canvas.style.opacity = '1';
|
|
1748
|
+
// Only fitView on first tick if there's no focus= param to preserve
|
|
1749
|
+
const _hashHasFocus = window.location.hash.includes('?') || window.location.hash.includes('focus=');
|
|
1750
|
+
if (!_hashHasFocus) {
|
|
1751
|
+
this._canvas.fitView();
|
|
1752
|
+
}
|
|
1753
|
+
// In continuous mode: restore hash navigation after initial convergence settles
|
|
1754
|
+
setTimeout(() => {
|
|
1755
|
+
const fullHash = window.location.hash;
|
|
1756
|
+
const hasPath = /^#graph\//.test(fullHash);
|
|
1757
|
+
const hasParams = fullHash.includes('?');
|
|
1758
|
+
if (hasPath || hasParams) {
|
|
1759
|
+
if (this._router) {
|
|
1760
|
+
this._router.restoreFromHash(editor);
|
|
1761
|
+
} else if (this._pgCanvasGraph) {
|
|
1762
|
+
this._restoreFlatFocus();
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}, 800);
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
this._forceLayout.onDone = (finalPositions) => {
|
|
1770
|
+
if (!this._canvas) return;
|
|
1771
|
+
this._forceTickRaf = null;
|
|
1772
|
+
|
|
1773
|
+
this._canvas.setBatchMode(true);
|
|
1774
|
+
for (const [nodeId, pos] of Object.entries(finalPositions)) {
|
|
1775
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
1776
|
+
}
|
|
1777
|
+
this._canvas.setBatchMode(false);
|
|
1778
|
+
this._canvas.syncPhantom?.();
|
|
1779
|
+
this._canvas.refreshConnections();
|
|
1780
|
+
// Ensure canvas is visible and loader is gone
|
|
1781
|
+
if (!this._initialViewRestored) {
|
|
1782
|
+
this._initialViewRestored = true;
|
|
1783
|
+
this._hideLoader();
|
|
1784
|
+
this._canvas.style.transition = 'opacity 0.2s ease-in';
|
|
1785
|
+
this._canvas.style.opacity = '1';
|
|
1786
|
+
} else {
|
|
1787
|
+
this._hideLoader();
|
|
1788
|
+
}
|
|
1789
|
+
// Restore focus/hash navigation now that all node positions are final
|
|
1790
|
+
const fullHash = window.location.hash;
|
|
1791
|
+
const hasPath = /^#graph\//.test(fullHash);
|
|
1792
|
+
const hasParams = fullHash.includes('?');
|
|
1793
|
+
if (hasPath || hasParams) {
|
|
1794
|
+
if (this._router) {
|
|
1795
|
+
this._router.restoreFromHash(editor);
|
|
1796
|
+
} else if (this._pgCanvasGraph) {
|
|
1797
|
+
this._restoreFlatFocus();
|
|
1798
|
+
}
|
|
1799
|
+
} else {
|
|
1800
|
+
this._canvas.fitView();
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
this._setProgress(85, 'Simulating layout…', `${editorNodes.length} nodes · ${editorConns.length} edges`);
|
|
1805
|
+
this._forceLayout.start({
|
|
1806
|
+
nodes: forceNodes,
|
|
1807
|
+
edges: forceEdges,
|
|
1808
|
+
groups: groups || {},
|
|
1809
|
+
options: {
|
|
1810
|
+
chargeStrength: nodeCount > 500 ? -300 : -150,
|
|
1811
|
+
linkDistance: nodeCount > 500 ? 100 : 150,
|
|
1812
|
+
nodeWidth: 260,
|
|
1813
|
+
nodeHeight: 40,
|
|
1814
|
+
mode: 'continuous',
|
|
1815
|
+
brownian: 0,
|
|
1816
|
+
},
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
// Hook drag events to pin/unpin nodes in force simulation
|
|
1820
|
+
// When user picks up a node → pin it (fix position in simulation)
|
|
1821
|
+
// When user moves it → update pinned position (neighbors react)
|
|
1822
|
+
// When user drops it → unpin (let simulation settle naturally)
|
|
1823
|
+
editor.on('nodepicked', (node) => {
|
|
1824
|
+
if (!this._forceLayout?.running) return;
|
|
1825
|
+
const el = this._canvas?.getNodeView?.(node.id) || this._canvas?.querySelector(`[node-id="${node.id}"]`);
|
|
1826
|
+
const pos = el?._position;
|
|
1827
|
+
if (pos) {
|
|
1828
|
+
this._forceLayout.pin(node.id, pos.x, pos.y);
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
editor.on('nodetranslated', ({ id, position }) => {
|
|
1833
|
+
if (!this._forceLayout?.running) return;
|
|
1834
|
+
this._forceLayout.pin(id, position.x, position.y);
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
editor.on('nodedragged', ({ id }) => {
|
|
1838
|
+
this._wasDragged = true;
|
|
1839
|
+
if (!this._forceLayout?.running) return;
|
|
1840
|
+
this._forceLayout.unpin(id);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
// Don't wait for ResizeObserver — we already started the worker above.
|
|
1844
|
+
// Skip the rest of the Pass 1 initialization (pass 2 will be a no-op since
|
|
1845
|
+
// _initialViewRestored will be true by the time ResizeObserver fires).
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
// Post-drill-in layout: recalculate inner node positions using real DOM sizes
|
|
1851
|
+
// Pre-computed innerPositions use hardcoded nodeHeight which may not match actual rendered heights
|
|
1852
|
+
// IMPORTANT: Must be registered BEFORE restoreFromHash, which may trigger drillDown on page refresh
|
|
1853
|
+
if (!this._drillLayoutListener) {
|
|
1854
|
+
this._drillLayoutListener = (e) => {
|
|
1855
|
+
if (!this._canvas) return;
|
|
1856
|
+
const enteredNode = e.detail?.node;
|
|
1857
|
+
if (!enteredNode?._isSubgraph) return;
|
|
1858
|
+
const innerEditor = enteredNode.getInnerEditor();
|
|
1859
|
+
if (!innerEditor) return;
|
|
1860
|
+
|
|
1861
|
+
// Wait for inner nodes to render, then re-layout with measured sizes
|
|
1862
|
+
requestAnimationFrame(() => {
|
|
1863
|
+
requestAnimationFrame(() => {
|
|
1864
|
+
const nodeSizes = this._canvas.measureNodeSizes();
|
|
1865
|
+
if (!nodeSizes || Object.keys(nodeSizes).length === 0) return;
|
|
1866
|
+
|
|
1867
|
+
const corrected = computeAutoLayout(innerEditor, {
|
|
1868
|
+
nodeSizes,
|
|
1869
|
+
nodeHeight: 80,
|
|
1870
|
+
gapY: 100,
|
|
1871
|
+
gapX: 120,
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
this._canvas.setBatchMode(true);
|
|
1875
|
+
for (const [nodeId, pos] of Object.entries(corrected)) {
|
|
1876
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
1877
|
+
}
|
|
1878
|
+
this._canvas.setBatchMode(false);
|
|
1879
|
+
this._canvas.refreshConnections();
|
|
1880
|
+
|
|
1881
|
+
if (window.location.hash.includes('focus=')) {
|
|
1882
|
+
const searchStr = window.location.search || (window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '');
|
|
1883
|
+
const params = new URLSearchParams(searchStr);
|
|
1884
|
+
const focusParam = params.get('focus');
|
|
1885
|
+
if (focusParam && this._router) {
|
|
1886
|
+
// Defer to allow DOM to settle, then fly to the correct newly measured position
|
|
1887
|
+
requestAnimationFrame(() => this._router.navigateTo(decodeURIComponent(focusParam)));
|
|
1888
|
+
}
|
|
1889
|
+
} else if (this._canvas.fitView) {
|
|
1890
|
+
requestAnimationFrame(() => this._canvas.fitView());
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
};
|
|
1895
|
+
this._canvas.addEventListener('subgraph-enter', this._drillLayoutListener);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Safety net: update URL on subgraph exit (back to root)
|
|
1899
|
+
// SubgraphRouter's handleExit should handle this, but as defense-in-depth
|
|
1900
|
+
if (!this._exitUrlListener) {
|
|
1901
|
+
this._exitUrlListener = (e) => {
|
|
1902
|
+
const level = e.detail?.level;
|
|
1903
|
+
if (level === 0) {
|
|
1904
|
+
// Exiting to root — extract parent directory from current URL to use as focus
|
|
1905
|
+
const hash = window.location.hash;
|
|
1906
|
+
const pathMatch = hash.match(/#graph\/([^?&]+)/);
|
|
1907
|
+
if (pathMatch) {
|
|
1908
|
+
let focusDir = pathMatch[1];
|
|
1909
|
+
// Walk up to find known directory
|
|
1910
|
+
if (this._dirNodeMap) {
|
|
1911
|
+
const segments = focusDir.replace(/\/$/, '').split('/');
|
|
1912
|
+
while (segments.length > 0) {
|
|
1913
|
+
const candidate = segments.join('/') + '/';
|
|
1914
|
+
if (this._dirNodeMap.has(candidate)) {
|
|
1915
|
+
focusDir = candidate;
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1918
|
+
segments.pop();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
if (focusDir) {
|
|
1922
|
+
this._updateHashParam('focus', focusDir);
|
|
1923
|
+
} else {
|
|
1924
|
+
this._updateHashParam('focus', null);
|
|
1925
|
+
}
|
|
1926
|
+
} else {
|
|
1927
|
+
this._updateHashParam('focus', null);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
this._canvas.addEventListener('subgraph-exit', this._exitUrlListener);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// NOTE: restoreFromHash is NOT called here (pass 1) because positions aren't stable yet.
|
|
1935
|
+
// It will be called from _runRelayoutPass (pass 2) after node sizes are measured.
|
|
1936
|
+
|
|
1937
|
+
// Dedicated node ResizeObserver ensures that late inflation of inner ports
|
|
1938
|
+
// triggers not only a line refresh, but initially schedules a full Pass 2 layout
|
|
1939
|
+
// so things don't overlap vertically in a messy stack.
|
|
1940
|
+
if (!this._nodeObserver) {
|
|
1941
|
+
this._nodeObserver = new ResizeObserver((entries) => {
|
|
1942
|
+
if (!this._canvas) return;
|
|
1943
|
+
let needsRefresh = false;
|
|
1944
|
+
for (const entry of entries) {
|
|
1945
|
+
if (entry.target.tagName.toLowerCase() === 'graph-node') {
|
|
1946
|
+
const el = entry.target;
|
|
1947
|
+
const newW = entry.contentRect.width;
|
|
1948
|
+
const newH = entry.contentRect.height;
|
|
1949
|
+
|
|
1950
|
+
// Ignore culling-induced resizes (when contentVisibility: hidden makes dimensions 0)
|
|
1951
|
+
if (newW === 0 || newH === 0) continue;
|
|
1952
|
+
|
|
1953
|
+
// Ignore if the dimensions are practically identical to cached (allow up to 3px jitter for transform/zoom text rendering rounding)
|
|
1954
|
+
if (el._cachedW && Math.abs(el._cachedW - newW) <= 3 && Math.abs(el._cachedH - newH) <= 3) {
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Real resize detected! Update cache and flag refresh
|
|
1959
|
+
el._cachedW = newW;
|
|
1960
|
+
el._cachedH = newH;
|
|
1961
|
+
needsRefresh = true;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
if (needsRefresh) {
|
|
1966
|
+
// Immediately secure connections
|
|
1967
|
+
if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
|
|
1968
|
+
this._refreshRaf = requestAnimationFrame(() => this._canvas.refreshConnections());
|
|
1969
|
+
|
|
1970
|
+
// Trigger full layout recalculation debounced ONLY during initial load
|
|
1971
|
+
if (!this._initialViewRestored) {
|
|
1972
|
+
if (this._layoutPassTimer) clearTimeout(this._layoutPassTimer);
|
|
1973
|
+
this._layoutPassTimer = setTimeout(() => {
|
|
1974
|
+
this._runRelayoutPass(isStructured, dirFiles, dirNodeMap, editor, groups);
|
|
1975
|
+
}, 150);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Attach ResizeObserver to all graph-nodes
|
|
1982
|
+
requestAnimationFrame(() => {
|
|
1983
|
+
if (!this._canvas) return;
|
|
1984
|
+
const nodes = this._canvas.querySelectorAll('graph-node');
|
|
1985
|
+
for (const el of nodes) {
|
|
1986
|
+
this._nodeObserver.observe(el);
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
// Provide the dynamic layout function which replaces the old static setTimeout
|
|
1991
|
+
this._runRelayoutPass = (isStructured, dirFiles, dirNodeMap, editor, groups) => {
|
|
1992
|
+
if (!this._canvas) return;
|
|
1993
|
+
const nodeSizes = this._canvas.measureNodeSizes();
|
|
1994
|
+
|
|
1995
|
+
let correctedPositions;
|
|
1996
|
+
if (isStructured && dirFiles) {
|
|
1997
|
+
const dirPaths = {};
|
|
1998
|
+
const rootNodeIds = new Set(editor.getNodes().map(n => n.id));
|
|
1999
|
+
for (const [dir, nodeId] of dirNodeMap.entries()) {
|
|
2000
|
+
if (rootNodeIds.has(nodeId)) {
|
|
2001
|
+
dirPaths[nodeId] = dir;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
correctedPositions = computeTreeLayout(editor, {
|
|
2005
|
+
dirPaths, nodeSizes,
|
|
2006
|
+
nodeWidth: 250, nodeHeight: 100,
|
|
2007
|
+
gapX: 40, gapY: 60,
|
|
2008
|
+
startX: 60, startY: 60,
|
|
2009
|
+
});
|
|
2010
|
+
} else {
|
|
2011
|
+
// FLAT mode: force-directed layout with group-aware circular initial positions
|
|
2012
|
+
const editorNodes = [...editor.getNodes()];
|
|
2013
|
+
const editorConns = [...editor.getConnections()];
|
|
2014
|
+
|
|
2015
|
+
// For small graphs, static AutoLayout is fast enough
|
|
2016
|
+
if (editorNodes.length < 50) {
|
|
2017
|
+
const layoutResult = computeAutoLayout(editor, {
|
|
2018
|
+
groups, nodeSizes, existingPositions: this._canvas.getPositions()
|
|
2019
|
+
});
|
|
2020
|
+
correctedPositions = layoutResult.positions ? layoutResult.positions : layoutResult;
|
|
2021
|
+
} else {
|
|
2022
|
+
// ── Group-aware circular initial positions ──
|
|
2023
|
+
// Instead of Sugiyama (vertical line), place groups in concentric rings.
|
|
2024
|
+
// This gives the force simulation a balanced 2D starting point.
|
|
2025
|
+
correctedPositions = {};
|
|
2026
|
+
const groupEntries = groups ? Object.entries(groups) : [];
|
|
2027
|
+
const totalNodes = editorNodes.length;
|
|
2028
|
+
|
|
2029
|
+
if (groupEntries.length > 1) {
|
|
2030
|
+
// Place each group's centroid on a spiral, then fan members around it
|
|
2031
|
+
const globalRadius = Math.sqrt(totalNodes) * 80;
|
|
2032
|
+
let groupIdx = 0;
|
|
2033
|
+
for (const [, memberIds] of groupEntries) {
|
|
2034
|
+
const angle = (2 * Math.PI * groupIdx) / groupEntries.length;
|
|
2035
|
+
const r = globalRadius * (0.3 + 0.7 * (groupIdx / groupEntries.length));
|
|
2036
|
+
const cx = Math.cos(angle) * r;
|
|
2037
|
+
const cy = Math.sin(angle) * r;
|
|
2038
|
+
|
|
2039
|
+
const memberRadius = Math.sqrt(memberIds.length) * 60;
|
|
2040
|
+
for (let mi = 0; mi < memberIds.length; mi++) {
|
|
2041
|
+
const mAngle = (2 * Math.PI * mi) / memberIds.length;
|
|
2042
|
+
correctedPositions[memberIds[mi]] = {
|
|
2043
|
+
x: cx + Math.cos(mAngle) * memberRadius + (Math.random() - 0.5) * 20,
|
|
2044
|
+
y: cy + Math.sin(mAngle) * memberRadius + (Math.random() - 0.5) * 20,
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
groupIdx++;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// Fill any ungrouped nodes in a ring
|
|
2052
|
+
for (const n of editorNodes) {
|
|
2053
|
+
if (!correctedPositions[n.id]) {
|
|
2054
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
2055
|
+
const r = Math.sqrt(totalNodes) * 50 + Math.random() * 200;
|
|
2056
|
+
correctedPositions[n.id] = {
|
|
2057
|
+
x: Math.cos(angle) * r,
|
|
2058
|
+
y: Math.sin(angle) * r,
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// Start force simulation. Apply circular seed BEFORE starting so there's no
|
|
2064
|
+
// position race between the random seed write (below) and the first worker tick.
|
|
2065
|
+
if (!this._forceLayout) {
|
|
2066
|
+
const workerUrl = new URL('../vendor/symbiote-node/canvas/ForceWorker.js', import.meta.url).href;
|
|
2067
|
+
this._forceLayout = new ForceLayout(workerUrl);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const forceNodes = editorNodes.map(n => ({
|
|
2071
|
+
id: n.id,
|
|
2072
|
+
x: correctedPositions[n.id]?.x ?? 0,
|
|
2073
|
+
y: correctedPositions[n.id]?.y ?? 0,
|
|
2074
|
+
group: groups ? Object.entries(groups).find(([, ids]) => ids.includes(n.id))?.[0] : null,
|
|
2075
|
+
w: nodeSizes[n.id]?.w || n.params?.calculatedWidth || 260,
|
|
2076
|
+
h: nodeSizes[n.id]?.h || n.params?.calculatedHeight || 60,
|
|
2077
|
+
}));
|
|
2078
|
+
|
|
2079
|
+
const forceEdges = editorConns.map(c => ({
|
|
2080
|
+
from: c.from,
|
|
2081
|
+
to: c.to,
|
|
2082
|
+
}));
|
|
2083
|
+
|
|
2084
|
+
// onTick: provide live visual feedback so user sees nodes spreading, not a freeze.
|
|
2085
|
+
this._forceLayout.onTick = (tickPositions) => {
|
|
2086
|
+
if (!this._canvas) return;
|
|
2087
|
+
this._canvas.setBatchMode(true);
|
|
2088
|
+
for (const [nodeId, pos] of Object.entries(tickPositions)) {
|
|
2089
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
2090
|
+
}
|
|
2091
|
+
this._canvas.setBatchMode(false);
|
|
2092
|
+
// Throttle connection refresh: expensive for large graphs
|
|
2093
|
+
if (!this._forceTickRaf) {
|
|
2094
|
+
this._forceTickRaf = requestAnimationFrame(() => {
|
|
2095
|
+
this._forceTickRaf = null;
|
|
2096
|
+
this._canvas?.refreshConnections();
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
this._forceLayout.onDone = (finalPositions) => {
|
|
2102
|
+
if (!this._canvas) return;
|
|
2103
|
+
this._forceTickRaf = null;
|
|
2104
|
+
|
|
2105
|
+
this._canvas.setBatchMode(true);
|
|
2106
|
+
for (const [nodeId, pos] of Object.entries(finalPositions)) {
|
|
2107
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
2108
|
+
}
|
|
2109
|
+
this._canvas.setBatchMode(false);
|
|
2110
|
+
this._canvas.syncPhantom?.();
|
|
2111
|
+
this._canvas.refreshConnections();
|
|
2112
|
+
// Reveal canvas and fit view on convergence
|
|
2113
|
+
if (!this._initialViewRestored) {
|
|
2114
|
+
this._initialViewRestored = true;
|
|
2115
|
+
const fullHash = window.location.hash;
|
|
2116
|
+
const hasPath = /^#graph\//.test(fullHash);
|
|
2117
|
+
const hasParams = fullHash.includes('?');
|
|
2118
|
+
if (hasPath || hasParams) {
|
|
2119
|
+
if (this._router) {
|
|
2120
|
+
this._router.restoreFromHash(editor);
|
|
2121
|
+
} else if (this._pgCanvasGraph) {
|
|
2122
|
+
this._restoreFlatFocus();
|
|
2123
|
+
}
|
|
2124
|
+
} else {
|
|
2125
|
+
this._canvas.fitView();
|
|
2126
|
+
}
|
|
2127
|
+
requestAnimationFrame(() => {
|
|
2128
|
+
if (this._canvas) {
|
|
2129
|
+
this._hideLoader();
|
|
2130
|
+
this._canvas.style.transition = 'opacity 0.15s ease-in';
|
|
2131
|
+
this._canvas.style.opacity = '1';
|
|
2132
|
+
}
|
|
2133
|
+
});
|
|
2134
|
+
} else {
|
|
2135
|
+
this._canvas.fitView();
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
this._forceLayout.start({
|
|
2140
|
+
nodes: forceNodes,
|
|
2141
|
+
edges: forceEdges,
|
|
2142
|
+
groups: groups || {},
|
|
2143
|
+
options: {
|
|
2144
|
+
chargeStrength: totalNodes > 500 ? -300 : -150,
|
|
2145
|
+
linkDistance: totalNodes > 500 ? 100 : 150,
|
|
2146
|
+
},
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
// BUG-FIX: Do NOT write correctedPositions to canvas here.
|
|
2150
|
+
// The circular seed has already been applied to forceNodes above.
|
|
2151
|
+
// Writing it now would overwrite the first worker tick with stale random positions.
|
|
2152
|
+
return;
|
|
2153
|
+
} // end if (editorNodes.length >= 50)
|
|
2154
|
+
} // end outer else (flat/tree mode branch)
|
|
2155
|
+
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
// Apply positions (only for non-force paths: small flat graphs and tree mode)
|
|
2160
|
+
this._canvas.setBatchMode(true);
|
|
2161
|
+
for (const [nodeId, pos] of Object.entries(correctedPositions)) {
|
|
2162
|
+
this._canvas.setNodePosition(nodeId, pos.x, pos.y);
|
|
2163
|
+
}
|
|
2164
|
+
this._canvas.setBatchMode(false);
|
|
2165
|
+
|
|
2166
|
+
requestAnimationFrame(() => this._canvas.refreshConnections());
|
|
2167
|
+
|
|
2168
|
+
// Only restore view focus/drill-down once after first layout stabilizes
|
|
2169
|
+
if (!this._initialViewRestored) {
|
|
2170
|
+
this._initialViewRestored = true;
|
|
2171
|
+
|
|
2172
|
+
// restoreFromHash handles path, ?focus=, and ?in= params
|
|
2173
|
+
const fullHash = window.location.hash;
|
|
2174
|
+
const hasPath = /^#graph\//.test(fullHash);
|
|
2175
|
+
const hasParams = fullHash.includes('?');
|
|
2176
|
+
if (hasPath || hasParams) {
|
|
2177
|
+
if (this._router) {
|
|
2178
|
+
this._router.restoreFromHash(editor);
|
|
2179
|
+
} else if (this._pgCanvasGraph) {
|
|
2180
|
+
this._restoreFlatFocus();
|
|
2181
|
+
}
|
|
2182
|
+
} else {
|
|
2183
|
+
this._canvas.fitView();
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
// Reveal canvas after layout is stable
|
|
2189
|
+
requestAnimationFrame(() => {
|
|
2190
|
+
if (this._canvas) {
|
|
2191
|
+
this._hideLoader();
|
|
2192
|
+
this._canvas.style.transition = 'opacity 0.15s ease-in';
|
|
2193
|
+
this._canvas.style.opacity = '1';
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
};
|
|
2199
|
+
|
|
2200
|
+
// Failsafe: if the node dimensions were completely cached/synchronous and
|
|
2201
|
+
// ResizeObserver didn't have anything new to report, we trigger it once manually.
|
|
2202
|
+
if (!this._failsafeTimer) {
|
|
2203
|
+
this._failsafeTimer = setTimeout(() => {
|
|
2204
|
+
if (!this._initialViewRestored && this._runRelayoutPass) {
|
|
2205
|
+
this._runRelayoutPass(isStructured, dirFiles, dirNodeMap, editor, groups);
|
|
2206
|
+
} else {
|
|
2207
|
+
// Handle late layout updates for drilled views
|
|
2208
|
+
if (window.location.hash.includes('focus=')) {
|
|
2209
|
+
const searchStr = window.location.search || (window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '');
|
|
2210
|
+
const params = new URLSearchParams(searchStr);
|
|
2211
|
+
const focusParam = params.get('focus');
|
|
2212
|
+
if (focusParam && this._router) {
|
|
2213
|
+
this._router.navigateTo(decodeURIComponent(focusParam));
|
|
2214
|
+
}
|
|
2215
|
+
} else if (this._canvas.fitView) {
|
|
2216
|
+
this._canvas.fitView();
|
|
2217
|
+
}
|
|
2218
|
+
this._canvas.refreshConnections();
|
|
2219
|
+
}
|
|
2220
|
+
}, 300);
|
|
2221
|
+
}
|
|
2222
|
+
// Phase 3: Directory frames (flat mode only)
|
|
2223
|
+
// DISABLED: Zone group frames temporarily turned off
|
|
2224
|
+
// if (!isStructured) this._addDirectoryFrames(editor, fileMap, dirFiles, positions);
|
|
2225
|
+
|
|
2226
|
+
// Store skeleton for Phase 2 pin resolution (flat mode only)
|
|
2227
|
+
this._skeleton = skeleton;
|
|
2228
|
+
this._pinExpansion?.clearPins();
|
|
2229
|
+
if (!isStructured) {
|
|
2230
|
+
if (!this._pinExpansion) {
|
|
2231
|
+
this._pinExpansion = new PinExpansion(this._canvas, {
|
|
2232
|
+
onPinClick: (pin, nodeId) => {
|
|
2233
|
+
if (pin.file) {
|
|
2234
|
+
state.activeFile = pin.file;
|
|
2235
|
+
emit('file-selected', { path: pin.file, line: pin.line || 1 });
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
/*
|
|
2241
|
+
if (!this._lodManager) {
|
|
2242
|
+
this._lodManager = new LODManager(this._canvas, { threshold: 0.7 });
|
|
2243
|
+
this._lodManager.onLodChange((lod) => {
|
|
2244
|
+
this._pinExpansion?.applyLOD(lod);
|
|
2245
|
+
});
|
|
2246
|
+
this._lodManager.attach();
|
|
2247
|
+
}
|
|
2248
|
+
this._lodManager.update();
|
|
2249
|
+
*/
|
|
2250
|
+
this._buildPinCache(skeleton, fileMap);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Update stats
|
|
2254
|
+
const stats = skeleton.s || {};
|
|
2255
|
+
const viaCount = editor.getConnections().filter(c => c._via).length;
|
|
2256
|
+
const statsEl = this.querySelector('.pcb-stats');
|
|
2257
|
+
if (statsEl) {
|
|
2258
|
+
statsEl.innerHTML = `
|
|
2259
|
+
<span><span class="pcb-stat-val">${fileMap.size}</span> files</span>
|
|
2260
|
+
<span><span class="pcb-stat-val">${stats.functions || 0}</span> fn</span>
|
|
2261
|
+
<span><span class="pcb-stat-val">${stats.classes || 0}</span> cls</span>
|
|
2262
|
+
<span><span class="pcb-stat-val">${editor.getConnections().length}</span> edges</span>
|
|
2263
|
+
${viaCount > 0 ? `<span><span class="pcb-stat-val">${viaCount}</span> vias</span>` : ''}
|
|
2264
|
+
`;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Post-render reflow: measure actual DOM SubgraphNode sizes and re-position
|
|
2271
|
+
* to eliminate overlaps. Uses a simple top-to-bottom column packing approach.
|
|
2272
|
+
* @param {NodeEditor} editor
|
|
2273
|
+
* @param {Object} initialPositions
|
|
2274
|
+
*/
|
|
2275
|
+
_reflowStructuredNodes(editor, initialPositions) {
|
|
2276
|
+
if (!this._canvas) return;
|
|
2277
|
+
|
|
2278
|
+
// Collect actual dimensions from DOM
|
|
2279
|
+
const entries = [];
|
|
2280
|
+
for (const node of editor.getNodes()) {
|
|
2281
|
+
const el = this._canvas.getNodeView?.(node.id);
|
|
2282
|
+
if (!el) continue;
|
|
2283
|
+
const rect = el.getBoundingClientRect();
|
|
2284
|
+
const zoom = this._canvas.$.zoom || 1;
|
|
2285
|
+
// Convert screen dimensions to world-space
|
|
2286
|
+
const w = rect.width / zoom;
|
|
2287
|
+
const h = rect.height / zoom;
|
|
2288
|
+
const pos = el._position || { x: 0, y: 0 };
|
|
2289
|
+
entries.push({ id: node.id, x: pos.x, y: pos.y, w, h });
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (entries.length === 0) return;
|
|
2293
|
+
|
|
2294
|
+
// Sort by original Y position (preserve column ordering from AutoLayout)
|
|
2295
|
+
entries.sort((a, b) => {
|
|
2296
|
+
const dx = Math.abs(a.x - b.x);
|
|
2297
|
+
// Same column if within 50px horizontally
|
|
2298
|
+
if (dx < 50) return a.y - b.y;
|
|
2299
|
+
return a.x - b.x;
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
// Group into columns (nodes within 50px x-distance = same column)
|
|
2303
|
+
const GAP = 40; // px gap between nodes
|
|
2304
|
+
const columns = [];
|
|
2305
|
+
let currentCol = [entries[0]];
|
|
2306
|
+
for (let i = 1; i < entries.length; i++) {
|
|
2307
|
+
if (Math.abs(entries[i].x - currentCol[0].x) < 50) {
|
|
2308
|
+
currentCol.push(entries[i]);
|
|
2309
|
+
} else {
|
|
2310
|
+
columns.push(currentCol);
|
|
2311
|
+
currentCol = [entries[i]];
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
columns.push(currentCol);
|
|
2315
|
+
|
|
2316
|
+
// Reflow each column vertically
|
|
2317
|
+
this._canvas.setBatchMode(true);
|
|
2318
|
+
for (const col of columns) {
|
|
2319
|
+
col.sort((a, b) => a.y - b.y);
|
|
2320
|
+
let nextY = col[0].y;
|
|
2321
|
+
for (const entry of col) {
|
|
2322
|
+
if (entry.y < nextY) {
|
|
2323
|
+
this._canvas.setNodePosition(entry.id, entry.x, nextY);
|
|
2324
|
+
entry.y = nextY;
|
|
2325
|
+
}
|
|
2326
|
+
nextY = entry.y + entry.h + GAP;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
this._canvas.setBatchMode(false);
|
|
2330
|
+
|
|
2331
|
+
if (this._router) {
|
|
2332
|
+
this._router.restoreFromHash(editor);
|
|
2333
|
+
} else if (this._pgCanvasGraph) {
|
|
2334
|
+
this._restoreFlatFocus();
|
|
2335
|
+
}
|
|
2336
|
+
this._canvas.refreshConnections();
|
|
2337
|
+
// Only fit the full view if no focus= was applied (focus already positions the viewport)
|
|
2338
|
+
if (!window.location.hash.includes('focus=')) {
|
|
2339
|
+
this._canvas.fitView();
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Restore drill-down state from a path (directory or file).
|
|
2345
|
+
* Finds the SubgraphNode whose params.path matches and drills in.
|
|
2346
|
+
* @param {string} targetPath - e.g. 'src/core/' or 'src/core/parser.js'
|
|
2347
|
+
* @param {NodeEditor} editor
|
|
2348
|
+
* @returns {boolean}
|
|
2349
|
+
*/
|
|
2350
|
+
|
|
2351
|
+
// ── Phase 2: IC Chip Expansion ──
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* Build pin cache: for each file node, resolve its exported symbol names
|
|
2355
|
+
* from skeleton.X (minified IDs) via skeleton.L (legend)
|
|
2356
|
+
* @param {object} skeleton
|
|
2357
|
+
* @param {Map<string, string>} fileMap
|
|
2358
|
+
*/
|
|
2359
|
+
_buildPinCache(skeleton, fileMap) {
|
|
2360
|
+
const X = skeleton.X || {};
|
|
2361
|
+
const L = skeleton.L || {};
|
|
2362
|
+
const n = skeleton.n || {};
|
|
2363
|
+
|
|
2364
|
+
// Build reverse legend: minifiedId → fullName
|
|
2365
|
+
const revL = {};
|
|
2366
|
+
for (const [minId, fullName] of Object.entries(L)) {
|
|
2367
|
+
revL[minId] = fullName;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
for (const [filePath, nodeId] of fileMap) {
|
|
2371
|
+
const symbols = X[filePath] || [];
|
|
2372
|
+
const pins = [];
|
|
2373
|
+
|
|
2374
|
+
for (const sym of symbols) {
|
|
2375
|
+
// Phase 4: X entries can be {id, l} objects with line numbers or plain strings
|
|
2376
|
+
const symId = typeof sym === 'object' ? sym.id : sym;
|
|
2377
|
+
const line = typeof sym === 'object' ? sym.l : null;
|
|
2378
|
+
const fullName = revL[symId] || symId;
|
|
2379
|
+
// Determine kind: class or function
|
|
2380
|
+
const nodeData = n[symId];
|
|
2381
|
+
const kind = nodeData ? 'class' : 'fn';
|
|
2382
|
+
pins.push({ name: fullName, kind, line, file: filePath });
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// Also check classes that belong to this file (from skeleton.n)
|
|
2386
|
+
for (const [id, data] of Object.entries(n)) {
|
|
2387
|
+
if (data.f === filePath) {
|
|
2388
|
+
const fullName = revL[id] || id;
|
|
2389
|
+
// Avoid duplicates (already in X)
|
|
2390
|
+
if (!pins.some(p => p.name === fullName)) {
|
|
2391
|
+
pins.push({ name: fullName, kind: 'class', line: data.l || null, file: filePath });
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
if (pins.length > 0) {
|
|
2397
|
+
this._pinExpansion?.setPins(nodeId, pins);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
// ── Phase 3: Directory Frames & Via Markers ──
|
|
2405
|
+
|
|
2406
|
+
/** @type {string[]} Directory color palette — PCB silkscreen tones */
|
|
2407
|
+
static DIR_COLORS = [
|
|
2408
|
+
'rgba(200, 117, 51, 0.25)', // copper
|
|
2409
|
+
'rgba(212, 160, 74, 0.20)', // gold
|
|
2410
|
+
'rgba(100, 180, 120, 0.20)', // solder mask green
|
|
2411
|
+
'rgba(80, 150, 200, 0.20)', // blue layer
|
|
2412
|
+
'rgba(160, 100, 200, 0.20)', // purple trace
|
|
2413
|
+
'rgba(200, 80, 80, 0.20)', // power layer red
|
|
2414
|
+
'rgba(120, 200, 200, 0.20)', // teal
|
|
2415
|
+
'rgba(200, 180, 80, 0.20)', // yellow
|
|
2416
|
+
];
|
|
2417
|
+
|
|
2418
|
+
/**
|
|
2419
|
+
* Create directory grouping frames from dirFiles map and node positions
|
|
2420
|
+
* @param {NodeEditor} editor
|
|
2421
|
+
* @param {Map<string, string>} fileMap
|
|
2422
|
+
* @param {Map<string, string[]>} dirFiles
|
|
2423
|
+
* @param {Object<string, {x: number, y: number}>} positions
|
|
2424
|
+
*/
|
|
2425
|
+
_addDirectoryFrames(editor, fileMap, dirFiles, positions) {
|
|
2426
|
+
if (!dirFiles || dirFiles.size < 2) return; // frames only useful with 2+ dirs
|
|
2427
|
+
|
|
2428
|
+
const padding = 30;
|
|
2429
|
+
const nodeW = 120;
|
|
2430
|
+
const nodeH = 80;
|
|
2431
|
+
let colorIdx = 0;
|
|
2432
|
+
|
|
2433
|
+
for (const [dir, files] of dirFiles) {
|
|
2434
|
+
if (files.length < 2) continue; // skip single-file dirs
|
|
2435
|
+
|
|
2436
|
+
// Compute bounding box of all nodes in this directory
|
|
2437
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2438
|
+
let hasPositions = false;
|
|
2439
|
+
|
|
2440
|
+
for (const file of files) {
|
|
2441
|
+
const nodeId = fileMap.get(file);
|
|
2442
|
+
if (!nodeId) continue;
|
|
2443
|
+
const pos = positions[nodeId];
|
|
2444
|
+
if (!pos) continue;
|
|
2445
|
+
hasPositions = true;
|
|
2446
|
+
|
|
2447
|
+
if (pos.x < minX) minX = pos.x;
|
|
2448
|
+
if (pos.y < minY) minY = pos.y;
|
|
2449
|
+
if (pos.x + nodeW > maxX) maxX = pos.x + nodeW;
|
|
2450
|
+
if (pos.y + nodeH > maxY) maxY = pos.y + nodeH;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
if (!hasPositions) continue;
|
|
2454
|
+
|
|
2455
|
+
// Create frame with padding
|
|
2456
|
+
const dirLabel = dir.replace(/\/$/, '').split('/').pop() || 'root';
|
|
2457
|
+
const color = DepGraph.DIR_COLORS[colorIdx % DepGraph.DIR_COLORS.length];
|
|
2458
|
+
colorIdx++;
|
|
2459
|
+
|
|
2460
|
+
try {
|
|
2461
|
+
const frame = new Frame(dirLabel, {
|
|
2462
|
+
x: minX - padding,
|
|
2463
|
+
y: minY - padding,
|
|
2464
|
+
width: (maxX - minX) + padding * 2,
|
|
2465
|
+
height: (maxY - minY) + padding * 2,
|
|
2466
|
+
color,
|
|
2467
|
+
});
|
|
2468
|
+
editor.addFrame(frame);
|
|
2469
|
+
} catch {
|
|
2470
|
+
// Skip if frame creation fails
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* Toggle layer visibility
|
|
2477
|
+
* @param {'zones'|'vias'} layer
|
|
2478
|
+
* @param {boolean} visible
|
|
2479
|
+
*/
|
|
2480
|
+
_toggleLayer(layer, visible) {
|
|
2481
|
+
if (!this._canvas) return;
|
|
2482
|
+
|
|
2483
|
+
if (layer === 'zones') {
|
|
2484
|
+
// Toggle all graph-frame elements
|
|
2485
|
+
const frames = this._canvas.querySelectorAll('graph-frame');
|
|
2486
|
+
for (const frame of frames) {
|
|
2487
|
+
frame.style.display = visible ? '' : 'none';
|
|
2488
|
+
}
|
|
2489
|
+
} else if (layer === 'vias') {
|
|
2490
|
+
// Toggle dash styling on via connections
|
|
2491
|
+
// We use a data attribute on the canvas itself, CSS handles the rest
|
|
2492
|
+
if (visible) {
|
|
2493
|
+
this._canvas.removeAttribute('data-hide-vias');
|
|
2494
|
+
} else {
|
|
2495
|
+
this._canvas.setAttribute('data-hide-vias', '');
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/**
|
|
2501
|
+
* Handle agent tool events for autopilot mode
|
|
2502
|
+
* @param {object} event
|
|
2503
|
+
*/
|
|
2504
|
+
_handleAutopilot(event) {
|
|
2505
|
+
if (!this._editor || !this._canvas) return;
|
|
2506
|
+
|
|
2507
|
+
const toolName = event.tool || event.name || '';
|
|
2508
|
+
const args = event.args || {};
|
|
2509
|
+
|
|
2510
|
+
// tool:call events
|
|
2511
|
+
if (event.phase === 'call' || event.type === 'tool:call') {
|
|
2512
|
+
if (toolName === 'navigate' && args.action === 'expand' && args.symbol) {
|
|
2513
|
+
this._focusSymbol(args.symbol);
|
|
2514
|
+
} else if (toolName === 'navigate' && args.action === 'deps' && args.symbol) {
|
|
2515
|
+
this._highlightDeps(args.symbol);
|
|
2516
|
+
} else if (toolName === 'navigate' && args.action === 'call_chain') {
|
|
2517
|
+
// Phase 4: animate call chain when agent traces a path
|
|
2518
|
+
if (args.from && args.to) {
|
|
2519
|
+
this._highlightCallChain(args.from, args.to);
|
|
2520
|
+
}
|
|
2521
|
+
} else if (toolName === 'navigate' && args.action === 'usages' && args.symbol) {
|
|
2522
|
+
this._highlightDeps(args.symbol);
|
|
2523
|
+
} else if (toolName === 'get_skeleton') {
|
|
2524
|
+
this._canvas.fitView();
|
|
2525
|
+
} else if (toolName === 'compact' && args.path) {
|
|
2526
|
+
this._pulseFile(args.path);
|
|
2527
|
+
} else if (toolName === 'view_file' && args.path) {
|
|
2528
|
+
// Agent opened a file — focus it on the board
|
|
2529
|
+
this._focusFile(args.path);
|
|
2530
|
+
this._pulseFile(args.path);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// ── Phase 4: Camera Animation & Code Drill-down ──
|
|
2536
|
+
|
|
2537
|
+
/**
|
|
2538
|
+
* Smooth camera animation to a node position
|
|
2539
|
+
* @param {string} nodeId
|
|
2540
|
+
* @param {number} [targetZoom=1]
|
|
2541
|
+
* @param {number} [duration=400]
|
|
2542
|
+
*/
|
|
2543
|
+
_animateToNode(nodeId, targetZoom = 1, duration = 400) {
|
|
2544
|
+
if (!this._canvas) return;
|
|
2545
|
+
const positions = this._canvas.getPositions();
|
|
2546
|
+
const pos = positions[nodeId];
|
|
2547
|
+
if (!pos) return;
|
|
2548
|
+
|
|
2549
|
+
const canvasRect = this._canvas.getBoundingClientRect();
|
|
2550
|
+
const targetPanX = canvasRect.width / 2 - pos[0] * targetZoom;
|
|
2551
|
+
const targetPanY = canvasRect.height / 2 - pos[1] * targetZoom;
|
|
2552
|
+
|
|
2553
|
+
const startZoom = this._canvas.$.zoom;
|
|
2554
|
+
const startPanX = this._canvas.$.panX;
|
|
2555
|
+
const startPanY = this._canvas.$.panY;
|
|
2556
|
+
const startTime = performance.now();
|
|
2557
|
+
|
|
2558
|
+
const animate = (now) => {
|
|
2559
|
+
const elapsed = now - startTime;
|
|
2560
|
+
const t = Math.min(elapsed / duration, 1);
|
|
2561
|
+
// Ease-out cubic
|
|
2562
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
2563
|
+
|
|
2564
|
+
this._canvas.$.zoom = startZoom + (targetZoom - startZoom) * ease;
|
|
2565
|
+
this._canvas.$.panX = startPanX + (targetPanX - startPanX) * ease;
|
|
2566
|
+
this._canvas.$.panY = startPanY + (targetPanY - startPanY) * ease;
|
|
2567
|
+
|
|
2568
|
+
if (t < 1) requestAnimationFrame(animate);
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
requestAnimationFrame(animate);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* Focus on a file node with smooth animation
|
|
2576
|
+
* @param {string} filePath
|
|
2577
|
+
*/
|
|
2578
|
+
_focusFile(filePath) {
|
|
2579
|
+
const nodeId = this._fileMap.get(filePath);
|
|
2580
|
+
if (!nodeId) return;
|
|
2581
|
+
this._animateToNode(nodeId, 1, 400);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/**
|
|
2585
|
+
* Focus on a symbol (resolve to file first)
|
|
2586
|
+
* @param {string} symbol
|
|
2587
|
+
*/
|
|
2588
|
+
_focusSymbol(symbol) {
|
|
2589
|
+
if (!this._skeleton) return;
|
|
2590
|
+
// Try to find the file containing this symbol
|
|
2591
|
+
for (const [key, data] of Object.entries(this._skeleton.n || {})) {
|
|
2592
|
+
if (key === symbol && data.f) {
|
|
2593
|
+
this._focusFile(data.f);
|
|
2594
|
+
this._pulseFile(data.f);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/**
|
|
2601
|
+
* Highlight dependencies of a symbol with flow animation
|
|
2602
|
+
* @param {string} symbol
|
|
2603
|
+
*/
|
|
2604
|
+
_highlightDeps(symbol) {
|
|
2605
|
+
if (!this._skeleton) return;
|
|
2606
|
+
const data = (this._skeleton.n || {})[symbol];
|
|
2607
|
+
if (!data?.f) return;
|
|
2608
|
+
|
|
2609
|
+
// Focus + pulse the main file
|
|
2610
|
+
this._focusFile(data.f);
|
|
2611
|
+
this._pulseFile(data.f);
|
|
2612
|
+
|
|
2613
|
+
// Highlight connections from this file
|
|
2614
|
+
const nodeId = this._fileMap.get(data.f);
|
|
2615
|
+
if (!nodeId) return;
|
|
2616
|
+
|
|
2617
|
+
const connections = this._editor.getConnections()
|
|
2618
|
+
.filter(c => c.from === nodeId || c.to === nodeId);
|
|
2619
|
+
|
|
2620
|
+
for (const conn of connections) {
|
|
2621
|
+
this._canvas.setFlowing(conn.id, true);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Stop flow animation after 3 seconds
|
|
2625
|
+
setTimeout(() => {
|
|
2626
|
+
for (const conn of connections) {
|
|
2627
|
+
this._canvas.setFlowing(conn.id, false);
|
|
2628
|
+
}
|
|
2629
|
+
}, 3000);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* Highlight a call chain: animate sequential connection flow from source to target
|
|
2634
|
+
* @param {string} fromSymbol
|
|
2635
|
+
* @param {string} toSymbol
|
|
2636
|
+
*/
|
|
2637
|
+
_highlightCallChain(fromSymbol, toSymbol) {
|
|
2638
|
+
if (!this._skeleton) return;
|
|
2639
|
+
|
|
2640
|
+
// Resolve files
|
|
2641
|
+
const fromData = (this._skeleton.n || {})[fromSymbol];
|
|
2642
|
+
const toData = (this._skeleton.n || {})[toSymbol];
|
|
2643
|
+
const fromFile = fromData?.f;
|
|
2644
|
+
const toFile = toData?.f;
|
|
2645
|
+
if (!fromFile || !toFile) return;
|
|
2646
|
+
|
|
2647
|
+
const fromId = this._fileMap.get(fromFile);
|
|
2648
|
+
const toId = this._fileMap.get(toFile);
|
|
2649
|
+
if (!fromId || !toId) return;
|
|
2650
|
+
|
|
2651
|
+
// Find shortest path via BFS on connection graph
|
|
2652
|
+
const adj = new Map();
|
|
2653
|
+
for (const conn of this._editor.getConnections()) {
|
|
2654
|
+
if (!adj.has(conn.from)) adj.set(conn.from, []);
|
|
2655
|
+
adj.get(conn.from).push({ to: conn.to, connId: conn.id });
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
const visited = new Set([fromId]);
|
|
2659
|
+
const queue = [[fromId, []]];
|
|
2660
|
+
let path = null;
|
|
2661
|
+
|
|
2662
|
+
while (queue.length > 0) {
|
|
2663
|
+
const [current, connPath] = queue.shift();
|
|
2664
|
+
if (current === toId) {
|
|
2665
|
+
path = connPath;
|
|
2666
|
+
break;
|
|
2667
|
+
}
|
|
2668
|
+
for (const edge of (adj.get(current) || [])) {
|
|
2669
|
+
if (!visited.has(edge.to)) {
|
|
2670
|
+
visited.add(edge.to);
|
|
2671
|
+
queue.push([edge.to, [...connPath, edge.connId]]);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (!path || path.length === 0) return;
|
|
2677
|
+
|
|
2678
|
+
// Focus on source node first
|
|
2679
|
+
this._animateToNode(fromId, 0.8, 300);
|
|
2680
|
+
|
|
2681
|
+
// Animate flow along the path sequentially
|
|
2682
|
+
const stepDuration = 800;
|
|
2683
|
+
path.forEach((connId, idx) => {
|
|
2684
|
+
setTimeout(() => {
|
|
2685
|
+
this._canvas.setFlowing(connId, true);
|
|
2686
|
+
}, idx * stepDuration);
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
// Stop all flow after chain completes + hold time
|
|
2690
|
+
setTimeout(() => {
|
|
2691
|
+
for (const connId of path) {
|
|
2692
|
+
this._canvas.setFlowing(connId, false);
|
|
2693
|
+
}
|
|
2694
|
+
// Pan to destination
|
|
2695
|
+
this._animateToNode(toId, 1, 400);
|
|
2696
|
+
this._pulseFile(toFile);
|
|
2697
|
+
}, path.length * stepDuration + 1000);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/**
|
|
2701
|
+
* Pulse a file node (brief highlight)
|
|
2702
|
+
* @param {string} filePath
|
|
2703
|
+
*/
|
|
2704
|
+
_pulseFile(filePath) {
|
|
2705
|
+
const nodeId = this._fileMap.get(filePath);
|
|
2706
|
+
if (!nodeId) return;
|
|
2707
|
+
this._canvas.highlightTrace([{ nodeId }], 200);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
DepGraph.rootStyles = PCB_CSS;
|
|
2712
|
+
DepGraph.reg('pg-dep-graph');
|