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
@@ -0,0 +1,1705 @@
1
+ import Symbiote from '@symbiotejs/symbiote';
2
+
3
+ const INIT_NODE_COUNT = 40;
4
+ const EDGE_RATIO = 1.2;
5
+ const DOT_RADIUS = 6;
6
+ const HIT_RADIUS = 14;
7
+
8
+ /**
9
+ * Universal node radius calculator.
10
+ * @param {object} node - Node object with isGroup, children, aScale
11
+ * @param {number} conns - Number of connections (from adjMap)
12
+ * @param {object} [opts] - Options
13
+ * @param {number} [opts.scale] - Override aScale (default: node.aScale || 1)
14
+ * @returns {number} Visual radius in world units
15
+ */
16
+ function getNodeRadius(node, conns, opts = {}) {
17
+ const hubScale = 1 + Math.min(conns, 8) * 0.1;
18
+ const aScale = opts.scale ?? (node.aScale || 1);
19
+ let baseR = DOT_RADIUS * hubScale * aScale;
20
+ if (node.isGroup) {
21
+ const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
22
+ const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
23
+ const spacing = innerR * 2.5;
24
+ const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
25
+ // r = orbitR + innerR + ringW + padding
26
+ // ringW = r * 0.12
27
+ // r = (orbitR + innerR + 2) / 0.88
28
+ return (orbitR + innerR + 2) / 0.88;
29
+ }
30
+ return baseR;
31
+ }
32
+
33
+ const NODE_TYPES = ['data', 'action', 'output', 'config', 'external', 'style', 'docs', 'asset'];
34
+ const TYPE_COLORS = {
35
+ action: [255, 150, 140], // Soft coral (JS/TS logic)
36
+ output: [120, 210, 170], // Sage green (HTML/Entry/UI)
37
+ data: [120, 180, 255], // Pastel blue (JSON data)
38
+ config: [255, 200, 120], // Warm amber (Configs, Env)
39
+ external: [190, 150, 255], // Lavender (Tests, Specs)
40
+ style: [255, 180, 220], // Pastel pink (CSS/SCSS)
41
+ docs: [200, 210, 215], // Slate/grey-blue (MD/TXT)
42
+ asset: [150, 230, 230], // Mint/Cyan (SVG/PNG)
43
+ group: [230, 180, 110], // Golden pastel orange
44
+ };
45
+
46
+ const MENU_ITEMS = [
47
+ { action: 'drill', label: 'Enter Group', path: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' },
48
+ { action: 'explore', label: 'Explore', path: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' },
49
+ { action: 'view-code', label: 'View Code', path: 'M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6-1.4-1.4z' },
50
+ ];
51
+
52
+ export class CanvasGraph extends Symbiote {
53
+ init$ = {
54
+ // These defaults will be updated from external controller if needed
55
+ chargeStrength: -150,
56
+ linkDistance: 150,
57
+ linkStrength: 0.25,
58
+ centerStrength: 0,
59
+ velocityDecay: 0.92,
60
+ collideStrength: 1.0,
61
+ alphaDecay: 0.015,
62
+ theta: 0.7,
63
+ alphaFloor: 0.0001,
64
+ alphaTarget: 0.0001,
65
+ brownian: 0,
66
+ brownianThresh: 0.001,
67
+ pinReheat: 0.02,
68
+ pinCap: 0.08,
69
+ wellStrength: 0.8,
70
+ centerPull: 0.3,
71
+ wellRepulsion: 5.0,
72
+ crossLinkScale: 0.2,
73
+ };
74
+
75
+ _bgR = 15;
76
+ _bgG = 23;
77
+ _bgB = 42;
78
+ _ghostColor = 'rgb(22,30,50)';
79
+
80
+ initCallback() {
81
+ this.nodes = [];
82
+ this.edges = [];
83
+ this.nodeMap = new Map();
84
+ this.adjMap = new Map();
85
+ this.interactionDepths = new Map();
86
+ this.nodePositions = new Map();
87
+ this.nodeIds = [];
88
+
89
+ this.worker = null;
90
+ this.paused = false;
91
+ this.dragNode = null;
92
+ this.activeNode = null;
93
+ this.hoverNode = null;
94
+ this.nextActiveNode = null;
95
+ this.deactivating = false;
96
+ this.menuAnim = 0;
97
+ this.dragOffset = { x: 0, y: 0 };
98
+ this.renderMode = 'dots';
99
+
100
+ this.focusX = 0;
101
+ this.focusY = 0;
102
+ this.focusActive = false;
103
+
104
+ this.panX = 0;
105
+ this.panY = 0;
106
+ this.zoom = 0.5;
107
+ this._targetZoom = 0.5;
108
+ this._targetPanX = null; // null = no animation target
109
+ this._targetPanY = null;
110
+ this._zoomAnchor = null; // {mx, my} — screen point to keep stable during zoom
111
+ this.isPanning = false;
112
+ this.panStart = { x: 0, y: 0, px: 0, py: 0 };
113
+
114
+ this.frameCount = 0;
115
+ this.tickCount = 0;
116
+ this.lastFpsTime = performance.now();
117
+ this.lastAlpha = 0;
118
+
119
+ this.smoothPositions = new Map();
120
+ this.prevPositions = new Map();
121
+ this.smoothing = 0.99;
122
+
123
+ this.graphDB = { nodes: new Map(), edges: [], rootNodes: [] };
124
+ this.currentGroupId = null;
125
+ this._loopRunning = false; // Whether the rAF draw loop is active
126
+ this._idleFrames = 0; // Count consecutive frames with no visual change
127
+ this._prevDragDeltaX = 0; // Previous frame's focus drag delta X
128
+ this._prevDragDeltaY = 0; // Previous frame's focus drag delta Y
129
+ this._skeleton = null; // Skeleton data reference for metadata
130
+
131
+ // Info panel state (typewriter HUD to the right of active node)
132
+ this._infoPanel = {
133
+ nodeId: null,
134
+ lines: [],
135
+ opacity: 0,
136
+ startTime: 0,
137
+ totalExtent: 0,
138
+ totalExtentY: 0,
139
+ _centeredForNode: null, // Track which node we've centered for
140
+ };
141
+
142
+ this.canvas = document.createElement('canvas');
143
+ this.appendChild(this.canvas);
144
+ this.ctx = this.canvas.getContext('2d');
145
+
146
+ this.offscreenCanvases = {};
147
+ for (let i = 1; i <= 4; i++) {
148
+ const oc = document.createElement('canvas');
149
+ this.offscreenCanvases[i] = { canvas: oc, ctx: oc.getContext('2d', { alpha: true }) };
150
+ }
151
+
152
+ this.layerAnim = {
153
+ 0: { scale: 1, opacity: 1, parallax: 0 },
154
+ 1: { scale: 1, opacity: 1, parallax: 0 },
155
+ 2: { scale: 1, opacity: 1, parallax: 0 },
156
+ 3: { scale: 1, opacity: 1, parallax: 0 },
157
+ 4: { scale: 1, opacity: 1, parallax: 0 }
158
+ };
159
+
160
+ this.LAYER_TARGETS = {
161
+ scale: [1.12, 1.0, 0.95, 0.88, 0.78],
162
+ opacity: [1.0, 0.9, 0.55, 0.06, 0.03],
163
+ blur: [0, 0, 1, 3, 5],
164
+ parallax: [0, 0, 0.02, 0.04, 0.07]
165
+ };
166
+
167
+ this.depthGroups = {
168
+ 0: { edges: [], nodes: [] },
169
+ 1: { edges: [], nodes: [] },
170
+ 2: { edges: [], nodes: [] },
171
+ 3: { edges: [], nodes: [] },
172
+ 4: { edges: [], nodes: [] }
173
+ };
174
+
175
+ const resizeObserver = new ResizeObserver(() => this.resizeCanvas());
176
+ resizeObserver.observe(this);
177
+ this.resizeCanvas();
178
+
179
+ this.bindEvents();
180
+
181
+ this._wakeLoop();
182
+
183
+ // Bind existing graph-breadcrumb from symbiote-node
184
+ if (this.ref.breadcrumb) {
185
+ this.ref.breadcrumb.onNavigate((levelStr) => {
186
+ // levelStr is the path string we passed into 'level' property
187
+ this.setPath(levelStr || null);
188
+ });
189
+ }
190
+
191
+ setTimeout(() => {
192
+ let rawBg = getComputedStyle(document.body).getPropertyValue('--sn-bg').trim();
193
+ if (!rawBg) rawBg = getComputedStyle(document.body).backgroundColor;
194
+
195
+ // Robust way to parse ANY color in browser
196
+ const tempCtx = document.createElement('canvas').getContext('2d');
197
+ tempCtx.fillStyle = '#1a1a1a'; // fallback
198
+ tempCtx.fillStyle = rawBg;
199
+ this._bgR = 26; this._bgG = 26; this._bgB = 26; // Default
200
+
201
+ if (tempCtx.fillStyle.startsWith('#')) {
202
+ const hex = tempCtx.fillStyle;
203
+ this._bgR = parseInt(hex.length === 4 ? hex[1]+hex[1] : hex.slice(1,3), 16);
204
+ this._bgG = parseInt(hex.length === 4 ? hex[2]+hex[2] : hex.slice(3,5), 16);
205
+ this._bgB = parseInt(hex.length === 4 ? hex[3]+hex[3] : hex.slice(5,7), 16);
206
+ }
207
+
208
+ // If the background is extremely dark, we need a larger boost to be visible
209
+ const boost = 25;
210
+ this._ghostColor = `rgb(${Math.min(255, this._bgR + boost)}, ${Math.min(255, this._bgG + boost)}, ${Math.min(255, this._bgB + boost)})`;
211
+ }, 100);
212
+ }
213
+
214
+ disconnectedCallback() {
215
+ this._loopRunning = false;
216
+ if (this._animationFrame) cancelAnimationFrame(this._animationFrame);
217
+ if (this.worker) this.worker.terminate();
218
+ }
219
+
220
+ /**
221
+ * Ensure the rAF draw loop is running. Safe to call repeatedly.
222
+ * Called by all state-changing entry points (interaction, worker, resize).
223
+ */
224
+ _wakeLoop() {
225
+ if (this._loopRunning) return;
226
+ this._loopRunning = true;
227
+ this._idleFrames = 0;
228
+ this._animationFrame = requestAnimationFrame(() => this.draw());
229
+ }
230
+
231
+ resizeCanvas() {
232
+ const dpr = window.devicePixelRatio || 1;
233
+ const rect = this.getBoundingClientRect();
234
+ this._wakeLoop(); // Dimensions changed — redraw
235
+ if (rect.width === 0) return;
236
+ this.canvas.style.width = rect.width + 'px';
237
+ this.canvas.style.height = rect.height + 'px';
238
+ this.canvas.width = rect.width * dpr;
239
+ this.canvas.height = rect.height * dpr;
240
+ }
241
+
242
+ resetView() {
243
+ this.fitView();
244
+ }
245
+
246
+ fitView(padding = 60, animate = true) {
247
+ if (!this.nodePositions.size) return;
248
+ const rect = this.canvas.getBoundingClientRect();
249
+ if (rect.width === 0 || rect.height === 0) return;
250
+
251
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
252
+ for (const pos of this.nodePositions.values()) {
253
+ if (pos.x < minX) minX = pos.x;
254
+ if (pos.y < minY) minY = pos.y;
255
+ if (pos.x > maxX) maxX = pos.x;
256
+ if (pos.y > maxY) maxY = pos.y;
257
+ }
258
+
259
+ const graphW = maxX - minX || 1;
260
+ const graphH = maxY - minY || 1;
261
+ const cx = (minX + maxX) / 2;
262
+ const cy = (minY + maxY) / 2;
263
+
264
+ const newZoom = Math.max(0.02, Math.min(
265
+ (rect.width - padding * 2) / graphW,
266
+ (rect.height - padding * 2) / graphH,
267
+ 2.0
268
+ ));
269
+ const newPanX = rect.width / 2 - cx * newZoom;
270
+ const newPanY = rect.height / 2 - cy * newZoom;
271
+
272
+ if (animate) {
273
+ this._targetZoom = newZoom;
274
+ this._targetPanX = newPanX;
275
+ this._targetPanY = newPanY;
276
+ this._zoomAnchor = null;
277
+ } else {
278
+ this.zoom = newZoom;
279
+ this._targetZoom = newZoom;
280
+ this.panX = newPanX;
281
+ this.panY = newPanY;
282
+ this._targetPanX = null;
283
+ this._targetPanY = null;
284
+ }
285
+ this.needsDraw = true;
286
+ this._wakeLoop();
287
+ }
288
+
289
+ pulseNode(nodeId, durationMs = 1500) {
290
+ this._pulses = this._pulses || [];
291
+ this._pulses.push({
292
+ id: nodeId,
293
+ startTime: performance.now(),
294
+ duration: durationMs
295
+ });
296
+ this.needsDraw = true;
297
+ this._wakeLoop();
298
+ }
299
+
300
+ flyToNode(nodeId, options = {}) {
301
+ const node = this.graphDB?.nodes.get(nodeId);
302
+ if (node && node.parentId) {
303
+ if (node.parentId !== this.currentGroupId) {
304
+ this.loadLevel(node.parentId);
305
+ setTimeout(() => this.flyToNode(nodeId, options), 500);
306
+ return;
307
+ }
308
+ }
309
+
310
+ const pos = this.getSmooth(nodeId) || this.nodePositions.get(nodeId);
311
+ if (!pos) return;
312
+
313
+ const rect = this.canvas.getBoundingClientRect();
314
+ if (rect.width === 0) return;
315
+
316
+ // Set zoom target: use provided zoom level, or force a comfortable minimum for focus
317
+ const targetZoom = options.zoom || Math.max(1.2, Math.min(2.0, this.zoom));
318
+ this._targetZoom = targetZoom;
319
+ this._targetPanX = rect.width / 2 - pos.x * targetZoom;
320
+ this._targetPanY = rect.height / 2 - pos.y * targetZoom;
321
+ this._zoomAnchor = null;
322
+
323
+ // Activate the node
324
+ const foundNode = this.nodeMap?.get(nodeId);
325
+ if (foundNode) {
326
+ this.activeNode = foundNode;
327
+ this.updateInteractionDepths();
328
+ }
329
+ this.needsDraw = true;
330
+ this._wakeLoop();
331
+ }
332
+
333
+ setPath(pathStr) {
334
+ if (!pathStr) {
335
+ if (this.currentGroupId) this.loadLevel(null);
336
+ return;
337
+ }
338
+
339
+ // The pathStr is exactly the group ID in our new universal routing model
340
+ if (pathStr !== this.currentGroupId) {
341
+ this.loadLevel(pathStr);
342
+ }
343
+ }
344
+
345
+ // ─── HELPERS ───
346
+ _dirOf(filePath) {
347
+ const idx = filePath.lastIndexOf('/');
348
+ return idx >= 0 ? filePath.slice(0, idx + 1) : './';
349
+ }
350
+
351
+ _resolveImport(importPath, fromFile, knownFiles) {
352
+ if (knownFiles.has(importPath)) return importPath;
353
+ if (knownFiles.has(importPath + '.js')) return importPath + '.js';
354
+ if (importPath.startsWith('.')) {
355
+ const dir = this._dirOf(fromFile);
356
+ let resolved = dir + importPath.replace(/^\.\//, '');
357
+ const parts = resolved.split('/');
358
+ const normalized = [];
359
+ for (const part of parts) {
360
+ if (part === '..') normalized.pop();
361
+ else if (part !== '.') normalized.push(part);
362
+ }
363
+ resolved = normalized.join('/');
364
+ if (knownFiles.has(resolved)) return resolved;
365
+ if (knownFiles.has(resolved + '.js')) return resolved + '.js';
366
+ if (knownFiles.has(resolved + '/index.js')) return resolved + '/index.js';
367
+ }
368
+ return null;
369
+ }
370
+
371
+ _classifyFile(file, classFiles) {
372
+ const name = file.split('/').pop().toLowerCase();
373
+ const ext = name.split('.').pop();
374
+
375
+ // Explicit file names
376
+ if (name.includes('test') || name.includes('spec')) return 'external';
377
+ if (name === 'index.js' || name === 'index.mjs') return 'output';
378
+ if (name === 'package.json' || name.startsWith('.env') || name.startsWith('.git')) return 'config';
379
+
380
+ // By extension
381
+ if (ext === 'css' || ext === 'scss' || ext === 'less') return 'style';
382
+ if (ext === 'html' || ext === 'tpl' || ext === 'vue' || ext === 'jsx' || ext === 'tsx') return 'output';
383
+ if (ext === 'json' || ext === 'yaml' || ext === 'yml' || ext === 'toml') return 'config';
384
+ if (ext === 'md' || ext === 'txt' || ext === 'csv') return 'docs';
385
+ if (ext === 'svg' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'ico') return 'asset';
386
+
387
+ // Default code files
388
+ if (ext === 'js' || ext === 'ts' || ext === 'mjs' || ext === 'py' || ext === 'go' || ext === 'rs') return 'action';
389
+
390
+ if (classFiles.has(file)) return 'action';
391
+ return 'data';
392
+ }
393
+
394
+ // ─── SKELETON PARSER ───
395
+ setSkeleton(skeleton) {
396
+ this._skeleton = skeleton;
397
+ this.graphDB = { nodes: new Map(), edges: [], rootNodes: [] };
398
+ const N = skeleton.n || {};
399
+ const X = skeleton.X || {};
400
+ const I = skeleton.I || {};
401
+ const L = skeleton.L || {};
402
+
403
+ // 1. Collect all known files
404
+ const allFiles = new Set();
405
+ const classFiles = new Set(); // files that have classes/exports
406
+ for (const data of Object.values(N)) {
407
+ if (data.f) { allFiles.add(data.f); classFiles.add(data.f); }
408
+ }
409
+ for (const file of Object.keys(X)) allFiles.add(file);
410
+ // skeleton.f = { "dirPath/": ["file1.js", ...] }
411
+ for (const [dir, names] of Object.entries(skeleton.f || {})) {
412
+ for (const name of names) allFiles.add(dir === './' ? name : dir + name);
413
+ }
414
+ // skeleton.a = asset files (non-source)
415
+ for (const [dir, names] of Object.entries(skeleton.a || {})) {
416
+ for (const name of names) allFiles.add(dir === './' ? name : dir + name);
417
+ }
418
+
419
+ // 2. Build directory hierarchy from file paths
420
+ const dirs = new Set();
421
+ for (const file of allFiles) {
422
+ const parts = file.split('/');
423
+ for (let i = 1; i < parts.length; i++) {
424
+ dirs.add(parts.slice(0, i).join('/'));
425
+ }
426
+ }
427
+ // Create directory group nodes
428
+ for (const dir of [...dirs].sort()) {
429
+ const parentDir = dir.includes('/') ? dir.substring(0, dir.lastIndexOf('/')) : null;
430
+ const label = dir.split('/').pop();
431
+ const node = { id: dir, label, w: 160, h: 40, type: 'group', isGroup: true, parentId: parentDir, children: [] };
432
+ this.graphDB.nodes.set(dir, node);
433
+ if (!parentDir || !dirs.has(parentDir)) {
434
+ this.graphDB.rootNodes.push(dir);
435
+ }
436
+ }
437
+ // Link child directories to parents
438
+ for (const node of this.graphDB.nodes.values()) {
439
+ if (node.parentId && this.graphDB.nodes.has(node.parentId)) {
440
+ this.graphDB.nodes.get(node.parentId).children.push(node.id);
441
+ }
442
+ }
443
+
444
+ // 3. Create file nodes
445
+ for (const file of allFiles) {
446
+ const parentId = this._dirOf(file).replace(/\/$/, '') || null;
447
+ const actualParent = parentId && this.graphDB.nodes.has(parentId) ? parentId : null;
448
+ const type = this._classifyFile(file, classFiles);
449
+ const label = file.split('/').pop();
450
+ const node = { id: file, label, w: 160, h: 40, type, isGroup: false, parentId: actualParent, children: [] };
451
+ this.graphDB.nodes.set(file, node);
452
+ if (actualParent) {
453
+ this.graphDB.nodes.get(actualParent).children.push(file);
454
+ } else {
455
+ this.graphDB.rootNodes.push(file);
456
+ }
457
+ }
458
+
459
+ // 4. Extract edges from skeleton.I (import sources)
460
+ const edgeList = [];
461
+ const edgeSet = new Set();
462
+ for (const [srcFile, imports] of Object.entries(I)) {
463
+ if (!allFiles.has(srcFile)) continue;
464
+ for (const impPath of imports) {
465
+ // Skip bare module imports (node_modules)
466
+ if (!impPath.startsWith('.') && !impPath.startsWith('/')) continue;
467
+ const targetFile = this._resolveImport(impPath, srcFile, allFiles);
468
+ if (!targetFile || targetFile === srcFile) continue;
469
+ const key = srcFile + '>' + targetFile;
470
+ if (edgeSet.has(key)) continue;
471
+ edgeSet.add(key);
472
+ edgeList.push({ from: srcFile, to: targetFile });
473
+ }
474
+ }
475
+ this.graphDB.edges = edgeList;
476
+
477
+ // Center viewport BEFORE worker starts — prevents nodes flashing at top-left
478
+ const rect = this.canvas.getBoundingClientRect();
479
+ if (rect.width > 0) {
480
+ this.panX = rect.width / 2;
481
+ this.panY = rect.height / 2;
482
+ }
483
+
484
+ this.loadLevel(null);
485
+ }
486
+
487
+ // ... (Other test-force-sim logic converted to class methods with `this.`)
488
+ rebuildNodeMap() { this.nodeMap = new Map(this.nodes.map(n => [n.id, n])); }
489
+
490
+ rebuildAdjMap() {
491
+ this.adjMap.clear();
492
+ for (const n of this.nodes) this.adjMap.set(n.id, new Set());
493
+ for (const e of this.edges) {
494
+ if (this.adjMap.has(e.from)) this.adjMap.get(e.from).add(e.to);
495
+ if (this.adjMap.has(e.to)) this.adjMap.get(e.to).add(e.from);
496
+ }
497
+ }
498
+
499
+ updateInteractionDepths() {
500
+ this.interactionDepths.clear();
501
+ const activeGroupId = this.currentGroupId;
502
+ const focusNode = this.activeNode || this.dragNode;
503
+
504
+ // Establish baseline target depths for all nodes
505
+ for (const node of this.nodes) {
506
+ if (activeGroupId) {
507
+ if (node.parentId === activeGroupId) node.targetDepth = focusNode ? 3 : 0;
508
+ else if (node.id === activeGroupId) node.targetDepth = 4; // Hide the container group itself
509
+ else node.targetDepth = 4; // Other nodes hidden when inside a group
510
+ } else {
511
+ node.targetDepth = focusNode ? 3 : 0; // Dim to 3 if focused, 0 otherwise
512
+ }
513
+ }
514
+
515
+ for (const edge of this.edges) { edge.targetDepth = 4; edge.minTargetDepth = 4; }
516
+
517
+ if (!focusNode) {
518
+ for (const edge of this.edges) {
519
+ const d1 = this.nodeMap.get(edge.from)?.targetDepth ?? 4;
520
+ const d2 = this.nodeMap.get(edge.to)?.targetDepth ?? 4;
521
+ edge.targetDepth = Math.max(d1, d2);
522
+ edge.minTargetDepth = Math.min(d1, d2);
523
+ }
524
+ return;
525
+ }
526
+
527
+ // BFS from focusNode
528
+ const queue = [[focusNode.id, 0]];
529
+ const visited = new Set([focusNode.id]);
530
+ this.interactionDepths.set(focusNode.id, 0);
531
+
532
+ while (queue.length > 0) {
533
+ const [curr, depth] = queue.shift();
534
+ const currNode = this.nodeMap.get(curr);
535
+ if (currNode) currNode.targetDepth = depth;
536
+
537
+ if (depth >= 3) continue;
538
+ const neighbors = this.adjMap.get(curr) || new Set();
539
+ for (const n of neighbors) {
540
+ if (!visited.has(n)) {
541
+ visited.add(n);
542
+ this.interactionDepths.set(n, depth + 1);
543
+ queue.push([n, depth + 1]);
544
+ }
545
+ }
546
+ }
547
+
548
+ for (const edge of this.edges) {
549
+ const d1 = this.interactionDepths.has(edge.from) ? this.interactionDepths.get(edge.from) : 4;
550
+ const d2 = this.interactionDepths.has(edge.to) ? this.interactionDepths.get(edge.to) : 4;
551
+ edge.targetDepth = Math.max(d1, d2);
552
+ edge.minTargetDepth = Math.min(d1, d2);
553
+ }
554
+ }
555
+
556
+ loadLevel(groupId = null) {
557
+ this._wakeLoop(); // View changed — resume rendering
558
+ this.activeNode = null;
559
+ this.dragNode = null;
560
+ this.hoverNode = null;
561
+ this.menuAnim = 0;
562
+ this.deactivating = false;
563
+
564
+ for (const node of this.graphDB.nodes.values()) {
565
+ if (node.isGroup) {
566
+ const groupR = getNodeRadius(node, 0);
567
+ node.w = groupR * 2;
568
+ node.h = groupR * 2;
569
+ }
570
+ }
571
+
572
+ let activeIds = [...this.graphDB.rootNodes];
573
+
574
+ if (!groupId) {
575
+ this.currentGroupId = null;
576
+ if (this.ref.breadcrumb) this.ref.breadcrumb.setPath([]);
577
+ } else {
578
+ const group = this.graphDB.nodes.get(groupId);
579
+ if (group) {
580
+ this.currentGroupId = groupId;
581
+ if (!activeIds.includes(groupId)) activeIds.push(groupId);
582
+ activeIds.push(...group.children);
583
+
584
+ const childR = DOT_RADIUS * 1.5;
585
+ const dynamicSize = Math.sqrt(group.children.length) * childR * 3 + childR * 4;
586
+ group.w = dynamicSize;
587
+ group.h = dynamicSize;
588
+
589
+ // Render existing symbiote-node breadcrumbs
590
+ if (this.ref.breadcrumb) {
591
+ const parts = groupId.split('/');
592
+ const pathArr = [{ label: 'Root', level: '' }];
593
+ let acc = '';
594
+ for (let i = 0; i < parts.length; i++) {
595
+ if (!parts[i]) continue;
596
+ acc += (acc ? '/' : '') + parts[i];
597
+ pathArr.push({ label: parts[i], level: acc });
598
+ }
599
+ this.ref.breadcrumb.setPath(pathArr);
600
+ }
601
+
602
+ } else {
603
+ // Fallback to root if group not found
604
+ this.currentGroupId = null;
605
+ if (this.ref.breadcrumb) this.ref.breadcrumb.setPath([]);
606
+ }
607
+ }
608
+
609
+ this.nodes = activeIds.map(id => this.graphDB.nodes.get(id)).filter(Boolean);
610
+
611
+ for (const n of this.nodes) {
612
+ if (n.parentId && n.parentId === groupId) {
613
+ n.w = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 160 * 0.6;
614
+ n.h = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 40 * 0.6;
615
+ }
616
+ }
617
+
618
+ const activeSet = new Set(activeIds);
619
+ this.edges = this.graphDB.edges.filter(e => activeSet.has(e.from) || activeSet.has(e.to));
620
+
621
+ this.rebuildNodeMap();
622
+ this.rebuildAdjMap();
623
+ this.updateInteractionDepths();
624
+
625
+ const options = {
626
+ chargeStrength: this.$.chargeStrength,
627
+ linkDistance: this.$.linkDistance,
628
+ linkStrength: this.$.linkStrength,
629
+ centerStrength: this.$.centerStrength,
630
+ velocityDecay: this.$.velocityDecay,
631
+ collideStrength: this.$.collideStrength,
632
+ alphaDecay: this.$.alphaDecay,
633
+ theta: this.$.theta,
634
+ nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
635
+ nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
636
+ mode: 'continuous',
637
+ activeGroupId: this.currentGroupId,
638
+ boundaryRadius: this.currentGroupId ? this.graphDB.nodes.get(this.currentGroupId).w / 2 : null,
639
+ attractors: null,
640
+ };
641
+
642
+ this.startWorker(options);
643
+
644
+ this.dispatchEvent(new CustomEvent('path-changed', { detail: { path: this.currentGroupId || '' } }));
645
+ }
646
+
647
+ startWorker(customOptions = null) {
648
+ if (this.worker) this.worker.terminate();
649
+ const workerUrl = new URL('../../vendor/symbiote-node/canvas/ForceWorker.js', location.href).href;
650
+ this.worker = new Worker(workerUrl);
651
+
652
+ this.worker.onmessage = (e) => {
653
+ const { type } = e.data;
654
+ if (type === 'nodeIds') this.nodeIds = e.data.ids;
655
+ if (type === 'tick') {
656
+ const draggedId = this.dragNode ? this.dragNode.id : null;
657
+ if (e.data.packed) {
658
+ const buf = new Float32Array(e.data.packed);
659
+ for (let i = 0; i < this.nodeIds.length; i++) {
660
+ const id = this.nodeIds[i];
661
+ if (id === draggedId) continue;
662
+ const pos = this.nodePositions.get(id);
663
+ if (pos) { pos.x = buf[i * 2]; pos.y = buf[i * 2 + 1]; }
664
+ else this.nodePositions.set(id, { x: buf[i * 2], y: buf[i * 2 + 1] });
665
+ }
666
+ } else if (e.data.positions) {
667
+ for (const [id, p] of Object.entries(e.data.positions)) {
668
+ if (id === draggedId) continue;
669
+ const pos = this.nodePositions.get(id);
670
+ if (pos) { pos.x = p.x; pos.y = p.y; }
671
+ else this.nodePositions.set(id, p);
672
+ }
673
+ }
674
+ this.lastAlpha = e.data.alpha || 0;
675
+ this.tickCount++;
676
+ this.frameCount++;
677
+ this._wakeLoop(); // Worker sent new positions — resume rendering
678
+ this.dispatchEvent(new CustomEvent('layout-tick', { detail: { alpha: this.lastAlpha } }));
679
+ }
680
+ if (type === 'done' && e.data.positions) {
681
+ for (const [id, pos] of Object.entries(e.data.positions)) this.nodePositions.set(id, pos);
682
+ this.dispatchEvent(new CustomEvent('layout-done'));
683
+ }
684
+ };
685
+
686
+ const options = customOptions || {
687
+ chargeStrength: this.$.chargeStrength,
688
+ linkDistance: this.$.linkDistance,
689
+ linkStrength: this.$.linkStrength,
690
+ centerStrength: this.$.centerStrength,
691
+ velocityDecay: this.$.velocityDecay,
692
+ collideStrength: this.$.collideStrength,
693
+ alphaDecay: this.$.alphaDecay,
694
+ theta: this.$.theta,
695
+ wellStrength: this.$.wellStrength,
696
+ centerPull: this.$.centerPull,
697
+ wellRepulsion: this.$.wellRepulsion,
698
+ crossLinkScale: this.$.crossLinkScale,
699
+ nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
700
+ nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
701
+ mode: 'continuous',
702
+ };
703
+
704
+ this.worker.postMessage({
705
+ type: 'init',
706
+ nodes: this.nodes.map(n => {
707
+ const pos = this.smoothPositions.get(n.id);
708
+ let finalW = n.w, finalH = n.h;
709
+ if (this.renderMode === 'dots') {
710
+ const conns = this.adjMap.get(n.id)?.size || 0;
711
+ const r = getNodeRadius(n, conns);
712
+ finalW = finalH = r * 2;
713
+ }
714
+ return {
715
+ id: n.id, type: n.type, parentId: n.parentId, isGroup: !!n.isGroup,
716
+ children: n.children || [], x: pos?.x, y: pos?.y, w: finalW, h: finalH,
717
+ };
718
+ }),
719
+ edges: this.edges.filter(e => this.nodeMap.has(e.from) && this.nodeMap.has(e.to)),
720
+ groups: {}, options
721
+ });
722
+
723
+ this.worker.postMessage({ type: 'updateConfig', config: {
724
+ contAlphaFloor: this.$.alphaFloor, contAlphaTarget: this.$.alphaTarget,
725
+ brownian: this.$.brownian, brownianThresh: this.$.brownianThresh,
726
+ pinReheat: this.$.pinReheat, pinCap: this.$.pinCap,
727
+ }});
728
+
729
+ this.smoothPositions.clear();
730
+ this.paused = false;
731
+ }
732
+
733
+ getSmooth(id) { return this.smoothPositions.get(id) || this.nodePositions.get(id); }
734
+
735
+ nodeCenter(id) {
736
+ const pos = this.getSmooth(id);
737
+ if (!pos) return null;
738
+ if (this.renderMode === 'dots') return { x: pos.x, y: pos.y };
739
+ const node = this.nodeMap.get(id);
740
+ if (!node) return { x: pos.x, y: pos.y };
741
+ return { x: pos.x + node.w / 2, y: pos.y + node.h / 2 };
742
+ }
743
+
744
+ resizeOffscreenCanvases() {
745
+ const dpr = window.devicePixelRatio || 1;
746
+ for (let i = 1; i <= 4; i++) {
747
+ const oc = this.offscreenCanvases[i].canvas;
748
+ if (oc.width !== this.canvas.width || oc.height !== this.canvas.height) {
749
+ oc.width = this.canvas.width;
750
+ oc.height = this.canvas.height;
751
+ }
752
+ }
753
+ }
754
+
755
+ blendBg(r, g, b, alpha) {
756
+ const br = this._bgR, bg = this._bgG, bb = this._bgB;
757
+ const rr = (r * alpha + br * (1 - alpha)) | 0;
758
+ const gg = (g * alpha + bg * (1 - alpha)) | 0;
759
+ const bbb = (b * alpha + bb * (1 - alpha)) | 0;
760
+ return `rgb(${rr},${gg},${bbb})`;
761
+ }
762
+
763
+ draw() {
764
+ if (!this.canvas) return;
765
+ const dpr = window.devicePixelRatio || 1;
766
+
767
+ // Smooth zoom interpolation
768
+ const zoomAnimating = Math.abs(this._targetZoom - this.zoom) > 0.0001;
769
+ if (zoomAnimating) {
770
+ const oldZoom = this.zoom;
771
+ this.zoom += (this._targetZoom - this.zoom) * 0.15;
772
+ // Keep anchor point stable during wheel zoom
773
+ if (this._zoomAnchor) {
774
+ const { mx, my } = this._zoomAnchor;
775
+ this.panX = mx - (mx - this.panX) * (this.zoom / oldZoom);
776
+ this.panY = my - (my - this.panY) * (this.zoom / oldZoom);
777
+ }
778
+ }
779
+
780
+ // Smooth pan interpolation (for fitView / flyToNode animations)
781
+ if (this._targetPanX !== null) {
782
+ const panDx = this._targetPanX - this.panX;
783
+ const panDy = this._targetPanY - this.panY;
784
+ if (Math.abs(panDx) < 0.5 && Math.abs(panDy) < 0.5) {
785
+ this.panX = this._targetPanX;
786
+ this.panY = this._targetPanY;
787
+ this._targetPanX = null;
788
+ this._targetPanY = null;
789
+ } else {
790
+ this.panX += panDx * 0.15;
791
+ this.panY += panDy * 0.15;
792
+ }
793
+ }
794
+
795
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
796
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
797
+
798
+ this.resizeOffscreenCanvases();
799
+ const mainCtx = this.ctx;
800
+ const isIdle = (!this.activeNode && !this.currentGroupId) || this.deactivating;
801
+
802
+ if (this.deactivating && this.activeNode) {
803
+ const settled = Math.abs(this.layerAnim[0].scale - 1) < 0.01 && Math.abs(this.layerAnim[4].scale - 1) < 0.01;
804
+ if (settled) {
805
+ if (this.nextActiveNode) {
806
+ this.activeNode = this.nextActiveNode;
807
+ this.nextActiveNode = null;
808
+ } else {
809
+ this.activeNode = null;
810
+ this.dispatchEvent(new CustomEvent('node-deselected'));
811
+ }
812
+ this.deactivating = false;
813
+ this.updateInteractionDepths();
814
+ }
815
+ }
816
+
817
+ const inGroupMode = !!this.currentGroupId;
818
+ const lerpSpeed = isIdle ? 0.08 : 0.06;
819
+ for (let d = 0; d <= 4; d++) {
820
+ const la = this.layerAnim[d];
821
+ const tScale = isIdle ? 1 : this.LAYER_TARGETS.scale[d];
822
+ const tOpacity = isIdle ? 1 : this.LAYER_TARGETS.opacity[d];
823
+ const tParallax = isIdle ? 0 : this.LAYER_TARGETS.parallax[d];
824
+
825
+ const speed = (inGroupMode && d >= 3) ? 0.3 : lerpSpeed;
826
+ la.scale += (tScale - la.scale) * speed;
827
+ la.opacity += (tOpacity - la.opacity) * speed;
828
+ la.parallax += (tParallax - la.parallax) * speed;
829
+ }
830
+
831
+ const vcx = this.canvas.width / 2;
832
+ const vcy = this.canvas.height / 2;
833
+ let dragDeltaX = 0, dragDeltaY = 0;
834
+
835
+ if (this.activeNode && !this.deactivating) {
836
+ const dp = this.nodePositions.get(this.activeNode.id);
837
+ if (dp) {
838
+ // Center the combined node+panel area — only once per node activation
839
+ if (this._infoPanel._centeredForNode !== this.activeNode.id && this._infoPanel.totalExtent > 0) {
840
+ this._infoPanel._centeredForNode = this.activeNode.id;
841
+ const panelOffsetX = this._infoPanel.totalExtent / 2;
842
+ const panelOffsetY = this._infoPanel.totalExtentY / 2;
843
+ const rect = this.canvas.getBoundingClientRect();
844
+ if (rect.width > 0) {
845
+ this._targetPanX = rect.width / 2 - (dp.x + panelOffsetX) * this.zoom;
846
+ this._targetPanY = rect.height / 2 - (dp.y + panelOffsetY) * this.zoom;
847
+ }
848
+ }
849
+
850
+ const targetFX = dpr * this.zoom * dp.x + dpr * this.panX;
851
+ const targetFY = dpr * this.zoom * dp.y + dpr * this.panY;
852
+ if (!this.focusActive) {
853
+ this.focusX = targetFX;
854
+ this.focusY = targetFY;
855
+ this.focusActive = true;
856
+ } else {
857
+ this.focusX += (targetFX - this.focusX) * 0.12;
858
+ this.focusY += (targetFY - this.focusY) * 0.12;
859
+ }
860
+ dragDeltaX = this.focusX - vcx;
861
+ dragDeltaY = this.focusY - vcy;
862
+ }
863
+ } else {
864
+ this.focusX += (vcx - this.focusX) * 0.08;
865
+ this.focusY += (vcy - this.focusY) * 0.08;
866
+ dragDeltaX = this.focusX - vcx;
867
+ dragDeltaY = this.focusY - vcy;
868
+ if (Math.abs(dragDeltaX) < 1 && Math.abs(dragDeltaY) < 1) {
869
+ this.focusActive = false;
870
+ dragDeltaX = 0;
871
+ dragDeltaY = 0;
872
+ }
873
+ }
874
+
875
+ for (let i = 1; i <= 4; i++) {
876
+ const octx = this.offscreenCanvases[i].ctx;
877
+ const la = this.layerAnim[i];
878
+ const s = la.scale;
879
+ const pOffX = -la.parallax * dragDeltaX;
880
+ const pOffY = -la.parallax * dragDeltaY;
881
+
882
+ octx.setTransform(1, 0, 0, 1, 0, 0);
883
+ octx.clearRect(0, 0, this.canvas.width, this.canvas.height);
884
+ octx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
885
+ s * dpr * this.panX + vcx * (1 - s) + pOffX,
886
+ s * dpr * this.panY + vcy * (1 - s) + pOffY);
887
+ }
888
+
889
+ const t = 1 - this.smoothing;
890
+ for (const [id, raw] of this.nodePositions) {
891
+ const prev = this.smoothPositions.get(id);
892
+ if (!prev) {
893
+ this.smoothPositions.set(id, { x: raw.x, y: raw.y });
894
+ } else {
895
+ if (this.dragNode && this.dragNode.id === id) {
896
+ prev.x = raw.x; prev.y = raw.y;
897
+ } else {
898
+ prev.x += (raw.x - prev.x) * t;
899
+ prev.y += (raw.y - prev.y) * t;
900
+ }
901
+ }
902
+ }
903
+
904
+ for (let i = 0; i <= 4; i++) {
905
+ this.depthGroups[i].edges.length = 0;
906
+ this.depthGroups[i].nodes.length = 0;
907
+ }
908
+
909
+ for (const edge of this.edges) {
910
+ this.depthGroups[edge.targetDepth !== undefined ? edge.targetDepth : 4].edges.push(edge);
911
+ }
912
+
913
+ const focusNodes = [];
914
+ for (const node of this.nodes) {
915
+ if (node === this.activeNode || node === this.dragNode || node === this.hoverNode) {
916
+ focusNodes.push(node);
917
+ } else {
918
+ this.depthGroups[node.targetDepth !== undefined ? node.targetDepth : 4].nodes.push(node);
919
+ }
920
+ }
921
+ for (const node of focusNodes) {
922
+ this.depthGroups[node.targetDepth !== undefined ? node.targetDepth : 4].nodes.push(node);
923
+ }
924
+
925
+ const getLayerTransform = (d) => {
926
+ const s = this.layerAnim[d].scale;
927
+ if (d > 0) {
928
+ const pOffX = -this.layerAnim[d].parallax * dragDeltaX;
929
+ const pOffY = -this.layerAnim[d].parallax * dragDeltaY;
930
+ return { A: s * dpr * this.zoom, E: s * dpr * this.panX + vcx * (1 - s) + pOffX, F: s * dpr * this.panY + vcy * (1 - s) + pOffY };
931
+ } else {
932
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
933
+ return { A: s * dpr * this.zoom, E: this.focusX * (1 - s) + s * dpr * this.panX, F: this.focusY * (1 - s) + s * dpr * this.panY };
934
+ } else {
935
+ return { A: dpr * this.zoom, E: dpr * this.panX, F: dpr * this.panY };
936
+ }
937
+ }
938
+ };
939
+
940
+ const drawDepth = (d, currentCtx) => {
941
+ const la = this.layerAnim[d];
942
+ const layerOpacity = la.opacity;
943
+ const isGhost = inGroupMode && d >= 3;
944
+ const GHOST_COLOR = this._ghostColor;
945
+ const tCurrent = getLayerTransform(d);
946
+
947
+ const mapPosToEdgeLayer = (pos, nodeDepth) => {
948
+ if (!pos || nodeDepth === d) return pos;
949
+ const tNode = getLayerTransform(nodeDepth);
950
+ const screenX = tNode.A * pos.x + tNode.E;
951
+ const screenY = tNode.A * pos.y + tNode.F;
952
+ return { x: (screenX - tCurrent.E) / tCurrent.A, y: (screenY - tCurrent.F) / tCurrent.A };
953
+ };
954
+
955
+ currentCtx.strokeStyle = 'rgba(74, 158, 255, 0.25)';
956
+ currentCtx.lineWidth = 1.5;
957
+
958
+ // Edges
959
+ for (const edge of this.depthGroups[d].edges) {
960
+ let from = this.nodeCenter(edge.from);
961
+ let to = this.nodeCenter(edge.to);
962
+
963
+ if ((!from || !to) && this.currentGroupId) {
964
+ const activeId = this.currentGroupId;
965
+ const activePos = this.smoothPositions.get(activeId);
966
+ const activeNode = this.graphDB.nodes.get(activeId);
967
+ if (activePos && activeNode) {
968
+ const radius = activeNode.w / 2;
969
+ if (!from && to) {
970
+ const angle = parseInt(edge.from.slice(-1), 16) || 0;
971
+ from = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
972
+ } else if (from && !to) {
973
+ const angle = parseInt(edge.to.slice(-1), 16) || 0;
974
+ to = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
975
+ }
976
+ }
977
+ }
978
+
979
+ if (!from || !to) continue;
980
+
981
+ let tAlpha = 0.5, tWidth = 1.5;
982
+ if (this.dragNode) {
983
+ const minD = edge.minTargetDepth;
984
+ if (minD === 0) { tAlpha = 1; tWidth = 3.0; }
985
+ else if (minD === 1) { tAlpha = 0.8; tWidth = 2.0; }
986
+ else if (minD === 2) { tAlpha = 0.4; tWidth = 1.5; }
987
+ else { tAlpha = 0.05; tWidth = 1.0; }
988
+ }
989
+
990
+ const edgeOpacity = tAlpha * layerOpacity;
991
+ edge.aAlpha = edge.aAlpha !== undefined ? edge.aAlpha : 0.5;
992
+ edge.aWidth = edge.aWidth || 1.5;
993
+ edge.aAlpha += (edgeOpacity - edge.aAlpha) * 0.1;
994
+ edge.aWidth += (tWidth - edge.aWidth) * 0.1;
995
+
996
+ const nodeFrom = this.nodeMap ? this.nodeMap.get(edge.from) : null;
997
+ const nodeTo = this.nodeMap ? this.nodeMap.get(edge.to) : null;
998
+ const fromDepth = nodeFrom?.targetDepth ?? 4;
999
+ const toDepth = nodeTo?.targetDepth ?? 4;
1000
+
1001
+ from = mapPosToEdgeLayer(from, fromDepth);
1002
+ to = mapPosToEdgeLayer(to, toDepth);
1003
+
1004
+ const zoomFactor = this.zoom * (this.layerAnim[d]?.scale || 1);
1005
+ const wFrom = (edge.aWidth * 2.0) / zoomFactor, wTo = wFrom;
1006
+ const dx = to.x - from.x, dy = to.y - from.y;
1007
+ const len = Math.sqrt(dx * dx + dy * dy);
1008
+ if (len < 0.1) continue;
1009
+
1010
+ const nx = -dy / len, ny = dx / len;
1011
+
1012
+ let fillStyle;
1013
+ if (isGhost) {
1014
+ fillStyle = GHOST_COLOR;
1015
+ } else if (this.dragNode || this.activeNode) {
1016
+ const fromOpacity = this.layerAnim[fromDepth].opacity;
1017
+ const toOpacity = this.layerAnim[toDepth].opacity;
1018
+ const fromTC = TYPE_COLORS[nodeFrom?.type] || TYPE_COLORS.data;
1019
+ const toTC = TYPE_COLORS[nodeTo?.type] || TYPE_COLORS.data;
1020
+ const grad = currentCtx.createLinearGradient(from.x, from.y, to.x, to.y);
1021
+ grad.addColorStop(0, this.blendBg(fromTC[0], fromTC[1], fromTC[2], fromOpacity * 0.7));
1022
+ grad.addColorStop(1, this.blendBg(toTC[0], toTC[1], toTC[2], toOpacity * 0.7));
1023
+ fillStyle = grad;
1024
+ } else {
1025
+ const fromTC = TYPE_COLORS[nodeFrom?.type] || TYPE_COLORS.data;
1026
+ fillStyle = this.blendBg(fromTC[0], fromTC[1], fromTC[2], 0.35);
1027
+ }
1028
+
1029
+ currentCtx.fillStyle = fillStyle;
1030
+ currentCtx.beginPath();
1031
+ const midX = from.x + dx * 0.5, midY = from.y + dy * 0.5;
1032
+ const pinchRatio = Math.max(0.001, Math.pow(20 / Math.max(20, len), 2.8));
1033
+ const pinchW = Math.min(wFrom, wTo) * pinchRatio;
1034
+ const ang = Math.atan2(dy, dx);
1035
+
1036
+ currentCtx.moveTo(from.x + nx * wFrom, from.y + ny * wFrom);
1037
+ currentCtx.quadraticCurveTo(midX + nx * pinchW, midY + ny * pinchW, to.x + nx * wTo, to.y + ny * wTo);
1038
+ currentCtx.arc(to.x, to.y, wTo, ang + Math.PI/2, ang - Math.PI/2, true);
1039
+ currentCtx.quadraticCurveTo(midX - nx * pinchW, midY - ny * pinchW, from.x - nx * wFrom, from.y - ny * wFrom);
1040
+ currentCtx.arc(from.x, from.y, wFrom, ang - Math.PI/2, ang - Math.PI * 1.5, true);
1041
+ currentCtx.closePath();
1042
+ currentCtx.fill();
1043
+ }
1044
+
1045
+ // Nodes
1046
+ for (const node of this.depthGroups[d].nodes) {
1047
+ if (this.currentGroupId && node.id === this.currentGroupId) continue;
1048
+ const pos = this.getSmooth(node.id);
1049
+ if (!pos) continue;
1050
+ const isActive = this.activeNode && this.activeNode.id === node.id;
1051
+ const tc = TYPE_COLORS[node.type] || TYPE_COLORS.data;
1052
+ const conns = this.adjMap.get(node.id)?.size || 0;
1053
+ const hubScale = 1 + Math.min(conns, 8) * 0.1;
1054
+
1055
+ const targetScale = isActive ? 1.5 : 1;
1056
+ node.aScale = node.aScale !== undefined ? node.aScale : 1;
1057
+ node.aScale += (targetScale - node.aScale) * 0.12;
1058
+
1059
+ node.aGlow = node.aGlow !== undefined ? node.aGlow : 0;
1060
+ node.aGlow += ((isActive ? 1 : 0) - node.aGlow) * 0.1;
1061
+
1062
+ if (this.renderMode === 'dots') {
1063
+ let r = getNodeRadius(node, conns, { scale: node.aScale });
1064
+
1065
+ if (isGhost) {
1066
+ currentCtx.beginPath();
1067
+ currentCtx.arc(pos.x, pos.y, r * 0.7, 0, Math.PI * 2);
1068
+ currentCtx.fillStyle = GHOST_COLOR;
1069
+ currentCtx.fill();
1070
+ } else if (node.isGroup) {
1071
+ const ringW = r * 0.12;
1072
+ currentCtx.beginPath();
1073
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1074
+ currentCtx.fillStyle = `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${layerOpacity})`;
1075
+ currentCtx.fill();
1076
+
1077
+ currentCtx.beginPath();
1078
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1079
+ currentCtx.arc(pos.x, pos.y, r - ringW, 0, Math.PI * 2, true);
1080
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
1081
+ currentCtx.fill();
1082
+
1083
+ let baseR = DOT_RADIUS * hubScale * (node.aScale || 1);
1084
+ const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
1085
+ const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
1086
+
1087
+ // Calculate perfect orbit radius to maintain consistent spacing between dots
1088
+ const spacing = innerR * 2.5; // Gap between dots
1089
+ const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
1090
+ const isHovered = this.hoverNode && this.hoverNode.id === node.id;
1091
+ node.aRotSpeed = node.aRotSpeed || 0;
1092
+ const targetRotSpeed = (isActive || isHovered) ? 0.025 : 0;
1093
+ node.aRotSpeed += (targetRotSpeed - node.aRotSpeed) * 0.05;
1094
+ node.aRot = (node.aRot || 0) + node.aRotSpeed;
1095
+
1096
+ for (let k = 0; k < childCount; k++) {
1097
+ const angle = (k * Math.PI * 2 / childCount) - Math.PI / 2 + node.aRot;
1098
+ const cx = pos.x + Math.cos(angle) * orbitR;
1099
+ const cy = pos.y + Math.sin(angle) * orbitR;
1100
+ currentCtx.beginPath();
1101
+ currentCtx.arc(cx, cy, innerR, 0, Math.PI * 2);
1102
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity * 0.7);
1103
+ currentCtx.fill();
1104
+ }
1105
+ if (node.aGlow > 0.01) {
1106
+ currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
1107
+ currentCtx.lineWidth = 2 * node.aGlow;
1108
+ currentCtx.beginPath();
1109
+ currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
1110
+ currentCtx.stroke();
1111
+ }
1112
+ } else {
1113
+ currentCtx.beginPath();
1114
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1115
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
1116
+ currentCtx.fill();
1117
+ if (node.aGlow > 0.01) {
1118
+ currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
1119
+ currentCtx.lineWidth = 2 * node.aGlow;
1120
+ currentCtx.beginPath();
1121
+ currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
1122
+ currentCtx.stroke();
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ };
1128
+
1129
+ for (let d = 4; d >= 1; d--) drawDepth(d, this.offscreenCanvases[d].ctx);
1130
+
1131
+ mainCtx.setTransform(1, 0, 0, 1, 0, 0);
1132
+ for (let d = 4; d >= 1; d--) {
1133
+ const blurPx = this.LAYER_TARGETS.blur[d];
1134
+ const blurIntensity = Math.abs(1 - this.layerAnim[d].scale) * blurPx * 8;
1135
+ mainCtx.filter = blurIntensity > 0.3 ? `blur(${blurIntensity.toFixed(1)}px)` : 'none';
1136
+ mainCtx.drawImage(this.offscreenCanvases[d].canvas, 0, 0);
1137
+ }
1138
+ mainCtx.filter = 'none';
1139
+
1140
+ {
1141
+ const s = this.layerAnim[0].scale;
1142
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1143
+ mainCtx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom, this.focusX * (1 - s) + s * dpr * this.panX, this.focusY * (1 - s) + s * dpr * this.panY);
1144
+ } else {
1145
+ mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1146
+ }
1147
+ drawDepth(0, mainCtx);
1148
+
1149
+ if (this._pulses && this._pulses.length > 0) {
1150
+ const now = performance.now();
1151
+ this._pulses = this._pulses.filter(p => {
1152
+ const elapsed = now - p.startTime;
1153
+ if (elapsed > p.duration) return false;
1154
+ const pos = this.getSmooth(p.id) || this.nodePositions.get(p.id);
1155
+ if (!pos) return false;
1156
+ const progress = elapsed / p.duration;
1157
+ const pulsePhase = (progress * 3) % 1;
1158
+ const r = 20 + (pulsePhase * 80);
1159
+ const opacity = 1 - pulsePhase;
1160
+ mainCtx.beginPath();
1161
+ mainCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1162
+ mainCtx.fillStyle = `rgba(76, 139, 245, ${opacity * 0.4})`;
1163
+ mainCtx.fill();
1164
+ mainCtx.lineWidth = 2;
1165
+ mainCtx.strokeStyle = `rgba(76, 139, 245, ${opacity * 0.8})`;
1166
+ mainCtx.stroke();
1167
+ this.needsDraw = true;
1168
+ return true;
1169
+ });
1170
+ }
1171
+ }
1172
+
1173
+ const showMenu = this.activeNode && !this.dragNode && !this.deactivating;
1174
+ if (showMenu) {
1175
+ this.menuAnim = Math.min(1, this.menuAnim + 0.08);
1176
+ } else {
1177
+ this.menuAnim = Math.max(0, this.menuAnim - 0.15);
1178
+ }
1179
+
1180
+ if (this.menuAnim > 0.01 && this.activeNode) {
1181
+ const apos = this.getSmooth(this.activeNode.id);
1182
+ if (apos) {
1183
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1184
+ const nodeR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
1185
+ const menuDist = nodeR + 14;
1186
+ const itemR = 6;
1187
+
1188
+ const easeOut = 1 - Math.pow(1 - this.menuAnim, 3);
1189
+ const mr = menuDist * easeOut;
1190
+ const ir = itemR * Math.max(0, easeOut);
1191
+
1192
+ const s = this.layerAnim[0].scale;
1193
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1194
+ mainCtx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom, this.focusX * (1 - s) + s * dpr * this.panX, this.focusY * (1 - s) + s * dpr * this.panY);
1195
+ } else {
1196
+ mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1197
+ }
1198
+
1199
+ const tc = TYPE_COLORS[this.activeNode.type] || TYPE_COLORS.data;
1200
+ for (let i = 0; i < MENU_ITEMS.length; i++) {
1201
+ const item = MENU_ITEMS[i];
1202
+ const angle = (i / MENU_ITEMS.length) * Math.PI * 2 - Math.PI / 2;
1203
+ const ix = apos.x + Math.cos(angle) * mr;
1204
+ const iy = apos.y + Math.sin(angle) * mr;
1205
+
1206
+ mainCtx.beginPath();
1207
+ mainCtx.arc(ix, iy, ir, 0, Math.PI * 2);
1208
+ mainCtx.fillStyle = item.danger ? `rgba(60, 20, 20, ${0.9 * easeOut})` : `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.9 * easeOut})`;
1209
+ mainCtx.fill();
1210
+
1211
+ mainCtx.save();
1212
+ const iconScale = (ir * 1.2) / 24;
1213
+ if (iconScale > 0) {
1214
+ mainCtx.translate(ix - 12 * iconScale, iy - 12 * iconScale);
1215
+ mainCtx.scale(iconScale, iconScale);
1216
+ const p = new Path2D(item.path);
1217
+ mainCtx.fillStyle = item.danger ? `rgba(255, 107, 107, ${easeOut})` : `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${easeOut})`;
1218
+ mainCtx.fill(p);
1219
+ }
1220
+ mainCtx.restore();
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // Info panel — typewriter HUD to the right of active node
1226
+ this._drawInfoPanel(mainCtx, dpr, dragDeltaX, dragDeltaY, vcx, vcy);
1227
+
1228
+ // Idle detection: stop the loop when nothing is animating
1229
+ const zoomSettled = Math.abs(this._targetZoom - this.zoom) < 0.001;
1230
+ // Track focus movement rate (delta-of-delta), not absolute offset
1231
+ const prevDX = this._prevDragDeltaX || 0;
1232
+ const prevDY = this._prevDragDeltaY || 0;
1233
+ const focusMovement = Math.abs(dragDeltaX - prevDX) + Math.abs(dragDeltaY - prevDY);
1234
+ this._prevDragDeltaX = dragDeltaX;
1235
+ this._prevDragDeltaY = dragDeltaY;
1236
+ const focusSettled = focusMovement < 0.1;
1237
+ const layerSettled = this.layerAnim[0] && Math.abs(this.layerAnim[0].scale - (isIdle ? 1 : this.LAYER_TARGETS.scale[0])) < 0.005;
1238
+ const workerActive = this.lastAlpha > 0.001;
1239
+ const hasDrag = !!this.dragNode || this.isPanning;
1240
+ const hasActiveAnim = this.deactivating;
1241
+ const hasPanAnim = this._targetPanX !== null;
1242
+
1243
+ const infoPanelAnimating = this._infoPanel.opacity > 0.01 && (this._infoPanel.opacity < 0.99 || this._infoPanel.lines.some(l => l.revealed < l.text.length));
1244
+ if (zoomSettled && focusSettled && layerSettled && !workerActive && !hasDrag && !hasActiveAnim && !hasPanAnim && !infoPanelAnimating) {
1245
+ this._idleFrames++;
1246
+ } else {
1247
+ this._idleFrames = 0;
1248
+ }
1249
+
1250
+ // Allow 3 extra frames after convergence to flush final sub-pixel lerps
1251
+ if (this._idleFrames > 3) {
1252
+ this._loopRunning = false;
1253
+ return;
1254
+ }
1255
+
1256
+ this._animationFrame = requestAnimationFrame(() => this.draw());
1257
+ }
1258
+
1259
+ /**
1260
+ * Build metadata lines for the info panel from skeleton + node data
1261
+ * @param {object} node - graph node
1262
+ * @returns {string[]}
1263
+ */
1264
+ _buildInfoLines(node) {
1265
+ const lines = [];
1266
+ lines.push(node.label);
1267
+ if (node.id !== node.label) lines.push(node.id);
1268
+ lines.push('');
1269
+
1270
+ const typeLabels = {
1271
+ data: 'Data',
1272
+ action: 'Action',
1273
+ output: 'Output',
1274
+ config: 'Config',
1275
+ external: 'External',
1276
+ style: 'Style',
1277
+ docs: 'Docs',
1278
+ asset: 'Asset',
1279
+ group: 'Directory'
1280
+ };
1281
+ lines.push(`Type: ${typeLabels[node.type] || node.type}`);
1282
+
1283
+ const conns = this.adjMap.get(node.id)?.size || 0;
1284
+ if (conns > 0) lines.push(`Connections: ${conns}`);
1285
+
1286
+ if (node.children?.length > 0) {
1287
+ lines.push(`Children: ${node.children.length}`);
1288
+ }
1289
+
1290
+ if (this._skeleton) {
1291
+ const X = this._skeleton.X || {};
1292
+ const exports = X[node.id];
1293
+ if (exports && exports.length > 0) {
1294
+ lines.push('');
1295
+ lines.push('Exports:');
1296
+ for (const exp of exports.slice(0, 8)) {
1297
+ lines.push(` ${exp}`);
1298
+ }
1299
+ if (exports.length > 8) lines.push(` ... +${exports.length - 8}`);
1300
+ }
1301
+
1302
+ const L = this._skeleton.L || {};
1303
+ const loc = L[node.id];
1304
+ if (loc) lines.push(`Lines: ${loc}`);
1305
+ }
1306
+
1307
+ return lines;
1308
+ }
1309
+
1310
+ /**
1311
+ * Draw info panel HUD to the right of the active node
1312
+ * @param {CanvasRenderingContext2D} ctx
1313
+ * @param {number} dpr
1314
+ * @param {number} dragDeltaX
1315
+ * @param {number} dragDeltaY
1316
+ * @param {number} vcx
1317
+ * @param {number} vcy
1318
+ */
1319
+ _drawInfoPanel(ctx, dpr, dragDeltaX, dragDeltaY, vcx, vcy) {
1320
+ const ip = this._infoPanel;
1321
+ const showPanel = this.activeNode && !this.dragNode && !this.deactivating;
1322
+
1323
+ if (showPanel && this.activeNode) {
1324
+ if (ip.nodeId !== this.activeNode.id) {
1325
+ ip.nodeId = this.activeNode.id;
1326
+ ip.lines = this._buildInfoLines(this.activeNode).map(text => ({ text, revealed: 0 }));
1327
+ ip.startTime = performance.now();
1328
+ ip.opacity = 0;
1329
+ }
1330
+ ip.opacity = Math.min(1, ip.opacity + 0.06);
1331
+ } else {
1332
+ ip.opacity = Math.max(0, ip.opacity - 0.12);
1333
+ if (ip.opacity <= 0) { ip.nodeId = null; ip.lines = []; ip.totalExtent = 0; ip.totalExtentY = 0; ip._centeredForNode = null; }
1334
+ }
1335
+
1336
+ if (ip.opacity <= 0.01 || ip.lines.length === 0) return;
1337
+
1338
+ const elapsed = performance.now() - ip.startTime;
1339
+ const CHAR_SPEED = 18;
1340
+ const LINE_DELAY = 60;
1341
+ let charBudget = Math.floor(elapsed / CHAR_SPEED);
1342
+ for (let i = 0; i < ip.lines.length; i++) {
1343
+ const line = ip.lines[i];
1344
+ const available = Math.max(0, charBudget - i * LINE_DELAY / CHAR_SPEED);
1345
+ line.revealed = Math.min(line.text.length, Math.floor(available));
1346
+ }
1347
+
1348
+ const apos = this.activeNode ? this.getSmooth(this.activeNode.id) : null;
1349
+ if (!apos) return;
1350
+
1351
+ // Apply depth-0 transform — panel lives in world-space, scales with nodes
1352
+ const s = this.layerAnim[0].scale;
1353
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1354
+ ctx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
1355
+ this.focusX * (1 - s) + s * dpr * this.panX,
1356
+ this.focusY * (1 - s) + s * dpr * this.panY);
1357
+ } else {
1358
+ ctx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1359
+ }
1360
+
1361
+ // All dimensions in world units
1362
+ const fontSize = 11;
1363
+ const smallFontSize = 9;
1364
+ const lineHeight = 15;
1365
+ const padX = 14;
1366
+ const padY = 10;
1367
+
1368
+ // Compute actual node radius to avoid overlap
1369
+ // Must account for: dot radius + glow + radial menu items
1370
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1371
+ const dotR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
1372
+ // Menu orbits at dotR + 14, each item has radius 6
1373
+ const menuExtent = dotR + 14 + 6;
1374
+ const panelGap = 10;
1375
+ const panelX = apos.x + menuExtent + panelGap;
1376
+ const panelY = apos.y - padY;
1377
+
1378
+ ctx.font = `600 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
1379
+
1380
+ // Measure panel width from FULL text content (not just revealed)
1381
+ // This ensures totalExtent is stable from the first frame — no oscillation
1382
+ let maxW = 60;
1383
+ for (const line of ip.lines) {
1384
+ const w = ctx.measureText(line.text).width;
1385
+ if (w > maxW) maxW = w;
1386
+ }
1387
+ const panelW = maxW + padX * 2;
1388
+ const panelH = ip.lines.length * lineHeight + padY * 2;
1389
+
1390
+ // Store total extent for focus centering
1391
+ ip.totalExtent = menuExtent + panelGap + panelW;
1392
+ // Vertical: panel extends from (apos.y - padY) to (apos.y - padY + panelH + 16)
1393
+ // The offset from node center to the vertical midpoint of the panel
1394
+ ip.totalExtentY = (panelH + 16) / 2 - padY;
1395
+
1396
+ const tc = TYPE_COLORS[this.activeNode?.type] || TYPE_COLORS.data;
1397
+ const cornerR = 6;
1398
+
1399
+ ctx.save();
1400
+ ctx.globalAlpha = ip.opacity;
1401
+
1402
+ // Blurred backdrop
1403
+ ctx.filter = 'blur(16px)';
1404
+ ctx.beginPath();
1405
+ ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
1406
+ ctx.fillStyle = `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${0.85 * ip.opacity})`;
1407
+ ctx.fill();
1408
+ ctx.filter = 'none';
1409
+
1410
+ // Border
1411
+ ctx.beginPath();
1412
+ ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
1413
+ ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.15 * ip.opacity})`;
1414
+ ctx.lineWidth = 0.8;
1415
+ ctx.stroke();
1416
+
1417
+ // Left accent
1418
+ ctx.beginPath();
1419
+ ctx.moveTo(panelX, panelY + cornerR);
1420
+ ctx.lineTo(panelX, panelY + panelH + 16 - cornerR);
1421
+ ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.5 * ip.opacity})`;
1422
+ ctx.lineWidth = 1.5;
1423
+ ctx.stroke();
1424
+
1425
+ // Text lines
1426
+ let textY = panelY + padY + fontSize;
1427
+ for (let i = 0; i < ip.lines.length; i++) {
1428
+ const line = ip.lines[i];
1429
+ const text = line.text.substring(0, line.revealed);
1430
+ if (!text) { textY += lineHeight; continue; }
1431
+
1432
+ if (i === 0) {
1433
+ ctx.font = `700 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
1434
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${ip.opacity})`;
1435
+ } else if (i === 1 && this.activeNode?.id !== this.activeNode?.label) {
1436
+ ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
1437
+ ctx.fillStyle = `rgba(255, 255, 255, ${0.35 * ip.opacity})`;
1438
+ } else if (line.text.startsWith(' ')) {
1439
+ ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
1440
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.6 * ip.opacity})`;
1441
+ } else if (line.text.includes(':')) {
1442
+ ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
1443
+ ctx.fillStyle = `rgba(255, 255, 255, ${0.5 * ip.opacity})`;
1444
+ } else {
1445
+ ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
1446
+ ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * ip.opacity})`;
1447
+ }
1448
+
1449
+ ctx.fillText(text, panelX + padX, textY);
1450
+
1451
+ if (line.revealed < line.text.length && line.revealed > 0) {
1452
+ const cursorX = panelX + padX + ctx.measureText(text).width + 2;
1453
+ if (Math.floor(performance.now() / 400) % 2 === 0) {
1454
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.8 * ip.opacity})`;
1455
+ ctx.fillRect(cursorX, textY - fontSize + 2, 1.5, fontSize);
1456
+ }
1457
+ }
1458
+ textY += lineHeight;
1459
+ }
1460
+
1461
+ ctx.restore();
1462
+ }
1463
+
1464
+ screenToWorld(sx, sy) {
1465
+ const rect = this.canvas.getBoundingClientRect();
1466
+ return {
1467
+ x: (sx - rect.left - this.panX) / this.zoom,
1468
+ y: (sy - rect.top - this.panY) / this.zoom,
1469
+ };
1470
+ }
1471
+
1472
+ hitTest(wx, wy) {
1473
+ const inGroup = !!this.currentGroupId;
1474
+ const activeGroupId = this.currentGroupId;
1475
+ for (let i = this.nodes.length - 1; i >= 0; i--) {
1476
+ const node = this.nodes[i];
1477
+ if (inGroup && node.parentId !== activeGroupId && node.id !== activeGroupId) continue;
1478
+ const pos = this.getSmooth(node.id);
1479
+ if (!pos) continue;
1480
+
1481
+ if (this.renderMode === 'dots') {
1482
+ const dx = wx - pos.x, dy = wy - pos.y;
1483
+ const hitR = node.isGroup ? HIT_RADIUS * 1.5 : HIT_RADIUS;
1484
+ if (dx * dx + dy * dy <= hitR * hitR) return node;
1485
+ }
1486
+ }
1487
+ return null;
1488
+ }
1489
+
1490
+ bindEvents() {
1491
+ this.canvas.addEventListener('pointerdown', (e) => {
1492
+ this._wakeLoop(); // User interaction — resume rendering
1493
+ const world = this.screenToWorld(e.clientX, e.clientY);
1494
+
1495
+ if (this.activeNode && !this.dragNode && this.menuAnim > 0.5) {
1496
+ const apos = this.getSmooth(this.activeNode.id);
1497
+ if (apos) {
1498
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1499
+ const nodeR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
1500
+ const menuDist = nodeR + 14;
1501
+ const itemR = 6;
1502
+
1503
+ for (let i = 0; i < MENU_ITEMS.length; i++) {
1504
+ const angle = (i / MENU_ITEMS.length) * Math.PI * 2 - Math.PI / 2;
1505
+ const ix = apos.x + Math.cos(angle) * menuDist;
1506
+ const iy = apos.y + Math.sin(angle) * menuDist;
1507
+ const dx = world.x - ix, dy = world.y - iy;
1508
+ if (dx * dx + dy * dy < itemR * itemR * 2) {
1509
+ const action = MENU_ITEMS[i].action;
1510
+ if (action === 'drill') {
1511
+ if (this.activeNode.isGroup) this.loadLevel(this.activeNode.id);
1512
+ } else {
1513
+ // Dispatch prod action
1514
+ this.dispatchEvent(new CustomEvent('toolbar-action', {
1515
+ detail: { action, nodeId: this.activeNode.id },
1516
+ bubbles: true,
1517
+ composed: true
1518
+ }));
1519
+ }
1520
+ e.preventDefault();
1521
+ return;
1522
+ }
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ const hit = this.hitTest(world.x, world.y);
1528
+ if (hit) {
1529
+ const vis = this.getSmooth(hit.id);
1530
+ const sim = this.nodePositions.get(hit.id);
1531
+ if (vis && sim) { sim.x = vis.x; sim.y = vis.y; }
1532
+
1533
+ let isNewActivation = false;
1534
+ if (this.activeNode && this.activeNode.id !== hit.id) {
1535
+ isNewActivation = true;
1536
+ if (this.currentGroupId) {
1537
+ // Instant switch inside group
1538
+ this.activeNode = hit;
1539
+ this.dragNode = hit;
1540
+ this.menuAnim = 0;
1541
+ this.updateInteractionDepths();
1542
+ } else {
1543
+ this.nextActiveNode = hit;
1544
+ this.deactivating = true;
1545
+ this.dragNode = hit;
1546
+ }
1547
+ } else {
1548
+ if (!this.activeNode) isNewActivation = true;
1549
+ this.activeNode = hit;
1550
+ this.dragNode = hit;
1551
+ this.deactivating = false;
1552
+ this.updateInteractionDepths();
1553
+ }
1554
+ this._nodeActivatedOnDown = isNewActivation;
1555
+ const pos = this.nodePositions.get(hit.id);
1556
+ this.dragOffset.x = world.x - pos.x;
1557
+ this.dragOffset.y = world.y - pos.y;
1558
+ this._dragStartX = e.clientX;
1559
+ this._dragStartY = e.clientY;
1560
+ this.canvas.style.cursor = 'grabbing';
1561
+ this.canvas.setPointerCapture(e.pointerId);
1562
+ this.worker.postMessage({ type: 'pin', id: hit.id, x: pos.x, y: pos.y });
1563
+ e.preventDefault();
1564
+ } else {
1565
+ // Start panning — cancel any fitView/flyToNode animation
1566
+ this._targetPanX = null;
1567
+ this._targetPanY = null;
1568
+ this.isPanning = true;
1569
+ this._dragStartX = e.clientX;
1570
+ this._dragStartY = e.clientY;
1571
+ this.panStart = { x: this.panX, y: this.panY, px: e.clientX, py: e.clientY };
1572
+ this.canvas.style.cursor = 'grabbing';
1573
+ this.canvas.setPointerCapture(e.pointerId);
1574
+ }
1575
+ });
1576
+
1577
+ this.canvas.addEventListener('pointermove', (e) => {
1578
+ if (this.dragNode) {
1579
+ this._wakeLoop(); // Dragging node — resume rendering
1580
+ const world = this.screenToWorld(e.clientX, e.clientY);
1581
+ const newX = world.x - this.dragOffset.x;
1582
+ const newY = world.y - this.dragOffset.y;
1583
+ this.nodePositions.set(this.dragNode.id, { x: newX, y: newY });
1584
+ this.worker.postMessage({ type: 'pin', id: this.dragNode.id, x: newX, y: newY });
1585
+ this.hoverNode = null;
1586
+ } else if (this.isPanning) {
1587
+ this._wakeLoop(); // Panning — resume rendering
1588
+ this.panX = this.panStart.x + (e.clientX - this.panStart.px);
1589
+ this.panY = this.panStart.y + (e.clientY - this.panStart.py);
1590
+ this.hoverNode = null;
1591
+ } else {
1592
+ const world = this.screenToWorld(e.clientX, e.clientY);
1593
+ this.hoverNode = this.hitTest(world.x, world.y);
1594
+ }
1595
+ });
1596
+
1597
+ this.canvas.addEventListener('pointerup', (e) => {
1598
+ const draggedNode = this.dragNode;
1599
+ if (this.dragNode) {
1600
+ this.worker.postMessage({ type: 'unpin', id: this.dragNode.id });
1601
+ this.dragNode = null;
1602
+ }
1603
+ this.isPanning = false;
1604
+ this.canvas.style.cursor = 'default';
1605
+
1606
+ // Detect click vs drag: if pointer moved less than 5px, it's a click
1607
+ const dx = e.clientX - (this._dragStartX || 0);
1608
+ const dy = e.clientY - (this._dragStartY || 0);
1609
+ const wasClick = (dx * dx + dy * dy) < 25;
1610
+
1611
+ if (wasClick) {
1612
+ const world = this.screenToWorld(e.clientX, e.clientY);
1613
+ const node = this.hitTest(world.x, world.y);
1614
+ if (node) {
1615
+ if (node.isGroup) {
1616
+ const now = Date.now();
1617
+ if (now - this.lastClickTime < 300 && this.lastClickNode === node.id) {
1618
+ // Double click on group
1619
+ this.loadLevel(node.id);
1620
+ } else {
1621
+ // Single click on group
1622
+ this.dispatchEvent(new CustomEvent('group-selected', { detail: { path: node.id } }));
1623
+ }
1624
+ this.lastClickTime = now;
1625
+ this.lastClickNode = node.id;
1626
+ } else {
1627
+ // File node click
1628
+ this.dispatchEvent(new CustomEvent('file-selected', { detail: { path: node.id } }));
1629
+ }
1630
+ } else {
1631
+ // Click on empty space → deselect active node
1632
+ if (this.activeNode && !this.deactivating) {
1633
+ this.deactivating = true;
1634
+ this.dragNode = null;
1635
+ this.dispatchEvent(new CustomEvent('node-deselected'));
1636
+ }
1637
+ }
1638
+ } else if (draggedNode && this._nodeActivatedOnDown) {
1639
+ // We dragged a node that was just activated on pointerdown.
1640
+ // Emit selection event so URL and UI synchronize.
1641
+ if (draggedNode.isGroup) {
1642
+ this.dispatchEvent(new CustomEvent('group-selected', { detail: { path: draggedNode.id } }));
1643
+ } else {
1644
+ this.dispatchEvent(new CustomEvent('file-selected', { detail: { path: draggedNode.id } }));
1645
+ }
1646
+ }
1647
+ this._nodeActivatedOnDown = false;
1648
+ this._dragStartX = 0;
1649
+ this._dragStartY = 0;
1650
+ });
1651
+
1652
+ this.canvas.addEventListener('wheel', (e) => {
1653
+ e.preventDefault();
1654
+ const rect = this.canvas.getBoundingClientRect();
1655
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
1656
+ const factor = e.deltaY > 0 ? 0.92 : 1.08;
1657
+ this._targetZoom = Math.max(0.02, Math.min(5, this._targetZoom * factor));
1658
+ this._zoomAnchor = { mx, my };
1659
+ this._wakeLoop(); // Zoom changed — resume rendering
1660
+ }, { passive: false });
1661
+
1662
+ this.canvas.addEventListener('dblclick', (e) => {
1663
+ // Check if we didn't hit a node
1664
+ const world = this.screenToWorld(e.clientX, e.clientY);
1665
+ if (!this.hitTest(world.x, world.y)) {
1666
+ if (!this.nodePositions.size) return;
1667
+ let sx = 0, sy = 0, count = 0;
1668
+ for (const pos of this.nodePositions.values()) { sx += pos.x; sy += pos.y; count++; }
1669
+ const cx = sx / count, cy = sy / count;
1670
+ const rect = this.canvas.getBoundingClientRect();
1671
+ this.panX = rect.width / 2 - cx * this.zoom;
1672
+ this.panY = rect.height / 2 - cy * this.zoom;
1673
+ this._wakeLoop(); // Double-click recenter — resume rendering
1674
+ }
1675
+ });
1676
+ }
1677
+ }
1678
+
1679
+ CanvasGraph.template = /*html*/`
1680
+ <style>
1681
+ :host {
1682
+ display: block;
1683
+ position: relative;
1684
+ width: 100%;
1685
+ height: 100%;
1686
+ overflow: hidden;
1687
+ background: #0f172a;
1688
+ }
1689
+ pg-canvas-graph > canvas {
1690
+ position: absolute;
1691
+ top: 0;
1692
+ left: 0;
1693
+ width: 100%;
1694
+ height: 100%;
1695
+ display: block;
1696
+ outline: none;
1697
+ user-select: none;
1698
+ cursor: default;
1699
+ }
1700
+ pg-canvas-graph > canvas.grabbing { cursor: grabbing; }
1701
+ </style>
1702
+ <graph-breadcrumb ref="breadcrumb" style="position: absolute; top: 16px; left: 16px; z-index: 10;"></graph-breadcrumb>
1703
+ `;
1704
+
1705
+ CanvasGraph.reg('pg-canvas-graph');