mcp-vector-search 0.12.6__py3-none-any.whl → 1.1.22__py3-none-any.whl

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 (92) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +111 -0
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +74 -0
  7. mcp_vector_search/analysis/collectors/base.py +164 -0
  8. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  9. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  10. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  11. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  12. mcp_vector_search/analysis/collectors/smells.py +325 -0
  13. mcp_vector_search/analysis/debt.py +516 -0
  14. mcp_vector_search/analysis/interpretation.py +685 -0
  15. mcp_vector_search/analysis/metrics.py +414 -0
  16. mcp_vector_search/analysis/reporters/__init__.py +7 -0
  17. mcp_vector_search/analysis/reporters/console.py +646 -0
  18. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  19. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  20. mcp_vector_search/analysis/storage/__init__.py +93 -0
  21. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  22. mcp_vector_search/analysis/storage/schema.py +245 -0
  23. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  24. mcp_vector_search/analysis/trends.py +308 -0
  25. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  26. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  27. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  28. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  29. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  30. mcp_vector_search/cli/commands/analyze.py +1062 -0
  31. mcp_vector_search/cli/commands/chat.py +1455 -0
  32. mcp_vector_search/cli/commands/index.py +621 -5
  33. mcp_vector_search/cli/commands/index_background.py +467 -0
  34. mcp_vector_search/cli/commands/init.py +13 -0
  35. mcp_vector_search/cli/commands/install.py +597 -335
  36. mcp_vector_search/cli/commands/install_old.py +8 -4
  37. mcp_vector_search/cli/commands/mcp.py +78 -6
  38. mcp_vector_search/cli/commands/reset.py +68 -26
  39. mcp_vector_search/cli/commands/search.py +224 -8
  40. mcp_vector_search/cli/commands/setup.py +1184 -0
  41. mcp_vector_search/cli/commands/status.py +339 -5
  42. mcp_vector_search/cli/commands/uninstall.py +276 -357
  43. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  44. mcp_vector_search/cli/commands/visualize/cli.py +292 -0
  45. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  46. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  47. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
  48. mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
  49. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  50. mcp_vector_search/cli/commands/visualize/server.py +600 -0
  51. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  52. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  53. mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
  54. mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
  55. mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
  56. mcp_vector_search/cli/didyoumean.py +27 -2
  57. mcp_vector_search/cli/main.py +127 -160
  58. mcp_vector_search/cli/output.py +158 -13
  59. mcp_vector_search/config/__init__.py +4 -0
  60. mcp_vector_search/config/default_thresholds.yaml +52 -0
  61. mcp_vector_search/config/settings.py +12 -0
  62. mcp_vector_search/config/thresholds.py +273 -0
  63. mcp_vector_search/core/__init__.py +16 -0
  64. mcp_vector_search/core/auto_indexer.py +3 -3
  65. mcp_vector_search/core/boilerplate.py +186 -0
  66. mcp_vector_search/core/config_utils.py +394 -0
  67. mcp_vector_search/core/database.py +406 -94
  68. mcp_vector_search/core/embeddings.py +24 -0
  69. mcp_vector_search/core/exceptions.py +11 -0
  70. mcp_vector_search/core/git.py +380 -0
  71. mcp_vector_search/core/git_hooks.py +4 -4
  72. mcp_vector_search/core/indexer.py +632 -54
  73. mcp_vector_search/core/llm_client.py +756 -0
  74. mcp_vector_search/core/models.py +91 -1
  75. mcp_vector_search/core/project.py +17 -0
  76. mcp_vector_search/core/relationships.py +473 -0
  77. mcp_vector_search/core/scheduler.py +11 -11
  78. mcp_vector_search/core/search.py +179 -29
  79. mcp_vector_search/mcp/server.py +819 -9
  80. mcp_vector_search/parsers/python.py +285 -5
  81. mcp_vector_search/utils/__init__.py +2 -0
  82. mcp_vector_search/utils/gitignore.py +0 -3
  83. mcp_vector_search/utils/gitignore_updater.py +212 -0
  84. mcp_vector_search/utils/monorepo.py +66 -4
  85. mcp_vector_search/utils/timing.py +10 -6
  86. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
  87. mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
  88. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
  89. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
  90. mcp_vector_search/cli/commands/visualize.py +0 -1467
  91. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  92. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,4542 @@
