project-graph-mcp 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/ARCHITECTURE.md +81 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +9 -4
  4. package/package.json +6 -13
  5. package/src/compact/expand.js +1 -1
  6. package/src/core/graph-builder.js +2 -2
  7. package/src/core/parser.js +2 -2
  8. package/src/network/server.js +1 -2
  9. package/vendor/symbiote-node/CHANGELOG.md +31 -0
  10. package/vendor/symbiote-node/LICENSE +21 -0
  11. package/vendor/symbiote-node/README.md +206 -0
  12. package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
  13. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
  14. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
  15. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
  16. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
  17. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
  18. package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
  19. package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
  20. package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
  21. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
  22. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
  23. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  24. package/vendor/symbiote-node/canvas/LODManager.js +88 -0
  25. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
  26. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
  27. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
  28. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
  29. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
  30. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  31. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
  32. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
  33. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
  34. package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
  35. package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
  36. package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
  37. package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
  38. package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
  39. package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
  40. package/vendor/symbiote-node/core/Connection.js +45 -0
  41. package/vendor/symbiote-node/core/Editor.js +451 -0
  42. package/vendor/symbiote-node/core/Frame.js +31 -0
  43. package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
  44. package/vendor/symbiote-node/core/GraphText.js +210 -0
  45. package/vendor/symbiote-node/core/Node.js +143 -0
  46. package/vendor/symbiote-node/core/Portal.js +104 -0
  47. package/vendor/symbiote-node/core/Socket.js +185 -0
  48. package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
  49. package/vendor/symbiote-node/index.js +103 -0
  50. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
  51. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
  52. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  53. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  54. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
  55. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  56. package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
  57. package/vendor/symbiote-node/interactions/Drag.js +102 -0
  58. package/vendor/symbiote-node/interactions/Selector.js +132 -0
  59. package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
  60. package/vendor/symbiote-node/interactions/Zoom.js +140 -0
  61. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
  62. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
  63. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
  64. package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
  65. package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
  66. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
  67. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
  68. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
  69. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
  70. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  71. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
  72. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  73. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
  74. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
  75. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
  76. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
  77. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
  78. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
  79. package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
  80. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
  81. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
  82. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
  83. package/vendor/symbiote-node/layout/index.js +16 -0
  84. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
  85. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
  86. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  87. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
  88. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
  89. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
  90. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
  91. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
  92. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
  93. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
  94. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
  95. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
  96. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
  97. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
  98. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
  99. package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
  100. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
  101. package/vendor/symbiote-node/package.json +59 -0
  102. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  103. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
  104. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
  105. package/vendor/symbiote-node/plugins/History.js +384 -0
  106. package/vendor/symbiote-node/plugins/Readonly.js +59 -0
  107. package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
  108. package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
  109. package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
  110. package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
  111. package/vendor/symbiote-node/shapes/PillShape.js +91 -0
  112. package/vendor/symbiote-node/shapes/RectShape.js +72 -0
  113. package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
  114. package/vendor/symbiote-node/shapes/index.js +53 -0
  115. package/vendor/symbiote-node/themes/Palette.js +32 -0
  116. package/vendor/symbiote-node/themes/Skin.js +113 -0
  117. package/vendor/symbiote-node/themes/Theme.js +84 -0
  118. package/vendor/symbiote-node/themes/carbon.js +137 -0
  119. package/vendor/symbiote-node/themes/dark.js +137 -0
  120. package/vendor/symbiote-node/themes/ebook.js +138 -0
  121. package/vendor/symbiote-node/themes/grey.js +137 -0
  122. package/vendor/symbiote-node/themes/light.js +137 -0
  123. package/vendor/symbiote-node/themes/neon.js +138 -0
  124. package/vendor/symbiote-node/themes/pcb.js +273 -0
  125. package/vendor/symbiote-node/themes/synthwave.js +137 -0
  126. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
  127. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
  128. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
  129. package/web/app.js +6 -5
  130. package/web/components/canvas-graph.js +1666 -0
  131. package/web/components/event-feed/CodeWidget.js +32 -0
  132. package/web/components/event-feed/EventWidget.js +97 -0
  133. package/web/components/event-feed/ListWidget.js +57 -0
  134. package/web/components/event-feed/MiniGraphWidget.js +69 -0
  135. package/web/dashboard.js +1 -1
  136. package/web/index.html +4 -0
  137. package/web/panels/ActionBoard/ActionBoard.js +1 -1
  138. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
  139. package/web/panels/code-viewer.js +50 -15
  140. package/web/panels/dep-graph.js +2712 -7
  141. package/web/panels/file-tree.js +5 -2
  142. package/web/panels/live-monitor.js +75 -3
  143. package/web/style.css +33 -0
  144. package/docs/img/explorer-compact.jpg +0 -0
  145. package/docs/img/explorer-expanded.jpg +0 -0
  146. package/src/.contextignore +0 -22
  147. package/src/.project-graph-cache.json +0 -1
  148. package/src/compact/.project-graph-cache.json +0 -1
  149. package/web/.project-graph-cache.json +0 -1
  150. package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
