project-graph-mcp 2.2.6 → 2.3.1

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