1
+ """Simple D3.js tree visualization for code graph.
2
+
3
+ Clean, minimal implementation focusing on core functionality:
4
+ - Hierarchical tree layout (linear and circular)
5
+ - Expandable/collapsible directories and files
6
+ - File expansion shows code chunks as child nodes
7
+ - Chunk selection to view content in side panel
8
+
9
+ Design Decision: Complete rewrite from scratch
10
+ Rationale: Previous implementation was 4085 lines (5x over 800-line limit)
11
+ with excessive complexity. This minimal version provides core functionality
12
+ in <450 lines while maintaining clarity and maintainability.
13
+
14
+ Node Types and Colors:
15
+ - Orange (collapsed directory) / Blue (expanded directory)
16
+ - Gray (collapsed file) / White (expanded file)
17
+ - Purple (chunk nodes) - smaller circles with purple text
18
+
19
+ Trade-offs:
20
+ - Simplicity vs Features: Removed advanced features (force-directed, filters)
21
+ - Performance vs Clarity: Straightforward DOM updates over optimized rendering
22
+ - Flexibility vs Simplicity: Fixed layouts instead of customizable options
23
+
24
+ Extension Points: Add features incrementally based on user feedback rather
25
+ than preemptive feature bloat.
26
+ """
27
+
28
+
29
+ def get_all_scripts() -> str:
30
+ """Generate all JavaScript for the visualization.
31
+
32
+ Returns:
33
+ Complete JavaScript code as a single string
34
+ """
35
+ return """
36
+ // ============================================================================
37
+ // GLOBAL STATE
38
+ // ============================================================================
39
+
40
+ let allNodes = [];
41
+ let allLinks = [];
42
+ let currentLayout = 'linear'; // 'linear' or 'circular'
43
+ let treeData = null;
44
+ let isViewerOpen = false;
45
+
46
+ // Navigation history for back/forward
47
+ let navigationHistory = [];
48
+ let navigationIndex = -1;
49
+
50
+ // Call lines visibility
51
+ let showCallLines = true;
52
+
53
+ // File filter: 'all', 'code', 'docs'
54
+ let currentFileFilter = 'all';
55
+
56
+ // Chunk types for code nodes (function, class, method, text, imports, module)
57
+ const chunkTypes = ['function', 'class', 'method', 'text', 'imports', 'module'];
58
+
59
+ // Size scaling configuration
60
+ const sizeConfig = {
61
+ minRadius: 12, // Minimum node radius (50% larger for readability)
62
+ maxRadius: 24, // Maximum node radius
63
+ chunkMinRadius: 5, // Minimum for small chunks (more visible size contrast)
64
+ chunkMaxRadius: 28 // Maximum for large chunks (more visible size contrast)
65
+ };
66
+
67
+ // Dynamic dimensions that update when viewer opens/closes
68
+ function getViewportDimensions() {
69
+ const container = document.getElementById('main-container');
70
+ return {
71
+ width: container.clientWidth,
72
+ height: container.clientHeight
73
+ };
74
+ }
75
+
76
+ const margin = {top: 40, right: 120, bottom: 20, left: 120};
77
+
78
+ // ============================================================================
79
+ // DATA LOADING
80
+ // ============================================================================
81
+
82
+ let graphStatusCheckInterval = null;
83
+
84
+ async function checkGraphStatus() {
85
+ try {
86
+ const response = await fetch('/api/graph-status');
87
+ const status = await response.json();
88
+ return status;
89
+ } catch (error) {
90
+ console.error('Failed to check graph status:', error);
91
+ return { ready: false, size: 0 };
92
+ }
93
+ }
94
+
95
+ function showLoadingIndicator(message) {
96
+ const loadingDiv = document.getElementById('graph-loading-indicator') || createLoadingDiv();
97
+ loadingDiv.querySelector('.loading-message').textContent = message;
98
+ loadingDiv.style.display = 'flex';
99
+ }
100
+
101
+ function hideLoadingIndicator() {
102
+ const loadingDiv = document.getElementById('graph-loading-indicator');
103
+ if (loadingDiv) {
104
+ loadingDiv.style.display = 'none';
105
+ }
106
+ }
107
+
108
+ function createLoadingDiv() {
109
+ const div = document.createElement('div');
110
+ div.id = 'graph-loading-indicator';
111
+ div.style.cssText = `
112
+ position: fixed;
113
+ top: 50%;
114
+ left: 50%;
115
+ transform: translate(-50%, -50%);
116
+ background: rgba(255, 255, 255, 0.95);
117
+ padding: 30px 50px;
118
+ border-radius: 8px;
119
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
120
+ display: flex;
121
+ flex-direction: column;
122
+ align-items: center;
123
+ gap: 15px;
124
+ z-index: 10000;
125
+ `;
126
+
127
+ const spinner = document.createElement('div');
128
+ spinner.style.cssText = `
129
+ border: 4px solid #f3f3f3;
130
+ border-top: 4px solid #3498db;
131
+ border-radius: 50%;
132
+ width: 40px;
133
+ height: 40px;
134
+ animation: spin 1s linear infinite;
135
+ `;
136
+
137
+ const message = document.createElement('div');
138
+ message.className = 'loading-message';
139
+ message.style.cssText = 'color: #333; font-size: 16px; font-family: Arial, sans-serif;';
140
+ message.textContent = 'Loading graph data...';
141
+
142
+ div.appendChild(spinner);
143
+ div.appendChild(message);
144
+ document.body.appendChild(div);
145
+
146
+ // Add spinner animation
147
+ const style = document.createElement('style');
148
+ style.textContent = '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }';
149
+ document.head.appendChild(style);
150
+
151
+ return div;
152
+ }
153
+
154
+ async function loadGraphData() {
155
+ try {
156
+ // Check if graph is ready
157
+ const status = await checkGraphStatus();
158
+
159
+ if (!status.ready) {
160
+ console.log('Graph data not ready yet, will poll every 5 seconds...');
161
+ showLoadingIndicator('Generating graph data... This may take a few minutes.');
162
+
163
+ // Start polling for graph readiness
164
+ graphStatusCheckInterval = setInterval(async () => {
165
+ const checkStatus = await checkGraphStatus();
166
+ if (checkStatus.ready) {
167
+ clearInterval(graphStatusCheckInterval);
168
+ console.log('Graph data is now ready, loading...');
169
+ showLoadingIndicator('Graph data ready! Loading visualization...');
170
+ await loadGraphDataActual();
171
+ hideLoadingIndicator();
172
+ }
173
+ }, 5000); // Poll every 5 seconds
174
+
175
+ return;
176
+ }
177
+
178
+ // Graph is already ready, load it
179
+ await loadGraphDataActual();
180
+ } catch (error) {
181
+ console.error('Failed to load graph data:', error);
182
+ hideLoadingIndicator();
183
+ document.body.innerHTML =
184
+ '<div style="color: red; padding: 20px; font-family: Arial;">Error loading visualization data. Check console for details.</div>';
185
+ }
186
+ }
187
+
188
+ async function loadGraphDataActual() {
189
+ try {
190
+ const response = await fetch('/api/graph');
191
+ const data = await response.json();
192
+
193
+ // Check if we got an error response
194
+ if (data.error) {
195
+ console.warn('Graph data not available yet:', data.error);
196
+ showLoadingIndicator('Waiting for graph data...');
197
+ return;
198
+ }
199
+
200
+ allNodes = data.nodes || [];
201
+ allLinks = data.links || [];
202
+
203
+ // Store trend data globally for visualization
204
+ window.graphTrendData = data.trends || null;
205
+ if (window.graphTrendData) {
206
+ console.log(`Loaded trend data: ${window.graphTrendData.entries_count} entries`);
207
+ }
208
+
209
+ console.log(`Loaded ${allNodes.length} nodes and ${allLinks.length} links`);
210
+
211
+ // DEBUG: Log first few nodes to see actual structure
212
+ console.log('=== SAMPLE NODE STRUCTURE ===');
213
+ if (allNodes.length > 0) {
214
+ console.log('First node:', JSON.stringify(allNodes[0], null, 2));
215
+ if (allNodes.length > 1) {
216
+ console.log('Second node:', JSON.stringify(allNodes[1], null, 2));
217
+ }
218
+ }
219
+
220
+ // Count node types
221
+ const typeCounts = {};
222
+ allNodes.forEach(node => {
223
+ const type = node.type || 'undefined';
224
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
225
+ });
226
+ console.log('Node type counts:', typeCounts);
227
+ console.log('=== END SAMPLE NODE STRUCTURE ===');
228
+
229
+ buildTreeStructure();
230
+ renderVisualization();
231
+ } catch (error) {
232
+ console.error('Failed to load graph data:', error);
233
+ document.body.innerHTML =
234
+ '<div style="color: red; padding: 20px; font-family: Arial;">Error loading visualization data. Check console for details.</div>';
235
+ }
236
+ }
237
+
238
+ // ============================================================================
239
+ // TREE STRUCTURE BUILDING
240
+ // ============================================================================
241
+
242
+ function buildTreeStructure() {
243
+ // Include directories, files, AND chunks (function, class, method, text, imports, module)
244
+ const treeNodes = allNodes.filter(node => {
245
+ const type = node.type;
246
+ return type === 'directory' || type === 'file' || chunkTypes.includes(type);
247
+ });
248
+
249
+ console.log(`Filtered to ${treeNodes.length} tree nodes (directories, files, and chunks)`);
250
+
251
+ // Count node types for debugging
252
+ const dirCount = treeNodes.filter(n => n.type === 'directory').length;
253
+ const fileCount = treeNodes.filter(n => n.type === 'file').length;
254
+ const chunkCount = treeNodes.filter(n => chunkTypes.includes(n.type)).length;
255
+ console.log(`Node breakdown: ${dirCount} directories, ${fileCount} files, ${chunkCount} chunks`);
256
+
257
+ // Create lookup maps
258
+ const nodeMap = new Map();
259
+ treeNodes.forEach(node => {
260
+ nodeMap.set(node.id, {
261
+ ...node,
262
+ children: []
263
+ });
264
+ });
265
+
266
+ // Build parent-child relationships
267
+ const parentMap = new Map();
268
+
269
+ // DEBUG: Analyze link structure
270
+ console.log('=== LINK STRUCTURE DEBUG ===');
271
+ console.log(`Total links: ${allLinks.length}`);
272
+
273
+ // Get unique link types (handle undefined)
274
+ const linkTypes = [...new Set(allLinks.map(l => l.type || 'undefined'))];
275
+ console.log('Link types found:', linkTypes);
276
+
277
+ // Count links by type
278
+ const linkTypeCounts = {};
279
+ allLinks.forEach(link => {
280
+ const type = link.type || 'undefined';
281
+ linkTypeCounts[type] = (linkTypeCounts[type] || 0) + 1;
282
+ });
283
+ console.log('Link type counts:', linkTypeCounts);
284
+
285
+ // Sample first few links
286
+ console.log('Sample links (first 5):');
287
+ allLinks.slice(0, 5).forEach((link, i) => {
288
+ console.log(` Link ${i}:`, JSON.stringify(link, null, 2));
289
+ });
290
+
291
+ // Check if links have properties we expect
292
+ if (allLinks.length > 0) {
293
+ const firstLink = allLinks[0];
294
+ console.log('Link properties:', Object.keys(firstLink));
295
+ }
296
+ console.log('=== END LINK STRUCTURE DEBUG ===');
297
+
298
+ // Build parent-child relationships from links
299
+ // Process all containment and hierarchy links to establish the tree structure
300
+ console.log('=== BUILDING TREE RELATIONSHIPS ===');
301
+
302
+ let relationshipsProcessed = {
303
+ dir_hierarchy: 0,
304
+ dir_containment: 0,
305
+ file_containment: 0,
306
+ chunk_hierarchy: 0 // chunk_hierarchy links = class -> method
307
+ };
308
+
309
+ let relationshipsMatched = {
310
+ dir_hierarchy: 0,
311
+ dir_containment: 0,
312
+ file_containment: 0,
313
+ chunk_hierarchy: 0
314
+ };
315
+
316
+ // Process all relationship links
317
+ allLinks.forEach(link => {
318
+ const linkType = link.type;
319
+
320
+ // Determine relationship category
321
+ let category = null;
322
+ if (linkType === 'dir_hierarchy') {
323
+ category = 'dir_hierarchy';
324
+ } else if (linkType === 'dir_containment') {
325
+ category = 'dir_containment';
326
+ } else if (linkType === 'file_containment') {
327
+ category = 'file_containment';
328
+ } else if (linkType === 'chunk_hierarchy') {
329
+ // chunk_hierarchy links are chunk-to-chunk (e.g., class -> method)
330
+ category = 'chunk_hierarchy';
331
+ } else {
332
+ // Skip semantic, caller, undefined, and other non-hierarchical links
333
+ // This includes links without a 'type' field (e.g., subproject links)
334
+ return;
335
+ }
336
+
337
+ relationshipsProcessed[category]++;
338
+
339
+ // Get parent and child nodes from the map
340
+ const parentNode = nodeMap.get(link.source);
341
+ const childNode = nodeMap.get(link.target);
342
+
343
+ // Both nodes must exist in our tree node set
344
+ if (!parentNode || !childNode) {
345
+ if (relationshipsProcessed[category] <= 3) { // Log first few misses
346
+ console.log(`${category} link skipped - parent: ${link.source} (exists: ${!!parentNode}), child: ${link.target} (exists: ${!!childNode})`);
347
+ }
348
+ return;
349
+ }
350
+
351
+ // Establish parent-child relationship
352
+ // Add child to parent's children array
353
+ parentNode.children.push(childNode);
354
+
355
+ // Record the parent in parentMap (used to identify root nodes)
356
+ parentMap.set(link.target, link.source);
357
+
358
+ relationshipsMatched[category]++;
359
+ });
360
+
361
+ console.log('Relationship processing summary:');
362
+ console.log(` dir_hierarchy: ${relationshipsMatched.dir_hierarchy}/${relationshipsProcessed.dir_hierarchy} matched`);
363
+ console.log(` dir_containment: ${relationshipsMatched.dir_containment}/${relationshipsProcessed.dir_containment} matched`);
364
+ console.log(` file_containment: ${relationshipsMatched.file_containment}/${relationshipsProcessed.file_containment} matched`);
365
+ console.log(` chunk_hierarchy (class→method): ${relationshipsMatched.chunk_hierarchy}/${relationshipsProcessed.chunk_hierarchy} matched`);
366
+ console.log(` Total parent-child links: ${parentMap.size}`);
367
+ console.log('=== END TREE RELATIONSHIPS ===');
368
+
369
+ // Find root nodes (nodes with no parents)
370
+ // IMPORTANT: Exclude chunk types from roots - they should only appear as children of files
371
+ // Orphaned chunks (without file_containment links) are excluded from the tree
372
+ const rootNodes = treeNodes
373
+ .filter(node => !parentMap.has(node.id))
374
+ .filter(node => !chunkTypes.includes(node.type)) // Exclude orphaned chunks
375
+ .map(node => nodeMap.get(node.id))
376
+ .filter(node => node !== undefined);
377
+
378
+ console.log('=== ROOT NODE ANALYSIS ===');
379
+ console.log(`Found ${rootNodes.length} root nodes (directories and files only)`);
380
+
381
+ // DEBUG: Count root node types
382
+ const rootTypeCounts = {};
383
+ rootNodes.forEach(node => {
384
+ const type = node.type || 'undefined';
385
+ rootTypeCounts[type] = (rootTypeCounts[type] || 0) + 1;
386
+ });
387
+ console.log('Root node type breakdown:', rootTypeCounts);
388
+
389
+ // If we have chunk nodes as roots, something went wrong
390
+ const chunkRoots = rootNodes.filter(n => chunkTypes.includes(n.type)).length;
391
+ if (chunkRoots > 0) {
392
+ console.warn(`WARNING: ${chunkRoots} chunk nodes are roots - they should be children of files!`);
393
+ }
394
+
395
+ // If we have file nodes as roots (except for top-level files), might be missing dir_containment
396
+ const fileRoots = rootNodes.filter(n => n.type === 'file').length;
397
+ if (fileRoots > 0) {
398
+ console.log(`INFO: ${fileRoots} file nodes are roots (this is normal for files not in subdirectories)`);
399
+ }
400
+
401
+ console.log('=== END ROOT NODE ANALYSIS ===');
402
+
403
+ // Create virtual root if multiple roots
404
+ if (rootNodes.length === 0) {
405
+ console.error('No root nodes found!');
406
+ treeData = {name: 'Empty', id: 'root', type: 'directory', children: []};
407
+ } else if (rootNodes.length === 1) {
408
+ treeData = rootNodes[0];
409
+ } else {
410
+ treeData = {
411
+ name: 'Project Root',
412
+ id: 'virtual-root',
413
+ type: 'directory',
414
+ children: rootNodes
415
+ };
416
+ }
417
+
418
+ // Collapse single-child chains to make the tree more compact
419
+ // - Directory with single directory child: src -> mcp_vector_search becomes "src/mcp_vector_search"
420
+ // - File with single chunk child: promote the chunk's children to the file level
421
+ function collapseSingleChildChains(node) {
422
+ if (!node || !node.children) return;
423
+
424
+ // First, recursively process all children
425
+ node.children.forEach(child => collapseSingleChildChains(child));
426
+
427
+ // Case 1: Directory with single directory child - combine names
428
+ if (node.type === 'directory' && node.children.length === 1) {
429
+ const onlyChild = node.children[0];
430
+ if (onlyChild.type === 'directory') {
431
+ // Merge: combine names with "/"
432
+ console.log(`Collapsing dir chain: ${node.name} + ${onlyChild.name}`);
433
+ node.name = `${node.name}/${onlyChild.name}`;
434
+ // Take the child's children as our own
435
+ node.children = onlyChild.children || [];
436
+ node._children = onlyChild._children || null;
437
+ // Preserve the deepest node's id for any link references
438
+ node.collapsed_ids = node.collapsed_ids || [node.id];
439
+ node.collapsed_ids.push(onlyChild.id);
440
+
441
+ // Recursively check again in case there's another single child
442
+ collapseSingleChildChains(node);
443
+ }
444
+ }
445
+
446
+ // Case 2: File with single chunk child - promote chunk's children to file
447
+ // This handles files where there's just one L1 (e.g., imports or a single class)
448
+ if (node.type === 'file' && node.children && node.children.length === 1) {
449
+ const onlyChild = node.children[0];
450
+ if (chunkTypes.includes(onlyChild.type)) {
451
+ // If the chunk has children, promote them to the file level
452
+ const chunkChildren = onlyChild.children || onlyChild._children || [];
453
+ if (chunkChildren.length > 0) {
454
+ console.log(`Promoting ${chunkChildren.length} children from ${onlyChild.type} to file ${node.name}`);
455
+ // Replace the single chunk with its children
456
+ node.children = chunkChildren;
457
+ // Store info about the collapsed chunk (include ALL relevant properties)
458
+ node.collapsed_chunk = {
459
+ type: onlyChild.type,
460
+ name: onlyChild.name,
461
+ id: onlyChild.id,
462
+ content: onlyChild.content,
463
+ docstring: onlyChild.docstring,
464
+ start_line: onlyChild.start_line,
465
+ end_line: onlyChild.end_line,
466
+ file_path: onlyChild.file_path,
467
+ language: onlyChild.language,
468
+ complexity: onlyChild.complexity
469
+ };
470
+ } else {
471
+ // Collapse file+chunk into combined name (like directory chains)
472
+ console.log(`Collapsing file+chunk: ${node.name}/${onlyChild.name}`);
473
+ node.name = `${node.name}/${onlyChild.name}`;
474
+ node.children = null; // Remove chunk child - now a leaf node
475
+ node._children = null;
476
+ node.collapsed_ids = node.collapsed_ids || [node.id];
477
+ node.collapsed_ids.push(onlyChild.id);
478
+
479
+ // Store chunk data for display when clicked
480
+ node.collapsed_chunk = {
481
+ type: onlyChild.type,
482
+ name: onlyChild.name,
483
+ id: onlyChild.id,
484
+ content: onlyChild.content,
485
+ start_line: onlyChild.start_line,
486
+ end_line: onlyChild.end_line,
487
+ complexity: onlyChild.complexity
488
+ };
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ // Apply single-child chain collapsing to all root children
495
+ console.log('=== COLLAPSING SINGLE-CHILD CHAINS ===');
496
+ if (treeData.children) {
497
+ treeData.children.forEach(child => collapseSingleChildChains(child));
498
+ }
499
+ console.log('=== END COLLAPSING SINGLE-CHILD CHAINS ===');
500
+
501
+ // Collapse all directories and files by default
502
+ function collapseAll(node) {
503
+ if (node.children && node.children.length > 0) {
504
+ // First, recursively process all descendants
505
+ node.children.forEach(child => collapseAll(child));
506
+
507
+ // Then collapse this node (move children to _children)
508
+ node._children = node.children;
509
+ node.children = null;
510
+ }
511
+ }
512
+
513
+ // Collapse ALL nodes except the root itself
514
+ // This ensures only the root node is visible initially, all children are collapsed
515
+ if (treeData.children) {
516
+ treeData.children.forEach(child => collapseAll(child));
517
+ }
518
+
519
+ console.log('Tree structure built with all directories and files collapsed');
520
+
521
+ // Calculate line counts for all nodes (for proportional node rendering)
522
+ allLineCounts = []; // Reset for fresh calculation
523
+ calculateNodeSizes(treeData);
524
+ calculatePercentiles(); // Calculate 20th/80th percentile thresholds
525
+ console.log('Node sizes calculated with percentile-based sizing');
526
+
527
+ // DEBUG: Check a few file nodes to see if they have chunks in _children
528
+ console.log('=== POST-COLLAPSE FILE CHECK ===');
529
+ let filesChecked = 0;
530
+ let filesWithChunks = 0;
531
+
532
+ function checkFilesRecursive(node) {
533
+ if (node.type === 'file') {
534
+ filesChecked++;
535
+ const chunkCount = (node._children || []).length;
536
+ if (chunkCount > 0) {
537
+ filesWithChunks++;
538
+ console.log(`File ${node.name} has ${chunkCount} chunks in _children`);
539
+ }
540
+ }
541
+
542
+ // Check both visible and hidden children
543
+ const childrenToCheck = node.children || node._children || [];
544
+ childrenToCheck.forEach(child => checkFilesRecursive(child));
545
+ }
546
+
547
+ checkFilesRecursive(treeData);
548
+ console.log(`Checked ${filesChecked} files, ${filesWithChunks} have chunks`);
549
+ console.log('=== END POST-COLLAPSE FILE CHECK ===');
550
+ }
551
+
552
+ // ============================================================================
553
+ // NODE SIZE CALCULATION
554
+ // ============================================================================
555
+
556
+ // Global variables for size scaling - now tracking line counts
557
+ let globalMinLines = Infinity;
558
+ let globalMaxLines = 0;
559
+ let allLineCounts = []; // Collect all line counts for percentile calculation
560
+
561
+ function calculateNodeSizes(node) {
562
+ if (!node) return 0;
563
+
564
+ // For chunks: use actual line count (primary metric)
565
+ // Falls back to content-based estimate only if line numbers unavailable
566
+ if (chunkTypes.includes(node.type)) {
567
+ // Primary: use actual line span from start_line/end_line
568
+ // This ensures visual correlation with displayed line ranges
569
+ const contentLength = node.content ? node.content.length : 0;
570
+ const lineCount = (node.start_line && node.end_line)
571
+ ? node.end_line - node.start_line + 1
572
+ : Math.max(1, Math.floor(contentLength / 40)); // Fallback: estimate ~40 chars per line
573
+
574
+ // Use actual line count for sizing (NOT content length)
575
+ // This prevents inversion where sparse 101-line files appear smaller than dense 3-line files
576
+ node._lineCount = lineCount;
577
+ allLineCounts.push(lineCount);
578
+
579
+ if (lineCount > 0) {
580
+ globalMinLines = Math.min(globalMinLines, lineCount);
581
+ globalMaxLines = Math.max(globalMaxLines, lineCount);
582
+ }
583
+ return lineCount;
584
+ }
585
+
586
+ // For files and directories: sum of children line counts
587
+ const children = node.children || node._children || [];
588
+ let totalLines = 0;
589
+
590
+ children.forEach(child => {
591
+ totalLines += calculateNodeSizes(child);
592
+ });
593
+
594
+ // Handle collapsed file+chunk: use the collapsed chunk's line count
595
+ if (node.type === 'file' && node.collapsed_chunk) {
596
+ const cc = node.collapsed_chunk;
597
+ if (cc.start_line && cc.end_line) {
598
+ totalLines = cc.end_line - cc.start_line + 1;
599
+ } else if (cc.content) {
600
+ totalLines = Math.max(1, Math.floor(cc.content.length / 40));
601
+ }
602
+ }
603
+
604
+ node._lineCount = totalLines || 1; // Minimum 1 for empty dirs/files
605
+
606
+ // DON'T add files/directories to allLineCounts - they skew percentiles
607
+ // Only chunks should affect percentile calculation since only chunks use percentile sizing
608
+ // (Files and directories use separate minRadius/maxRadius, not chunkMinRadius/chunkMaxRadius)
609
+
610
+ if (node._lineCount > 0) {
611
+ globalMinLines = Math.min(globalMinLines, node._lineCount);
612
+ globalMaxLines = Math.max(globalMaxLines, node._lineCount);
613
+ }
614
+
615
+ return node._lineCount;
616
+ }
617
+
618
+ // Calculate percentile thresholds after all nodes are processed
619
+ let percentile20 = 0;
620
+ let percentile80 = 0;
621
+
622
+ function calculatePercentiles() {
623
+ if (allLineCounts.length === 0) return;
624
+
625
+ const sorted = [...allLineCounts].sort((a, b) => a - b);
626
+ const p20Index = Math.floor(sorted.length * 0.20);
627
+ const p80Index = Math.floor(sorted.length * 0.80);
628
+
629
+ percentile20 = sorted[p20Index] || 1;
630
+ percentile80 = sorted[p80Index] || sorted[sorted.length - 1] || 1;
631
+
632
+ console.log(`Line count percentiles (chunks only): 20th=${percentile20}, 80th=${percentile80}, range=${percentile80 - percentile20}`);
633
+ console.log(`Total chunks: ${allLineCounts.length}, min=${globalMinLines}, max=${globalMaxLines}`);
634
+ }
635
+
636
+ // Count external calls for a node
637
+ function getExternalCallCounts(nodeData) {
638
+ if (!nodeData.id) return { inbound: 0, outbound: 0, inboundNodes: [], outboundNodes: [] };
639
+
640
+ const nodeFilePath = nodeData.file_path;
641
+ const inboundNodes = []; // Array of {id, name, file_path}
642
+ const outboundNodes = []; // Array of {id, name, file_path}
643
+
644
+ // Use a Set to deduplicate by source/target node
645
+ const inboundSeen = new Set();
646
+ const outboundSeen = new Set();
647
+
648
+ allLinks.forEach(link => {
649
+ if (link.type === 'caller') {
650
+ if (link.target === nodeData.id) {
651
+ // Something calls this node
652
+ const callerNode = allNodes.find(n => n.id === link.source);
653
+ if (callerNode && callerNode.file_path !== nodeFilePath && !inboundSeen.has(callerNode.id)) {
654
+ inboundSeen.add(callerNode.id);
655
+ inboundNodes.push({ id: callerNode.id, name: callerNode.name, file_path: callerNode.file_path });
656
+ }
657
+ }
658
+ if (link.source === nodeData.id) {
659
+ // This node calls something
660
+ const calleeNode = allNodes.find(n => n.id === link.target);
661
+ if (calleeNode && calleeNode.file_path !== nodeFilePath && !outboundSeen.has(calleeNode.id)) {
662
+ outboundSeen.add(calleeNode.id);
663
+ outboundNodes.push({ id: calleeNode.id, name: calleeNode.name, file_path: calleeNode.file_path });
664
+ }
665
+ }
666
+ }
667
+ });
668
+
669
+ return {
670
+ inbound: inboundNodes.length,
671
+ outbound: outboundNodes.length,
672
+ inboundNodes,
673
+ outboundNodes
674
+ };
675
+ }
676
+
677
+ // Store external call data for line drawing
678
+ let externalCallData = [];
679
+
680
+ function collectExternalCallData() {
681
+ externalCallData = [];
682
+
683
+ allNodes.forEach(nodeData => {
684
+ if (!chunkTypes.includes(nodeData.type)) return;
685
+
686
+ const counts = getExternalCallCounts(nodeData);
687
+ if (counts.inbound > 0 || counts.outbound > 0) {
688
+ externalCallData.push({
689
+ nodeId: nodeData.id,
690
+ inboundNodes: counts.inboundNodes,
691
+ outboundNodes: counts.outboundNodes
692
+ });
693
+ }
694
+ });
695
+ }
696
+
697
+ function drawExternalCallLines(svg, root) {
698
+ // Remove existing external call lines
699
+ svg.selectAll('.external-call-line').remove();
700
+
701
+ // Build a map of node positions from the tree (visible nodes only)
702
+ const nodePositions = new Map();
703
+ root.descendants().forEach(d => {
704
+ nodePositions.set(d.data.id, { x: d.x, y: d.y, node: d });
705
+ });
706
+
707
+ // Build a map from node ID to tree node (for parent traversal)
708
+ const treeNodeMap = new Map();
709
+ root.descendants().forEach(d => {
710
+ treeNodeMap.set(d.data.id, d);
711
+ });
712
+
713
+ // Helper: Find position for a node, falling back to visible ancestors
714
+ // If the target node is not visible (collapsed), find its closest visible ancestor
715
+ function getPositionWithFallback(nodeId) {
716
+ // First check if node is directly visible
717
+ if (nodePositions.has(nodeId)) {
718
+ return nodePositions.get(nodeId);
719
+ }
720
+
721
+ // Node not visible - try to find via tree traversal
722
+ // Strategy 1: Find by file_path matching in visible nodes
723
+ const targetNode = allNodes.find(n => n.id === nodeId);
724
+ if (!targetNode) {
725
+ return null;
726
+ }
727
+
728
+ // Strategy 2: Look for the file that contains this chunk
729
+ if (targetNode.file_path) {
730
+ // Look for visible file nodes that match this path
731
+ for (const [id, pos] of nodePositions) {
732
+ const visibleNode = allNodes.find(n => n.id === id);
733
+ if (visibleNode) {
734
+ // Check if this is the file containing our chunk
735
+ if (visibleNode.type === 'file' &&
736
+ (visibleNode.path === targetNode.file_path ||
737
+ visibleNode.file_path === targetNode.file_path ||
738
+ visibleNode.name === targetNode.file_path.split('/').pop())) {
739
+ return pos;
740
+ }
741
+ }
742
+ }
743
+
744
+ // Strategy 3: Look for directory containing the file
745
+ const pathParts = targetNode.file_path.split('/');
746
+ // Go from most specific (file's directory) to least specific (root)
747
+ for (let i = pathParts.length - 1; i >= 0; i--) {
748
+ const dirName = pathParts[i];
749
+ if (!dirName) continue;
750
+
751
+ // Find a visible directory with this name
752
+ for (const [id, pos] of nodePositions) {
753
+ const visibleNode = allNodes.find(n => n.id === id);
754
+ if (visibleNode && visibleNode.type === 'directory' && visibleNode.name === dirName) {
755
+ return pos;
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ return null;
762
+ }
763
+
764
+ // Create a group for external call lines (behind nodes)
765
+ let lineGroup = svg.select('.external-lines-group');
766
+ if (lineGroup.empty()) {
767
+ lineGroup = svg.insert('g', ':first-child')
768
+ .attr('class', 'external-lines-group');
769
+ }
770
+
771
+ // Respect the toggle state
772
+ lineGroup.style('display', showCallLines ? 'block' : 'none');
773
+
774
+ console.log(`[CallLines] Drawing lines for ${externalCallData.length} nodes with external calls`);
775
+
776
+ let linesDrawn = 0;
777
+ externalCallData.forEach(data => {
778
+ const sourcePos = getPositionWithFallback(data.nodeId);
779
+ if (!sourcePos) {
780
+ console.log(`[CallLines] No source position for ${data.nodeId}`);
781
+ return;
782
+ }
783
+
784
+ // Draw lines to inbound nodes (callers) - dashed blue (fainter)
785
+ data.inboundNodes.forEach(caller => {
786
+ const targetPos = getPositionWithFallback(caller.id);
787
+ if (targetPos) {
788
+ lineGroup.append('path')
789
+ .attr('class', 'external-call-line inbound-line')
790
+ .attr('d', `M${targetPos.y},${targetPos.x} C${(targetPos.y + sourcePos.y)/2},${targetPos.x} ${(targetPos.y + sourcePos.y)/2},${sourcePos.x} ${sourcePos.y},${sourcePos.x}`)
791
+ .attr('fill', 'none')
792
+ .attr('stroke', '#58a6ff')
793
+ .attr('stroke-width', 1)
794
+ .attr('stroke-dasharray', '4,2')
795
+ .attr('opacity', 0.35)
796
+ .attr('pointer-events', 'none');
797
+ linesDrawn++;
798
+ }
799
+ });
800
+
801
+ // Draw lines to outbound nodes (callees) - dashed orange (fainter)
802
+ data.outboundNodes.forEach(callee => {
803
+ const targetPos = getPositionWithFallback(callee.id);
804
+ if (targetPos) {
805
+ lineGroup.append('path')
806
+ .attr('class', 'external-call-line outbound-line')
807
+ .attr('d', `M${sourcePos.y},${sourcePos.x} C${(sourcePos.y + targetPos.y)/2},${sourcePos.x} ${(sourcePos.y + targetPos.y)/2},${targetPos.x} ${targetPos.y},${targetPos.x}`)
808
+ .attr('fill', 'none')
809
+ .attr('stroke', '#f0883e')
810
+ .attr('stroke-width', 1)
811
+ .attr('stroke-dasharray', '4,2')
812
+ .attr('opacity', 0.35)
813
+ .attr('pointer-events', 'none');
814
+ linesDrawn++;
815
+ }
816
+ });
817
+ });
818
+
819
+ console.log(`[CallLines] Drew ${linesDrawn} call lines`);
820
+ }
821
+
822
+ // Draw external call lines for CIRCULAR layout
823
+ // Converts polar coordinates (angle, radius) to Cartesian (x, y)
824
+ function drawExternalCallLinesCircular(svg, root) {
825
+ // Remove existing external call lines
826
+ svg.selectAll('.external-call-line').remove();
827
+
828
+ // Helper: Convert polar to Cartesian coordinates
829
+ // In D3 radial layout: d.x = angle (radians), d.y = radius
830
+ function polarToCartesian(angle, radius) {
831
+ return {
832
+ x: radius * Math.cos(angle - Math.PI / 2),
833
+ y: radius * Math.sin(angle - Math.PI / 2)
834
+ };
835
+ }
836
+
837
+ // Build a map of node positions from the tree (visible nodes only)
838
+ const nodePositions = new Map();
839
+ root.descendants().forEach(d => {
840
+ const cartesian = polarToCartesian(d.x, d.y);
841
+ nodePositions.set(d.data.id, { x: cartesian.x, y: cartesian.y, angle: d.x, radius: d.y, node: d });
842
+ });
843
+
844
+ // Helper: Find position for a node, falling back to visible ancestors
845
+ function getPositionWithFallback(nodeId) {
846
+ // First check if node is directly visible
847
+ if (nodePositions.has(nodeId)) {
848
+ return nodePositions.get(nodeId);
849
+ }
850
+
851
+ // Node not visible - try to find via tree traversal
852
+ const targetNode = allNodes.find(n => n.id === nodeId);
853
+ if (!targetNode) {
854
+ return null;
855
+ }
856
+
857
+ // Look for the file that contains this chunk
858
+ if (targetNode.file_path) {
859
+ // Look for visible file nodes that match this path
860
+ for (const [id, pos] of nodePositions) {
861
+ const visibleNode = allNodes.find(n => n.id === id);
862
+ if (visibleNode) {
863
+ if (visibleNode.type === 'file' &&
864
+ (visibleNode.path === targetNode.file_path ||
865
+ visibleNode.file_path === targetNode.file_path ||
866
+ visibleNode.name === targetNode.file_path.split('/').pop())) {
867
+ return pos;
868
+ }
869
+ }
870
+ }
871
+
872
+ // Look for directory containing the file
873
+ const pathParts = targetNode.file_path.split('/');
874
+ for (let i = pathParts.length - 1; i >= 0; i--) {
875
+ const dirName = pathParts[i];
876
+ if (!dirName) continue;
877
+
878
+ for (const [id, pos] of nodePositions) {
879
+ const visibleNode = allNodes.find(n => n.id === id);
880
+ if (visibleNode && visibleNode.type === 'directory' && visibleNode.name === dirName) {
881
+ return pos;
882
+ }
883
+ }
884
+ }
885
+ }
886
+
887
+ return null;
888
+ }
889
+
890
+ // Create a group for external call lines (behind nodes)
891
+ let lineGroup = svg.select('.external-lines-group');
892
+ if (lineGroup.empty()) {
893
+ lineGroup = svg.insert('g', ':first-child')
894
+ .attr('class', 'external-lines-group');
895
+ }
896
+
897
+ // Respect the toggle state
898
+ lineGroup.style('display', showCallLines ? 'block' : 'none');
899
+
900
+ console.log(`[CallLines Circular] Drawing lines for ${externalCallData.length} nodes with external calls`);
901
+
902
+ let linesDrawn = 0;
903
+ externalCallData.forEach(data => {
904
+ const sourcePos = getPositionWithFallback(data.nodeId);
905
+ if (!sourcePos) {
906
+ return;
907
+ }
908
+
909
+ // Draw lines to inbound nodes (callers) - dashed blue (fainter)
910
+ data.inboundNodes.forEach(caller => {
911
+ const targetPos = getPositionWithFallback(caller.id);
912
+ if (targetPos) {
913
+ // Use quadratic bezier curves that go through the center for circular layout
914
+ const midX = (sourcePos.x + targetPos.x) / 2 * 0.3;
915
+ const midY = (sourcePos.y + targetPos.y) / 2 * 0.3;
916
+
917
+ lineGroup.append('path')
918
+ .attr('class', 'external-call-line inbound-line')
919
+ .attr('d', `M${targetPos.x},${targetPos.y} Q${midX},${midY} ${sourcePos.x},${sourcePos.y}`)
920
+ .attr('fill', 'none')
921
+ .attr('stroke', '#58a6ff')
922
+ .attr('stroke-width', 1)
923
+ .attr('stroke-dasharray', '4,2')
924
+ .attr('opacity', 0.35)
925
+ .attr('pointer-events', 'none');
926
+ linesDrawn++;
927
+ }
928
+ });
929
+
930
+ // Draw lines to outbound nodes (callees) - dashed orange (fainter)
931
+ data.outboundNodes.forEach(callee => {
932
+ const targetPos = getPositionWithFallback(callee.id);
933
+ if (targetPos) {
934
+ // Use quadratic bezier curves that go through the center for circular layout
935
+ const midX = (sourcePos.x + targetPos.x) / 2 * 0.3;
936
+ const midY = (sourcePos.y + targetPos.y) / 2 * 0.3;
937
+
938
+ lineGroup.append('path')
939
+ .attr('class', 'external-call-line outbound-line')
940
+ .attr('d', `M${sourcePos.x},${sourcePos.y} Q${midX},${midY} ${targetPos.x},${targetPos.y}`)
941
+ .attr('fill', 'none')
942
+ .attr('stroke', '#f0883e')
943
+ .attr('stroke-width', 1)
944
+ .attr('stroke-dasharray', '4,2')
945
+ .attr('opacity', 0.35)
946
+ .attr('pointer-events', 'none');
947
+ linesDrawn++;
948
+ }
949
+ });
950
+ });
951
+
952
+ console.log(`[CallLines Circular] Drew ${linesDrawn} call lines`);
953
+ }
954
+
955
+ // Get color based on complexity (darker = more complex)
956
+ // Uses HSL color model for smooth gradients
957
+ function getComplexityColor(d, baseHue) {
958
+ const nodeData = d.data;
959
+ const complexity = nodeData.complexity;
960
+
961
+ // If no complexity data, return a default based on type
962
+ if (complexity === undefined || complexity === null) {
963
+ // Default colors for non-complex nodes
964
+ if (nodeData.type === 'directory') {
965
+ return nodeData._children ? '#f39c12' : '#3498db'; // Orange/Blue
966
+ } else if (nodeData.type === 'file') {
967
+ return nodeData._children ? '#95a5a6' : '#ecf0f1'; // Gray/White
968
+ } else if (chunkTypes.includes(nodeData.type)) {
969
+ return '#9b59b6'; // Default purple
970
+ }
971
+ return '#95a5a6';
972
+ }
973
+
974
+ // Complexity ranges: 0-5 (low), 5-10 (medium), 10-20 (high), 20+ (very high)
975
+ // Map to lightness: 70% (light) to 30% (dark)
976
+ const maxComplexity = 25; // Cap for scaling
977
+ const normalizedComplexity = Math.min(complexity, maxComplexity) / maxComplexity;
978
+
979
+ // Lightness goes from 65% (low complexity) to 35% (high complexity)
980
+ const lightness = 65 - (normalizedComplexity * 30);
981
+
982
+ // Saturation increases slightly with complexity (60% to 80%)
983
+ const saturation = 60 + (normalizedComplexity * 20);
984
+
985
+ return `hsl(${baseHue}, ${saturation}%, ${lightness}%)`;
986
+ }
987
+
988
+ // Get node fill color with complexity shading
989
+ function getNodeFillColor(d) {
990
+ const nodeData = d.data;
991
+
992
+ if (nodeData.type === 'directory') {
993
+ // Orange (30°) if collapsed, Blue (210°) if expanded
994
+ const hue = nodeData._children ? 30 : 210;
995
+ // Directories aggregate complexity from children
996
+ const avgComplexity = calculateAverageComplexity(nodeData);
997
+ if (avgComplexity > 0) {
998
+ const lightness = 55 - (Math.min(avgComplexity, 15) / 15) * 20;
999
+ return `hsl(${hue}, 70%, ${lightness}%)`;
1000
+ }
1001
+ return nodeData._children ? '#f39c12' : '#3498db';
1002
+ } else if (nodeData.type === 'file') {
1003
+ // Gray files, but show complexity if available
1004
+ const avgComplexity = calculateAverageComplexity(nodeData);
1005
+ if (avgComplexity > 0) {
1006
+ // Gray hue (0° with 0 saturation) to slight red tint for complexity
1007
+ const saturation = Math.min(avgComplexity, 15) * 2; // 0-30%
1008
+ const lightness = 70 - (Math.min(avgComplexity, 15) / 15) * 25;
1009
+ return `hsl(0, ${saturation}%, ${lightness}%)`;
1010
+ }
1011
+ return nodeData._children ? '#95a5a6' : '#ecf0f1';
1012
+ } else if (chunkTypes.includes(nodeData.type)) {
1013
+ // Purple (280°) for chunks, darker with higher complexity
1014
+ return getComplexityColor(d, 280);
1015
+ }
1016
+
1017
+ return '#95a5a6';
1018
+ }
1019
+
1020
+ // Calculate average complexity for a node (recursively for dirs/files)
1021
+ function calculateAverageComplexity(node) {
1022
+ if (chunkTypes.includes(node.type)) {
1023
+ return node.complexity || 0;
1024
+ }
1025
+
1026
+ const children = node.children || node._children || [];
1027
+ if (children.length === 0) return 0;
1028
+
1029
+ let totalComplexity = 0;
1030
+ let count = 0;
1031
+
1032
+ children.forEach(child => {
1033
+ if (chunkTypes.includes(child.type) && child.complexity) {
1034
+ totalComplexity += child.complexity;
1035
+ count++;
1036
+ } else {
1037
+ const childAvg = calculateAverageComplexity(child);
1038
+ if (childAvg > 0) {
1039
+ totalComplexity += childAvg;
1040
+ count++;
1041
+ }
1042
+ }
1043
+ });
1044
+
1045
+ return count > 0 ? totalComplexity / count : 0;
1046
+ }
1047
+
1048
+ // Get stroke color based on complexity - red outline for high complexity
1049
+ function getNodeStrokeColor(d) {
1050
+ const nodeData = d.data;
1051
+
1052
+ // Only chunks have direct complexity
1053
+ if (chunkTypes.includes(nodeData.type)) {
1054
+ const complexity = nodeData.complexity || 0;
1055
+ // Complexity thresholds:
1056
+ // 0-5: white (simple)
1057
+ // 5-10: orange (moderate)
1058
+ // 10+: red (complex)
1059
+ if (complexity >= 10) {
1060
+ return '#e74c3c'; // Red for high complexity
1061
+ } else if (complexity >= 5) {
1062
+ return '#f39c12'; // Orange for moderate
1063
+ }
1064
+ return '#fff'; // White for low complexity
1065
+ }
1066
+
1067
+ // Files and directories: check average complexity of children
1068
+ if (nodeData.type === 'file' || nodeData.type === 'directory') {
1069
+ const avgComplexity = calculateAverageComplexity(nodeData);
1070
+ if (avgComplexity >= 10) {
1071
+ return '#e74c3c'; // Red
1072
+ } else if (avgComplexity >= 5) {
1073
+ return '#f39c12'; // Orange
1074
+ }
1075
+ }
1076
+
1077
+ return '#fff'; // Default white
1078
+ }
1079
+
1080
+ // Get stroke width based on complexity - thicker for high complexity
1081
+ function getNodeStrokeWidth(d) {
1082
+ const nodeData = d.data;
1083
+ const complexity = chunkTypes.includes(nodeData.type)
1084
+ ? (nodeData.complexity || 0)
1085
+ : calculateAverageComplexity(nodeData);
1086
+
1087
+ if (complexity >= 10) return 3; // Thick red outline
1088
+ if (complexity >= 5) return 2.5; // Medium orange outline
1089
+ return 2; // Default
1090
+ }
1091
+
1092
+ function getNodeRadius(d) {
1093
+ const nodeData = d.data;
1094
+
1095
+ // Size configuration based on node type
1096
+ const dirMinRadius = 8; // Min for directories
1097
+ const dirMaxRadius = 40; // Max for directories
1098
+ const fileMinRadius = 6; // Min for files
1099
+ const fileMaxRadius = 30; // Max for files
1100
+ const chunkMinRadius = sizeConfig.chunkMinRadius; // From config
1101
+ const chunkMaxRadius = sizeConfig.chunkMaxRadius; // From config
1102
+
1103
+ // Directory nodes: size by file_count (logarithmic scale)
1104
+ if (nodeData.type === 'directory') {
1105
+ const fileCount = nodeData.file_count || 0;
1106
+ if (fileCount === 0) return dirMinRadius;
1107
+
1108
+ // Logarithmic scale: log2(fileCount + 1) for smooth scaling
1109
+ // +1 to avoid log(0), gives range [1, log2(max_files+1)]
1110
+ const logCount = Math.log2(fileCount + 1);
1111
+ const maxLogCount = Math.log2(100 + 1); // Assume max ~100 files per dir
1112
+ const normalized = Math.min(logCount / maxLogCount, 1.0);
1113
+
1114
+ return dirMinRadius + (normalized * (dirMaxRadius - dirMinRadius));
1115
+ }
1116
+
1117
+ // File nodes: size by total lines of code (sum of all chunks)
1118
+ if (nodeData.type === 'file') {
1119
+ const lineCount = nodeData._lineCount || 1;
1120
+
1121
+ // Collapsed file+chunk: use chunk sizing (since it's really a chunk)
1122
+ if (nodeData.collapsed_chunk) {
1123
+ const minLines = 5;
1124
+ const maxLines = 150;
1125
+
1126
+ let normalized;
1127
+ if (lineCount <= minLines) {
1128
+ normalized = 0;
1129
+ } else if (lineCount >= maxLines) {
1130
+ normalized = 1;
1131
+ } else {
1132
+ const logMin = Math.log(minLines);
1133
+ const logMax = Math.log(maxLines);
1134
+ const logCount = Math.log(lineCount);
1135
+ normalized = (logCount - logMin) / (logMax - logMin);
1136
+ }
1137
+ return chunkMinRadius + (normalized * (chunkMaxRadius - chunkMinRadius));
1138
+ }
1139
+
1140
+ // Regular files: linear scaling based on total lines
1141
+ const minFileLines = 5;
1142
+ const maxFileLines = 300;
1143
+
1144
+ let normalized;
1145
+ if (lineCount <= minFileLines) {
1146
+ normalized = 0;
1147
+ } else if (lineCount >= maxFileLines) {
1148
+ normalized = 1;
1149
+ } else {
1150
+ normalized = (lineCount - minFileLines) / (maxFileLines - minFileLines);
1151
+ }
1152
+
1153
+ return fileMinRadius + (normalized * (fileMaxRadius - fileMinRadius));
1154
+ }
1155
+
1156
+ // Chunk nodes: size by lines of code (absolute thresholds, not percentiles)
1157
+ // This ensures 330-line functions are ALWAYS big, regardless of codebase distribution
1158
+ if (chunkTypes.includes(nodeData.type)) {
1159
+ const lineCount = nodeData._lineCount || 1;
1160
+
1161
+ // Absolute thresholds for intuitive sizing:
1162
+ // - 1-10 lines: small (imports, constants, simple functions)
1163
+ // - 10-50 lines: medium (typical functions)
1164
+ // - 50-150 lines: large (complex functions)
1165
+ // - 150+ lines: maximum (very large functions/classes)
1166
+ const minLines = 5;
1167
+ const maxLines = 150; // Anything over 150 lines gets max size
1168
+
1169
+ let normalized;
1170
+ if (lineCount <= minLines) {
1171
+ normalized = 0;
1172
+ } else if (lineCount >= maxLines) {
1173
+ normalized = 1;
1174
+ } else {
1175
+ // Logarithmic scaling for better visual distribution
1176
+ const logMin = Math.log(minLines);
1177
+ const logMax = Math.log(maxLines);
1178
+ const logCount = Math.log(lineCount);
1179
+ normalized = (logCount - logMin) / (logMax - logMin);
1180
+ }
1181
+
1182
+ return chunkMinRadius + (normalized * (chunkMaxRadius - chunkMinRadius));
1183
+ }
1184
+
1185
+ // Default fallback for other node types
1186
+ return sizeConfig.minRadius;
1187
+ }
1188
+
1189
+ // ============================================================================
1190
+ // VISUALIZATION RENDERING
1191
+ // ============================================================================
1192
+
1193
+ function renderVisualization() {
1194
+ console.log('=== RENDER VISUALIZATION ===');
1195
+ console.log(`Current layout: ${currentLayout}`);
1196
+ console.log(`Tree data exists: ${treeData !== null}`);
1197
+ if (treeData) {
1198
+ console.log(`Root node: ${treeData.name}, children: ${(treeData.children || []).length}, _children: ${(treeData._children || []).length}`);
1199
+ }
1200
+
1201
+ // Clear existing content
1202
+ const graphElement = d3.select('#graph');
1203
+ console.log(`Graph element found: ${!graphElement.empty()}`);
1204
+ graphElement.selectAll('*').remove();
1205
+
1206
+ if (currentLayout === 'linear') {
1207
+ console.log('Calling renderLinearTree()...');
1208
+ renderLinearTree();
1209
+ } else {
1210
+ console.log('Calling renderCircularTree()...');
1211
+ renderCircularTree();
1212
+ }
1213
+ console.log('=== END RENDER VISUALIZATION ===');
1214
+ }
1215
+
1216
+ // ============================================================================
1217
+ // LINEAR TREE LAYOUT
1218
+ // ============================================================================
1219
+
1220
+ function renderLinearTree() {
1221
+ console.log('=== RENDER LINEAR TREE ===');
1222
+ const { width, height } = getViewportDimensions();
1223
+ console.log(`Viewport dimensions: ${width}x${height}`);
1224
+
1225
+ const svg = d3.select('#graph')
1226
+ .attr('width', width)
1227
+ .attr('height', height);
1228
+
1229
+ const g = svg.append('g')
1230
+ .attr('transform', `translate(${margin.left},${margin.top})`);
1231
+
1232
+ // Create tree layout
1233
+ // For horizontal tree: size is [height, width] where height controls vertical spread
1234
+ const treeLayout = d3.tree()
1235
+ .size([height - margin.top - margin.bottom, width - margin.left - margin.right]);
1236
+
1237
+ console.log(`Tree layout size: ${height - margin.top - margin.bottom} x ${width - margin.left - margin.right}`);
1238
+
1239
+ // Create hierarchy from tree data
1240
+ // D3 hierarchy automatically respects children vs _children
1241
+ console.log('Creating D3 hierarchy...');
1242
+
1243
+ // DEBUG: Check if treeData children have content property BEFORE D3 processes them
1244
+ console.log('=== PRE-D3 HIERARCHY DEBUG ===');
1245
+ if (treeData.children && treeData.children.length > 0) {
1246
+ const firstChild = treeData.children[0];
1247
+ console.log('First root child:', firstChild.name, 'type:', firstChild.type);
1248
+ console.log('First child keys:', Object.keys(firstChild));
1249
+ console.log('First child has content:', 'content' in firstChild);
1250
+
1251
+ // Find a chunk node in the tree
1252
+ function findFirstChunk(node) {
1253
+ if (chunkTypes.includes(node.type)) {
1254
+ return node;
1255
+ }
1256
+ if (node.children) {
1257
+ for (const child of node.children) {
1258
+ const found = findFirstChunk(child);
1259
+ if (found) return found;
1260
+ }
1261
+ }
1262
+ if (node._children) {
1263
+ for (const child of node._children) {
1264
+ const found = findFirstChunk(child);
1265
+ if (found) return found;
1266
+ }
1267
+ }
1268
+ return null;
1269
+ }
1270
+
1271
+ const sampleChunk = findFirstChunk(treeData);
1272
+ if (sampleChunk) {
1273
+ console.log('Sample chunk node BEFORE D3:', sampleChunk.name, 'type:', sampleChunk.type);
1274
+ console.log('Sample chunk keys:', Object.keys(sampleChunk));
1275
+ console.log('Sample chunk has content:', 'content' in sampleChunk);
1276
+ console.log('Sample chunk content length:', sampleChunk.content ? sampleChunk.content.length : 0);
1277
+ }
1278
+ }
1279
+ console.log('=== END PRE-D3 HIERARCHY DEBUG ===');
1280
+
1281
+ const root = d3.hierarchy(treeData, d => d.children);
1282
+ console.log(`Hierarchy created: ${root.descendants().length} nodes`);
1283
+
1284
+ // DEBUG: Check if content is preserved AFTER D3 processes them
1285
+ console.log('=== POST-D3 HIERARCHY DEBUG ===');
1286
+ const debugDescendants = root.descendants();
1287
+ const chunkDescendants = debugDescendants.filter(d => chunkTypes.includes(d.data.type));
1288
+ console.log(`Found ${chunkDescendants.length} chunk nodes in D3 hierarchy`);
1289
+ if (chunkDescendants.length > 0) {
1290
+ const firstChunkD3 = chunkDescendants[0];
1291
+ console.log('First chunk in D3 hierarchy:', firstChunkD3.data.name, 'type:', firstChunkD3.data.type);
1292
+ console.log('First chunk d.data keys:', Object.keys(firstChunkD3.data));
1293
+ console.log('First chunk has content in d.data:', 'content' in firstChunkD3.data);
1294
+ console.log('First chunk content length:', firstChunkD3.data.content ? firstChunkD3.data.content.length : 0);
1295
+ }
1296
+ console.log('=== END POST-D3 HIERARCHY DEBUG ===');
1297
+
1298
+ // Apply tree layout
1299
+ console.log('Applying tree layout...');
1300
+ treeLayout(root);
1301
+ console.log('Tree layout applied');
1302
+
1303
+ // Add zoom behavior
1304
+ const zoom = d3.zoom()
1305
+ .scaleExtent([0.1, 3])
1306
+ .on('zoom', (event) => {
1307
+ g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
1308
+ });
1309
+
1310
+ svg.call(zoom);
1311
+
1312
+ // Draw links
1313
+ const links = root.links();
1314
+ console.log(`Drawing ${links.length} links`);
1315
+ g.selectAll('.link')
1316
+ .data(links)
1317
+ .enter()
1318
+ .append('path')
1319
+ .attr('class', 'link')
1320
+ .attr('d', d3.linkHorizontal()
1321
+ .x(d => d.y)
1322
+ .y(d => d.x))
1323
+ .attr('fill', 'none')
1324
+ .attr('stroke', '#ccc')
1325
+ .attr('stroke-width', 1.5);
1326
+
1327
+ // Draw nodes
1328
+ const descendants = root.descendants();
1329
+ console.log(`Drawing ${descendants.length} nodes`);
1330
+ const nodes = g.selectAll('.node')
1331
+ .data(descendants)
1332
+ .enter()
1333
+ .append('g')
1334
+ .attr('class', 'node')
1335
+ .attr('transform', d => `translate(${d.y},${d.x})`)
1336
+ .on('click', handleNodeClick)
1337
+ .style('cursor', 'pointer');
1338
+
1339
+ console.log(`Created ${nodes.size()} node elements`);
1340
+
1341
+ // Node circles - sized proportionally to content, colored by complexity
1342
+ nodes.append('circle')
1343
+ .attr('r', d => getNodeRadius(d)) // Dynamic size based on content
1344
+ .attr('fill', d => getNodeFillColor(d)) // Complexity-based coloring
1345
+ .attr('stroke', d => getNodeStrokeColor(d)) // Red/orange for high complexity
1346
+ .attr('stroke-width', d => getNodeStrokeWidth(d))
1347
+ .attr('class', d => {
1348
+ // Add complexity grade class if available
1349
+ const grade = d.data.complexity_grade || '';
1350
+ const hasSmells = (d.data.smell_count && d.data.smell_count > 0) || (d.data.smells && d.data.smells.length > 0);
1351
+ const classes = [];
1352
+ if (grade) classes.push(`grade-${grade}`);
1353
+ if (hasSmells) classes.push('has-smells');
1354
+ return classes.join(' ');
1355
+ });
1356
+
1357
+ // Add external call arrow indicators (only for chunk nodes)
1358
+ nodes.each(function(d) {
1359
+ const node = d3.select(this);
1360
+ const nodeData = d.data;
1361
+
1362
+ // Only add indicators for code chunks (functions, classes, methods)
1363
+ if (!chunkTypes.includes(nodeData.type)) return;
1364
+
1365
+ const counts = getExternalCallCounts(nodeData);
1366
+ const radius = getNodeRadius(d);
1367
+
1368
+ // Inbound arrow: ← before the node (functions from other files call this)
1369
+ if (counts.inbound > 0) {
1370
+ node.append('text')
1371
+ .attr('class', 'call-indicator inbound')
1372
+ .attr('x', -(radius + 8))
1373
+ .attr('y', 5)
1374
+ .attr('text-anchor', 'end')
1375
+ .attr('fill', '#58a6ff')
1376
+ .attr('font-size', '14px')
1377
+ .attr('font-weight', 'bold')
1378
+ .attr('cursor', 'pointer')
1379
+ .text(counts.inbound > 1 ? `${counts.inbound}←` : '←')
1380
+ .append('title')
1381
+ .text(`Called by ${counts.inbound} external function(s):\n${counts.inboundNodes.map(n => n.name).join(', ')}`);
1382
+ }
1383
+
1384
+ // Outbound arrow: → after the label (this calls functions in other files)
1385
+ if (counts.outbound > 0) {
1386
+ // Get approximate label width
1387
+ const labelText = nodeData.name || '';
1388
+ const labelWidth = labelText.length * 7;
1389
+
1390
+ node.append('text')
1391
+ .attr('class', 'call-indicator outbound')
1392
+ .attr('x', radius + labelWidth + 16)
1393
+ .attr('y', 5)
1394
+ .attr('text-anchor', 'start')
1395
+ .attr('fill', '#f0883e')
1396
+ .attr('font-size', '14px')
1397
+ .attr('font-weight', 'bold')
1398
+ .attr('cursor', 'pointer')
1399
+ .text(counts.outbound > 1 ? `→${counts.outbound}` : '→')
1400
+ .append('title')
1401
+ .text(`Calls ${counts.outbound} external function(s):\n${counts.outboundNodes.map(n => n.name).join(', ')}`);
1402
+ }
1403
+ });
1404
+
1405
+ // Collect and draw external call lines
1406
+ collectExternalCallData();
1407
+ drawExternalCallLines(g, root);
1408
+
1409
+ // Node labels - positioned to the right of node, left-aligned
1410
+ // Use transform to position text, as x attribute can have rendering issues
1411
+ const labels = nodes.append('text')
1412
+ .attr('class', 'node-label')
1413
+ .attr('transform', d => `translate(${getNodeRadius(d) + 6}, 0)`)
1414
+ .attr('dominant-baseline', 'middle')
1415
+ .attr('text-anchor', 'start')
1416
+ .text(d => d.data.name)
1417
+ .style('font-size', d => chunkTypes.includes(d.data.type) ? '15px' : '18px')
1418
+ .style('font-family', 'Arial, sans-serif')
1419
+ .style('fill', d => chunkTypes.includes(d.data.type) ? '#bb86fc' : '#adbac7')
1420
+ .style('cursor', 'pointer')
1421
+ .style('pointer-events', 'all')
1422
+ .on('click', handleNodeClick);
1423
+
1424
+ console.log(`Created ${labels.size()} label elements`);
1425
+ console.log('=== END RENDER LINEAR TREE ===');
1426
+ }
1427
+
1428
+ // ============================================================================
1429
+ // CIRCULAR TREE LAYOUT
1430
+ // ============================================================================
1431
+
1432
+ function renderCircularTree() {
1433
+ const { width, height } = getViewportDimensions();
1434
+ const svg = d3.select('#graph')
1435
+ .attr('width', width)
1436
+ .attr('height', height);
1437
+
1438
+ const radius = Math.min(width, height) / 2 - 100;
1439
+
1440
+ const g = svg.append('g')
1441
+ .attr('transform', `translate(${width/2},${height/2})`);
1442
+
1443
+ // Create radial tree layout
1444
+ const treeLayout = d3.tree()
1445
+ .size([2 * Math.PI, radius])
1446
+ .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth);
1447
+
1448
+ // Create hierarchy
1449
+ // D3 hierarchy automatically respects children vs _children
1450
+ const root = d3.hierarchy(treeData, d => d.children);
1451
+
1452
+ // Apply layout
1453
+ treeLayout(root);
1454
+
1455
+ // Add zoom behavior
1456
+ const zoom = d3.zoom()
1457
+ .scaleExtent([0.1, 3])
1458
+ .on('zoom', (event) => {
1459
+ g.attr('transform', `translate(${width/2},${height/2}) ${event.transform}`);
1460
+ });
1461
+
1462
+ svg.call(zoom);
1463
+
1464
+ // Draw links
1465
+ g.selectAll('.link')
1466
+ .data(root.links())
1467
+ .enter()
1468
+ .append('path')
1469
+ .attr('class', 'link')
1470
+ .attr('d', d3.linkRadial()
1471
+ .angle(d => d.x)
1472
+ .radius(d => d.y))
1473
+ .attr('fill', 'none')
1474
+ .attr('stroke', '#ccc')
1475
+ .attr('stroke-width', 1.5);
1476
+
1477
+ // Draw nodes
1478
+ const nodes = g.selectAll('.node')
1479
+ .data(root.descendants())
1480
+ .enter()
1481
+ .append('g')
1482
+ .attr('class', 'node')
1483
+ .attr('transform', d => `
1484
+ rotate(${d.x * 180 / Math.PI - 90})
1485
+ translate(${d.y},0)
1486
+ `)
1487
+ .on('click', handleNodeClick)
1488
+ .style('cursor', 'pointer');
1489
+
1490
+ // Node circles - sized proportionally to content, colored by complexity
1491
+ nodes.append('circle')
1492
+ .attr('r', d => getNodeRadius(d)) // Dynamic size based on content
1493
+ .attr('fill', d => getNodeFillColor(d)) // Complexity-based coloring
1494
+ .attr('stroke', d => getNodeStrokeColor(d)) // Red/orange for high complexity
1495
+ .attr('stroke-width', d => getNodeStrokeWidth(d))
1496
+ .attr('class', d => {
1497
+ // Add complexity grade class if available
1498
+ const grade = d.data.complexity_grade || '';
1499
+ const hasSmells = (d.data.smell_count && d.data.smell_count > 0) || (d.data.smells && d.data.smells.length > 0);
1500
+ const classes = [];
1501
+ if (grade) classes.push(`grade-${grade}`);
1502
+ if (hasSmells) classes.push('has-smells');
1503
+ return classes.join(' ');
1504
+ });
1505
+
1506
+ // Add external call arrow indicators (only for chunk nodes)
1507
+ nodes.each(function(d) {
1508
+ const node = d3.select(this);
1509
+ const nodeData = d.data;
1510
+
1511
+ if (!chunkTypes.includes(nodeData.type)) return;
1512
+
1513
+ const counts = getExternalCallCounts(nodeData);
1514
+ const radius = getNodeRadius(d);
1515
+
1516
+ // Inbound indicator
1517
+ if (counts.inbound > 0) {
1518
+ node.append('text')
1519
+ .attr('x', 0)
1520
+ .attr('y', -(radius + 8))
1521
+ .attr('text-anchor', 'middle')
1522
+ .attr('fill', '#58a6ff')
1523
+ .attr('font-size', '10px')
1524
+ .attr('font-weight', 'bold')
1525
+ .text(counts.inbound > 1 ? `↓${counts.inbound}` : '↓')
1526
+ .append('title')
1527
+ .text(`Called by ${counts.inbound} external function(s)`);
1528
+ }
1529
+
1530
+ // Outbound indicator
1531
+ if (counts.outbound > 0) {
1532
+ node.append('text')
1533
+ .attr('x', 0)
1534
+ .attr('y', radius + 12)
1535
+ .attr('text-anchor', 'middle')
1536
+ .attr('fill', '#f0883e')
1537
+ .attr('font-size', '10px')
1538
+ .attr('font-weight', 'bold')
1539
+ .text(counts.outbound > 1 ? `↑${counts.outbound}` : '↑')
1540
+ .append('title')
1541
+ .text(`Calls ${counts.outbound} external function(s)`);
1542
+ }
1543
+ });
1544
+
1545
+ // Node labels - positioned to the right of node, left-aligned
1546
+ // Use transform to position text, as x attribute can have rendering issues
1547
+ nodes.append('text')
1548
+ .attr('class', 'node-label')
1549
+ .attr('transform', d => {
1550
+ const offset = getNodeRadius(d) + 6;
1551
+ const rotate = d.x >= Math.PI ? 'rotate(180)' : '';
1552
+ return `translate(${offset}, 0) ${rotate}`;
1553
+ })
1554
+ .attr('dominant-baseline', 'middle')
1555
+ .attr('text-anchor', d => d.x >= Math.PI ? 'end' : 'start')
1556
+ .text(d => d.data.name)
1557
+ .style('font-size', d => chunkTypes.includes(d.data.type) ? '15px' : '18px')
1558
+ .style('font-family', 'Arial, sans-serif')
1559
+ .style('fill', d => chunkTypes.includes(d.data.type) ? '#bb86fc' : '#adbac7')
1560
+ .style('cursor', 'pointer')
1561
+ .style('pointer-events', 'all')
1562
+ .on('click', handleNodeClick);
1563
+
1564
+ // Collect and draw external call lines (circular version)
1565
+ collectExternalCallData();
1566
+ drawExternalCallLinesCircular(g, root);
1567
+ }
1568
+
1569
+ // ============================================================================
1570
+ // INTERACTION HANDLERS
1571
+ // ============================================================================
1572
+
1573
+ function handleNodeClick(event, d) {
1574
+ event.stopPropagation();
1575
+
1576
+ const nodeData = d.data;
1577
+
1578
+ console.log('=== NODE CLICK DEBUG ===');
1579
+ console.log(`Clicked node: ${nodeData.name} (type: ${nodeData.type}, id: ${nodeData.id})`);
1580
+ console.log(`Has children: ${nodeData.children ? nodeData.children.length : 0}`);
1581
+ console.log(`Has _children: ${nodeData._children ? nodeData._children.length : 0}`);
1582
+
1583
+ if (nodeData.type === 'directory') {
1584
+ // Toggle directory: swap children <-> _children
1585
+ if (nodeData.children) {
1586
+ // Currently expanded - collapse it
1587
+ console.log('Collapsing directory');
1588
+ nodeData._children = nodeData.children;
1589
+ nodeData.children = null;
1590
+ } else if (nodeData._children) {
1591
+ // Currently collapsed - expand it
1592
+ console.log('Expanding directory');
1593
+ nodeData.children = nodeData._children;
1594
+ nodeData._children = null;
1595
+ }
1596
+
1597
+ // Re-render to show/hide children
1598
+ renderVisualization();
1599
+
1600
+ // Don't auto-open viewer panel for directories - just expand/collapse
1601
+ } else if (nodeData.type === 'file') {
1602
+ // Check if this file has a collapsed chunk (single chunk with no children)
1603
+ if (nodeData.collapsed_chunk) {
1604
+ console.log(`Collapsed file+chunk: ${nodeData.name}, showing content directly`);
1605
+ displayChunkContent({
1606
+ ...nodeData.collapsed_chunk,
1607
+ name: nodeData.collapsed_chunk.name,
1608
+ type: nodeData.collapsed_chunk.type
1609
+ });
1610
+ return;
1611
+ }
1612
+
1613
+ // Check if this is a single-chunk file - skip to content directly
1614
+ const childrenArray = nodeData._children || nodeData.children;
1615
+
1616
+ if (childrenArray && childrenArray.length === 1) {
1617
+ const onlyChild = childrenArray[0];
1618
+
1619
+ if (chunkTypes.includes(onlyChild.type)) {
1620
+ console.log(`Single-chunk file: ${nodeData.name}, showing content directly`);
1621
+
1622
+ // Expand the file visually (for tree consistency)
1623
+ if (nodeData._children) {
1624
+ nodeData.children = nodeData._children;
1625
+ nodeData._children = null;
1626
+ renderVisualization();
1627
+ }
1628
+
1629
+ // Auto-display the chunk content
1630
+ displayChunkContent(onlyChild);
1631
+ return; // Skip normal file toggle behavior
1632
+ }
1633
+ }
1634
+
1635
+ // Continue with existing toggle logic for multi-chunk files
1636
+ // Toggle file: swap children <-> _children
1637
+ if (nodeData.children) {
1638
+ // Currently expanded - collapse it
1639
+ console.log('Collapsing file');
1640
+ nodeData._children = nodeData.children;
1641
+ nodeData.children = null;
1642
+ } else if (nodeData._children) {
1643
+ // Currently collapsed - expand it
1644
+ console.log('Expanding file');
1645
+ nodeData.children = nodeData._children;
1646
+ nodeData._children = null;
1647
+ } else {
1648
+ console.log('WARNING: File has neither children nor _children!');
1649
+ }
1650
+
1651
+ // Re-render to show/hide children
1652
+ renderVisualization();
1653
+
1654
+ // Don't auto-open viewer panel for files - just expand/collapse
1655
+ } else if (chunkTypes.includes(nodeData.type)) {
1656
+ // Chunks can have children too (e.g., imports -> functions, class -> methods)
1657
+ // If chunk has children, toggle expand/collapse
1658
+ if (nodeData.children || nodeData._children) {
1659
+ if (nodeData.children) {
1660
+ // Currently expanded - collapse it
1661
+ console.log(`Collapsing ${nodeData.type} chunk`);
1662
+ nodeData._children = nodeData.children;
1663
+ nodeData.children = null;
1664
+ } else if (nodeData._children) {
1665
+ // Currently collapsed - expand it
1666
+ console.log(`Expanding ${nodeData.type} chunk to show ${nodeData._children.length} children`);
1667
+ nodeData.children = nodeData._children;
1668
+ nodeData._children = null;
1669
+ }
1670
+ // Re-render to show/hide children
1671
+ renderVisualization();
1672
+ }
1673
+
1674
+ // Also show chunk content in side panel
1675
+ console.log('Displaying chunk content');
1676
+ displayChunkContent(nodeData);
1677
+ }
1678
+
1679
+ console.log('=== END NODE CLICK DEBUG ===');
1680
+ }
1681
+
1682
+ function displayDirectoryInfo(dirData, addToHistory = true) {
1683
+ openViewerPanel();
1684
+
1685
+ // Add to navigation history
1686
+ if (addToHistory) {
1687
+ addToNavHistory({type: 'directory', data: dirData});
1688
+ }
1689
+
1690
+ const title = document.getElementById('viewer-title');
1691
+ const content = document.getElementById('viewer-content');
1692
+
1693
+ title.textContent = `📁 ${dirData.name}`;
1694
+
1695
+ // Count children
1696
+ const children = dirData.children || dirData._children || [];
1697
+ const dirs = children.filter(c => c.type === 'directory').length;
1698
+ const files = children.filter(c => c.type === 'file').length;
1699
+
1700
+ let html = '';
1701
+
1702
+ // Navigation bar with breadcrumbs and back/forward
1703
+ html += renderNavigationBar(dirData);
1704
+
1705
+ html += '<div class="viewer-section">';
1706
+ html += '<div class="viewer-section-title">Directory Information</div>';
1707
+ html += '<div class="viewer-info-grid">';
1708
+ html += `<div class="viewer-info-row">`;
1709
+ html += `<span class="viewer-info-label">Name:</span>`;
1710
+ html += `<span class="viewer-info-value">${escapeHtml(dirData.name)}</span>`;
1711
+ html += `</div>`;
1712
+ html += `<div class="viewer-info-row">`;
1713
+ html += `<span class="viewer-info-label">Subdirectories:</span>`;
1714
+ html += `<span class="viewer-info-value">${dirs}</span>`;
1715
+ html += `</div>`;
1716
+ html += `<div class="viewer-info-row">`;
1717
+ html += `<span class="viewer-info-label">Files:</span>`;
1718
+ html += `<span class="viewer-info-value">${files}</span>`;
1719
+ html += `</div>`;
1720
+ html += `<div class="viewer-info-row">`;
1721
+ html += `<span class="viewer-info-label">Total Items:</span>`;
1722
+ html += `<span class="viewer-info-value">${children.length}</span>`;
1723
+ html += `</div>`;
1724
+ html += '</div>';
1725
+ html += '</div>';
1726
+
1727
+ if (children.length > 0) {
1728
+ html += '<div class="viewer-section">';
1729
+ html += '<div class="viewer-section-title">Contents</div>';
1730
+ html += '<div class="dir-list">';
1731
+
1732
+ // Sort: directories first, then files
1733
+ const sortedChildren = [...children].sort((a, b) => {
1734
+ if (a.type === 'directory' && b.type !== 'directory') return -1;
1735
+ if (a.type !== 'directory' && b.type === 'directory') return 1;
1736
+ return a.name.localeCompare(b.name);
1737
+ });
1738
+
1739
+ sortedChildren.forEach(child => {
1740
+ const icon = child.type === 'directory' ? '📁' : '📄';
1741
+ const type = child.type === 'directory' ? 'dir' : 'file';
1742
+ const childData = JSON.stringify(child).replace(/"/g, '&quot;');
1743
+ const clickHandler = child.type === 'directory'
1744
+ ? `navigateToDirectory(${childData})`
1745
+ : `navigateToFile(${childData})`;
1746
+ html += `<div class="dir-list-item clickable" onclick="${clickHandler}">`;
1747
+ html += `<span class="dir-icon">${icon}</span>`;
1748
+ html += `<span class="dir-name">${escapeHtml(child.name)}</span>`;
1749
+ html += `<span class="dir-type">${type}</span>`;
1750
+ html += `<span class="dir-arrow">→</span>`;
1751
+ html += `</div>`;
1752
+ });
1753
+
1754
+ html += '</div>';
1755
+ html += '</div>';
1756
+ }
1757
+
1758
+ content.innerHTML = html;
1759
+
1760
+ // Hide section dropdown for directories (no code sections)
1761
+ populateSectionDropdown([]);
1762
+ }
1763
+
1764
+ function displayFileInfo(fileData, addToHistory = true) {
1765
+ openViewerPanel();
1766
+
1767
+ // Add to navigation history
1768
+ if (addToHistory) {
1769
+ addToNavHistory({type: 'file', data: fileData});
1770
+ }
1771
+
1772
+ const title = document.getElementById('viewer-title');
1773
+ const content = document.getElementById('viewer-content');
1774
+
1775
+ title.textContent = `📄 ${fileData.name}`;
1776
+
1777
+ // Get chunks
1778
+ const chunks = fileData.children || fileData._children || [];
1779
+
1780
+ let html = '';
1781
+
1782
+ // Navigation bar with breadcrumbs and back/forward
1783
+ html += renderNavigationBar(fileData);
1784
+
1785
+ html += '<div class="viewer-section">';
1786
+ html += '<div class="viewer-section-title">File Information</div>';
1787
+ html += '<div class="viewer-info-grid">';
1788
+ html += `<div class="viewer-info-row">`;
1789
+ html += `<span class="viewer-info-label">Name:</span>`;
1790
+ html += `<span class="viewer-info-value">${escapeHtml(fileData.name)}</span>`;
1791
+ html += `</div>`;
1792
+ html += `<div class="viewer-info-row">`;
1793
+ html += `<span class="viewer-info-label">Chunks:</span>`;
1794
+ html += `<span class="viewer-info-value">${chunks.length}</span>`;
1795
+ html += `</div>`;
1796
+ if (fileData.path) {
1797
+ html += `<div class="viewer-info-row">`;
1798
+ html += `<span class="viewer-info-label">Path:</span>`;
1799
+ html += `<span class="viewer-info-value" style="word-break: break-all;">${escapeHtml(fileData.path)}</span>`;
1800
+ html += `</div>`;
1801
+ }
1802
+ html += '</div>';
1803
+ html += '</div>';
1804
+
1805
+ if (chunks.length > 0) {
1806
+ html += '<div class="viewer-section">';
1807
+ html += '<div class="viewer-section-title">Code Chunks</div>';
1808
+ html += '<div class="chunk-list">';
1809
+
1810
+ chunks.forEach(chunk => {
1811
+ const icon = getChunkIcon(chunk.type);
1812
+ const chunkName = chunk.name || chunk.type || 'chunk';
1813
+ const lines = chunk.start_line && chunk.end_line
1814
+ ? `Lines ${chunk.start_line}-${chunk.end_line}`
1815
+ : 'Unknown lines';
1816
+
1817
+ html += `<div class="chunk-list-item" onclick="displayChunkContent(${JSON.stringify(chunk).replace(/"/g, '&quot;')})">`;
1818
+ html += `<span class="chunk-icon">${icon}</span>`;
1819
+ html += `<div class="chunk-info">`;
1820
+ html += `<div class="chunk-name">${escapeHtml(chunkName)}</div>`;
1821
+ html += `<div class="chunk-meta">${lines} • ${chunk.type || 'code'}</div>`;
1822
+ html += `</div>`;
1823
+ html += `</div>`;
1824
+ });
1825
+
1826
+ html += '</div>';
1827
+ html += '</div>';
1828
+ }
1829
+
1830
+ content.innerHTML = html;
1831
+
1832
+ // Hide section dropdown for files (no code sections in file view)
1833
+ populateSectionDropdown([]);
1834
+ }
1835
+
1836
+ function displayChunkContent(chunkData, addToHistory = true) {
1837
+ openViewerPanel();
1838
+
1839
+ // Expand path to node and highlight it in tree
1840
+ if (chunkData.id) {
1841
+ expandAndHighlightNode(chunkData.id);
1842
+ }
1843
+
1844
+ // Add to navigation history
1845
+ if (addToHistory) {
1846
+ addToNavHistory({type: 'chunk', data: chunkData});
1847
+ }
1848
+
1849
+ const title = document.getElementById('viewer-title');
1850
+ const content = document.getElementById('viewer-content');
1851
+
1852
+ const chunkName = chunkData.name || chunkData.type || 'Chunk';
1853
+ title.textContent = `${getChunkIcon(chunkData.type)} ${chunkName}`;
1854
+
1855
+ let html = '';
1856
+
1857
+ // Navigation bar with breadcrumbs and back/forward
1858
+ html += renderNavigationBar(chunkData);
1859
+
1860
+ // Track sections for dropdown navigation
1861
+ const sections = [];
1862
+
1863
+ // === ORDER: Docstring (comments), Code, Metadata ===
1864
+
1865
+ // === 1. Docstring Section (Comments) ===
1866
+ if (chunkData.docstring) {
1867
+ sections.push({ id: 'docstring', label: '📖 Docstring' });
1868
+ html += '<div class="viewer-section" data-section="docstring">';
1869
+ html += '<div class="viewer-section-title">📖 Docstring</div>';
1870
+ html += `<div style="color: #8b949e; font-style: italic; padding: 8px 12px; background: #161b22; border-radius: 4px; white-space: pre-wrap;">${escapeHtml(chunkData.docstring)}</div>`;
1871
+ html += '</div>';
1872
+ }
1873
+
1874
+ // === 2. Source Code Section ===
1875
+ if (chunkData.content) {
1876
+ sections.push({ id: 'source-code', label: '📝 Source Code' });
1877
+ html += '<div class="viewer-section" data-section="source-code">';
1878
+ html += '<div class="viewer-section-title">📝 Source Code</div>';
1879
+ const langClass = getLanguageClass(chunkData.file_path);
1880
+ html += `<pre><code class="hljs${langClass ? ' language-' + langClass : ''}">${escapeHtml(chunkData.content)}</code></pre>`;
1881
+ html += '</div>';
1882
+ } else {
1883
+ html += '<p style="color: #8b949e; padding: 20px; text-align: center;">No content available for this chunk.</p>';
1884
+ }
1885
+
1886
+ // === 3. Metadata Section ===
1887
+ sections.push({ id: 'metadata', label: 'ℹ️ Metadata' });
1888
+ html += '<div class="viewer-section" data-section="metadata">';
1889
+ html += '<div class="viewer-section-title">ℹ️ Metadata</div>';
1890
+ html += '<div class="viewer-info-grid">';
1891
+
1892
+ // Basic info
1893
+ html += `<div class="viewer-info-row">`;
1894
+ html += `<span class="viewer-info-label">Name:</span>`;
1895
+ html += `<span class="viewer-info-value">${escapeHtml(chunkName)}</span>`;
1896
+ html += `</div>`;
1897
+
1898
+ html += `<div class="viewer-info-row">`;
1899
+ html += `<span class="viewer-info-label">Type:</span>`;
1900
+ html += `<span class="viewer-info-value">${escapeHtml(chunkData.type || 'code')}</span>`;
1901
+ html += `</div>`;
1902
+
1903
+ // File path (clickable - navigates to file node)
1904
+ if (chunkData.file_path) {
1905
+ const shortPath = chunkData.file_path.split('/').slice(-3).join('/');
1906
+ const escapedPath = escapeHtml(chunkData.file_path).replace(/'/g, "\\'");
1907
+ html += `<div class="viewer-info-row">`;
1908
+ html += `<span class="viewer-info-label">File:</span>`;
1909
+ html += `<span class="viewer-info-value clickable" onclick="navigateToFileByPath('${escapedPath}')" title="Click to navigate to file: ${escapeHtml(chunkData.file_path)}">.../${escapeHtml(shortPath)}</span>`;
1910
+ html += `</div>`;
1911
+ }
1912
+
1913
+ // Line numbers
1914
+ if (chunkData.start_line && chunkData.end_line) {
1915
+ html += `<div class="viewer-info-row">`;
1916
+ html += `<span class="viewer-info-label">Lines:</span>`;
1917
+ html += `<span class="viewer-info-value">${chunkData.start_line} - ${chunkData.end_line} (${chunkData.end_line - chunkData.start_line + 1} lines)</span>`;
1918
+ html += `</div>`;
1919
+ }
1920
+
1921
+ // Language
1922
+ if (chunkData.language) {
1923
+ html += `<div class="viewer-info-row">`;
1924
+ html += `<span class="viewer-info-label">Language:</span>`;
1925
+ html += `<span class="viewer-info-value">${escapeHtml(chunkData.language)}</span>`;
1926
+ html += `</div>`;
1927
+ }
1928
+
1929
+ // Complexity
1930
+ if (chunkData.complexity !== undefined && chunkData.complexity !== null) {
1931
+ const complexityColor = chunkData.complexity > 10 ? '#f85149' : chunkData.complexity > 5 ? '#d29922' : '#3fb950';
1932
+ html += `<div class="viewer-info-row">`;
1933
+ html += `<span class="viewer-info-label">Complexity:</span>`;
1934
+ html += `<span class="viewer-info-value" style="color: ${complexityColor}">${chunkData.complexity.toFixed(1)}</span>`;
1935
+ html += `</div>`;
1936
+ }
1937
+
1938
+ html += '</div>';
1939
+ html += '</div>';
1940
+
1941
+ // === 4. External Calls & Callers Section (Cross-file references) ===
1942
+ const chunkId = chunkData.id;
1943
+ const currentFilePath = chunkData.file_path;
1944
+
1945
+ if (chunkId) {
1946
+ // Find all caller relationships
1947
+ const allCallers = allLinks.filter(l => l.type === 'caller' && l.target === chunkId);
1948
+ const allCallees = allLinks.filter(l => l.type === 'caller' && l.source === chunkId);
1949
+
1950
+ // Separate external (different file) from local (same file) relationships
1951
+ // Use Maps to deduplicate by node.id
1952
+ const externalCallersMap = new Map();
1953
+ const localCallersMap = new Map();
1954
+ allCallers.forEach(link => {
1955
+ const callerNode = allNodes.find(n => n.id === link.source);
1956
+ if (callerNode) {
1957
+ if (callerNode.file_path !== currentFilePath) {
1958
+ if (!externalCallersMap.has(callerNode.id)) {
1959
+ externalCallersMap.set(callerNode.id, { link, node: callerNode });
1960
+ }
1961
+ } else {
1962
+ if (!localCallersMap.has(callerNode.id)) {
1963
+ localCallersMap.set(callerNode.id, { link, node: callerNode });
1964
+ }
1965
+ }
1966
+ }
1967
+ });
1968
+ const externalCallers = Array.from(externalCallersMap.values());
1969
+ const localCallers = Array.from(localCallersMap.values());
1970
+
1971
+ const externalCalleesMap = new Map();
1972
+ const localCalleesMap = new Map();
1973
+ allCallees.forEach(link => {
1974
+ const calleeNode = allNodes.find(n => n.id === link.target);
1975
+ if (calleeNode) {
1976
+ if (calleeNode.file_path !== currentFilePath) {
1977
+ if (!externalCalleesMap.has(calleeNode.id)) {
1978
+ externalCalleesMap.set(calleeNode.id, { link, node: calleeNode });
1979
+ }
1980
+ } else {
1981
+ if (!localCalleesMap.has(calleeNode.id)) {
1982
+ localCalleesMap.set(calleeNode.id, { link, node: calleeNode });
1983
+ }
1984
+ }
1985
+ }
1986
+ });
1987
+ const externalCallees = Array.from(externalCalleesMap.values());
1988
+ const localCallees = Array.from(localCalleesMap.values());
1989
+
1990
+ // === External Callers Section (functions from other files that call this) ===
1991
+ if (externalCallers.length > 0) {
1992
+ sections.push({ id: 'external-callers', label: '📥 External Callers' });
1993
+ html += '<div class="viewer-section" data-section="external-callers">';
1994
+ html += '<div class="viewer-section-title">📥 External Callers <span style="color: #8b949e; font-weight: normal;">(functions from other files calling this)</span></div>';
1995
+ html += '<div style="display: flex; flex-direction: column; gap: 6px;">';
1996
+ externalCallers.slice(0, 10).forEach(({ link, node }) => {
1997
+ const shortPath = node.file_path ? node.file_path.split('/').slice(-2).join('/') : '';
1998
+ html += `<div class="external-call-item" onclick="focusNodeInTree('${link.source}')" title="${escapeHtml(node.file_path || '')}">`;
1999
+ html += `<span class="external-call-icon">←</span>`;
2000
+ html += `<span class="external-call-name">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
2001
+ html += `<span class="external-call-path">${escapeHtml(shortPath)}</span>`;
2002
+ html += `</div>`;
2003
+ });
2004
+ if (externalCallers.length > 10) {
2005
+ html += `<div style="color: #8b949e; font-size: 11px; padding-left: 20px;">+${externalCallers.length - 10} more external callers</div>`;
2006
+ }
2007
+ html += '</div></div>';
2008
+ }
2009
+
2010
+ // === External Calls Section (functions in other files this calls) ===
2011
+ if (externalCallees.length > 0) {
2012
+ sections.push({ id: 'external-calls', label: '📤 External Calls' });
2013
+ html += '<div class="viewer-section" data-section="external-calls">';
2014
+ html += '<div class="viewer-section-title">📤 External Calls <span style="color: #8b949e; font-weight: normal;">(functions in other files this calls)</span></div>';
2015
+ html += '<div style="display: flex; flex-direction: column; gap: 6px;">';
2016
+ externalCallees.slice(0, 10).forEach(({ link, node }) => {
2017
+ const shortPath = node.file_path ? node.file_path.split('/').slice(-2).join('/') : '';
2018
+ html += `<div class="external-call-item" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(node.file_path || '')}">`;
2019
+ html += `<span class="external-call-icon">→</span>`;
2020
+ html += `<span class="external-call-name">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
2021
+ html += `<span class="external-call-path">${escapeHtml(shortPath)}</span>`;
2022
+ html += `</div>`;
2023
+ });
2024
+ if (externalCallees.length > 10) {
2025
+ html += `<div style="color: #8b949e; font-size: 11px; padding-left: 20px;">+${externalCallees.length - 10} more external calls</div>`;
2026
+ }
2027
+ html += '</div></div>';
2028
+ }
2029
+
2030
+ // === Local (Same-File) Relationships Section ===
2031
+ if (localCallers.length > 0 || localCallees.length > 0) {
2032
+ sections.push({ id: 'local-references', label: '🔗 Local References' });
2033
+ html += '<div class="viewer-section" data-section="local-references">';
2034
+ html += '<div class="viewer-section-title">🔗 Local References <span style="color: #8b949e; font-weight: normal;">(same file)</span></div>';
2035
+
2036
+ if (localCallers.length > 0) {
2037
+ html += '<div style="margin-bottom: 8px;">';
2038
+ html += '<div style="color: #58a6ff; font-size: 11px; margin-bottom: 4px;">Called by:</div>';
2039
+ html += '<div style="display: flex; flex-wrap: wrap; gap: 4px;">';
2040
+ localCallers.slice(0, 8).forEach(({ link, node }) => {
2041
+ html += `<span class="relationship-tag caller" onclick="focusNodeInTree('${link.source}')" title="${escapeHtml(node.name || '')}">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
2042
+ });
2043
+ if (localCallers.length > 8) {
2044
+ html += `<span style="color: #8b949e; font-size: 10px;">+${localCallers.length - 8} more</span>`;
2045
+ }
2046
+ html += '</div></div>';
2047
+ }
2048
+
2049
+ if (localCallees.length > 0) {
2050
+ html += '<div>';
2051
+ html += '<div style="color: #f0883e; font-size: 11px; margin-bottom: 4px;">Calls:</div>';
2052
+ html += '<div style="display: flex; flex-wrap: wrap; gap: 4px;">';
2053
+ localCallees.slice(0, 8).forEach(({ link, node }) => {
2054
+ html += `<span class="relationship-tag callee" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(node.name || '')}">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
2055
+ });
2056
+ if (localCallees.length > 8) {
2057
+ html += `<span style="color: #8b949e; font-size: 10px;">+${localCallees.length - 8} more</span>`;
2058
+ }
2059
+ html += '</div></div>';
2060
+ }
2061
+
2062
+ html += '</div>';
2063
+ }
2064
+
2065
+ // === Semantically Similar Section ===
2066
+ const semanticLinks = allLinks.filter(l => l.type === 'semantic' && l.source === chunkId);
2067
+ if (semanticLinks.length > 0) {
2068
+ sections.push({ id: 'semantic', label: '🧠 Semantically Similar' });
2069
+ html += '<div class="viewer-section" data-section="semantic">';
2070
+ html += '<div class="viewer-section-title">🧠 Semantically Similar</div>';
2071
+ html += '<div style="display: flex; flex-direction: column; gap: 4px;">';
2072
+ semanticLinks.slice(0, 5).forEach(link => {
2073
+ const similarNode = allNodes.find(n => n.id === link.target);
2074
+ if (similarNode) {
2075
+ const similarity = (link.similarity * 100).toFixed(0);
2076
+ const label = similarNode.name || similarNode.id.substring(0, 8);
2077
+ html += `<div class="semantic-item" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(similarNode.file_path || '')}">`;
2078
+ html += `<span class="semantic-score">${similarity}%</span>`;
2079
+ html += `<span class="semantic-name">${escapeHtml(label)}</span>`;
2080
+ html += `<span class="semantic-type">${similarNode.type || ''}</span>`;
2081
+ html += `</div>`;
2082
+ }
2083
+ });
2084
+ html += '</div>';
2085
+ html += '</div>';
2086
+ }
2087
+ }
2088
+
2089
+ content.innerHTML = html;
2090
+
2091
+ // Apply syntax highlighting to code blocks
2092
+ content.querySelectorAll('pre code').forEach((block) => {
2093
+ if (typeof hljs !== 'undefined') {
2094
+ hljs.highlightElement(block);
2095
+ }
2096
+ });
2097
+
2098
+ // Populate section dropdown for navigation
2099
+ populateSectionDropdown(sections);
2100
+ }
2101
+
2102
+ // Focus on a node in the tree (expand path, scroll, highlight)
2103
+ function focusNodeInTree(nodeId) {
2104
+ console.log(`Focusing on node in tree: ${nodeId}`);
2105
+
2106
+ // Find the node in allNodes (the original data)
2107
+ const targetNodeData = allNodes.find(n => n.id === nodeId);
2108
+ if (!targetNodeData) {
2109
+ console.log(`Node ${nodeId} not found in allNodes`);
2110
+ return;
2111
+ }
2112
+
2113
+ // Find the path to this node in the tree structure
2114
+ // We need to find and expand all ancestors to make the node visible
2115
+ const pathToNode = findPathToNode(treeData, nodeId);
2116
+
2117
+ if (pathToNode.length > 0) {
2118
+ console.log(`Found path to node: ${pathToNode.map(n => n.name).join(' -> ')}`);
2119
+
2120
+ // Expand all nodes along the path (except the target node itself)
2121
+ pathToNode.slice(0, -1).forEach(node => {
2122
+ if (node._children) {
2123
+ // Node is collapsed, expand it
2124
+ console.log(`Expanding ${node.name} to reveal path`);
2125
+ node.children = node._children;
2126
+ node._children = null;
2127
+ }
2128
+ });
2129
+
2130
+ // Re-render the tree to show the expanded path
2131
+ renderVisualization();
2132
+
2133
+ // After render, scroll to and highlight the target node
2134
+ setTimeout(() => {
2135
+ highlightNodeInTree(nodeId);
2136
+ }, 100);
2137
+ } else {
2138
+ console.log(`Path to node ${nodeId} not found in tree - it may be orphaned`);
2139
+ }
2140
+
2141
+ // Display the content in the viewer panel
2142
+ if (chunkTypes.includes(targetNodeData.type)) {
2143
+ displayChunkContent(targetNodeData);
2144
+ } else if (targetNodeData.type === 'file') {
2145
+ displayFileInfo(targetNodeData);
2146
+ } else if (targetNodeData.type === 'directory') {
2147
+ displayDirectoryInfo(targetNodeData);
2148
+ }
2149
+ }
2150
+
2151
+ // Find path from root to a specific node by ID
2152
+ function findPathToNode(node, targetId, path = []) {
2153
+ if (!node) return [];
2154
+
2155
+ // Add current node to path
2156
+ const currentPath = [...path, node];
2157
+
2158
+ // Check if this is the target
2159
+ if (node.id === targetId) {
2160
+ return currentPath;
2161
+ }
2162
+
2163
+ // Check visible children
2164
+ if (node.children) {
2165
+ for (const child of node.children) {
2166
+ const result = findPathToNode(child, targetId, currentPath);
2167
+ if (result.length > 0) return result;
2168
+ }
2169
+ }
2170
+
2171
+ // Check hidden children
2172
+ if (node._children) {
2173
+ for (const child of node._children) {
2174
+ const result = findPathToNode(child, targetId, currentPath);
2175
+ if (result.length > 0) return result;
2176
+ }
2177
+ }
2178
+
2179
+ return [];
2180
+ }
2181
+
2182
+ // Expand path to a node and highlight it (without triggering content display)
2183
+ function expandAndHighlightNode(nodeId) {
2184
+ console.log('=== EXPAND AND HIGHLIGHT ===');
2185
+ console.log('Target nodeId:', nodeId);
2186
+
2187
+ // Find the path to this node in the tree structure
2188
+ const pathToNode = findPathToNode(treeData, nodeId);
2189
+
2190
+ if (pathToNode.length > 0) {
2191
+ console.log('Found path:', pathToNode.map(n => n.name).join(' -> '));
2192
+
2193
+ // Expand all nodes along the path (except the target node itself)
2194
+ let needsRerender = false;
2195
+ pathToNode.slice(0, -1).forEach(node => {
2196
+ if (node._children) {
2197
+ console.log('Expanding:', node.name);
2198
+ node.children = node._children;
2199
+ node._children = null;
2200
+ needsRerender = true;
2201
+ }
2202
+ });
2203
+
2204
+ if (needsRerender) {
2205
+ renderVisualization();
2206
+ }
2207
+
2208
+ // Highlight after a short delay to allow render to complete
2209
+ setTimeout(() => {
2210
+ highlightNodeInTree(nodeId);
2211
+ }, 50);
2212
+ } else {
2213
+ console.log('Path not found - trying direct highlight');
2214
+ highlightNodeInTree(nodeId);
2215
+ }
2216
+ }
2217
+
2218
+ // Highlight and scroll to a node in the rendered tree
2219
+ function highlightNodeInTree(nodeId, persistent = true) {
2220
+ console.log('=== HIGHLIGHT NODE ===');
2221
+ console.log('Looking for nodeId:', nodeId);
2222
+
2223
+ // Remove any existing highlight
2224
+ d3.selectAll('.node-highlight').classed('node-highlight', false);
2225
+ if (persistent) {
2226
+ d3.selectAll('.node-selected').classed('node-selected', false);
2227
+ }
2228
+
2229
+ // Find and highlight the target node in the rendered SVG
2230
+ const svg = d3.select('#graph');
2231
+ const allNodes = svg.selectAll('.node');
2232
+ console.log('Total nodes in SVG:', allNodes.size());
2233
+
2234
+ const targetNode = allNodes.filter(d => d.data.id === nodeId);
2235
+ console.log('Matching nodes found:', targetNode.size());
2236
+
2237
+ if (!targetNode.empty()) {
2238
+ // Add highlight class (persistent = orange glow that stays)
2239
+ if (persistent) {
2240
+ targetNode.classed('node-selected', true);
2241
+ console.log('Applied .node-selected class');
2242
+ } else {
2243
+ targetNode.classed('node-highlight', true);
2244
+ console.log('Applied .node-highlight class');
2245
+ }
2246
+
2247
+ // Pulse the node circle - scale up from current size
2248
+ targetNode.select('circle')
2249
+ .transition()
2250
+ .duration(200)
2251
+ .attr('r', d => getNodeRadius(d) * 1.5) // Grow 50%
2252
+ .transition()
2253
+ .duration(200)
2254
+ .attr('r', d => getNodeRadius(d) * 0.8) // Shrink 20%
2255
+ .transition()
2256
+ .duration(200)
2257
+ .attr('r', d => getNodeRadius(d)); // Return to normal
2258
+
2259
+ // Get the node's position for scrolling
2260
+ const nodeTransform = targetNode.attr('transform');
2261
+ const match = nodeTransform.match(/translate\\(([^,]+),([^)]+)\\)/);
2262
+ if (match) {
2263
+ const x = parseFloat(match[1]);
2264
+ const y = parseFloat(match[2]);
2265
+
2266
+ // Pan the view to center on this node
2267
+ const { width, height } = getViewportDimensions();
2268
+ const zoom = d3.zoom().on('zoom', () => {}); // Get current zoom
2269
+ const svg = d3.select('#graph');
2270
+
2271
+ // Calculate center offset
2272
+ const centerX = width / 2 - x;
2273
+ const centerY = height / 2 - y;
2274
+
2275
+ // Apply smooth transition to center on node
2276
+ svg.transition()
2277
+ .duration(500)
2278
+ .call(
2279
+ d3.zoom().transform,
2280
+ d3.zoomIdentity.translate(centerX, centerY)
2281
+ );
2282
+ }
2283
+
2284
+ console.log(`Highlighted node ${nodeId}`);
2285
+ } else {
2286
+ console.log(`Node ${nodeId} not found in rendered tree`);
2287
+ }
2288
+ }
2289
+
2290
+ // Legacy function for backward compatibility
2291
+ function focusNode(nodeId) {
2292
+ focusNodeInTree(nodeId);
2293
+ }
2294
+
2295
+ function getChunkIcon(chunkType) {
2296
+ const icons = {
2297
+ 'function': '⚡',
2298
+ 'class': '🏛️',
2299
+ 'method': '🔧',
2300
+ 'code': '📝',
2301
+ 'import': '📦',
2302
+ 'comment': '💬',
2303
+ 'docstring': '📖'
2304
+ };
2305
+ return icons[chunkType] || '📝';
2306
+ }
2307
+
2308
+ function escapeHtml(text) {
2309
+ const div = document.createElement('div');
2310
+ div.textContent = text;
2311
+ return div.innerHTML;
2312
+ }
2313
+
2314
+ // Get language class for highlight.js based on file extension
2315
+ function getLanguageClass(filePath) {
2316
+ if (!filePath) return '';
2317
+ const ext = filePath.split('.').pop().toLowerCase();
2318
+ const langMap = {
2319
+ 'py': 'python',
2320
+ 'js': 'javascript',
2321
+ 'ts': 'typescript',
2322
+ 'tsx': 'typescript',
2323
+ 'jsx': 'javascript',
2324
+ 'java': 'java',
2325
+ 'go': 'go',
2326
+ 'rs': 'rust',
2327
+ 'rb': 'ruby',
2328
+ 'php': 'php',
2329
+ 'c': 'c',
2330
+ 'cpp': 'cpp',
2331
+ 'cc': 'cpp',
2332
+ 'h': 'c',
2333
+ 'hpp': 'cpp',
2334
+ 'cs': 'csharp',
2335
+ 'swift': 'swift',
2336
+ 'kt': 'kotlin',
2337
+ 'scala': 'scala',
2338
+ 'sh': 'bash',
2339
+ 'bash': 'bash',
2340
+ 'zsh': 'bash',
2341
+ 'sql': 'sql',
2342
+ 'html': 'html',
2343
+ 'htm': 'html',
2344
+ 'css': 'css',
2345
+ 'scss': 'scss',
2346
+ 'less': 'less',
2347
+ 'json': 'json',
2348
+ 'yaml': 'yaml',
2349
+ 'yml': 'yaml',
2350
+ 'xml': 'xml',
2351
+ 'md': 'markdown',
2352
+ 'markdown': 'markdown',
2353
+ 'toml': 'ini',
2354
+ 'ini': 'ini',
2355
+ 'cfg': 'ini',
2356
+ 'lua': 'lua',
2357
+ 'r': 'r',
2358
+ 'dart': 'dart',
2359
+ 'ex': 'elixir',
2360
+ 'exs': 'elixir',
2361
+ 'erl': 'erlang',
2362
+ 'hs': 'haskell',
2363
+ 'clj': 'clojure',
2364
+ 'vim': 'vim',
2365
+ 'dockerfile': 'dockerfile'
2366
+ };
2367
+ return langMap[ext] || '';
2368
+ }
2369
+
2370
+ // ============================================================================
2371
+ // LAYOUT TOGGLE
2372
+ // ============================================================================
2373
+
2374
+ function toggleCallLines(show) {
2375
+ showCallLines = show;
2376
+ const lineGroup = d3.select('.external-lines-group');
2377
+ if (!lineGroup.empty()) {
2378
+ lineGroup.style('display', show ? 'block' : 'none');
2379
+ }
2380
+ console.log(`Call lines ${show ? 'shown' : 'hidden'}`);
2381
+ }
2382
+
2383
+ function toggleLayout() {
2384
+ const toggleCheckbox = document.getElementById('layout-toggle');
2385
+ const labels = document.querySelectorAll('.toggle-label');
2386
+
2387
+ // Update layout based on checkbox state
2388
+ currentLayout = toggleCheckbox.checked ? 'circular' : 'linear';
2389
+
2390
+ // Update label highlighting
2391
+ labels.forEach((label, index) => {
2392
+ if (index === 0) {
2393
+ // Linear label (left)
2394
+ label.classList.toggle('active', currentLayout === 'linear');
2395
+ } else {
2396
+ // Circular label (right)
2397
+ label.classList.toggle('active', currentLayout === 'circular');
2398
+ }
2399
+ });
2400
+
2401
+ console.log(`Layout switched to: ${currentLayout}`);
2402
+ renderVisualization();
2403
+ }
2404
+
2405
+ // ============================================================================
2406
+ // FILE TYPE FILTER
2407
+ // ============================================================================
2408
+
2409
+ // Code file extensions
2410
+ const codeExtensions = new Set([
2411
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.c', '.cpp', '.h', '.hpp',
2412
+ '.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala', '.r',
2413
+ '.sh', '.bash', '.zsh', '.ps1', '.bat', '.sql', '.html', '.css', '.scss',
2414
+ '.sass', '.less', '.vue', '.svelte', '.astro', '.elm', '.clj', '.ex', '.exs',
2415
+ '.hs', '.ml', '.lua', '.pl', '.pm', '.m', '.mm', '.f', '.f90', '.for',
2416
+ '.asm', '.s', '.v', '.vhd', '.sv', '.nim', '.zig', '.d', '.dart', '.groovy',
2417
+ '.coffee', '.litcoffee', '.purs', '.rkt', '.scm', '.lisp', '.cl'
2418
+ ]);
2419
+
2420
+ // Doc file extensions
2421
+ const docExtensions = new Set([
2422
+ '.md', '.markdown', '.rst', '.txt', '.adoc', '.asciidoc', '.org', '.tex',
2423
+ '.rtf', '.doc', '.docx', '.pdf', '.json', '.yaml', '.yml', '.toml', '.ini',
2424
+ '.cfg', '.conf', '.xml', '.csv', '.tsv', '.log', '.man', '.info', '.pod',
2425
+ '.rdoc', '.textile', '.wiki'
2426
+ ]);
2427
+
2428
+ function getFileType(filename) {
2429
+ if (!filename) return 'unknown';
2430
+ const ext = '.' + filename.split('.').pop().toLowerCase();
2431
+ if (codeExtensions.has(ext)) return 'code';
2432
+ if (docExtensions.has(ext)) return 'docs';
2433
+ return 'unknown';
2434
+ }
2435
+
2436
+ function setFileFilter(filter) {
2437
+ currentFileFilter = filter;
2438
+
2439
+ // Update button states
2440
+ document.querySelectorAll('.filter-btn').forEach(btn => {
2441
+ btn.classList.toggle('active', btn.dataset.filter === filter);
2442
+ });
2443
+
2444
+ // Apply filter to the tree
2445
+ applyFileFilter();
2446
+
2447
+ console.log(`File filter set to: ${filter}`);
2448
+ }
2449
+
2450
+ function applyFileFilter() {
2451
+ if (!treeData) return;
2452
+
2453
+ console.log('=== APPLYING FILE FILTER (VISIBILITY) ===');
2454
+ console.log('Current filter:', currentFileFilter);
2455
+
2456
+ // Get all node elements in the visualization
2457
+ const nodeElements = d3.selectAll('.node');
2458
+ const linkElements = d3.selectAll('.link');
2459
+
2460
+ if (currentFileFilter === 'all') {
2461
+ // Show everything
2462
+ nodeElements.style('display', null);
2463
+ linkElements.style('display', null);
2464
+ console.log('Showing all nodes');
2465
+ return;
2466
+ }
2467
+
2468
+ // Build set of visible node IDs based on filter
2469
+ const visibleIds = new Set();
2470
+
2471
+ // Recursive function to check if a node or any descendant matches filter
2472
+ function checkNodeAndDescendants(node) {
2473
+ let hasMatchingDescendant = false;
2474
+
2475
+ // Check children (both visible and collapsed)
2476
+ const children = node.children || node._children || [];
2477
+ children.forEach(child => {
2478
+ if (checkNodeAndDescendants(child)) {
2479
+ hasMatchingDescendant = true;
2480
+ }
2481
+ });
2482
+
2483
+ // Check if this node itself matches
2484
+ let matches = false;
2485
+ if (node.type === 'directory') {
2486
+ // Directories are visible if they have matching descendants
2487
+ matches = hasMatchingDescendant;
2488
+ } else if (node.type === 'file') {
2489
+ const fileType = getFileType(node.name);
2490
+ matches = (fileType === currentFileFilter) || (fileType === 'unknown');
2491
+ } else if (chunkTypes.includes(node.type)) {
2492
+ // Chunks match if their parent file matches
2493
+ // For simplicity, check file_path extension
2494
+ if (node.file_path) {
2495
+ const ext = node.file_path.substring(node.file_path.lastIndexOf('.'));
2496
+ const fileType = getFileType(node.file_path);
2497
+ matches = (fileType === currentFileFilter) || (fileType === 'unknown');
2498
+ }
2499
+ }
2500
+
2501
+ if (matches || hasMatchingDescendant) {
2502
+ visibleIds.add(node.id);
2503
+ return true;
2504
+ }
2505
+ return false;
2506
+ }
2507
+
2508
+ checkNodeAndDescendants(treeData);
2509
+ console.log(`Filter found ${visibleIds.size} visible nodes`);
2510
+
2511
+ // Apply visibility to DOM elements
2512
+ nodeElements.style('display', function(d) {
2513
+ return visibleIds.has(d.data.id) ? null : 'none';
2514
+ });
2515
+
2516
+ // Hide links where either end is hidden
2517
+ linkElements.style('display', function(d) {
2518
+ const sourceVisible = visibleIds.has(d.source.data.id);
2519
+ const targetVisible = visibleIds.has(d.target.data.id);
2520
+ return (sourceVisible && targetVisible) ? null : 'none';
2521
+ });
2522
+
2523
+ console.log('=== FILTER COMPLETE (VISIBILITY) ===');
2524
+ }
2525
+
2526
+ // ============================================================================
2527
+ // VIEWER PANEL CONTROLS
2528
+ // ============================================================================
2529
+
2530
+ let isViewerExpanded = false;
2531
+
2532
+ function openViewerPanel() {
2533
+ const panel = document.getElementById('viewer-panel');
2534
+ const container = document.getElementById('main-container');
2535
+
2536
+ if (!isViewerOpen) {
2537
+ panel.classList.add('open');
2538
+ container.classList.add('viewer-open');
2539
+ isViewerOpen = true;
2540
+
2541
+ // Re-render visualization to adjust to new viewport size
2542
+ setTimeout(() => {
2543
+ renderVisualization();
2544
+ }, 300); // Wait for transition
2545
+ }
2546
+ }
2547
+
2548
+ function closeViewerPanel() {
2549
+ const panel = document.getElementById('viewer-panel');
2550
+ const container = document.getElementById('main-container');
2551
+
2552
+ panel.classList.remove('open');
2553
+ panel.classList.remove('expanded');
2554
+ container.classList.remove('viewer-open');
2555
+ isViewerOpen = false;
2556
+ isViewerExpanded = false;
2557
+
2558
+ // Update icon
2559
+ const icon = document.getElementById('expand-icon');
2560
+ if (icon) icon.textContent = '⬅';
2561
+
2562
+ // Re-render visualization to adjust to new viewport size
2563
+ setTimeout(() => {
2564
+ renderVisualization();
2565
+ }, 300); // Wait for transition
2566
+ }
2567
+
2568
+ function toggleViewerExpand() {
2569
+ const panel = document.getElementById('viewer-panel');
2570
+ const icon = document.getElementById('expand-icon');
2571
+
2572
+ isViewerExpanded = !isViewerExpanded;
2573
+
2574
+ if (isViewerExpanded) {
2575
+ panel.classList.add('expanded');
2576
+ if (icon) icon.textContent = '➡';
2577
+ } else {
2578
+ panel.classList.remove('expanded');
2579
+ if (icon) icon.textContent = '⬅';
2580
+ }
2581
+
2582
+ // Don't re-render graph on expand - only affects panel width
2583
+ // Graph will adjust on close via closeViewerPanel()
2584
+ }
2585
+
2586
+ function jumpToSection(sectionId) {
2587
+ if (!sectionId) return;
2588
+
2589
+ const viewerContent = document.getElementById('viewer-content');
2590
+ const sectionElement = viewerContent.querySelector(`[data-section="${sectionId}"]`);
2591
+
2592
+ if (sectionElement) {
2593
+ sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
2594
+
2595
+ // Briefly highlight the section
2596
+ sectionElement.style.transition = 'background-color 0.3s';
2597
+ sectionElement.style.backgroundColor = 'rgba(88, 166, 255, 0.15)';
2598
+ setTimeout(() => {
2599
+ sectionElement.style.backgroundColor = '';
2600
+ }, 1000);
2601
+ }
2602
+
2603
+ // Reset dropdown to default
2604
+ const dropdown = document.getElementById('section-dropdown');
2605
+ if (dropdown) dropdown.value = '';
2606
+ }
2607
+
2608
+ function populateSectionDropdown(sections) {
2609
+ const dropdown = document.getElementById('section-dropdown');
2610
+ if (!dropdown) return;
2611
+
2612
+ // Clear existing options except the first one
2613
+ dropdown.innerHTML = '<option value="">Jump to section...</option>';
2614
+
2615
+ // Add section options
2616
+ sections.forEach(section => {
2617
+ const option = document.createElement('option');
2618
+ option.value = section.id;
2619
+ option.textContent = section.label;
2620
+ dropdown.appendChild(option);
2621
+ });
2622
+
2623
+ // Show/hide dropdown based on whether we have sections
2624
+ const sectionNav = document.getElementById('section-nav');
2625
+ if (sectionNav) {
2626
+ sectionNav.style.display = sections.length > 1 ? 'block' : 'none';
2627
+ }
2628
+ }
2629
+
2630
+ // ============================================================================
2631
+ // NAVIGATION FUNCTIONS
2632
+ // ============================================================================
2633
+
2634
+ function addToNavHistory(item) {
2635
+ // Remove any forward history when adding new item
2636
+ if (navigationIndex < navigationHistory.length - 1) {
2637
+ navigationHistory = navigationHistory.slice(0, navigationIndex + 1);
2638
+ }
2639
+ navigationHistory.push(item);
2640
+ navigationIndex = navigationHistory.length - 1;
2641
+ console.log(`Navigation history: ${navigationHistory.length} items, index: ${navigationIndex}`);
2642
+ }
2643
+
2644
+ function goBack() {
2645
+ if (navigationIndex > 0) {
2646
+ navigationIndex--;
2647
+ const item = navigationHistory[navigationIndex];
2648
+ console.log(`Going back to: ${item.type} - ${item.data.name}`);
2649
+ if (item.type === 'directory') {
2650
+ displayDirectoryInfo(item.data, false); // false = don't add to history
2651
+ focusNodeInTree(item.data.id);
2652
+ } else if (item.type === 'file') {
2653
+ displayFileInfo(item.data, false); // false = don't add to history
2654
+ focusNodeInTree(item.data.id);
2655
+ } else if (item.type === 'chunk') {
2656
+ displayChunkContent(item.data, false); // false = don't add to history
2657
+ focusNodeInTree(item.data.id);
2658
+ }
2659
+ }
2660
+ }
2661
+
2662
+ function goForward() {
2663
+ if (navigationIndex < navigationHistory.length - 1) {
2664
+ navigationIndex++;
2665
+ const item = navigationHistory[navigationIndex];
2666
+ console.log(`Going forward to: ${item.type} - ${item.data.name}`);
2667
+ if (item.type === 'directory') {
2668
+ displayDirectoryInfo(item.data, false); // false = don't add to history
2669
+ focusNodeInTree(item.data.id);
2670
+ } else if (item.type === 'file') {
2671
+ displayFileInfo(item.data, false); // false = don't add to history
2672
+ focusNodeInTree(item.data.id);
2673
+ } else if (item.type === 'chunk') {
2674
+ displayChunkContent(item.data, false); // false = don't add to history
2675
+ focusNodeInTree(item.data.id);
2676
+ }
2677
+ }
2678
+ }
2679
+
2680
+ function navigateToDirectory(dirData) {
2681
+ console.log(`Navigating to directory: ${dirData.name}`);
2682
+ // Focus on the node in the tree (expand path and highlight)
2683
+ focusNodeInTree(dirData.id);
2684
+ }
2685
+
2686
+ function navigateToFile(fileData) {
2687
+ console.log(`Navigating to file: ${fileData.name}`);
2688
+ // Focus on the node in the tree (expand path and highlight)
2689
+ focusNodeInTree(fileData.id);
2690
+ }
2691
+
2692
+ // Navigate to a file by its file path (used when clicking on file paths in chunk metadata)
2693
+ function navigateToFileByPath(filePath) {
2694
+ console.log(`Navigating to file by path: ${filePath}`);
2695
+
2696
+ // Find the file node in allNodes that matches this path
2697
+ const fileNode = allNodes.find(n => {
2698
+ if (n.type !== 'file') return false;
2699
+ // Match against various path properties
2700
+ return n.path === filePath ||
2701
+ n.file_path === filePath ||
2702
+ (n.path && n.path.endsWith(filePath)) ||
2703
+ (n.file_path && n.file_path.endsWith(filePath)) ||
2704
+ filePath.endsWith(n.name);
2705
+ });
2706
+
2707
+ if (fileNode) {
2708
+ console.log(`Found file node: ${fileNode.name} (id: ${fileNode.id})`);
2709
+ focusNodeInTree(fileNode.id);
2710
+ } else {
2711
+ console.log(`File node not found for path: ${filePath}`);
2712
+ // Try to find by just the filename
2713
+ const fileName = filePath.split('/').pop();
2714
+ const fileByName = allNodes.find(n => n.type === 'file' && n.name === fileName);
2715
+ if (fileByName) {
2716
+ console.log(`Found file by name: ${fileByName.name}`);
2717
+ focusNodeInTree(fileByName.id);
2718
+ }
2719
+ }
2720
+ }
2721
+
2722
+ function renderNavigationBar(currentItem) {
2723
+ let html = '<div class="navigation-bar">';
2724
+
2725
+ // Back/Forward buttons
2726
+ const canGoBack = navigationIndex > 0;
2727
+ const canGoForward = navigationIndex < navigationHistory.length - 1;
2728
+
2729
+ html += `<button class="nav-btn ${canGoBack ? '' : 'disabled'}" onclick="goBack()" ${canGoBack ? '' : 'disabled'} title="Go Back">←</button>`;
2730
+ html += `<button class="nav-btn ${canGoForward ? '' : 'disabled'}" onclick="goForward()" ${canGoForward ? '' : 'disabled'} title="Go Forward">→</button>`;
2731
+
2732
+ // Breadcrumb trail
2733
+ html += '<div class="breadcrumb-trail">';
2734
+
2735
+ // Build breadcrumb from path
2736
+ if (currentItem && currentItem.id) {
2737
+ const path = findPathToNode(treeData, currentItem.id);
2738
+ path.forEach((node, index) => {
2739
+ const isLast = index === path.length - 1;
2740
+ const clickable = !isLast;
2741
+
2742
+ if (index > 0) {
2743
+ html += '<span class="breadcrumb-separator">/</span>';
2744
+ }
2745
+
2746
+ if (clickable) {
2747
+ html += `<span class="breadcrumb-item clickable" onclick="focusNodeInTree('${node.id}')">${escapeHtml(node.name)}</span>`;
2748
+ } else {
2749
+ html += `<span class="breadcrumb-item current">${escapeHtml(node.name)}</span>`;
2750
+ }
2751
+ });
2752
+ }
2753
+
2754
+ html += '</div>';
2755
+ html += '</div>';
2756
+
2757
+ return html;
2758
+ }
2759
+
2760
+ // ============================================================================
2761
+ // SEARCH FUNCTIONALITY
2762
+ // ============================================================================
2763
+
2764
+ let searchDebounceTimer = null;
2765
+ let searchResults = [];
2766
+ let selectedSearchIndex = -1;
2767
+
2768
+ function handleSearchInput(event) {
2769
+ const query = event.target.value.trim();
2770
+
2771
+ // Debounce search - wait 150ms after typing stops
2772
+ clearTimeout(searchDebounceTimer);
2773
+ searchDebounceTimer = setTimeout(() => {
2774
+ performSearch(query);
2775
+ }, 150);
2776
+ }
2777
+
2778
+ function handleSearchKeydown(event) {
2779
+ const resultsContainer = document.getElementById('search-results');
2780
+
2781
+ switch(event.key) {
2782
+ case 'ArrowDown':
2783
+ event.preventDefault();
2784
+ if (searchResults.length > 0) {
2785
+ selectedSearchIndex = Math.min(selectedSearchIndex + 1, searchResults.length - 1);
2786
+ updateSearchSelection();
2787
+ }
2788
+ break;
2789
+ case 'ArrowUp':
2790
+ event.preventDefault();
2791
+ if (searchResults.length > 0) {
2792
+ selectedSearchIndex = Math.max(selectedSearchIndex - 1, 0);
2793
+ updateSearchSelection();
2794
+ }
2795
+ break;
2796
+ case 'Enter':
2797
+ event.preventDefault();
2798
+ if (selectedSearchIndex >= 0 && selectedSearchIndex < searchResults.length) {
2799
+ selectSearchResult(searchResults[selectedSearchIndex]);
2800
+ }
2801
+ break;
2802
+ case 'Escape':
2803
+ closeSearchResults();
2804
+ document.getElementById('search-input').blur();
2805
+ break;
2806
+ }
2807
+ }
2808
+
2809
+ function performSearch(query) {
2810
+ const resultsContainer = document.getElementById('search-results');
2811
+
2812
+ if (!query || query.length < 2) {
2813
+ closeSearchResults();
2814
+ return;
2815
+ }
2816
+
2817
+ const lowerQuery = query.toLowerCase();
2818
+
2819
+ // Search through all nodes (directories, files, and chunks)
2820
+ searchResults = allNodes
2821
+ .filter(node => {
2822
+ // Match against name
2823
+ const nameMatch = node.name && node.name.toLowerCase().includes(lowerQuery);
2824
+ // Match against file path
2825
+ const pathMatch = node.file_path && node.file_path.toLowerCase().includes(lowerQuery);
2826
+ // Match against ID (useful for finding specific chunks)
2827
+ const idMatch = node.id && node.id.toLowerCase().includes(lowerQuery);
2828
+ return nameMatch || pathMatch || idMatch;
2829
+ })
2830
+ .slice(0, 20); // Limit to 20 results
2831
+
2832
+ // Sort results: exact matches first, then by type priority
2833
+ const typePriority = { 'directory': 1, 'file': 2, 'class': 3, 'function': 4, 'method': 5 };
2834
+ searchResults.sort((a, b) => {
2835
+ // Exact name match gets highest priority
2836
+ const aExact = a.name && a.name.toLowerCase() === lowerQuery ? 0 : 1;
2837
+ const bExact = b.name && b.name.toLowerCase() === lowerQuery ? 0 : 1;
2838
+ if (aExact !== bExact) return aExact - bExact;
2839
+
2840
+ // Then sort by type priority
2841
+ const aPriority = typePriority[a.type] || 10;
2842
+ const bPriority = typePriority[b.type] || 10;
2843
+ if (aPriority !== bPriority) return aPriority - bPriority;
2844
+
2845
+ // Finally sort alphabetically
2846
+ return (a.name || '').localeCompare(b.name || '');
2847
+ });
2848
+
2849
+ selectedSearchIndex = searchResults.length > 0 ? 0 : -1;
2850
+ renderSearchResults(query);
2851
+ }
2852
+
2853
+ function renderSearchResults(query) {
2854
+ const resultsContainer = document.getElementById('search-results');
2855
+
2856
+ if (searchResults.length === 0) {
2857
+ resultsContainer.innerHTML = '<div class="search-no-results">No results found</div>';
2858
+ resultsContainer.classList.add('visible');
2859
+ return;
2860
+ }
2861
+
2862
+ let html = '';
2863
+
2864
+ searchResults.forEach((node, index) => {
2865
+ const icon = getSearchResultIcon(node.type);
2866
+ const name = highlightMatch(node.name || node.id.substring(0, 20), query);
2867
+ const path = node.file_path ? node.file_path.split('/').slice(-3).join('/') : '';
2868
+ const type = node.type || 'unknown';
2869
+ const selected = index === selectedSearchIndex ? 'selected' : '';
2870
+
2871
+ html += `<div class="search-result-item ${selected}"
2872
+ data-index="${index}"
2873
+ onclick="selectSearchResultByIndex(${index})"
2874
+ onmouseenter="hoverSearchResult(${index})">`;
2875
+ html += `<span class="search-result-icon">${icon}</span>`;
2876
+ html += `<div class="search-result-info">`;
2877
+ html += `<div class="search-result-name">${name}</div>`;
2878
+ if (path) {
2879
+ html += `<div class="search-result-path">${escapeHtml(path)}</div>`;
2880
+ }
2881
+ html += `</div>`;
2882
+ html += `<span class="search-result-type">${type}</span>`;
2883
+ html += `</div>`;
2884
+ });
2885
+
2886
+ html += '<div class="search-hint">↑↓ Navigate • Enter Select • Esc Close</div>';
2887
+
2888
+ resultsContainer.innerHTML = html;
2889
+ resultsContainer.classList.add('visible');
2890
+ }
2891
+
2892
+ function getSearchResultIcon(type) {
2893
+ const icons = {
2894
+ 'directory': '📁',
2895
+ 'file': '📄',
2896
+ 'function': '⚡',
2897
+ 'class': '🏛️',
2898
+ 'method': '🔧',
2899
+ 'module': '📦',
2900
+ 'imports': '📦',
2901
+ 'text': '📝',
2902
+ 'code': '📝'
2903
+ };
2904
+ return icons[type] || '📄';
2905
+ }
2906
+
2907
+ function highlightMatch(text, query) {
2908
+ if (!text || !query) return escapeHtml(text || '');
2909
+
2910
+ const lowerText = text.toLowerCase();
2911
+ const lowerQuery = query.toLowerCase();
2912
+ const index = lowerText.indexOf(lowerQuery);
2913
+
2914
+ if (index === -1) return escapeHtml(text);
2915
+
2916
+ const before = text.substring(0, index);
2917
+ const match = text.substring(index, index + query.length);
2918
+ const after = text.substring(index + query.length);
2919
+
2920
+ return escapeHtml(before) + '<mark>' + escapeHtml(match) + '</mark>' + escapeHtml(after);
2921
+ }
2922
+
2923
+ function updateSearchSelection() {
2924
+ const items = document.querySelectorAll('.search-result-item');
2925
+ items.forEach((item, index) => {
2926
+ item.classList.toggle('selected', index === selectedSearchIndex);
2927
+ });
2928
+
2929
+ // Scroll selected item into view
2930
+ const selected = items[selectedSearchIndex];
2931
+ if (selected) {
2932
+ selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2933
+ }
2934
+ }
2935
+
2936
+ function hoverSearchResult(index) {
2937
+ selectedSearchIndex = index;
2938
+ updateSearchSelection();
2939
+ }
2940
+
2941
+ function selectSearchResultByIndex(index) {
2942
+ if (index >= 0 && index < searchResults.length) {
2943
+ selectSearchResult(searchResults[index]);
2944
+ }
2945
+ }
2946
+
2947
+ function selectSearchResult(node) {
2948
+ console.log(`Search selected: ${node.name} (${node.type})`);
2949
+
2950
+ // Close search dropdown
2951
+ closeSearchResults();
2952
+
2953
+ // Clear input
2954
+ document.getElementById('search-input').value = '';
2955
+
2956
+ // Focus on the node in the tree
2957
+ focusNodeInTree(node.id);
2958
+ }
2959
+
2960
+ function closeSearchResults() {
2961
+ const resultsContainer = document.getElementById('search-results');
2962
+ resultsContainer.classList.remove('visible');
2963
+ searchResults = [];
2964
+ selectedSearchIndex = -1;
2965
+ }
2966
+
2967
+ // ============================================================================
2968
+ // THEME TOGGLE
2969
+ // ============================================================================
2970
+
2971
+ function toggleTheme() {
2972
+ const html = document.documentElement;
2973
+ const currentTheme = html.getAttribute('data-theme') || 'dark';
2974
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
2975
+
2976
+ // Update theme
2977
+ if (newTheme === 'light') {
2978
+ html.setAttribute('data-theme', 'light');
2979
+ } else {
2980
+ html.removeAttribute('data-theme');
2981
+ }
2982
+
2983
+ // Save preference
2984
+ localStorage.setItem('theme', newTheme);
2985
+
2986
+ // Update icon only
2987
+ const themeIcon = document.getElementById('theme-icon');
2988
+
2989
+ if (newTheme === 'light') {
2990
+ themeIcon.textContent = '☀️';
2991
+ } else {
2992
+ themeIcon.textContent = '🌙';
2993
+ }
2994
+
2995
+ console.log(`Theme toggled to: ${newTheme}`);
2996
+ }
2997
+
2998
+ function loadThemePreference() {
2999
+ const savedTheme = localStorage.getItem('theme') || 'dark';
3000
+ const html = document.documentElement;
3001
+
3002
+ if (savedTheme === 'light') {
3003
+ html.setAttribute('data-theme', 'light');
3004
+ const themeIcon = document.getElementById('theme-icon');
3005
+ if (themeIcon) themeIcon.textContent = '☀️';
3006
+ }
3007
+
3008
+ console.log(`Loaded theme preference: ${savedTheme}`);
3009
+ }
3010
+
3011
+ // ============================================================================
3012
+ // ANALYSIS REPORTS
3013
+ // ============================================================================
3014
+
3015
+ function getComplexityGrade(complexity) {
3016
+ if (complexity === undefined || complexity === null) return 'N/A';
3017
+ if (complexity <= 5) return 'A';
3018
+ if (complexity <= 10) return 'B';
3019
+ if (complexity <= 15) return 'C';
3020
+ if (complexity <= 20) return 'D';
3021
+ return 'F';
3022
+ }
3023
+
3024
+ function getGradeColor(grade) {
3025
+ const colors = {
3026
+ 'A': '#2ea043',
3027
+ 'B': '#1f6feb',
3028
+ 'C': '#d29922',
3029
+ 'D': '#f0883e',
3030
+ 'F': '#da3633',
3031
+ 'N/A': '#6e7681'
3032
+ };
3033
+ return colors[grade] || colors['N/A'];
3034
+ }
3035
+
3036
+ function showComplexityReport() {
3037
+ openViewerPanel();
3038
+
3039
+ const viewerTitle = document.getElementById('viewer-title');
3040
+ const viewerContent = document.getElementById('viewer-content');
3041
+
3042
+ viewerTitle.textContent = '📊 Complexity Report';
3043
+
3044
+ // Collect all chunk nodes with complexity data
3045
+ const chunksWithComplexity = [];
3046
+
3047
+ function collectChunks(node) {
3048
+ if (chunkTypes.includes(node.type)) {
3049
+ const complexity = node.complexity !== undefined ? node.complexity : null;
3050
+ chunksWithComplexity.push({
3051
+ name: node.name,
3052
+ type: node.type,
3053
+ file_path: node.file_path || 'Unknown',
3054
+ start_line: node.start_line || 0,
3055
+ end_line: node.end_line || 0,
3056
+ complexity: complexity,
3057
+ grade: getComplexityGrade(complexity),
3058
+ node: node
3059
+ });
3060
+ }
3061
+
3062
+ // Recursively process children
3063
+ const children = node.children || node._children || [];
3064
+ children.forEach(child => collectChunks(child));
3065
+ }
3066
+
3067
+ // Start from treeData root
3068
+ if (treeData) {
3069
+ collectChunks(treeData);
3070
+ }
3071
+
3072
+ // Calculate statistics
3073
+ const totalFunctions = chunksWithComplexity.length;
3074
+ const validComplexity = chunksWithComplexity.filter(c => c.complexity !== null);
3075
+ const avgComplexity = validComplexity.length > 0
3076
+ ? (validComplexity.reduce((sum, c) => sum + c.complexity, 0) / validComplexity.length).toFixed(2)
3077
+ : 'N/A';
3078
+
3079
+ // Count by grade
3080
+ const gradeCounts = {
3081
+ 'A': 0, 'B': 0, 'C': 0, 'D': 0, 'F': 0, 'N/A': 0
3082
+ };
3083
+ chunksWithComplexity.forEach(c => {
3084
+ gradeCounts[c.grade]++;
3085
+ });
3086
+
3087
+ // Sort by complexity (highest first)
3088
+ const sortedChunks = [...chunksWithComplexity].sort((a, b) => {
3089
+ if (a.complexity === null) return 1;
3090
+ if (b.complexity === null) return -1;
3091
+ return b.complexity - a.complexity;
3092
+ });
3093
+
3094
+ // Build HTML
3095
+ let html = '<div class="complexity-report">';
3096
+
3097
+ // Summary Stats
3098
+ html += '<div class="complexity-summary">';
3099
+ html += '<div class="summary-grid">';
3100
+ html += `<div class="summary-card">
3101
+ <div class="summary-label">Total Functions</div>
3102
+ <div class="summary-value">${totalFunctions}</div>
3103
+ </div>`;
3104
+ html += `<div class="summary-card">
3105
+ <div class="summary-label">Average Complexity</div>
3106
+ <div class="summary-value">${avgComplexity}</div>
3107
+ </div>`;
3108
+ html += `<div class="summary-card">
3109
+ <div class="summary-label">With Complexity Data</div>
3110
+ <div class="summary-value">${validComplexity.length}</div>
3111
+ </div>`;
3112
+ html += '</div>';
3113
+
3114
+ // Grade Distribution
3115
+ html += '<div class="grade-distribution">';
3116
+ html += '<div class="distribution-title">Grade Distribution</div>';
3117
+ html += '<div class="distribution-bars">';
3118
+
3119
+ const maxCount = Math.max(...Object.values(gradeCounts));
3120
+ ['A', 'B', 'C', 'D', 'F', 'N/A'].forEach(grade => {
3121
+ const count = gradeCounts[grade];
3122
+ const percentage = totalFunctions > 0 ? (count / totalFunctions * 100) : 0;
3123
+ const barWidth = maxCount > 0 ? (count / maxCount * 100) : 0;
3124
+ html += `
3125
+ <div class="distribution-row">
3126
+ <div class="distribution-grade" style="color: ${getGradeColor(grade)}">${grade}</div>
3127
+ <div class="distribution-bar-container">
3128
+ <div class="distribution-bar" style="width: ${barWidth}%; background: ${getGradeColor(grade)}"></div>
3129
+ </div>
3130
+ <div class="distribution-count">${count} (${percentage.toFixed(1)}%)</div>
3131
+ </div>
3132
+ `;
3133
+ });
3134
+ html += '</div></div>';
3135
+ html += '</div>';
3136
+
3137
+ // Complexity Hotspots Table
3138
+ html += '<div class="complexity-hotspots">';
3139
+ html += '<h3 class="section-title">Complexity Hotspots</h3>';
3140
+
3141
+ if (sortedChunks.length === 0) {
3142
+ html += '<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No functions found with complexity data.</p>';
3143
+ } else {
3144
+ html += '<div class="hotspots-table-container">';
3145
+ html += '<table class="hotspots-table">';
3146
+ html += `
3147
+ <thead>
3148
+ <tr>
3149
+ <th>Name</th>
3150
+ <th>File</th>
3151
+ <th>Lines</th>
3152
+ <th>Complexity</th>
3153
+ <th>Grade</th>
3154
+ </tr>
3155
+ </thead>
3156
+ <tbody>
3157
+ `;
3158
+
3159
+ sortedChunks.forEach(chunk => {
3160
+ const lines = chunk.end_line > 0 ? chunk.end_line - chunk.start_line + 1 : 'N/A';
3161
+ const complexityDisplay = chunk.complexity !== null ? chunk.complexity.toFixed(1) : 'N/A';
3162
+ const gradeColor = getGradeColor(chunk.grade);
3163
+
3164
+ // Get relative file path
3165
+ const fileName = chunk.file_path.split('/').pop() || chunk.file_path;
3166
+ const lineRange = chunk.start_line > 0 ? `L${chunk.start_line}-${chunk.end_line}` : '';
3167
+
3168
+ html += `
3169
+ <tr class="hotspot-row" onclick='navigateToChunk(${JSON.stringify(chunk.name)})'>
3170
+ <td class="hotspot-name">${escapeHtml(chunk.name)}</td>
3171
+ <td class="hotspot-file" title="${escapeHtml(chunk.file_path)}">${escapeHtml(fileName)}</td>
3172
+ <td class="hotspot-lines">${lines}</td>
3173
+ <td class="hotspot-complexity">${complexityDisplay}</td>
3174
+ <td class="hotspot-grade">
3175
+ <span class="grade-badge" style="background: ${gradeColor}">${chunk.grade}</span>
3176
+ </td>
3177
+ </tr>
3178
+ `;
3179
+ });
3180
+
3181
+ html += '</tbody></table></div>';
3182
+ }
3183
+
3184
+ html += '</div>'; // complexity-hotspots
3185
+ html += '</div>'; // complexity-report
3186
+
3187
+ viewerContent.innerHTML = html;
3188
+
3189
+ // Hide section dropdown for reports (no code sections)
3190
+ const sectionNav = document.getElementById('section-nav');
3191
+ if (sectionNav) {
3192
+ sectionNav.style.display = 'none';
3193
+ }
3194
+ }
3195
+
3196
+ function navigateToChunk(chunkName) {
3197
+ // Find the chunk node in the tree
3198
+ function findChunk(node) {
3199
+ if (chunkTypes.includes(node.type) && node.name === chunkName) {
3200
+ return node;
3201
+ }
3202
+ const children = node.children || node._children || [];
3203
+ for (const child of children) {
3204
+ const found = findChunk(child);
3205
+ if (found) return found;
3206
+ }
3207
+ return null;
3208
+ }
3209
+
3210
+ if (treeData) {
3211
+ const chunk = findChunk(treeData);
3212
+ if (chunk) {
3213
+ // Display the chunk content
3214
+ displayChunkContent(chunk);
3215
+
3216
+ // Highlight the node in the visualization
3217
+ highlightNode(chunk.id);
3218
+ }
3219
+ }
3220
+ }
3221
+
3222
+ function escapeHtml(text) {
3223
+ const div = document.createElement('div');
3224
+ div.textContent = text;
3225
+ return div.innerHTML;
3226
+ }
3227
+
3228
+ function highlightNode(nodeId) {
3229
+ // Remove existing highlights
3230
+ d3.selectAll('.node').classed('node-highlight', false);
3231
+
3232
+ // Add highlight to the target node
3233
+ d3.selectAll('.node')
3234
+ .filter(d => d.data.id === nodeId)
3235
+ .classed('node-highlight', true);
3236
+ }
3237
+
3238
+ // ============================================================================
3239
+ // CODE SMELLS DETECTION AND REPORTING
3240
+ // ============================================================================
3241
+
3242
+ function detectCodeSmells(nodes) {
3243
+ const smells = [];
3244
+
3245
+ function analyzeNode(node) {
3246
+ // Only analyze code chunks
3247
+ if (!chunkTypes.includes(node.type)) {
3248
+ const children = node.children || node._children || [];
3249
+ children.forEach(child => analyzeNode(child));
3250
+ return;
3251
+ }
3252
+
3253
+ const lineCount = (node.end_line && node.start_line)
3254
+ ? node.end_line - node.start_line + 1
3255
+ : 0;
3256
+ const complexity = node.complexity || 0;
3257
+
3258
+ // 1. Long Method - Functions with > 50 lines
3259
+ if (node.type === 'function' || node.type === 'method') {
3260
+ if (lineCount > 100) {
3261
+ smells.push({
3262
+ type: 'Long Method',
3263
+ severity: 'error',
3264
+ node: node,
3265
+ details: `${lineCount} lines (very long)`
3266
+ });
3267
+ } else if (lineCount > 50) {
3268
+ smells.push({
3269
+ type: 'Long Method',
3270
+ severity: 'warning',
3271
+ node: node,
3272
+ details: `${lineCount} lines`
3273
+ });
3274
+ }
3275
+ }
3276
+
3277
+ // 2. High Complexity - Functions with complexity > 15
3278
+ if ((node.type === 'function' || node.type === 'method') && complexity > 0) {
3279
+ if (complexity > 20) {
3280
+ smells.push({
3281
+ type: 'High Complexity',
3282
+ severity: 'error',
3283
+ node: node,
3284
+ details: `Complexity: ${complexity.toFixed(1)} (very complex)`
3285
+ });
3286
+ } else if (complexity > 15) {
3287
+ smells.push({
3288
+ type: 'High Complexity',
3289
+ severity: 'warning',
3290
+ node: node,
3291
+ details: `Complexity: ${complexity.toFixed(1)}`
3292
+ });
3293
+ }
3294
+ }
3295
+
3296
+ // 3. Deep Nesting - Proxy using complexity > 20
3297
+ if ((node.type === 'function' || node.type === 'method') && complexity > 20) {
3298
+ smells.push({
3299
+ type: 'Deep Nesting',
3300
+ severity: complexity > 25 ? 'error' : 'warning',
3301
+ node: node,
3302
+ details: `Complexity: ${complexity.toFixed(1)} (likely deep nesting)`
3303
+ });
3304
+ }
3305
+
3306
+ // 4. God Class - Classes with > 20 methods or > 500 lines
3307
+ if (node.type === 'class') {
3308
+ const children = node.children || node._children || [];
3309
+ const methodCount = children.filter(c => c.type === 'method').length;
3310
+
3311
+ if (methodCount > 30 || lineCount > 800) {
3312
+ smells.push({
3313
+ type: 'God Class',
3314
+ severity: 'error',
3315
+ node: node,
3316
+ details: `${methodCount} methods, ${lineCount} lines (very large)`
3317
+ });
3318
+ } else if (methodCount > 20 || lineCount > 500) {
3319
+ smells.push({
3320
+ type: 'God Class',
3321
+ severity: 'warning',
3322
+ node: node,
3323
+ details: `${methodCount} methods, ${lineCount} lines`
3324
+ });
3325
+ }
3326
+ }
3327
+
3328
+ // Recursively process children
3329
+ const children = node.children || node._children || [];
3330
+ children.forEach(child => analyzeNode(child));
3331
+ }
3332
+
3333
+ if (nodes) {
3334
+ analyzeNode(nodes);
3335
+ }
3336
+
3337
+ return smells;
3338
+ }
3339
+
3340
+ function showCodeSmells() {
3341
+ openViewerPanel();
3342
+
3343
+ const viewerTitle = document.getElementById('viewer-title');
3344
+ const viewerContent = document.getElementById('viewer-content');
3345
+
3346
+ viewerTitle.textContent = '🔍 Code Smells';
3347
+
3348
+ // Detect all code smells
3349
+ const allSmells = detectCodeSmells(treeData);
3350
+
3351
+ // Count by type and severity
3352
+ const smellCounts = {
3353
+ 'Long Method': { total: 0, warning: 0, error: 0 },
3354
+ 'High Complexity': { total: 0, warning: 0, error: 0 },
3355
+ 'Deep Nesting': { total: 0, warning: 0, error: 0 },
3356
+ 'God Class': { total: 0, warning: 0, error: 0 }
3357
+ };
3358
+
3359
+ let totalWarnings = 0;
3360
+ let totalErrors = 0;
3361
+
3362
+ allSmells.forEach(smell => {
3363
+ smellCounts[smell.type].total++;
3364
+ smellCounts[smell.type][smell.severity]++;
3365
+ if (smell.severity === 'warning') totalWarnings++;
3366
+ if (smell.severity === 'error') totalErrors++;
3367
+ });
3368
+
3369
+ // Build HTML
3370
+ let html = '<div class="code-smells-report">';
3371
+
3372
+ // Summary Cards
3373
+ html += '<div class="smell-summary-grid">';
3374
+ html += `
3375
+ <div class="smell-summary-card">
3376
+ <div class="smell-card-header">
3377
+ <span class="smell-card-icon">🔍</span>
3378
+ <div class="smell-card-title">Total Smells</div>
3379
+ </div>
3380
+ <div class="smell-card-count">${allSmells.length}</div>
3381
+ </div>
3382
+ <div class="smell-summary-card warning">
3383
+ <div class="smell-card-header">
3384
+ <span class="smell-card-icon">⚠️</span>
3385
+ <div class="smell-card-title">Warnings</div>
3386
+ </div>
3387
+ <div class="smell-card-count" style="color: var(--warning)">${totalWarnings}</div>
3388
+ </div>
3389
+ <div class="smell-summary-card error">
3390
+ <div class="smell-card-header">
3391
+ <span class="smell-card-icon">🚨</span>
3392
+ <div class="smell-card-title">Errors</div>
3393
+ </div>
3394
+ <div class="smell-card-count" style="color: var(--error)">${totalErrors}</div>
3395
+ </div>
3396
+ `;
3397
+ html += '</div>';
3398
+
3399
+ // Filters
3400
+ html += '<div class="smell-filters">';
3401
+ html += '<div class="filter-title">Filter by Type</div>';
3402
+ html += '<div class="filter-checkboxes">';
3403
+
3404
+ Object.keys(smellCounts).forEach(type => {
3405
+ const count = smellCounts[type].total;
3406
+ html += `
3407
+ <div class="filter-checkbox-item">
3408
+ <input type="checkbox" id="filter-${type.replace(/\\s+/g, '-')}"
3409
+ checked onchange="filterCodeSmells()">
3410
+ <label class="filter-checkbox-label" for="filter-${type.replace(/\\s+/g, '-')}">${type}</label>
3411
+ <span class="filter-checkbox-count">${count}</span>
3412
+ </div>
3413
+ `;
3414
+ });
3415
+
3416
+ html += '</div></div>';
3417
+
3418
+ // Smells Table
3419
+ html += '<div id="smells-table-wrapper">';
3420
+ html += '<h3 class="section-title">Detected Code Smells</h3>';
3421
+
3422
+ if (allSmells.length === 0) {
3423
+ html += '<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No code smells detected! Great job! 🎉</p>';
3424
+ } else {
3425
+ html += '<div class="smells-table-container">';
3426
+ html += '<table class="smells-table">';
3427
+ html += `
3428
+ <thead>
3429
+ <tr>
3430
+ <th>Type</th>
3431
+ <th>Severity</th>
3432
+ <th>Name</th>
3433
+ <th>File</th>
3434
+ <th>Details</th>
3435
+ </tr>
3436
+ </thead>
3437
+ <tbody id="smells-table-body">
3438
+ `;
3439
+
3440
+ // Sort by severity (error first) then by type
3441
+ const sortedSmells = [...allSmells].sort((a, b) => {
3442
+ if (a.severity !== b.severity) {
3443
+ return a.severity === 'error' ? -1 : 1;
3444
+ }
3445
+ return a.type.localeCompare(b.type);
3446
+ });
3447
+
3448
+ sortedSmells.forEach(smell => {
3449
+ const fileName = smell.node.file_path ? smell.node.file_path.split('/').pop() : 'Unknown';
3450
+ const severityIcon = smell.severity === 'error' ? '🚨' : '⚠️';
3451
+
3452
+ html += `
3453
+ <tr class="smell-row" data-smell-type="${smell.type.replace(/\\s+/g, '-')}"
3454
+ onclick='navigateToChunk(${JSON.stringify(smell.node.name)})'>
3455
+ <td><span class="smell-type-badge">${escapeHtml(smell.type)}</span></td>
3456
+ <td><span class="severity-badge ${smell.severity}">${severityIcon} ${smell.severity.toUpperCase()}</span></td>
3457
+ <td class="smell-name">${escapeHtml(smell.node.name)}</td>
3458
+ <td class="smell-file" title="${escapeHtml(smell.node.file_path || '')}">${escapeHtml(fileName)}</td>
3459
+ <td class="smell-details">${escapeHtml(smell.details)}</td>
3460
+ </tr>
3461
+ `;
3462
+ });
3463
+
3464
+ html += '</tbody></table></div>';
3465
+ }
3466
+
3467
+ html += '</div>'; // smells-table-wrapper
3468
+ html += '</div>'; // code-smells-report
3469
+
3470
+ viewerContent.innerHTML = html;
3471
+
3472
+ // Hide section dropdown for reports (no code sections)
3473
+ const sectionNav = document.getElementById('section-nav');
3474
+ if (sectionNav) {
3475
+ sectionNav.style.display = 'none';
3476
+ }
3477
+ }
3478
+
3479
+ function filterCodeSmells() {
3480
+ const rows = document.querySelectorAll('.smell-row');
3481
+
3482
+ rows.forEach(row => {
3483
+ const smellType = row.getAttribute('data-smell-type');
3484
+ const checkbox = document.getElementById(`filter-${smellType}`);
3485
+
3486
+ if (checkbox && checkbox.checked) {
3487
+ row.style.display = '';
3488
+ } else {
3489
+ row.style.display = 'none';
3490
+ }
3491
+ });
3492
+ }
3493
+
3494
+ // ============================================================================
3495
+ // DEPENDENCIES ANALYSIS AND REPORTING
3496
+ // ============================================================================
3497
+
3498
+ function showDependencies() {
3499
+ openViewerPanel();
3500
+
3501
+ const viewerTitle = document.getElementById('viewer-title');
3502
+ const viewerContent = document.getElementById('viewer-content');
3503
+
3504
+ viewerTitle.textContent = '🔗 Code Structure';
3505
+
3506
+ // Code file extensions (exclude docs like .md, .txt, .rst)
3507
+ const codeExtensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt', '.scala', '.ex', '.exs', '.clj', '.vue', '.svelte'];
3508
+
3509
+ function isCodeFile(filePath) {
3510
+ const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
3511
+ return codeExtensions.includes(ext);
3512
+ }
3513
+
3514
+ // Build directory structure from nodes (code files only)
3515
+ const dirStructure = new Map();
3516
+
3517
+ allNodes.forEach(node => {
3518
+ if (node.type === 'file' && node.file_path && isCodeFile(node.file_path)) {
3519
+ const parts = node.file_path.split('/');
3520
+ const fileName = parts.pop();
3521
+ const dirPath = parts.join('/') || '/';
3522
+
3523
+ if (!dirStructure.has(dirPath)) {
3524
+ dirStructure.set(dirPath, []);
3525
+ }
3526
+ dirStructure.get(dirPath).push({
3527
+ name: fileName,
3528
+ path: node.file_path,
3529
+ chunks: allNodes.filter(n => n.file_path === node.file_path && n.type !== 'file').length
3530
+ });
3531
+ }
3532
+ });
3533
+
3534
+ // Calculate stats
3535
+ const totalDirs = dirStructure.size;
3536
+ const totalFiles = Array.from(dirStructure.values()).reduce((sum, files) => sum + files.length, 0);
3537
+ const totalChunks = allNodes.filter(n => n.type !== 'file' && n.type !== 'directory').length;
3538
+
3539
+ let html = `
3540
+ <div class="report-section">
3541
+ <h3>📁 Directory Overview</h3>
3542
+ <p style="color: var(--text-secondary); margin-bottom: 15px;">Showing code organization by directory structure.</p>
3543
+ <div class="metrics-grid">
3544
+ <div class="metric-card">
3545
+ <div class="metric-value">${totalDirs}</div>
3546
+ <div class="metric-label">Directories</div>
3547
+ </div>
3548
+ <div class="metric-card">
3549
+ <div class="metric-value">${totalFiles}</div>
3550
+ <div class="metric-label">Files</div>
3551
+ </div>
3552
+ <div class="metric-card">
3553
+ <div class="metric-value">${totalChunks}</div>
3554
+ <div class="metric-label">Code Chunks</div>
3555
+ </div>
3556
+ </div>
3557
+ </div>
3558
+ <div class="report-section">
3559
+ <h3>📂 Directory Structure</h3>
3560
+ `;
3561
+
3562
+ // Sort directories
3563
+ const sortedDirs = Array.from(dirStructure.entries()).sort((a, b) => a[0].localeCompare(b[0]));
3564
+
3565
+ sortedDirs.forEach(([dir, files]) => {
3566
+ const dirDisplay = dir === '/' ? 'Root' : dir;
3567
+ const totalChunksInDir = files.reduce((sum, f) => sum + f.chunks, 0);
3568
+
3569
+ html += `
3570
+ <div class="dependency-item" style="margin-bottom: 20px; padding: 12px; background: var(--bg-secondary); border-radius: 6px; border-left: 3px solid var(--accent-blue);">
3571
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
3572
+ <strong style="color: var(--accent-blue);">📁 ${escapeHtml(dirDisplay)}</strong>
3573
+ <span style="color: var(--text-secondary); font-size: 12px;">${files.length} files, ${totalChunksInDir} chunks</span>
3574
+ </div>
3575
+ <ul style="margin: 0; padding: 0 0 0 20px; list-style: none;">
3576
+ `;
3577
+
3578
+ files.sort((a, b) => a.name.localeCompare(b.name)).forEach(file => {
3579
+ html += `<li style="padding: 4px 0; color: var(--text-primary);">
3580
+ 📄 ${escapeHtml(file.name)}
3581
+ <span style="color: var(--text-secondary); font-size: 11px;">(${file.chunks} chunks)</span>
3582
+ </li>`;
3583
+ });
3584
+
3585
+ html += `</ul></div>`;
3586
+ });
3587
+
3588
+ html += '</div>';
3589
+
3590
+ viewerContent.innerHTML = html;
3591
+
3592
+ // Hide section dropdown for reports (no code sections)
3593
+ const sectionNav = document.getElementById('section-nav');
3594
+ if (sectionNav) {
3595
+ sectionNav.style.display = 'none';
3596
+ }
3597
+ }
3598
+
3599
+ function buildFileDependencyGraph() {
3600
+ // Map: file_path -> { dependsOn: Set<file_path>, usedBy: Set<file_path> }
3601
+ const fileDeps = new Map();
3602
+
3603
+ allLinks.forEach(link => {
3604
+ if (link.type === 'caller') {
3605
+ const sourceNode = allNodes.find(n => n.id === link.source);
3606
+ const targetNode = allNodes.find(n => n.id === link.target);
3607
+
3608
+ if (sourceNode && targetNode && sourceNode.file_path && targetNode.file_path) {
3609
+ // Skip self-references (same file)
3610
+ if (sourceNode.file_path === targetNode.file_path) {
3611
+ return;
3612
+ }
3613
+
3614
+ // sourceNode calls targetNode → source depends on target
3615
+ if (!fileDeps.has(sourceNode.file_path)) {
3616
+ fileDeps.set(sourceNode.file_path, { dependsOn: new Set(), usedBy: new Set() });
3617
+ }
3618
+ if (!fileDeps.has(targetNode.file_path)) {
3619
+ fileDeps.set(targetNode.file_path, { dependsOn: new Set(), usedBy: new Set() });
3620
+ }
3621
+
3622
+ fileDeps.get(sourceNode.file_path).dependsOn.add(targetNode.file_path);
3623
+ fileDeps.get(targetNode.file_path).usedBy.add(sourceNode.file_path);
3624
+ }
3625
+ }
3626
+ });
3627
+
3628
+ return fileDeps;
3629
+ }
3630
+
3631
+ function findCircularDeps(fileDeps) {
3632
+ // Simple cycle detection using DFS
3633
+ const cycles = [];
3634
+ const visited = new Set();
3635
+ const recStack = new Set();
3636
+ const pathStack = [];
3637
+
3638
+ function dfs(filePath) {
3639
+ visited.add(filePath);
3640
+ recStack.add(filePath);
3641
+ pathStack.push(filePath);
3642
+
3643
+ const deps = fileDeps.get(filePath);
3644
+ if (deps && deps.dependsOn) {
3645
+ for (const depFile of deps.dependsOn) {
3646
+ if (!visited.has(depFile)) {
3647
+ dfs(depFile);
3648
+ } else if (recStack.has(depFile)) {
3649
+ // Found a cycle
3650
+ const cycleStartIndex = pathStack.indexOf(depFile);
3651
+ if (cycleStartIndex !== -1) {
3652
+ const cycle = pathStack.slice(cycleStartIndex);
3653
+ cycle.push(depFile); // Complete the cycle
3654
+ // Check if this cycle is already recorded
3655
+ const cycleStr = cycle.sort().join('|');
3656
+ if (!cycles.some(c => c.sort().join('|') === cycleStr)) {
3657
+ cycles.push([...cycle]);
3658
+ }
3659
+ }
3660
+ }
3661
+ }
3662
+ }
3663
+
3664
+ pathStack.pop();
3665
+ recStack.delete(filePath);
3666
+ }
3667
+
3668
+ for (const filePath of fileDeps.keys()) {
3669
+ if (!visited.has(filePath)) {
3670
+ dfs(filePath);
3671
+ }
3672
+ }
3673
+
3674
+ return cycles;
3675
+ }
3676
+
3677
+ function toggleDependencyDetails(rowId, index) {
3678
+ const detailsRow = document.getElementById(`${rowId}-details`);
3679
+ const btn = document.querySelector(`#${rowId} .expand-btn`);
3680
+
3681
+ if (detailsRow.style.display === 'none') {
3682
+ detailsRow.style.display = '';
3683
+ btn.textContent = '▲';
3684
+ } else {
3685
+ detailsRow.style.display = 'none';
3686
+ btn.textContent = '▼';
3687
+ }
3688
+ }
3689
+
3690
+ // ============================================================================
3691
+ // TRENDS / METRICS SNAPSHOT
3692
+ // ============================================================================
3693
+
3694
+ function showTrends() {
3695
+ openViewerPanel();
3696
+
3697
+ const viewerTitle = document.getElementById('viewer-title');
3698
+ const viewerContent = document.getElementById('viewer-content');
3699
+
3700
+ viewerTitle.textContent = '📈 Codebase Metrics Snapshot';
3701
+
3702
+ // Calculate metrics from current codebase
3703
+ const metrics = calculateCodebaseMetrics();
3704
+
3705
+ // Build HTML
3706
+ let html = '<div class="trends-report">';
3707
+
3708
+ // Snapshot Banner
3709
+ html += '<div class="snapshot-banner">';
3710
+ html += '<div class="snapshot-header">';
3711
+ html += '<div class="snapshot-icon">📊</div>';
3712
+ html += '<div class="snapshot-info">';
3713
+ html += '<div class="snapshot-title">Codebase Metrics Snapshot</div>';
3714
+ html += `<div class="snapshot-timestamp">Generated: ${new Date().toLocaleString('en-US', {
3715
+ month: 'short',
3716
+ day: 'numeric',
3717
+ year: 'numeric',
3718
+ hour: 'numeric',
3719
+ minute: '2-digit',
3720
+ hour12: true
3721
+ })}</div>`;
3722
+ html += '</div></div>';
3723
+ html += '<div class="snapshot-description">';
3724
+ html += 'This snapshot serves as the baseline for future trend tracking. ';
3725
+ html += 'With git history analysis, you could track how these metrics evolve over time.';
3726
+ html += '</div>';
3727
+ html += '</div>';
3728
+
3729
+ // Key Metrics Cards
3730
+ html += '<div class="metrics-section">';
3731
+ html += '<h3 class="section-title">Key Metrics</h3>';
3732
+ html += '<div class="metrics-grid">';
3733
+
3734
+ html += `<div class="metric-card">
3735
+ <div class="metric-icon">📝</div>
3736
+ <div class="metric-value">${metrics.totalLines.toLocaleString()}</div>
3737
+ <div class="metric-label">Lines of Code</div>
3738
+ </div>`;
3739
+
3740
+ html += `<div class="metric-card">
3741
+ <div class="metric-icon">⚡</div>
3742
+ <div class="metric-value">${metrics.totalFunctions}</div>
3743
+ <div class="metric-label">Functions/Methods</div>
3744
+ </div>`;
3745
+
3746
+ html += `<div class="metric-card">
3747
+ <div class="metric-icon">🎯</div>
3748
+ <div class="metric-value">${metrics.totalClasses}</div>
3749
+ <div class="metric-label">Classes</div>
3750
+ </div>`;
3751
+
3752
+ html += `<div class="metric-card">
3753
+ <div class="metric-icon">📄</div>
3754
+ <div class="metric-value">${metrics.totalFiles}</div>
3755
+ <div class="metric-label">Files</div>
3756
+ </div>`;
3757
+
3758
+ html += '</div></div>';
3759
+
3760
+ // Code Health Score
3761
+ html += '<div class="health-section">';
3762
+ html += '<h3 class="section-title">Code Health Score</h3>';
3763
+ html += '<div class="health-card">';
3764
+
3765
+ const healthInfo = getHealthScoreInfo(metrics.healthScore);
3766
+ html += `<div class="health-score-display">
3767
+ <div class="health-score-value">${metrics.healthScore}/100</div>
3768
+ <div class="health-score-label">${healthInfo.emoji} ${healthInfo.label}</div>
3769
+ </div>`;
3770
+
3771
+ html += '<div class="health-progress-container">';
3772
+ html += `<div class="health-progress-bar" style="width: ${metrics.healthScore}%; background: ${healthInfo.color}"></div>`;
3773
+ html += '</div>';
3774
+
3775
+ html += `<div class="health-description">${healthInfo.description}</div>`;
3776
+ html += '</div></div>';
3777
+
3778
+ // Complexity Distribution
3779
+ html += '<div class="distribution-section">';
3780
+ html += '<h3 class="section-title">Complexity Distribution</h3>';
3781
+ html += '<div class="distribution-chart">';
3782
+
3783
+ const complexityDist = metrics.complexityDistribution;
3784
+ const maxPct = Math.max(...Object.values(complexityDist));
3785
+
3786
+ ['A', 'B', 'C', 'D', 'F'].forEach(grade => {
3787
+ const pct = complexityDist[grade] || 0;
3788
+ const barWidth = maxPct > 0 ? (pct / maxPct * 100) : 0;
3789
+ const color = getGradeColor(grade);
3790
+ const range = getComplexityRange(grade);
3791
+
3792
+ html += `<div class="distribution-bar-row">
3793
+ <div class="distribution-bar-label">
3794
+ <span class="distribution-grade" style="color: ${color}">${grade}</span>
3795
+ <span class="distribution-range">${range}</span>
3796
+ </div>
3797
+ <div class="distribution-bar-container">
3798
+ <div class="distribution-bar-fill" style="width: ${barWidth}%; background: ${color}"></div>
3799
+ </div>
3800
+ <div class="distribution-bar-value">${pct.toFixed(1)}%</div>
3801
+ </div>`;
3802
+ });
3803
+
3804
+ html += '</div></div>';
3805
+
3806
+ // Function Size Distribution
3807
+ html += '<div class="size-distribution-section">';
3808
+ html += '<h3 class="section-title">Function Size Distribution</h3>';
3809
+ html += '<div class="distribution-chart">';
3810
+
3811
+ const sizeDist = metrics.sizeDistribution;
3812
+ const maxSizePct = Math.max(...Object.values(sizeDist));
3813
+
3814
+ [
3815
+ { key: 'small', label: 'Small (1-20 lines)', color: '#238636' },
3816
+ { key: 'medium', label: 'Medium (21-50 lines)', color: '#1f6feb' },
3817
+ { key: 'large', label: 'Large (51-100 lines)', color: '#d29922' },
3818
+ { key: 'veryLarge', label: 'Very Large (100+ lines)', color: '#da3633' }
3819
+ ].forEach(({ key, label, color }) => {
3820
+ const pct = sizeDist[key] || 0;
3821
+ const barWidth = maxSizePct > 0 ? (pct / maxSizePct * 100) : 0;
3822
+
3823
+ html += `<div class="distribution-bar-row">
3824
+ <div class="distribution-bar-label">
3825
+ <span class="size-label">${label}</span>
3826
+ </div>
3827
+ <div class="distribution-bar-container">
3828
+ <div class="distribution-bar-fill" style="width: ${barWidth}%; background: ${color}"></div>
3829
+ </div>
3830
+ <div class="distribution-bar-value">${pct.toFixed(1)}%</div>
3831
+ </div>`;
3832
+ });
3833
+
3834
+ html += '</div></div>';
3835
+
3836
+ // Historical Trends Section
3837
+ html += '<div class="trends-section">';
3838
+ html += '<h3 class="section-title">📊 Historical Trends</h3>';
3839
+
3840
+ // Check if trend data is available
3841
+ if (window.graphTrendData && window.graphTrendData.entries && window.graphTrendData.entries.length > 0) {
3842
+ const trendEntries = window.graphTrendData.entries;
3843
+
3844
+ // Render trend charts
3845
+ html += '<div class="trends-container">';
3846
+ html += '<div id="health-score-chart" class="trend-chart"></div>';
3847
+ html += '<div id="complexity-chart" class="trend-chart"></div>';
3848
+ html += '<div id="files-chunks-chart" class="trend-chart"></div>';
3849
+ html += '</div>';
3850
+
3851
+ html += `<div class="trend-info">Showing ${trendEntries.length} data points from ${trendEntries[0].date} to ${trendEntries[trendEntries.length - 1].date}</div>`;
3852
+ } else {
3853
+ // No trend data yet - show placeholder
3854
+ html += '<div class="future-placeholder">';
3855
+ html += '<div class="future-icon">📊</div>';
3856
+ html += '<div class="future-title">No Historical Data Yet</div>';
3857
+ html += '<div class="future-description">';
3858
+ html += 'Trend data will be collected automatically after each indexing operation. ';
3859
+ html += 'Run <code>mcp-vector-search index</code> to generate the first snapshot.';
3860
+ html += '</div>';
3861
+ html += '</div>';
3862
+ }
3863
+
3864
+ html += '</div>'; // trends-section
3865
+
3866
+ html += '</div>'; // trends-report
3867
+
3868
+ viewerContent.innerHTML = html;
3869
+
3870
+ // Hide section dropdown for reports (no code sections)
3871
+ const sectionNav = document.getElementById('section-nav');
3872
+ if (sectionNav) {
3873
+ sectionNav.style.display = 'none';
3874
+ }
3875
+
3876
+ // Render D3 charts if trend data is available
3877
+ if (window.graphTrendData && window.graphTrendData.entries && window.graphTrendData.entries.length > 0) {
3878
+ renderTrendCharts(window.graphTrendData.entries);
3879
+ }
3880
+ }
3881
+
3882
+ // ============================================================================
3883
+ // REMEDIATION REPORT GENERATION
3884
+ // ============================================================================
3885
+
3886
+ function generateRemediationReport() {
3887
+ // Gather complexity data (metrics are stored directly on nodes, not in a metrics object)
3888
+ const complexityData = [];
3889
+ allNodes.forEach(node => {
3890
+ if (node.complexity !== undefined && node.complexity !== null) {
3891
+ const complexity = node.complexity;
3892
+ let grade = 'A';
3893
+ if (complexity > 40) grade = 'F';
3894
+ else if (complexity > 30) grade = 'D';
3895
+ else if (complexity > 20) grade = 'C';
3896
+ else if (complexity > 10) grade = 'B';
3897
+
3898
+ // Calculate lines from start_line and end_line
3899
+ const lines = (node.end_line && node.start_line) ? (node.end_line - node.start_line + 1) : 0;
3900
+
3901
+ if (grade !== 'A' && grade !== 'B') { // Only include C, D, F
3902
+ complexityData.push({
3903
+ name: node.name || node.id,
3904
+ file: node.file_path || 'Unknown',
3905
+ type: node.type || 'unknown',
3906
+ complexity: complexity,
3907
+ grade: grade,
3908
+ lines: lines
3909
+ });
3910
+ }
3911
+ }
3912
+ });
3913
+
3914
+ // Gather code smell data
3915
+ const smells = [];
3916
+ allNodes.forEach(node => {
3917
+ const file = node.file_path || 'Unknown';
3918
+ const name = node.name || node.id;
3919
+ const lines = (node.end_line && node.start_line) ? (node.end_line - node.start_line + 1) : 0;
3920
+ const complexity = node.complexity || 0;
3921
+ const depth = node.depth || 0;
3922
+
3923
+ // Long Method
3924
+ if (lines > 50) {
3925
+ smells.push({
3926
+ file, name,
3927
+ smell: 'Long Method',
3928
+ severity: lines > 100 ? 'error' : 'warning',
3929
+ detail: `${lines} lines (recommended: <50)`
3930
+ });
3931
+ }
3932
+
3933
+ // High Complexity
3934
+ if (complexity > 15) {
3935
+ smells.push({
3936
+ file, name,
3937
+ smell: 'High Complexity',
3938
+ severity: complexity > 25 ? 'error' : 'warning',
3939
+ detail: `Complexity: ${complexity} (recommended: <15)`
3940
+ });
3941
+ }
3942
+
3943
+ // Deep Nesting (using depth field)
3944
+ if (depth > 4) {
3945
+ smells.push({
3946
+ file, name,
3947
+ smell: 'Deep Nesting',
3948
+ severity: depth > 6 ? 'error' : 'warning',
3949
+ detail: `Depth: ${depth} (recommended: <4)`
3950
+ });
3951
+ }
3952
+
3953
+ // God Class (for classes only)
3954
+ if (node.type === 'class' && lines > 300) {
3955
+ smells.push({
3956
+ file, name,
3957
+ smell: 'God Class',
3958
+ severity: 'error',
3959
+ detail: `${lines} lines - consider breaking into smaller classes`
3960
+ });
3961
+ }
3962
+ });
3963
+
3964
+ // Sort by severity (errors first) then by file
3965
+ complexityData.sort((a, b) => {
3966
+ const gradeOrder = { F: 0, D: 1, C: 2 };
3967
+ return (gradeOrder[a.grade] || 99) - (gradeOrder[b.grade] || 99);
3968
+ });
3969
+
3970
+ smells.sort((a, b) => {
3971
+ if (a.severity !== b.severity) {
3972
+ return a.severity === 'error' ? -1 : 1;
3973
+ }
3974
+ return a.file.localeCompare(b.file);
3975
+ });
3976
+
3977
+ // Generate Markdown
3978
+ const date = new Date().toISOString().split('T')[0];
3979
+ let markdown = `# Code Remediation Report
3980
+ Generated: ${date}
3981
+
3982
+ ## Summary
3983
+
3984
+ - **High Complexity Items**: ${complexityData.length}
3985
+ - **Code Smells Detected**: ${smells.length}
3986
+ - **Critical Issues (Errors)**: ${smells.filter(s => s.severity === 'error').length}
3987
+
3988
+ ---
3989
+
3990
+ ## 🔴 Priority: High Complexity Code
3991
+
3992
+ These functions/methods have complexity scores that make them difficult to maintain and test.
3993
+
3994
+ | Grade | Name | File | Complexity | Lines |
3995
+ |-------|------|------|------------|-------|
3996
+ `;
3997
+
3998
+ complexityData.forEach(item => {
3999
+ const gradeEmoji = item.grade === 'F' ? '🔴' : item.grade === 'D' ? '🟠' : '🟡';
4000
+ markdown += `| ${gradeEmoji} ${item.grade} | \\`${item.name}\\` | ${item.file} | ${item.complexity} | ${item.lines} |\\n`;
4001
+ });
4002
+
4003
+ markdown += `
4004
+ ---
4005
+
4006
+ ## 🔍 Code Smells
4007
+
4008
+ ### Critical Issues (Errors)
4009
+
4010
+ `;
4011
+
4012
+ const errors = smells.filter(s => s.severity === 'error');
4013
+ if (errors.length === 0) {
4014
+ markdown += '_No critical issues found._\\n';
4015
+ } else {
4016
+ markdown += '| Smell | Name | File | Detail |\\n|-------|------|------|--------|\\n';
4017
+ errors.forEach(s => {
4018
+ markdown += `| 🔴 ${s.smell} | \\`${s.name}\\` | ${s.file} | ${s.detail} |\\n`;
4019
+ });
4020
+ }
4021
+
4022
+ markdown += `
4023
+ ### Warnings
4024
+
4025
+ `;
4026
+
4027
+ const warnings = smells.filter(s => s.severity === 'warning');
4028
+ if (warnings.length === 0) {
4029
+ markdown += '_No warnings found._\\n';
4030
+ } else {
4031
+ markdown += '| Smell | Name | File | Detail |\\n|-------|------|------|--------|\\n';
4032
+ warnings.forEach(s => {
4033
+ markdown += `| 🟡 ${s.smell} | \\`${s.name}\\` | ${s.file} | ${s.detail} |\\n`;
4034
+ });
4035
+ }
4036
+
4037
+ markdown += `
4038
+ ---
4039
+
4040
+ ## Recommended Actions
4041
+
4042
+ 1. **Start with Grade F items** - These have the highest complexity and are hardest to maintain
4043
+ 2. **Address Critical code smells** - God Classes and deeply nested code should be refactored
4044
+ 3. **Break down long methods** - Extract helper functions to reduce complexity
4045
+ 4. **Add tests before refactoring** - Ensure behavior is preserved
4046
+
4047
+ ---
4048
+
4049
+ _Generated by MCP Vector Search Visualization_
4050
+ `;
4051
+
4052
+ // Save the file with dialog (or fallback to download)
4053
+ const blob = new Blob([markdown], { type: 'text/markdown' });
4054
+ const defaultFilename = `remediation-report-${date}.md`;
4055
+
4056
+ async function saveWithDialog() {
4057
+ try {
4058
+ // Use File System Access API if available (shows save dialog)
4059
+ if ('showSaveFilePicker' in window) {
4060
+ const handle = await window.showSaveFilePicker({
4061
+ suggestedName: defaultFilename,
4062
+ types: [{
4063
+ description: 'Markdown files',
4064
+ accept: { 'text/markdown': ['.md'] }
4065
+ }]
4066
+ });
4067
+ const writable = await handle.createWritable();
4068
+ await writable.write(blob);
4069
+ await writable.close();
4070
+ return handle.name;
4071
+ }
4072
+ } catch (err) {
4073
+ if (err.name === 'AbortError') {
4074
+ return null; // User cancelled
4075
+ }
4076
+ console.warn('Save dialog failed, falling back to download:', err);
4077
+ }
4078
+
4079
+ // Fallback: standard download
4080
+ const url = URL.createObjectURL(blob);
4081
+ const a = document.createElement('a');
4082
+ a.href = url;
4083
+ a.download = defaultFilename;
4084
+ document.body.appendChild(a);
4085
+ a.click();
4086
+ document.body.removeChild(a);
4087
+ URL.revokeObjectURL(url);
4088
+ return defaultFilename;
4089
+ }
4090
+
4091
+ saveWithDialog().then(savedFilename => {
4092
+ if (savedFilename === null) {
4093
+ // User cancelled - don't show confirmation
4094
+ return;
4095
+ }
4096
+
4097
+ // Show confirmation
4098
+ openViewerPanel();
4099
+ document.getElementById('viewer-title').textContent = '📋 Report Saved';
4100
+ document.getElementById('viewer-content').innerHTML = `
4101
+ <div class="report-section">
4102
+ <h3>✅ Remediation Report Generated</h3>
4103
+ <p>The report has been saved as <code>${escapeHtml(savedFilename)}</code></p>
4104
+ <div class="metrics-grid">
4105
+ <div class="metric-card">
4106
+ <div class="metric-value">${complexityData.length}</div>
4107
+ <div class="metric-label">Complexity Issues</div>
4108
+ </div>
4109
+ <div class="metric-card">
4110
+ <div class="metric-value">${smells.length}</div>
4111
+ <div class="metric-label">Code Smells</div>
4112
+ </div>
4113
+ <div class="metric-card">
4114
+ <div class="metric-value">${errors.length}</div>
4115
+ <div class="metric-label">Critical Errors</div>
4116
+ </div>
4117
+ </div>
4118
+ <p style="margin-top: 15px; color: var(--text-secondary);">Share this report with your team for prioritized remediation.</p>
4119
+ </div>
4120
+ `;
4121
+
4122
+ // Hide section dropdown for reports (no code sections)
4123
+ const sectionNav = document.getElementById('section-nav');
4124
+ if (sectionNav) {
4125
+ sectionNav.style.display = 'none';
4126
+ }
4127
+ });
4128
+ }
4129
+
4130
+ // Render trend line charts using D3
4131
+ function renderTrendCharts(entries) {
4132
+ // Chart dimensions
4133
+ const margin = {top: 20, right: 30, bottom: 40, left: 50};
4134
+ const width = 600 - margin.left - margin.right;
4135
+ const height = 250 - margin.top - margin.bottom;
4136
+
4137
+ // Parse dates
4138
+ const parseDate = d3.timeParse('%Y-%m-%d');
4139
+ entries.forEach(d => {
4140
+ d.parsedDate = parseDate(d.date);
4141
+ });
4142
+
4143
+ // 1. Health Score Chart
4144
+ renderLineChart('#health-score-chart', entries, {
4145
+ title: 'Health Score Over Time',
4146
+ width, height, margin,
4147
+ yAccessor: d => d.metrics.health_score || 0,
4148
+ yLabel: 'Health Score',
4149
+ color: '#238636',
4150
+ yDomain: [0, 100]
4151
+ });
4152
+
4153
+ // 2. Average Complexity Chart
4154
+ renderLineChart('#complexity-chart', entries, {
4155
+ title: 'Average Complexity Over Time',
4156
+ width, height, margin,
4157
+ yAccessor: d => d.metrics.avg_complexity || 0,
4158
+ yLabel: 'Avg Complexity',
4159
+ color: '#d29922',
4160
+ yDomain: [0, d3.max(entries, d => d.metrics.avg_complexity || 0) * 1.1]
4161
+ });
4162
+
4163
+ // 3. Files and Chunks Chart (dual line)
4164
+ renderDualLineChart('#files-chunks-chart', entries, {
4165
+ title: 'Files and Chunks Over Time',
4166
+ width, height, margin,
4167
+ y1Accessor: d => d.metrics.total_files || 0,
4168
+ y2Accessor: d => d.metrics.total_chunks || 0,
4169
+ y1Label: 'Files',
4170
+ y2Label: 'Chunks',
4171
+ color1: '#1f6feb',
4172
+ color2: '#8957e5'
4173
+ });
4174
+ }
4175
+
4176
+ // Render single line chart
4177
+ function renderLineChart(selector, data, config) {
4178
+ const svg = d3.select(selector)
4179
+ .append('svg')
4180
+ .attr('width', config.width + config.margin.left + config.margin.right)
4181
+ .attr('height', config.height + config.margin.top + config.margin.bottom)
4182
+ .append('g')
4183
+ .attr('transform', `translate(${config.margin.left},${config.margin.top})`);
4184
+
4185
+ // Add title
4186
+ svg.append('text')
4187
+ .attr('x', config.width / 2)
4188
+ .attr('y', -5)
4189
+ .attr('text-anchor', 'middle')
4190
+ .style('font-size', '14px')
4191
+ .style('font-weight', 'bold')
4192
+ .text(config.title);
4193
+
4194
+ // Create scales
4195
+ const xScale = d3.scaleTime()
4196
+ .domain(d3.extent(data, d => d.parsedDate))
4197
+ .range([0, config.width]);
4198
+
4199
+ const yScale = d3.scaleLinear()
4200
+ .domain(config.yDomain || [0, d3.max(data, config.yAccessor)])
4201
+ .range([config.height, 0]);
4202
+
4203
+ // Create line generator
4204
+ const line = d3.line()
4205
+ .x(d => xScale(d.parsedDate))
4206
+ .y(d => yScale(config.yAccessor(d)))
4207
+ .curve(d3.curveMonotoneX);
4208
+
4209
+ // Add X axis
4210
+ svg.append('g')
4211
+ .attr('transform', `translate(0,${config.height})`)
4212
+ .call(d3.axisBottom(xScale).ticks(5).tickFormat(d3.timeFormat('%b %d')))
4213
+ .selectAll('text')
4214
+ .style('font-size', '11px');
4215
+
4216
+ // Add Y axis
4217
+ svg.append('g')
4218
+ .call(d3.axisLeft(yScale).ticks(5))
4219
+ .selectAll('text')
4220
+ .style('font-size', '11px');
4221
+
4222
+ // Add Y axis label
4223
+ svg.append('text')
4224
+ .attr('transform', 'rotate(-90)')
4225
+ .attr('y', 0 - config.margin.left + 10)
4226
+ .attr('x', 0 - (config.height / 2))
4227
+ .attr('dy', '1em')
4228
+ .style('text-anchor', 'middle')
4229
+ .style('font-size', '12px')
4230
+ .text(config.yLabel);
4231
+
4232
+ // Add line path
4233
+ svg.append('path')
4234
+ .datum(data)
4235
+ .attr('fill', 'none')
4236
+ .attr('stroke', config.color)
4237
+ .attr('stroke-width', 2)
4238
+ .attr('d', line);
4239
+
4240
+ // Add dots
4241
+ svg.selectAll('.dot')
4242
+ .data(data)
4243
+ .enter().append('circle')
4244
+ .attr('class', 'dot')
4245
+ .attr('cx', d => xScale(d.parsedDate))
4246
+ .attr('cy', d => yScale(config.yAccessor(d)))
4247
+ .attr('r', 4)
4248
+ .attr('fill', config.color)
4249
+ .style('cursor', 'pointer')
4250
+ .append('title')
4251
+ .text(d => `${d.date}: ${config.yAccessor(d).toFixed(1)}`);
4252
+ }
4253
+
4254
+ // Render dual line chart (two Y axes)
4255
+ function renderDualLineChart(selector, data, config) {
4256
+ const svg = d3.select(selector)
4257
+ .append('svg')
4258
+ .attr('width', config.width + config.margin.left + config.margin.right)
4259
+ .attr('height', config.height + config.margin.top + config.margin.bottom)
4260
+ .append('g')
4261
+ .attr('transform', `translate(${config.margin.left},${config.margin.top})`);
4262
+
4263
+ // Add title
4264
+ svg.append('text')
4265
+ .attr('x', config.width / 2)
4266
+ .attr('y', -5)
4267
+ .attr('text-anchor', 'middle')
4268
+ .style('font-size', '14px')
4269
+ .style('font-weight', 'bold')
4270
+ .text(config.title);
4271
+
4272
+ // Create scales
4273
+ const xScale = d3.scaleTime()
4274
+ .domain(d3.extent(data, d => d.parsedDate))
4275
+ .range([0, config.width]);
4276
+
4277
+ const y1Scale = d3.scaleLinear()
4278
+ .domain([0, d3.max(data, config.y1Accessor) * 1.1])
4279
+ .range([config.height, 0]);
4280
+
4281
+ const y2Scale = d3.scaleLinear()
4282
+ .domain([0, d3.max(data, config.y2Accessor) * 1.1])
4283
+ .range([config.height, 0]);
4284
+
4285
+ // Create line generators
4286
+ const line1 = d3.line()
4287
+ .x(d => xScale(d.parsedDate))
4288
+ .y(d => y1Scale(config.y1Accessor(d)))
4289
+ .curve(d3.curveMonotoneX);
4290
+
4291
+ const line2 = d3.line()
4292
+ .x(d => xScale(d.parsedDate))
4293
+ .y(d => y2Scale(config.y2Accessor(d)))
4294
+ .curve(d3.curveMonotoneX);
4295
+
4296
+ // Add X axis
4297
+ svg.append('g')
4298
+ .attr('transform', `translate(0,${config.height})`)
4299
+ .call(d3.axisBottom(xScale).ticks(5).tickFormat(d3.timeFormat('%b %d')))
4300
+ .selectAll('text')
4301
+ .style('font-size', '11px');
4302
+
4303
+ // Add Y1 axis (left)
4304
+ svg.append('g')
4305
+ .call(d3.axisLeft(y1Scale).ticks(5))
4306
+ .selectAll('text')
4307
+ .style('font-size', '11px')
4308
+ .style('fill', config.color1);
4309
+
4310
+ // Add Y2 axis (right)
4311
+ svg.append('g')
4312
+ .attr('transform', `translate(${config.width},0)`)
4313
+ .call(d3.axisRight(y2Scale).ticks(5))
4314
+ .selectAll('text')
4315
+ .style('font-size', '11px')
4316
+ .style('fill', config.color2);
4317
+
4318
+ // Add line 1
4319
+ svg.append('path')
4320
+ .datum(data)
4321
+ .attr('fill', 'none')
4322
+ .attr('stroke', config.color1)
4323
+ .attr('stroke-width', 2)
4324
+ .attr('d', line1);
4325
+
4326
+ // Add line 2
4327
+ svg.append('path')
4328
+ .datum(data)
4329
+ .attr('fill', 'none')
4330
+ .attr('stroke', config.color2)
4331
+ .attr('stroke-width', 2)
4332
+ .attr('stroke-dasharray', '5,5')
4333
+ .attr('d', line2);
4334
+
4335
+ // Add legend
4336
+ const legend = svg.append('g')
4337
+ .attr('transform', `translate(${config.width - 120}, 10)`);
4338
+
4339
+ legend.append('line')
4340
+ .attr('x1', 0).attr('x2', 20)
4341
+ .attr('y1', 5).attr('y2', 5)
4342
+ .attr('stroke', config.color1)
4343
+ .attr('stroke-width', 2);
4344
+ legend.append('text')
4345
+ .attr('x', 25).attr('y', 9)
4346
+ .style('font-size', '11px')
4347
+ .text(config.y1Label);
4348
+
4349
+ legend.append('line')
4350
+ .attr('x1', 0).attr('x2', 20)
4351
+ .attr('y1', 20).attr('y2', 20)
4352
+ .attr('stroke', config.color2)
4353
+ .attr('stroke-width', 2)
4354
+ .attr('stroke-dasharray', '5,5');
4355
+ legend.append('text')
4356
+ .attr('x', 25).attr('y', 24)
4357
+ .style('font-size', '11px')
4358
+ .text(config.y2Label);
4359
+ }
4360
+
4361
+ function calculateCodebaseMetrics() {
4362
+ const metrics = {
4363
+ totalLines: 0,
4364
+ totalFunctions: 0,
4365
+ totalClasses: 0,
4366
+ totalFiles: 0,
4367
+ complexityDistribution: { A: 0, B: 0, C: 0, D: 0, F: 0 },
4368
+ sizeDistribution: { small: 0, medium: 0, large: 0, veryLarge: 0 },
4369
+ healthScore: 0
4370
+ };
4371
+
4372
+ const chunksWithComplexity = [];
4373
+
4374
+ function analyzeNode(node) {
4375
+ // Count files
4376
+ if (node.type === 'file') {
4377
+ metrics.totalFiles++;
4378
+ }
4379
+
4380
+ // Count classes
4381
+ if (node.type === 'class') {
4382
+ metrics.totalClasses++;
4383
+ }
4384
+
4385
+ // Count functions and methods
4386
+ if (node.type === 'function' || node.type === 'method') {
4387
+ metrics.totalFunctions++;
4388
+
4389
+ // Calculate lines
4390
+ const lineCount = (node.end_line && node.start_line)
4391
+ ? node.end_line - node.start_line + 1
4392
+ : 0;
4393
+ metrics.totalLines += lineCount;
4394
+
4395
+ // Size distribution
4396
+ if (lineCount <= 20) {
4397
+ metrics.sizeDistribution.small++;
4398
+ } else if (lineCount <= 50) {
4399
+ metrics.sizeDistribution.medium++;
4400
+ } else if (lineCount <= 100) {
4401
+ metrics.sizeDistribution.large++;
4402
+ } else {
4403
+ metrics.sizeDistribution.veryLarge++;
4404
+ }
4405
+
4406
+ // Complexity distribution
4407
+ if (node.complexity !== undefined && node.complexity !== null) {
4408
+ const grade = getComplexityGrade(node.complexity);
4409
+ if (grade in metrics.complexityDistribution) {
4410
+ metrics.complexityDistribution[grade]++;
4411
+ }
4412
+ chunksWithComplexity.push(node);
4413
+ }
4414
+ }
4415
+
4416
+ // Recursively process children
4417
+ const children = node.children || node._children || [];
4418
+ children.forEach(child => analyzeNode(child));
4419
+ }
4420
+
4421
+ if (treeData) {
4422
+ analyzeNode(treeData);
4423
+ }
4424
+
4425
+ // Convert complexity counts to percentages
4426
+ const totalWithComplexity = chunksWithComplexity.length;
4427
+ if (totalWithComplexity > 0) {
4428
+ Object.keys(metrics.complexityDistribution).forEach(grade => {
4429
+ metrics.complexityDistribution[grade] =
4430
+ (metrics.complexityDistribution[grade] / totalWithComplexity) * 100;
4431
+ });
4432
+ }
4433
+
4434
+ // Convert size counts to percentages
4435
+ const totalFuncs = metrics.totalFunctions;
4436
+ if (totalFuncs > 0) {
4437
+ Object.keys(metrics.sizeDistribution).forEach(size => {
4438
+ metrics.sizeDistribution[size] =
4439
+ (metrics.sizeDistribution[size] / totalFuncs) * 100;
4440
+ });
4441
+ }
4442
+
4443
+ // Calculate health score
4444
+ metrics.healthScore = calculateHealthScore(chunksWithComplexity);
4445
+
4446
+ return metrics;
4447
+ }
4448
+
4449
+ function calculateHealthScore(chunks) {
4450
+ if (chunks.length === 0) return 100;
4451
+
4452
+ let score = 0;
4453
+ chunks.forEach(chunk => {
4454
+ const grade = getComplexityGrade(chunk.complexity);
4455
+ const gradeScores = { A: 100, B: 80, C: 60, D: 40, F: 20 };
4456
+ score += gradeScores[grade] || 50;
4457
+ });
4458
+
4459
+ return Math.round(score / chunks.length);
4460
+ }
4461
+
4462
+ function getHealthScoreInfo(score) {
4463
+ if (score >= 80) {
4464
+ return {
4465
+ emoji: '🟢',
4466
+ label: 'Excellent',
4467
+ color: '#238636',
4468
+ description: 'Your codebase has excellent complexity distribution with most code in the A-B range.'
4469
+ };
4470
+ } else if (score >= 60) {
4471
+ return {
4472
+ emoji: '🟡',
4473
+ label: 'Good',
4474
+ color: '#d29922',
4475
+ description: 'Your codebase is in good shape, but could benefit from refactoring some complex functions.'
4476
+ };
4477
+ } else if (score >= 40) {
4478
+ return {
4479
+ emoji: '🟠',
4480
+ label: 'Needs Attention',
4481
+ color: '#f0883e',
4482
+ description: 'Your codebase has significant complexity issues that should be addressed soon.'
4483
+ };
4484
+ } else {
4485
+ return {
4486
+ emoji: '🔴',
4487
+ label: 'Critical',
4488
+ color: '#da3633',
4489
+ description: 'Your codebase has critical complexity issues requiring immediate refactoring.'
4490
+ };
4491
+ }
4492
+ }
4493
+
4494
+ function getComplexityRange(grade) {
4495
+ const ranges = {
4496
+ 'A': '1-5',
4497
+ 'B': '6-10',
4498
+ 'C': '11-15',
4499
+ 'D': '16-20',
4500
+ 'F': '21+'
4501
+ };
4502
+ return ranges[grade] || '';
4503
+ }
4504
+
4505
+ function showComingSoon(reportName) {
4506
+ alert(`${reportName} - Coming Soon!\n\nThis feature will display detailed ${reportName.toLowerCase()} in a future release.`);
4507
+ }
4508
+
4509
+ // ============================================================================
4510
+ // INITIALIZATION
4511
+ // ============================================================================
4512
+
4513
+ // Load data and initialize UI when page loads
4514
+ document.addEventListener('DOMContentLoaded', () => {
4515
+ console.log('=== PAGE INITIALIZATION ===');
4516
+ console.log('DOMContentLoaded event fired');
4517
+
4518
+ // Load theme preference before anything else
4519
+ loadThemePreference();
4520
+
4521
+ // Initialize toggle label highlighting
4522
+ const labels = document.querySelectorAll('.toggle-label');
4523
+ console.log(`Found ${labels.length} toggle labels`);
4524
+ if (labels[0]) {
4525
+ labels[0].classList.add('active');
4526
+ console.log('Activated first toggle label (linear mode)');
4527
+ }
4528
+
4529
+ // Close search results when clicking outside
4530
+ document.addEventListener('click', (event) => {
4531
+ const searchContainer = document.querySelector('.search-container');
4532
+ if (searchContainer && !searchContainer.contains(event.target)) {
4533
+ closeSearchResults();
4534
+ }
4535
+ });
4536
+
4537
+ // Load graph data
4538
+ console.log('Calling loadGraphData()...');
4539
+ loadGraphData();
4540
+ console.log('=== END PAGE INITIALIZATION ===');
4541
+ });
4542
+ """