@@ -1,7 +1,2712 @@
1
- // @ctx .context/web/panels/dep-graph.ctx
2
- import s from"@symbiotejs/symbiote";import{api as e,state as p,events as n,emit as t}from"../app.js";
3
- export class DepGraph extends s{init$={contentHTML:'<div class="pg-placeholder">Select a file to see dependencies</div>'};initCallback(){n.addEventListener("file-selected",s=>this._loadDeps(s.detail.path)),n.addEventListener("skeleton-loaded",()=>this._renderOverview()),p.skeleton&&this._renderOverview(),this.addEventListener("click",s=>{const e=s.target.closest("[data-file]");if(e){const s=e.dataset.file;p.activeFile=s,t("file-selected",{path:s})}})}_renderOverview(){if(!p.skeleton)return;
4
- const s=p.skeleton.s||{},e=p.skeleton.X||{},n=(Object.values(p.skeleton.n||{}),['<div class="pg-graph-stats">']),t=s.files||Object.keys(e).length,a=s.functions||0,i=s.classes||0,o=Object.values(e).reduce((s,e)=>s+e.length,0);n.push(`<div class="pg-stat"><span class="pg-stat-val">${t}</span><span class="pg-stat-label">Files</span></div>`),n.push(`<div class="pg-stat"><span class="pg-stat-val">${a}</span><span class="pg-stat-label">Functions</span></div>`),n.push(`<div class="pg-stat"><span class="pg-stat-val">${i}</span><span class="pg-stat-label">Classes</span></div>`),n.push(`<div class="pg-stat"><span class="pg-stat-val">${o}</span><span class="pg-stat-label">Exports</span></div>`),n.push("</div>");
5
- const l=[];for(const[s,p]of Object.entries(e))l.push({file:s,exports:p.length});l.sort((s,e)=>e.exports-s.exports),n.push('<div class="pg-dep-section"><div class="pg-dep-label">Most Exported</div>');for(const s of l.slice(0,10))n.push(`<div class="pg-dep-item pg-dep-nav" data-file="${s.file}">\n <span class="pg-dep-file">${s.file}</span>\n <span class="pg-dep-badge">${s.exports} exports</span>\n </div>`);n.push("</div>"),this.$.contentHTML=n.join("")}async _loadDeps(s){this.$.contentHTML='<div class="pg-placeholder pg-pulse">Loading dependencies...</div>';try{const e=Object.values(p.skeleton?.n||{}).find(e=>e.f===s);if(!e)return void(this.$.contentHTML='<div class="pg-placeholder">File not in graph</div>');
6
- const n=[];if(n.push(`<h3 class="pg-dep-title">${s}</h3>`),e.i?.length){n.push('<div class="pg-dep-section"><div class="pg-dep-label">Imports</div>');for(const s of e.i){const e=s.s||s,t=Object.values(p.skeleton.n).find(s=>s.f===e||s.f?.endsWith("/"+e)||s.f?.endsWith(e+".js"));t?n.push(`<div class="pg-dep-item pg-dep-nav pg-dep-import" data-file="${t.f}">\n <span class="material-symbols-outlined" style="font-size:14px">arrow_back</span>\n ${e}\n </div>`):n.push(`<div class="pg-dep-item pg-dep-import">\n <span class="material-symbols-outlined" style="font-size:14px">arrow_back</span>\n ${e}\n <span class="pg-dep-ext">external</span>\n </div>`)}n.push("</div>")}const t=[];for(const e of Object.values(p.skeleton?.n||{})){if(e.f===s)continue;
7
- const p=e.i||[];for(const n of p){const p=n.s||n;if(p===s||s.endsWith(p)||s.endsWith(p+".js")){t.push(e.f);break}}}if(t.length){n.push('<div class="pg-dep-section"><div class="pg-dep-label">Imported By</div>');for(const s of t)n.push(`<div class="pg-dep-item pg-dep-nav pg-dep-importer" data-file="${s}">\n <span class="material-symbols-outlined" style="font-size:14px">arrow_forward</span>\n ${s}\n </div>`);n.push("</div>")}if(e.e?.length){n.push('<div class="pg-dep-section"><div class="pg-dep-label">Exports</div>');for(const s of e.e)n.push(`<div class="pg-dep-item pg-dep-export">→ ${s}</div>`);n.push("</div>")}if(e.fn?.length){n.push('<div class="pg-dep-section"><div class="pg-dep-label">Functions</div>');for(const s of e.fn){const e=s.n||s,p=s.p?`(${s.p})`:"()";n.push(`<div class="pg-dep-item pg-dep-fn">ƒ ${e}${p}</div>`)}n.push("</div>")}if(e.c?.length){n.push('<div class="pg-dep-section"><div class="pg-dep-label">Classes</div>');for(const s of e.c){const e=s.n||s,p=s.x?` extends ${s.x}`:"",t=s.m?.map(s=>s.n||s).join(", ")||"";n.push(`<div class="pg-dep-item pg-dep-class">◆ ${e}${p}</div>`),t&&n.push(`<div class="pg-dep-item pg-dep-methods">&nbsp;&nbsp;methods: ${t}</div>`)}n.push("</div>")}this.$.contentHTML=n.join("")}catch(s){this.$.contentHTML=`<div class="pg-placeholder" style="color:var(--sn-danger-color)">Error: ${s.message}</div>`}}}DepGraph.template='<div class="pg-graph-body" bind="innerHTML: contentHTML"></div>',DepGraph.rootStyles="\n pg-dep-graph { display:block; height:100%; overflow-y:auto; padding:12px; font-size:12px; font-family:var(--sn-font, Georgia, serif); }\n .pg-graph-stats { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }\n .pg-stat {\n background: var(--sn-node-bg, hsl(40, 33%, 96%));\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 80%));\n border-radius: 6px;\n padding: 10px 14px;\n text-align: center;\n flex: 1;\n min-width: 60px;\n }\n .pg-stat-val { display:block; font-size:22px; font-weight:700; color:var(--sn-cat-server, hsl(210, 45%, 45%)); font-family:monospace; }\n .pg-stat-label { font-size:9px; text-transform:uppercase; color:var(--sn-text-dim); letter-spacing:0.5px; }\n .pg-dep-title { font-size:13px; color:var(--sn-text); margin:0 0 12px 0; font-family:monospace; }\n .pg-dep-section { margin-bottom:12px; }\n .pg-dep-label { font-size:10px; text-transform:uppercase; letter-spacing:0.5px; color:var(--sn-text-dim); margin-bottom:4px; font-weight:600; }\n .pg-dep-item {\n padding: 4px 8px;\n border-radius: 4px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 11px;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .pg-dep-nav {\n cursor: pointer;\n transition: background 120ms ease;\n }\n .pg-dep-nav:hover {\n background: var(--sn-node-hover, hsl(36, 22%, 88%));\n }\n .pg-dep-import { color: hsl(210, 45%, 45%); }\n .pg-dep-importer { color: hsl(30, 55%, 50%); }\n .pg-dep-export { color: hsl(150, 40%, 38%); }\n .pg-dep-fn { color: hsl(250, 35%, 50%); }\n .pg-dep-class { color: hsl(330, 40%, 50%); }\n .pg-dep-methods { color: var(--sn-text-dim); font-size:10px; }\n .pg-dep-ext {\n font-size: 9px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n opacity: 0.5;\n margin-left: auto;\n }\n .pg-dep-file { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n .pg-dep-badge {\n font-size: 10px;\n color: var(--sn-text-dim);\n white-space: nowrap;\n }\n .pg-placeholder { color:var(--sn-text-dim); text-align:center; padding:30px; font-style:italic; }\n .pg-pulse { animation:pulse 1.5s ease infinite; }\n @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }\n",DepGraph.reg("pg-dep-graph");
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');