mcp-vector-search 0.12.6__py3-none-any.whl → 1.0.3__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 (65) hide show
  1. mcp_vector_search/__init__.py +2 -2
  2. mcp_vector_search/analysis/__init__.py +64 -0
  3. mcp_vector_search/analysis/collectors/__init__.py +39 -0
  4. mcp_vector_search/analysis/collectors/base.py +164 -0
  5. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  6. mcp_vector_search/analysis/metrics.py +341 -0
  7. mcp_vector_search/analysis/reporters/__init__.py +5 -0
  8. mcp_vector_search/analysis/reporters/console.py +222 -0
  9. mcp_vector_search/cli/commands/analyze.py +408 -0
  10. mcp_vector_search/cli/commands/chat.py +1262 -0
  11. mcp_vector_search/cli/commands/index.py +21 -3
  12. mcp_vector_search/cli/commands/init.py +13 -0
  13. mcp_vector_search/cli/commands/install.py +597 -335
  14. mcp_vector_search/cli/commands/install_old.py +8 -4
  15. mcp_vector_search/cli/commands/mcp.py +78 -6
  16. mcp_vector_search/cli/commands/reset.py +68 -26
  17. mcp_vector_search/cli/commands/search.py +30 -7
  18. mcp_vector_search/cli/commands/setup.py +1133 -0
  19. mcp_vector_search/cli/commands/status.py +37 -2
  20. mcp_vector_search/cli/commands/uninstall.py +276 -357
  21. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  22. mcp_vector_search/cli/commands/visualize/cli.py +276 -0
  23. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  24. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  25. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +29 -0
  26. mcp_vector_search/cli/commands/visualize/graph_builder.py +714 -0
  27. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  28. mcp_vector_search/cli/commands/visualize/server.py +311 -0
  29. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  30. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  31. mcp_vector_search/cli/commands/visualize/templates/base.py +180 -0
  32. mcp_vector_search/cli/commands/visualize/templates/scripts.py +2507 -0
  33. mcp_vector_search/cli/commands/visualize/templates/styles.py +1313 -0
  34. mcp_vector_search/cli/commands/visualize.py.original +2536 -0
  35. mcp_vector_search/cli/didyoumean.py +22 -2
  36. mcp_vector_search/cli/main.py +115 -159
  37. mcp_vector_search/cli/output.py +24 -8
  38. mcp_vector_search/config/__init__.py +4 -0
  39. mcp_vector_search/config/default_thresholds.yaml +52 -0
  40. mcp_vector_search/config/settings.py +12 -0
  41. mcp_vector_search/config/thresholds.py +185 -0
  42. mcp_vector_search/core/auto_indexer.py +3 -3
  43. mcp_vector_search/core/boilerplate.py +186 -0
  44. mcp_vector_search/core/config_utils.py +394 -0
  45. mcp_vector_search/core/database.py +369 -94
  46. mcp_vector_search/core/exceptions.py +11 -0
  47. mcp_vector_search/core/git_hooks.py +4 -4
  48. mcp_vector_search/core/indexer.py +221 -4
  49. mcp_vector_search/core/llm_client.py +751 -0
  50. mcp_vector_search/core/models.py +3 -0
  51. mcp_vector_search/core/project.py +17 -0
  52. mcp_vector_search/core/scheduler.py +11 -11
  53. mcp_vector_search/core/search.py +179 -29
  54. mcp_vector_search/mcp/server.py +24 -5
  55. mcp_vector_search/utils/__init__.py +2 -0
  56. mcp_vector_search/utils/gitignore_updater.py +212 -0
  57. mcp_vector_search/utils/monorepo.py +66 -4
  58. mcp_vector_search/utils/timing.py +10 -6
  59. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.0.3.dist-info}/METADATA +182 -52
  60. mcp_vector_search-1.0.3.dist-info/RECORD +97 -0
  61. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.0.3.dist-info}/WHEEL +1 -1
  62. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.0.3.dist-info}/entry_points.txt +1 -0
  63. mcp_vector_search/cli/commands/visualize.py +0 -1467
  64. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  65. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.0.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2507 @@
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
+ // Chunk types for code nodes (function, class, method, text, imports, module)
54
+ const chunkTypes = ['function', 'class', 'method', 'text', 'imports', 'module'];
55
+
56
+ // Size scaling configuration
57
+ const sizeConfig = {
58
+ minRadius: 12, // Minimum node radius (50% larger for readability)
59
+ maxRadius: 24, // Maximum node radius
60
+ chunkMinRadius: 9, // Minimum for chunks (50% larger for readability)
61
+ chunkMaxRadius: 16 // Maximum for chunks
62
+ };
63
+
64
+ // Dynamic dimensions that update when viewer opens/closes
65
+ function getViewportDimensions() {
66
+ const container = document.getElementById('main-container');
67
+ return {
68
+ width: container.clientWidth,
69
+ height: container.clientHeight
70
+ };
71
+ }
72
+
73
+ const margin = {top: 40, right: 120, bottom: 20, left: 120};
74
+
75
+ // ============================================================================
76
+ // DATA LOADING
77
+ // ============================================================================
78
+
79
+ async function loadGraphData() {
80
+ try {
81
+ const response = await fetch('/api/graph');
82
+ const data = await response.json();
83
+ allNodes = data.nodes || [];
84
+ allLinks = data.links || [];
85
+
86
+ console.log(`Loaded ${allNodes.length} nodes and ${allLinks.length} links`);
87
+
88
+ // DEBUG: Log first few nodes to see actual structure
89
+ console.log('=== SAMPLE NODE STRUCTURE ===');
90
+ if (allNodes.length > 0) {
91
+ console.log('First node:', JSON.stringify(allNodes[0], null, 2));
92
+ if (allNodes.length > 1) {
93
+ console.log('Second node:', JSON.stringify(allNodes[1], null, 2));
94
+ }
95
+ }
96
+
97
+ // Count node types
98
+ const typeCounts = {};
99
+ allNodes.forEach(node => {
100
+ const type = node.type || 'undefined';
101
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
102
+ });
103
+ console.log('Node type counts:', typeCounts);
104
+ console.log('=== END SAMPLE NODE STRUCTURE ===');
105
+
106
+ buildTreeStructure();
107
+ renderVisualization();
108
+ } catch (error) {
109
+ console.error('Failed to load graph data:', error);
110
+ document.body.innerHTML =
111
+ '<div style="color: red; padding: 20px; font-family: Arial;">Error loading visualization data. Check console for details.</div>';
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // TREE STRUCTURE BUILDING
117
+ // ============================================================================
118
+
119
+ function buildTreeStructure() {
120
+ // Include directories, files, AND chunks (function, class, method, text, imports, module)
121
+ const treeNodes = allNodes.filter(node => {
122
+ const type = node.type;
123
+ return type === 'directory' || type === 'file' || chunkTypes.includes(type);
124
+ });
125
+
126
+ console.log(`Filtered to ${treeNodes.length} tree nodes (directories, files, and chunks)`);
127
+
128
+ // Count node types for debugging
129
+ const dirCount = treeNodes.filter(n => n.type === 'directory').length;
130
+ const fileCount = treeNodes.filter(n => n.type === 'file').length;
131
+ const chunkCount = treeNodes.filter(n => chunkTypes.includes(n.type)).length;
132
+ console.log(`Node breakdown: ${dirCount} directories, ${fileCount} files, ${chunkCount} chunks`);
133
+
134
+ // Create lookup maps
135
+ const nodeMap = new Map();
136
+ treeNodes.forEach(node => {
137
+ nodeMap.set(node.id, {
138
+ ...node,
139
+ children: []
140
+ });
141
+ });
142
+
143
+ // Build parent-child relationships
144
+ const parentMap = new Map();
145
+
146
+ // DEBUG: Analyze link structure
147
+ console.log('=== LINK STRUCTURE DEBUG ===');
148
+ console.log(`Total links: ${allLinks.length}`);
149
+
150
+ // Get unique link types (handle undefined)
151
+ const linkTypes = [...new Set(allLinks.map(l => l.type || 'undefined'))];
152
+ console.log('Link types found:', linkTypes);
153
+
154
+ // Count links by type
155
+ const linkTypeCounts = {};
156
+ allLinks.forEach(link => {
157
+ const type = link.type || 'undefined';
158
+ linkTypeCounts[type] = (linkTypeCounts[type] || 0) + 1;
159
+ });
160
+ console.log('Link type counts:', linkTypeCounts);
161
+
162
+ // Sample first few links
163
+ console.log('Sample links (first 5):');
164
+ allLinks.slice(0, 5).forEach((link, i) => {
165
+ console.log(` Link ${i}:`, JSON.stringify(link, null, 2));
166
+ });
167
+
168
+ // Check if links have properties we expect
169
+ if (allLinks.length > 0) {
170
+ const firstLink = allLinks[0];
171
+ console.log('Link properties:', Object.keys(firstLink));
172
+ }
173
+ console.log('=== END LINK STRUCTURE DEBUG ===');
174
+
175
+ // Build parent-child relationships from links
176
+ // Process all containment and hierarchy links to establish the tree structure
177
+ console.log('=== BUILDING TREE RELATIONSHIPS ===');
178
+
179
+ let relationshipsProcessed = {
180
+ dir_hierarchy: 0,
181
+ dir_containment: 0,
182
+ file_containment: 0,
183
+ chunk_containment: 0 // undefined links = chunk-to-chunk (class -> method)
184
+ };
185
+
186
+ let relationshipsMatched = {
187
+ dir_hierarchy: 0,
188
+ dir_containment: 0,
189
+ file_containment: 0,
190
+ chunk_containment: 0
191
+ };
192
+
193
+ // Process all relationship links
194
+ allLinks.forEach(link => {
195
+ const linkType = link.type;
196
+
197
+ // Determine relationship category
198
+ let category = null;
199
+ if (linkType === 'dir_hierarchy') {
200
+ category = 'dir_hierarchy';
201
+ } else if (linkType === 'dir_containment') {
202
+ category = 'dir_containment';
203
+ } else if (linkType === 'file_containment') {
204
+ category = 'file_containment';
205
+ } else if (linkType === undefined || linkType === 'undefined') {
206
+ // Undefined links are chunk-to-chunk (e.g., class -> method)
207
+ category = 'chunk_containment';
208
+ } else {
209
+ // Skip semantic, caller, and other non-hierarchical links
210
+ return;
211
+ }
212
+
213
+ relationshipsProcessed[category]++;
214
+
215
+ // Get parent and child nodes from the map
216
+ const parentNode = nodeMap.get(link.source);
217
+ const childNode = nodeMap.get(link.target);
218
+
219
+ // Both nodes must exist in our tree node set
220
+ if (!parentNode || !childNode) {
221
+ if (relationshipsProcessed[category] <= 3) { // Log first few misses
222
+ console.log(`${category} link skipped - parent: ${link.source} (exists: ${!!parentNode}), child: ${link.target} (exists: ${!!childNode})`);
223
+ }
224
+ return;
225
+ }
226
+
227
+ // Establish parent-child relationship
228
+ // Add child to parent's children array
229
+ parentNode.children.push(childNode);
230
+
231
+ // Record the parent in parentMap (used to identify root nodes)
232
+ parentMap.set(link.target, link.source);
233
+
234
+ relationshipsMatched[category]++;
235
+ });
236
+
237
+ console.log('Relationship processing summary:');
238
+ console.log(` dir_hierarchy: ${relationshipsMatched.dir_hierarchy}/${relationshipsProcessed.dir_hierarchy} matched`);
239
+ console.log(` dir_containment: ${relationshipsMatched.dir_containment}/${relationshipsProcessed.dir_containment} matched`);
240
+ console.log(` file_containment: ${relationshipsMatched.file_containment}/${relationshipsProcessed.file_containment} matched`);
241
+ console.log(` chunk_containment: ${relationshipsMatched.chunk_containment}/${relationshipsProcessed.chunk_containment} matched`);
242
+ console.log(` Total parent-child links: ${parentMap.size}`);
243
+ console.log('=== END TREE RELATIONSHIPS ===');
244
+
245
+ // Find root nodes (nodes with no parents)
246
+ // IMPORTANT: Exclude chunk types from roots - they should only appear as children of files
247
+ // Orphaned chunks (without file_containment links) are excluded from the tree
248
+ const rootNodes = treeNodes
249
+ .filter(node => !parentMap.has(node.id))
250
+ .filter(node => !chunkTypes.includes(node.type)) // Exclude orphaned chunks
251
+ .map(node => nodeMap.get(node.id))
252
+ .filter(node => node !== undefined);
253
+
254
+ console.log('=== ROOT NODE ANALYSIS ===');
255
+ console.log(`Found ${rootNodes.length} root nodes (directories and files only)`);
256
+
257
+ // DEBUG: Count root node types
258
+ const rootTypeCounts = {};
259
+ rootNodes.forEach(node => {
260
+ const type = node.type || 'undefined';
261
+ rootTypeCounts[type] = (rootTypeCounts[type] || 0) + 1;
262
+ });
263
+ console.log('Root node type breakdown:', rootTypeCounts);
264
+
265
+ // If we have chunk nodes as roots, something went wrong
266
+ const chunkRoots = rootNodes.filter(n => chunkTypes.includes(n.type)).length;
267
+ if (chunkRoots > 0) {
268
+ console.warn(`WARNING: ${chunkRoots} chunk nodes are roots - they should be children of files!`);
269
+ }
270
+
271
+ // If we have file nodes as roots (except for top-level files), might be missing dir_containment
272
+ const fileRoots = rootNodes.filter(n => n.type === 'file').length;
273
+ if (fileRoots > 0) {
274
+ console.log(`INFO: ${fileRoots} file nodes are roots (this is normal for files not in subdirectories)`);
275
+ }
276
+
277
+ console.log('=== END ROOT NODE ANALYSIS ===');
278
+
279
+ // Create virtual root if multiple roots
280
+ if (rootNodes.length === 0) {
281
+ console.error('No root nodes found!');
282
+ treeData = {name: 'Empty', id: 'root', type: 'directory', children: []};
283
+ } else if (rootNodes.length === 1) {
284
+ treeData = rootNodes[0];
285
+ } else {
286
+ treeData = {
287
+ name: 'Project Root',
288
+ id: 'virtual-root',
289
+ type: 'directory',
290
+ children: rootNodes
291
+ };
292
+ }
293
+
294
+ // Collapse single-child chains to make the tree more compact
295
+ // - Directory with single directory child: src -> mcp_vector_search becomes "src/mcp_vector_search"
296
+ // - File with single chunk child: promote the chunk's children to the file level
297
+ function collapseSingleChildChains(node) {
298
+ if (!node || !node.children) return;
299
+
300
+ // First, recursively process all children
301
+ node.children.forEach(child => collapseSingleChildChains(child));
302
+
303
+ // Case 1: Directory with single directory child - combine names
304
+ if (node.type === 'directory' && node.children.length === 1) {
305
+ const onlyChild = node.children[0];
306
+ if (onlyChild.type === 'directory') {
307
+ // Merge: combine names with "/"
308
+ console.log(`Collapsing dir chain: ${node.name} + ${onlyChild.name}`);
309
+ node.name = `${node.name}/${onlyChild.name}`;
310
+ // Take the child's children as our own
311
+ node.children = onlyChild.children || [];
312
+ node._children = onlyChild._children || null;
313
+ // Preserve the deepest node's id for any link references
314
+ node.collapsed_ids = node.collapsed_ids || [node.id];
315
+ node.collapsed_ids.push(onlyChild.id);
316
+
317
+ // Recursively check again in case there's another single child
318
+ collapseSingleChildChains(node);
319
+ }
320
+ }
321
+
322
+ // Case 2: File with single chunk child - promote chunk's children to file
323
+ // This handles files where there's just one L1 (e.g., imports or a single class)
324
+ if (node.type === 'file' && node.children && node.children.length === 1) {
325
+ const onlyChild = node.children[0];
326
+ if (chunkTypes.includes(onlyChild.type)) {
327
+ // If the chunk has children, promote them to the file level
328
+ const chunkChildren = onlyChild.children || onlyChild._children || [];
329
+ if (chunkChildren.length > 0) {
330
+ console.log(`Promoting ${chunkChildren.length} children from ${onlyChild.type} to file ${node.name}`);
331
+ // Replace the single chunk with its children
332
+ node.children = chunkChildren;
333
+ // Store info about the collapsed chunk
334
+ node.collapsed_chunk = {
335
+ type: onlyChild.type,
336
+ name: onlyChild.name,
337
+ id: onlyChild.id
338
+ };
339
+ } else {
340
+ // Collapse file+chunk into combined name (like directory chains)
341
+ console.log(`Collapsing file+chunk: ${node.name}/${onlyChild.name}`);
342
+ node.name = `${node.name}/${onlyChild.name}`;
343
+ node.children = null; // Remove chunk child - now a leaf node
344
+ node._children = null;
345
+ node.collapsed_ids = node.collapsed_ids || [node.id];
346
+ node.collapsed_ids.push(onlyChild.id);
347
+
348
+ // Store chunk data for display when clicked
349
+ node.collapsed_chunk = {
350
+ type: onlyChild.type,
351
+ name: onlyChild.name,
352
+ id: onlyChild.id,
353
+ content: onlyChild.content,
354
+ start_line: onlyChild.start_line,
355
+ end_line: onlyChild.end_line,
356
+ complexity: onlyChild.complexity
357
+ };
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // Apply single-child chain collapsing to all root children
364
+ console.log('=== COLLAPSING SINGLE-CHILD CHAINS ===');
365
+ if (treeData.children) {
366
+ treeData.children.forEach(child => collapseSingleChildChains(child));
367
+ }
368
+ console.log('=== END COLLAPSING SINGLE-CHILD CHAINS ===');
369
+
370
+ // Collapse all directories and files by default
371
+ function collapseAll(node) {
372
+ if (node.children && node.children.length > 0) {
373
+ // First, recursively process all descendants
374
+ node.children.forEach(child => collapseAll(child));
375
+
376
+ // Then collapse this node (move children to _children)
377
+ node._children = node.children;
378
+ node.children = null;
379
+ }
380
+ }
381
+
382
+ // Collapse all child nodes of the root (but keep root's direct children visible initially)
383
+ // This way, only the root level (first level) is visible, all deeper levels are collapsed
384
+ if (treeData.children) {
385
+ treeData.children.forEach(child => {
386
+ // Collapse all descendants of each root child, but keep the root children themselves visible
387
+ if (child.children && child.children.length > 0) {
388
+ child.children.forEach(grandchild => collapseAll(grandchild));
389
+ // Move children to _children to collapse
390
+ child._children = child.children;
391
+ child.children = null;
392
+ }
393
+ });
394
+ }
395
+
396
+ console.log('Tree structure built with all directories and files collapsed');
397
+
398
+ // Calculate line counts for all nodes (for proportional node rendering)
399
+ allLineCounts = []; // Reset for fresh calculation
400
+ calculateNodeSizes(treeData);
401
+ calculatePercentiles(); // Calculate 20th/80th percentile thresholds
402
+ console.log('Node sizes calculated with percentile-based sizing');
403
+
404
+ // DEBUG: Check a few file nodes to see if they have chunks in _children
405
+ console.log('=== POST-COLLAPSE FILE CHECK ===');
406
+ let filesChecked = 0;
407
+ let filesWithChunks = 0;
408
+
409
+ function checkFilesRecursive(node) {
410
+ if (node.type === 'file') {
411
+ filesChecked++;
412
+ const chunkCount = (node._children || []).length;
413
+ if (chunkCount > 0) {
414
+ filesWithChunks++;
415
+ console.log(`File ${node.name} has ${chunkCount} chunks in _children`);
416
+ }
417
+ }
418
+
419
+ // Check both visible and hidden children
420
+ const childrenToCheck = node.children || node._children || [];
421
+ childrenToCheck.forEach(child => checkFilesRecursive(child));
422
+ }
423
+
424
+ checkFilesRecursive(treeData);
425
+ console.log(`Checked ${filesChecked} files, ${filesWithChunks} have chunks`);
426
+ console.log('=== END POST-COLLAPSE FILE CHECK ===');
427
+ }
428
+
429
+ // ============================================================================
430
+ // NODE SIZE CALCULATION
431
+ // ============================================================================
432
+
433
+ // Global variables for size scaling - now tracking line counts
434
+ let globalMinLines = Infinity;
435
+ let globalMaxLines = 0;
436
+ let allLineCounts = []; // Collect all line counts for percentile calculation
437
+
438
+ function calculateNodeSizes(node) {
439
+ if (!node) return 0;
440
+
441
+ // For chunks: use line count directly
442
+ if (chunkTypes.includes(node.type)) {
443
+ const lineCount = (node.start_line && node.end_line)
444
+ ? node.end_line - node.start_line + 1
445
+ : 1;
446
+ node._lineCount = lineCount;
447
+ allLineCounts.push(lineCount);
448
+
449
+ if (lineCount > 0) {
450
+ globalMinLines = Math.min(globalMinLines, lineCount);
451
+ globalMaxLines = Math.max(globalMaxLines, lineCount);
452
+ }
453
+ return lineCount;
454
+ }
455
+
456
+ // For files and directories: sum of children line counts
457
+ const children = node.children || node._children || [];
458
+ let totalLines = 0;
459
+
460
+ children.forEach(child => {
461
+ totalLines += calculateNodeSizes(child);
462
+ });
463
+
464
+ node._lineCount = totalLines || 1; // Minimum 1 for empty dirs/files
465
+
466
+ // DON'T add files/directories to allLineCounts - they skew percentiles
467
+ // Only chunks should affect percentile calculation since only chunks use percentile sizing
468
+ // (Files and directories use separate minRadius/maxRadius, not chunkMinRadius/chunkMaxRadius)
469
+
470
+ if (node._lineCount > 0) {
471
+ globalMinLines = Math.min(globalMinLines, node._lineCount);
472
+ globalMaxLines = Math.max(globalMaxLines, node._lineCount);
473
+ }
474
+
475
+ return node._lineCount;
476
+ }
477
+
478
+ // Calculate percentile thresholds after all nodes are processed
479
+ let percentile20 = 0;
480
+ let percentile80 = 0;
481
+
482
+ function calculatePercentiles() {
483
+ if (allLineCounts.length === 0) return;
484
+
485
+ const sorted = [...allLineCounts].sort((a, b) => a - b);
486
+ const p20Index = Math.floor(sorted.length * 0.20);
487
+ const p80Index = Math.floor(sorted.length * 0.80);
488
+
489
+ percentile20 = sorted[p20Index] || 1;
490
+ percentile80 = sorted[p80Index] || sorted[sorted.length - 1] || 1;
491
+
492
+ console.log(`Line count percentiles (chunks only): 20th=${percentile20}, 80th=${percentile80}, range=${percentile80 - percentile20}`);
493
+ console.log(`Total chunks: ${allLineCounts.length}, min=${globalMinLines}, max=${globalMaxLines}`);
494
+ }
495
+
496
+ // Count external calls for a node
497
+ function getExternalCallCounts(nodeData) {
498
+ if (!nodeData.id) return { inbound: 0, outbound: 0, inboundNodes: [], outboundNodes: [] };
499
+
500
+ const nodeFilePath = nodeData.file_path;
501
+ const inboundNodes = []; // Array of {id, name, file_path}
502
+ const outboundNodes = []; // Array of {id, name, file_path}
503
+
504
+ // Use a Set to deduplicate by source/target node
505
+ const inboundSeen = new Set();
506
+ const outboundSeen = new Set();
507
+
508
+ allLinks.forEach(link => {
509
+ if (link.type === 'caller') {
510
+ if (link.target === nodeData.id) {
511
+ // Something calls this node
512
+ const callerNode = allNodes.find(n => n.id === link.source);
513
+ if (callerNode && callerNode.file_path !== nodeFilePath && !inboundSeen.has(callerNode.id)) {
514
+ inboundSeen.add(callerNode.id);
515
+ inboundNodes.push({ id: callerNode.id, name: callerNode.name, file_path: callerNode.file_path });
516
+ }
517
+ }
518
+ if (link.source === nodeData.id) {
519
+ // This node calls something
520
+ const calleeNode = allNodes.find(n => n.id === link.target);
521
+ if (calleeNode && calleeNode.file_path !== nodeFilePath && !outboundSeen.has(calleeNode.id)) {
522
+ outboundSeen.add(calleeNode.id);
523
+ outboundNodes.push({ id: calleeNode.id, name: calleeNode.name, file_path: calleeNode.file_path });
524
+ }
525
+ }
526
+ }
527
+ });
528
+
529
+ return {
530
+ inbound: inboundNodes.length,
531
+ outbound: outboundNodes.length,
532
+ inboundNodes,
533
+ outboundNodes
534
+ };
535
+ }
536
+
537
+ // Store external call data for line drawing
538
+ let externalCallData = [];
539
+
540
+ function collectExternalCallData() {
541
+ externalCallData = [];
542
+
543
+ allNodes.forEach(nodeData => {
544
+ if (!chunkTypes.includes(nodeData.type)) return;
545
+
546
+ const counts = getExternalCallCounts(nodeData);
547
+ if (counts.inbound > 0 || counts.outbound > 0) {
548
+ externalCallData.push({
549
+ nodeId: nodeData.id,
550
+ inboundNodes: counts.inboundNodes,
551
+ outboundNodes: counts.outboundNodes
552
+ });
553
+ }
554
+ });
555
+ }
556
+
557
+ function drawExternalCallLines(svg, root) {
558
+ // Remove existing external call lines
559
+ svg.selectAll('.external-call-line').remove();
560
+
561
+ // Build a map of node positions from the tree (visible nodes only)
562
+ const nodePositions = new Map();
563
+ root.descendants().forEach(d => {
564
+ nodePositions.set(d.data.id, { x: d.x, y: d.y, node: d });
565
+ });
566
+
567
+ // Build a map from node ID to tree node (for parent traversal)
568
+ const treeNodeMap = new Map();
569
+ root.descendants().forEach(d => {
570
+ treeNodeMap.set(d.data.id, d);
571
+ });
572
+
573
+ // Helper: Find position for a node, falling back to visible ancestors
574
+ // If the target node is not visible (collapsed), find its closest visible ancestor
575
+ function getPositionWithFallback(nodeId) {
576
+ // First check if node is directly visible
577
+ if (nodePositions.has(nodeId)) {
578
+ return nodePositions.get(nodeId);
579
+ }
580
+
581
+ // Node not visible - try to find via tree traversal
582
+ // Strategy 1: Find by file_path matching in visible nodes
583
+ const targetNode = allNodes.find(n => n.id === nodeId);
584
+ if (!targetNode) {
585
+ return null;
586
+ }
587
+
588
+ // Strategy 2: Look for the file that contains this chunk
589
+ if (targetNode.file_path) {
590
+ // Look for visible file nodes that match this path
591
+ for (const [id, pos] of nodePositions) {
592
+ const visibleNode = allNodes.find(n => n.id === id);
593
+ if (visibleNode) {
594
+ // Check if this is the file containing our chunk
595
+ if (visibleNode.type === 'file' &&
596
+ (visibleNode.path === targetNode.file_path ||
597
+ visibleNode.file_path === targetNode.file_path ||
598
+ visibleNode.name === targetNode.file_path.split('/').pop())) {
599
+ return pos;
600
+ }
601
+ }
602
+ }
603
+
604
+ // Strategy 3: Look for directory containing the file
605
+ const pathParts = targetNode.file_path.split('/');
606
+ // Go from most specific (file's directory) to least specific (root)
607
+ for (let i = pathParts.length - 1; i >= 0; i--) {
608
+ const dirName = pathParts[i];
609
+ if (!dirName) continue;
610
+
611
+ // Find a visible directory with this name
612
+ for (const [id, pos] of nodePositions) {
613
+ const visibleNode = allNodes.find(n => n.id === id);
614
+ if (visibleNode && visibleNode.type === 'directory' && visibleNode.name === dirName) {
615
+ return pos;
616
+ }
617
+ }
618
+ }
619
+ }
620
+
621
+ return null;
622
+ }
623
+
624
+ // Create a group for external call lines (behind nodes)
625
+ let lineGroup = svg.select('.external-lines-group');
626
+ if (lineGroup.empty()) {
627
+ lineGroup = svg.insert('g', ':first-child')
628
+ .attr('class', 'external-lines-group');
629
+ }
630
+
631
+ // Respect the toggle state
632
+ lineGroup.style('display', showCallLines ? 'block' : 'none');
633
+
634
+ console.log(`[CallLines] Drawing lines for ${externalCallData.length} nodes with external calls`);
635
+
636
+ let linesDrawn = 0;
637
+ externalCallData.forEach(data => {
638
+ const sourcePos = getPositionWithFallback(data.nodeId);
639
+ if (!sourcePos) {
640
+ console.log(`[CallLines] No source position for ${data.nodeId}`);
641
+ return;
642
+ }
643
+
644
+ // Draw lines to inbound nodes (callers) - dashed blue (fainter)
645
+ data.inboundNodes.forEach(caller => {
646
+ const targetPos = getPositionWithFallback(caller.id);
647
+ if (targetPos) {
648
+ lineGroup.append('path')
649
+ .attr('class', 'external-call-line inbound-line')
650
+ .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}`)
651
+ .attr('fill', 'none')
652
+ .attr('stroke', '#58a6ff')
653
+ .attr('stroke-width', 1)
654
+ .attr('stroke-dasharray', '4,2')
655
+ .attr('opacity', 0.35)
656
+ .attr('pointer-events', 'none');
657
+ linesDrawn++;
658
+ }
659
+ });
660
+
661
+ // Draw lines to outbound nodes (callees) - dashed orange (fainter)
662
+ data.outboundNodes.forEach(callee => {
663
+ const targetPos = getPositionWithFallback(callee.id);
664
+ if (targetPos) {
665
+ lineGroup.append('path')
666
+ .attr('class', 'external-call-line outbound-line')
667
+ .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}`)
668
+ .attr('fill', 'none')
669
+ .attr('stroke', '#f0883e')
670
+ .attr('stroke-width', 1)
671
+ .attr('stroke-dasharray', '4,2')
672
+ .attr('opacity', 0.35)
673
+ .attr('pointer-events', 'none');
674
+ linesDrawn++;
675
+ }
676
+ });
677
+ });
678
+
679
+ console.log(`[CallLines] Drew ${linesDrawn} call lines`);
680
+ }
681
+
682
+ // Draw external call lines for CIRCULAR layout
683
+ // Converts polar coordinates (angle, radius) to Cartesian (x, y)
684
+ function drawExternalCallLinesCircular(svg, root) {
685
+ // Remove existing external call lines
686
+ svg.selectAll('.external-call-line').remove();
687
+
688
+ // Helper: Convert polar to Cartesian coordinates
689
+ // In D3 radial layout: d.x = angle (radians), d.y = radius
690
+ function polarToCartesian(angle, radius) {
691
+ return {
692
+ x: radius * Math.cos(angle - Math.PI / 2),
693
+ y: radius * Math.sin(angle - Math.PI / 2)
694
+ };
695
+ }
696
+
697
+ // Build a map of node positions from the tree (visible nodes only)
698
+ const nodePositions = new Map();
699
+ root.descendants().forEach(d => {
700
+ const cartesian = polarToCartesian(d.x, d.y);
701
+ nodePositions.set(d.data.id, { x: cartesian.x, y: cartesian.y, angle: d.x, radius: d.y, node: d });
702
+ });
703
+
704
+ // Helper: Find position for a node, falling back to visible ancestors
705
+ function getPositionWithFallback(nodeId) {
706
+ // First check if node is directly visible
707
+ if (nodePositions.has(nodeId)) {
708
+ return nodePositions.get(nodeId);
709
+ }
710
+
711
+ // Node not visible - try to find via tree traversal
712
+ const targetNode = allNodes.find(n => n.id === nodeId);
713
+ if (!targetNode) {
714
+ return null;
715
+ }
716
+
717
+ // Look for the file that contains this chunk
718
+ if (targetNode.file_path) {
719
+ // Look for visible file nodes that match this path
720
+ for (const [id, pos] of nodePositions) {
721
+ const visibleNode = allNodes.find(n => n.id === id);
722
+ if (visibleNode) {
723
+ if (visibleNode.type === 'file' &&
724
+ (visibleNode.path === targetNode.file_path ||
725
+ visibleNode.file_path === targetNode.file_path ||
726
+ visibleNode.name === targetNode.file_path.split('/').pop())) {
727
+ return pos;
728
+ }
729
+ }
730
+ }
731
+
732
+ // Look for directory containing the file
733
+ const pathParts = targetNode.file_path.split('/');
734
+ for (let i = pathParts.length - 1; i >= 0; i--) {
735
+ const dirName = pathParts[i];
736
+ if (!dirName) continue;
737
+
738
+ for (const [id, pos] of nodePositions) {
739
+ const visibleNode = allNodes.find(n => n.id === id);
740
+ if (visibleNode && visibleNode.type === 'directory' && visibleNode.name === dirName) {
741
+ return pos;
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ return null;
748
+ }
749
+
750
+ // Create a group for external call lines (behind nodes)
751
+ let lineGroup = svg.select('.external-lines-group');
752
+ if (lineGroup.empty()) {
753
+ lineGroup = svg.insert('g', ':first-child')
754
+ .attr('class', 'external-lines-group');
755
+ }
756
+
757
+ // Respect the toggle state
758
+ lineGroup.style('display', showCallLines ? 'block' : 'none');
759
+
760
+ console.log(`[CallLines Circular] Drawing lines for ${externalCallData.length} nodes with external calls`);
761
+
762
+ let linesDrawn = 0;
763
+ externalCallData.forEach(data => {
764
+ const sourcePos = getPositionWithFallback(data.nodeId);
765
+ if (!sourcePos) {
766
+ return;
767
+ }
768
+
769
+ // Draw lines to inbound nodes (callers) - dashed blue (fainter)
770
+ data.inboundNodes.forEach(caller => {
771
+ const targetPos = getPositionWithFallback(caller.id);
772
+ if (targetPos) {
773
+ // Use quadratic bezier curves that go through the center for circular layout
774
+ const midX = (sourcePos.x + targetPos.x) / 2 * 0.3;
775
+ const midY = (sourcePos.y + targetPos.y) / 2 * 0.3;
776
+
777
+ lineGroup.append('path')
778
+ .attr('class', 'external-call-line inbound-line')
779
+ .attr('d', `M${targetPos.x},${targetPos.y} Q${midX},${midY} ${sourcePos.x},${sourcePos.y}`)
780
+ .attr('fill', 'none')
781
+ .attr('stroke', '#58a6ff')
782
+ .attr('stroke-width', 1)
783
+ .attr('stroke-dasharray', '4,2')
784
+ .attr('opacity', 0.35)
785
+ .attr('pointer-events', 'none');
786
+ linesDrawn++;
787
+ }
788
+ });
789
+
790
+ // Draw lines to outbound nodes (callees) - dashed orange (fainter)
791
+ data.outboundNodes.forEach(callee => {
792
+ const targetPos = getPositionWithFallback(callee.id);
793
+ if (targetPos) {
794
+ // Use quadratic bezier curves that go through the center for circular layout
795
+ const midX = (sourcePos.x + targetPos.x) / 2 * 0.3;
796
+ const midY = (sourcePos.y + targetPos.y) / 2 * 0.3;
797
+
798
+ lineGroup.append('path')
799
+ .attr('class', 'external-call-line outbound-line')
800
+ .attr('d', `M${sourcePos.x},${sourcePos.y} Q${midX},${midY} ${targetPos.x},${targetPos.y}`)
801
+ .attr('fill', 'none')
802
+ .attr('stroke', '#f0883e')
803
+ .attr('stroke-width', 1)
804
+ .attr('stroke-dasharray', '4,2')
805
+ .attr('opacity', 0.35)
806
+ .attr('pointer-events', 'none');
807
+ linesDrawn++;
808
+ }
809
+ });
810
+ });
811
+
812
+ console.log(`[CallLines Circular] Drew ${linesDrawn} call lines`);
813
+ }
814
+
815
+ // Get color based on complexity (darker = more complex)
816
+ // Uses HSL color model for smooth gradients
817
+ function getComplexityColor(d, baseHue) {
818
+ const nodeData = d.data;
819
+ const complexity = nodeData.complexity;
820
+
821
+ // If no complexity data, return a default based on type
822
+ if (complexity === undefined || complexity === null) {
823
+ // Default colors for non-complex nodes
824
+ if (nodeData.type === 'directory') {
825
+ return nodeData._children ? '#f39c12' : '#3498db'; // Orange/Blue
826
+ } else if (nodeData.type === 'file') {
827
+ return nodeData._children ? '#95a5a6' : '#ecf0f1'; // Gray/White
828
+ } else if (chunkTypes.includes(nodeData.type)) {
829
+ return '#9b59b6'; // Default purple
830
+ }
831
+ return '#95a5a6';
832
+ }
833
+
834
+ // Complexity ranges: 0-5 (low), 5-10 (medium), 10-20 (high), 20+ (very high)
835
+ // Map to lightness: 70% (light) to 30% (dark)
836
+ const maxComplexity = 25; // Cap for scaling
837
+ const normalizedComplexity = Math.min(complexity, maxComplexity) / maxComplexity;
838
+
839
+ // Lightness goes from 65% (low complexity) to 35% (high complexity)
840
+ const lightness = 65 - (normalizedComplexity * 30);
841
+
842
+ // Saturation increases slightly with complexity (60% to 80%)
843
+ const saturation = 60 + (normalizedComplexity * 20);
844
+
845
+ return `hsl(${baseHue}, ${saturation}%, ${lightness}%)`;
846
+ }
847
+
848
+ // Get node fill color with complexity shading
849
+ function getNodeFillColor(d) {
850
+ const nodeData = d.data;
851
+
852
+ if (nodeData.type === 'directory') {
853
+ // Orange (30°) if collapsed, Blue (210°) if expanded
854
+ const hue = nodeData._children ? 30 : 210;
855
+ // Directories aggregate complexity from children
856
+ const avgComplexity = calculateAverageComplexity(nodeData);
857
+ if (avgComplexity > 0) {
858
+ const lightness = 55 - (Math.min(avgComplexity, 15) / 15) * 20;
859
+ return `hsl(${hue}, 70%, ${lightness}%)`;
860
+ }
861
+ return nodeData._children ? '#f39c12' : '#3498db';
862
+ } else if (nodeData.type === 'file') {
863
+ // Gray files, but show complexity if available
864
+ const avgComplexity = calculateAverageComplexity(nodeData);
865
+ if (avgComplexity > 0) {
866
+ // Gray hue (0° with 0 saturation) to slight red tint for complexity
867
+ const saturation = Math.min(avgComplexity, 15) * 2; // 0-30%
868
+ const lightness = 70 - (Math.min(avgComplexity, 15) / 15) * 25;
869
+ return `hsl(0, ${saturation}%, ${lightness}%)`;
870
+ }
871
+ return nodeData._children ? '#95a5a6' : '#ecf0f1';
872
+ } else if (chunkTypes.includes(nodeData.type)) {
873
+ // Purple (280°) for chunks, darker with higher complexity
874
+ return getComplexityColor(d, 280);
875
+ }
876
+
877
+ return '#95a5a6';
878
+ }
879
+
880
+ // Calculate average complexity for a node (recursively for dirs/files)
881
+ function calculateAverageComplexity(node) {
882
+ if (chunkTypes.includes(node.type)) {
883
+ return node.complexity || 0;
884
+ }
885
+
886
+ const children = node.children || node._children || [];
887
+ if (children.length === 0) return 0;
888
+
889
+ let totalComplexity = 0;
890
+ let count = 0;
891
+
892
+ children.forEach(child => {
893
+ if (chunkTypes.includes(child.type) && child.complexity) {
894
+ totalComplexity += child.complexity;
895
+ count++;
896
+ } else {
897
+ const childAvg = calculateAverageComplexity(child);
898
+ if (childAvg > 0) {
899
+ totalComplexity += childAvg;
900
+ count++;
901
+ }
902
+ }
903
+ });
904
+
905
+ return count > 0 ? totalComplexity / count : 0;
906
+ }
907
+
908
+ function getNodeRadius(d) {
909
+ const nodeData = d.data;
910
+ const lineCount = nodeData._lineCount || 1;
911
+
912
+ // Determine min/max based on node type
913
+ let minR, maxR;
914
+ if (chunkTypes.includes(nodeData.type)) {
915
+ minR = sizeConfig.chunkMinRadius;
916
+ maxR = sizeConfig.chunkMaxRadius;
917
+ } else {
918
+ minR = sizeConfig.minRadius;
919
+ maxR = sizeConfig.maxRadius;
920
+ }
921
+
922
+ // Percentile-based relative sizing:
923
+ // - Below 20th percentile → minimum size
924
+ // - Above 80th percentile → maximum size
925
+ // - Between 20th-80th → linear interpolation
926
+
927
+ if (percentile80 <= percentile20) {
928
+ return (minR + maxR) / 2; // Default if no range
929
+ }
930
+
931
+ let normalized;
932
+ if (lineCount <= percentile20) {
933
+ // Below 20th percentile - use minimum size
934
+ normalized = 0;
935
+ } else if (lineCount >= percentile80) {
936
+ // Above 80th percentile - use maximum size
937
+ normalized = 1;
938
+ } else {
939
+ // Linear interpolation between 20th and 80th percentile
940
+ normalized = (lineCount - percentile20) / (percentile80 - percentile20);
941
+ }
942
+
943
+ // Scale to radius range
944
+ return minR + (normalized * (maxR - minR));
945
+ }
946
+
947
+ // ============================================================================
948
+ // VISUALIZATION RENDERING
949
+ // ============================================================================
950
+
951
+ function renderVisualization() {
952
+ console.log('=== RENDER VISUALIZATION ===');
953
+ console.log(`Current layout: ${currentLayout}`);
954
+ console.log(`Tree data exists: ${treeData !== null}`);
955
+ if (treeData) {
956
+ console.log(`Root node: ${treeData.name}, children: ${(treeData.children || []).length}, _children: ${(treeData._children || []).length}`);
957
+ }
958
+
959
+ // Clear existing content
960
+ const graphElement = d3.select('#graph');
961
+ console.log(`Graph element found: ${!graphElement.empty()}`);
962
+ graphElement.selectAll('*').remove();
963
+
964
+ if (currentLayout === 'linear') {
965
+ console.log('Calling renderLinearTree()...');
966
+ renderLinearTree();
967
+ } else {
968
+ console.log('Calling renderCircularTree()...');
969
+ renderCircularTree();
970
+ }
971
+ console.log('=== END RENDER VISUALIZATION ===');
972
+ }
973
+
974
+ // ============================================================================
975
+ // LINEAR TREE LAYOUT
976
+ // ============================================================================
977
+
978
+ function renderLinearTree() {
979
+ console.log('=== RENDER LINEAR TREE ===');
980
+ const { width, height } = getViewportDimensions();
981
+ console.log(`Viewport dimensions: ${width}x${height}`);
982
+
983
+ const svg = d3.select('#graph')
984
+ .attr('width', width)
985
+ .attr('height', height);
986
+
987
+ const g = svg.append('g')
988
+ .attr('transform', `translate(${margin.left},${margin.top})`);
989
+
990
+ // Create tree layout
991
+ // For horizontal tree: size is [height, width] where height controls vertical spread
992
+ const treeLayout = d3.tree()
993
+ .size([height - margin.top - margin.bottom, width - margin.left - margin.right]);
994
+
995
+ console.log(`Tree layout size: ${height - margin.top - margin.bottom} x ${width - margin.left - margin.right}`);
996
+
997
+ // Create hierarchy from tree data
998
+ // D3 hierarchy automatically respects children vs _children
999
+ console.log('Creating D3 hierarchy...');
1000
+ const root = d3.hierarchy(treeData, d => d.children);
1001
+ console.log(`Hierarchy created: ${root.descendants().length} nodes`);
1002
+
1003
+ // Apply tree layout
1004
+ console.log('Applying tree layout...');
1005
+ treeLayout(root);
1006
+ console.log('Tree layout applied');
1007
+
1008
+ // Add zoom behavior
1009
+ const zoom = d3.zoom()
1010
+ .scaleExtent([0.1, 3])
1011
+ .on('zoom', (event) => {
1012
+ g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
1013
+ });
1014
+
1015
+ svg.call(zoom);
1016
+
1017
+ // Draw links
1018
+ const links = root.links();
1019
+ console.log(`Drawing ${links.length} links`);
1020
+ g.selectAll('.link')
1021
+ .data(links)
1022
+ .enter()
1023
+ .append('path')
1024
+ .attr('class', 'link')
1025
+ .attr('d', d3.linkHorizontal()
1026
+ .x(d => d.y)
1027
+ .y(d => d.x))
1028
+ .attr('fill', 'none')
1029
+ .attr('stroke', '#ccc')
1030
+ .attr('stroke-width', 1.5);
1031
+
1032
+ // Draw nodes
1033
+ const descendants = root.descendants();
1034
+ console.log(`Drawing ${descendants.length} nodes`);
1035
+ const nodes = g.selectAll('.node')
1036
+ .data(descendants)
1037
+ .enter()
1038
+ .append('g')
1039
+ .attr('class', 'node')
1040
+ .attr('transform', d => `translate(${d.y},${d.x})`)
1041
+ .on('click', handleNodeClick)
1042
+ .style('cursor', 'pointer');
1043
+
1044
+ console.log(`Created ${nodes.size()} node elements`);
1045
+
1046
+ // Node circles - sized proportionally to content, colored by complexity
1047
+ nodes.append('circle')
1048
+ .attr('r', d => getNodeRadius(d)) // Dynamic size based on content
1049
+ .attr('fill', d => getNodeFillColor(d)) // Complexity-based coloring
1050
+ .attr('stroke', '#fff')
1051
+ .attr('stroke-width', 2);
1052
+
1053
+ // Add external call arrow indicators (only for chunk nodes)
1054
+ nodes.each(function(d) {
1055
+ const node = d3.select(this);
1056
+ const nodeData = d.data;
1057
+
1058
+ // Only add indicators for code chunks (functions, classes, methods)
1059
+ if (!chunkTypes.includes(nodeData.type)) return;
1060
+
1061
+ const counts = getExternalCallCounts(nodeData);
1062
+ const radius = getNodeRadius(d);
1063
+
1064
+ // Inbound arrow: ← before the node (functions from other files call this)
1065
+ if (counts.inbound > 0) {
1066
+ node.append('text')
1067
+ .attr('class', 'call-indicator inbound')
1068
+ .attr('x', -(radius + 8))
1069
+ .attr('y', 5)
1070
+ .attr('text-anchor', 'end')
1071
+ .attr('fill', '#58a6ff')
1072
+ .attr('font-size', '14px')
1073
+ .attr('font-weight', 'bold')
1074
+ .attr('cursor', 'pointer')
1075
+ .text(counts.inbound > 1 ? `${counts.inbound}←` : '←')
1076
+ .append('title')
1077
+ .text(`Called by ${counts.inbound} external function(s):\n${counts.inboundNodes.map(n => n.name).join(', ')}`);
1078
+ }
1079
+
1080
+ // Outbound arrow: → after the label (this calls functions in other files)
1081
+ if (counts.outbound > 0) {
1082
+ // Get approximate label width
1083
+ const labelText = nodeData.name || '';
1084
+ const labelWidth = labelText.length * 7;
1085
+
1086
+ node.append('text')
1087
+ .attr('class', 'call-indicator outbound')
1088
+ .attr('x', radius + labelWidth + 16)
1089
+ .attr('y', 5)
1090
+ .attr('text-anchor', 'start')
1091
+ .attr('fill', '#f0883e')
1092
+ .attr('font-size', '14px')
1093
+ .attr('font-weight', 'bold')
1094
+ .attr('cursor', 'pointer')
1095
+ .text(counts.outbound > 1 ? `→${counts.outbound}` : '→')
1096
+ .append('title')
1097
+ .text(`Calls ${counts.outbound} external function(s):\n${counts.outboundNodes.map(n => n.name).join(', ')}`);
1098
+ }
1099
+ });
1100
+
1101
+ // Collect and draw external call lines
1102
+ collectExternalCallData();
1103
+ drawExternalCallLines(g, root);
1104
+
1105
+ // Node labels - positioned to the right of node, left-aligned
1106
+ // Use transform to position text, as x attribute can have rendering issues
1107
+ const labels = nodes.append('text')
1108
+ .attr('class', 'node-label')
1109
+ .attr('transform', d => `translate(${getNodeRadius(d) + 6}, 0)`)
1110
+ .attr('dominant-baseline', 'middle')
1111
+ .attr('text-anchor', 'start')
1112
+ .text(d => d.data.name)
1113
+ .style('font-size', d => chunkTypes.includes(d.data.type) ? '15px' : '18px')
1114
+ .style('font-family', 'Arial, sans-serif')
1115
+ .style('fill', d => chunkTypes.includes(d.data.type) ? '#bb86fc' : '#adbac7')
1116
+ .style('cursor', 'pointer')
1117
+ .style('pointer-events', 'all')
1118
+ .on('click', handleNodeClick);
1119
+
1120
+ console.log(`Created ${labels.size()} label elements`);
1121
+ console.log('=== END RENDER LINEAR TREE ===');
1122
+ }
1123
+
1124
+ // ============================================================================
1125
+ // CIRCULAR TREE LAYOUT
1126
+ // ============================================================================
1127
+
1128
+ function renderCircularTree() {
1129
+ const { width, height } = getViewportDimensions();
1130
+ const svg = d3.select('#graph')
1131
+ .attr('width', width)
1132
+ .attr('height', height);
1133
+
1134
+ const radius = Math.min(width, height) / 2 - 100;
1135
+
1136
+ const g = svg.append('g')
1137
+ .attr('transform', `translate(${width/2},${height/2})`);
1138
+
1139
+ // Create radial tree layout
1140
+ const treeLayout = d3.tree()
1141
+ .size([2 * Math.PI, radius])
1142
+ .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth);
1143
+
1144
+ // Create hierarchy
1145
+ // D3 hierarchy automatically respects children vs _children
1146
+ const root = d3.hierarchy(treeData, d => d.children);
1147
+
1148
+ // Apply layout
1149
+ treeLayout(root);
1150
+
1151
+ // Add zoom behavior
1152
+ const zoom = d3.zoom()
1153
+ .scaleExtent([0.1, 3])
1154
+ .on('zoom', (event) => {
1155
+ g.attr('transform', `translate(${width/2},${height/2}) ${event.transform}`);
1156
+ });
1157
+
1158
+ svg.call(zoom);
1159
+
1160
+ // Draw links
1161
+ g.selectAll('.link')
1162
+ .data(root.links())
1163
+ .enter()
1164
+ .append('path')
1165
+ .attr('class', 'link')
1166
+ .attr('d', d3.linkRadial()
1167
+ .angle(d => d.x)
1168
+ .radius(d => d.y))
1169
+ .attr('fill', 'none')
1170
+ .attr('stroke', '#ccc')
1171
+ .attr('stroke-width', 1.5);
1172
+
1173
+ // Draw nodes
1174
+ const nodes = g.selectAll('.node')
1175
+ .data(root.descendants())
1176
+ .enter()
1177
+ .append('g')
1178
+ .attr('class', 'node')
1179
+ .attr('transform', d => `
1180
+ rotate(${d.x * 180 / Math.PI - 90})
1181
+ translate(${d.y},0)
1182
+ `)
1183
+ .on('click', handleNodeClick)
1184
+ .style('cursor', 'pointer');
1185
+
1186
+ // Node circles - sized proportionally to content, colored by complexity
1187
+ nodes.append('circle')
1188
+ .attr('r', d => getNodeRadius(d)) // Dynamic size based on content
1189
+ .attr('fill', d => getNodeFillColor(d)) // Complexity-based coloring
1190
+ .attr('stroke', '#fff')
1191
+ .attr('stroke-width', 2);
1192
+
1193
+ // Add external call arrow indicators (only for chunk nodes)
1194
+ nodes.each(function(d) {
1195
+ const node = d3.select(this);
1196
+ const nodeData = d.data;
1197
+
1198
+ if (!chunkTypes.includes(nodeData.type)) return;
1199
+
1200
+ const counts = getExternalCallCounts(nodeData);
1201
+ const radius = getNodeRadius(d);
1202
+
1203
+ // Inbound indicator
1204
+ if (counts.inbound > 0) {
1205
+ node.append('text')
1206
+ .attr('x', 0)
1207
+ .attr('y', -(radius + 8))
1208
+ .attr('text-anchor', 'middle')
1209
+ .attr('fill', '#58a6ff')
1210
+ .attr('font-size', '10px')
1211
+ .attr('font-weight', 'bold')
1212
+ .text(counts.inbound > 1 ? `↓${counts.inbound}` : '↓')
1213
+ .append('title')
1214
+ .text(`Called by ${counts.inbound} external function(s)`);
1215
+ }
1216
+
1217
+ // Outbound indicator
1218
+ if (counts.outbound > 0) {
1219
+ node.append('text')
1220
+ .attr('x', 0)
1221
+ .attr('y', radius + 12)
1222
+ .attr('text-anchor', 'middle')
1223
+ .attr('fill', '#f0883e')
1224
+ .attr('font-size', '10px')
1225
+ .attr('font-weight', 'bold')
1226
+ .text(counts.outbound > 1 ? `↑${counts.outbound}` : '↑')
1227
+ .append('title')
1228
+ .text(`Calls ${counts.outbound} external function(s)`);
1229
+ }
1230
+ });
1231
+
1232
+ // Node labels - positioned to the right of node, left-aligned
1233
+ // Use transform to position text, as x attribute can have rendering issues
1234
+ nodes.append('text')
1235
+ .attr('class', 'node-label')
1236
+ .attr('transform', d => {
1237
+ const offset = getNodeRadius(d) + 6;
1238
+ const rotate = d.x >= Math.PI ? 'rotate(180)' : '';
1239
+ return `translate(${offset}, 0) ${rotate}`;
1240
+ })
1241
+ .attr('dominant-baseline', 'middle')
1242
+ .attr('text-anchor', d => d.x >= Math.PI ? 'end' : 'start')
1243
+ .text(d => d.data.name)
1244
+ .style('font-size', d => chunkTypes.includes(d.data.type) ? '15px' : '18px')
1245
+ .style('font-family', 'Arial, sans-serif')
1246
+ .style('fill', d => chunkTypes.includes(d.data.type) ? '#bb86fc' : '#adbac7')
1247
+ .style('cursor', 'pointer')
1248
+ .style('pointer-events', 'all')
1249
+ .on('click', handleNodeClick);
1250
+
1251
+ // Collect and draw external call lines (circular version)
1252
+ collectExternalCallData();
1253
+ drawExternalCallLinesCircular(g, root);
1254
+ }
1255
+
1256
+ // ============================================================================
1257
+ // INTERACTION HANDLERS
1258
+ // ============================================================================
1259
+
1260
+ function handleNodeClick(event, d) {
1261
+ event.stopPropagation();
1262
+
1263
+ const nodeData = d.data;
1264
+
1265
+ console.log('=== NODE CLICK DEBUG ===');
1266
+ console.log(`Clicked node: ${nodeData.name} (type: ${nodeData.type}, id: ${nodeData.id})`);
1267
+ console.log(`Has children: ${nodeData.children ? nodeData.children.length : 0}`);
1268
+ console.log(`Has _children: ${nodeData._children ? nodeData._children.length : 0}`);
1269
+
1270
+ if (nodeData.type === 'directory') {
1271
+ // Toggle directory: swap children <-> _children
1272
+ if (nodeData.children) {
1273
+ // Currently expanded - collapse it
1274
+ console.log('Collapsing directory');
1275
+ nodeData._children = nodeData.children;
1276
+ nodeData.children = null;
1277
+ } else if (nodeData._children) {
1278
+ // Currently collapsed - expand it
1279
+ console.log('Expanding directory');
1280
+ nodeData.children = nodeData._children;
1281
+ nodeData._children = null;
1282
+ }
1283
+
1284
+ // Re-render to show/hide children
1285
+ renderVisualization();
1286
+
1287
+ // Don't auto-open viewer panel for directories - just expand/collapse
1288
+ } else if (nodeData.type === 'file') {
1289
+ // Check if this file has a collapsed chunk (single chunk with no children)
1290
+ if (nodeData.collapsed_chunk) {
1291
+ console.log(`Collapsed file+chunk: ${nodeData.name}, showing content directly`);
1292
+ displayChunkContent({
1293
+ ...nodeData.collapsed_chunk,
1294
+ name: nodeData.collapsed_chunk.name,
1295
+ type: nodeData.collapsed_chunk.type
1296
+ });
1297
+ return;
1298
+ }
1299
+
1300
+ // Check if this is a single-chunk file - skip to content directly
1301
+ const childrenArray = nodeData._children || nodeData.children;
1302
+
1303
+ if (childrenArray && childrenArray.length === 1) {
1304
+ const onlyChild = childrenArray[0];
1305
+
1306
+ if (chunkTypes.includes(onlyChild.type)) {
1307
+ console.log(`Single-chunk file: ${nodeData.name}, showing content directly`);
1308
+
1309
+ // Expand the file visually (for tree consistency)
1310
+ if (nodeData._children) {
1311
+ nodeData.children = nodeData._children;
1312
+ nodeData._children = null;
1313
+ renderVisualization();
1314
+ }
1315
+
1316
+ // Auto-display the chunk content
1317
+ displayChunkContent(onlyChild);
1318
+ return; // Skip normal file toggle behavior
1319
+ }
1320
+ }
1321
+
1322
+ // Continue with existing toggle logic for multi-chunk files
1323
+ // Toggle file: swap children <-> _children
1324
+ if (nodeData.children) {
1325
+ // Currently expanded - collapse it
1326
+ console.log('Collapsing file');
1327
+ nodeData._children = nodeData.children;
1328
+ nodeData.children = null;
1329
+ } else if (nodeData._children) {
1330
+ // Currently collapsed - expand it
1331
+ console.log('Expanding file');
1332
+ nodeData.children = nodeData._children;
1333
+ nodeData._children = null;
1334
+ } else {
1335
+ console.log('WARNING: File has neither children nor _children!');
1336
+ }
1337
+
1338
+ // Re-render to show/hide children
1339
+ renderVisualization();
1340
+
1341
+ // Don't auto-open viewer panel for files - just expand/collapse
1342
+ } else if (chunkTypes.includes(nodeData.type)) {
1343
+ // Chunks can have children too (e.g., imports -> functions, class -> methods)
1344
+ // If chunk has children, toggle expand/collapse
1345
+ if (nodeData.children || nodeData._children) {
1346
+ if (nodeData.children) {
1347
+ // Currently expanded - collapse it
1348
+ console.log(`Collapsing ${nodeData.type} chunk`);
1349
+ nodeData._children = nodeData.children;
1350
+ nodeData.children = null;
1351
+ } else if (nodeData._children) {
1352
+ // Currently collapsed - expand it
1353
+ console.log(`Expanding ${nodeData.type} chunk to show ${nodeData._children.length} children`);
1354
+ nodeData.children = nodeData._children;
1355
+ nodeData._children = null;
1356
+ }
1357
+ // Re-render to show/hide children
1358
+ renderVisualization();
1359
+ }
1360
+
1361
+ // Also show chunk content in side panel
1362
+ console.log('Displaying chunk content');
1363
+ displayChunkContent(nodeData);
1364
+ }
1365
+
1366
+ console.log('=== END NODE CLICK DEBUG ===');
1367
+ }
1368
+
1369
+ function displayDirectoryInfo(dirData, addToHistory = true) {
1370
+ openViewerPanel();
1371
+
1372
+ // Add to navigation history
1373
+ if (addToHistory) {
1374
+ addToNavHistory({type: 'directory', data: dirData});
1375
+ }
1376
+
1377
+ const title = document.getElementById('viewer-title');
1378
+ const content = document.getElementById('viewer-content');
1379
+
1380
+ title.textContent = `📁 ${dirData.name}`;
1381
+
1382
+ // Count children
1383
+ const children = dirData.children || dirData._children || [];
1384
+ const dirs = children.filter(c => c.type === 'directory').length;
1385
+ const files = children.filter(c => c.type === 'file').length;
1386
+
1387
+ let html = '';
1388
+
1389
+ // Navigation bar with breadcrumbs and back/forward
1390
+ html += renderNavigationBar(dirData);
1391
+
1392
+ html += '<div class="viewer-section">';
1393
+ html += '<div class="viewer-section-title">Directory Information</div>';
1394
+ html += '<div class="viewer-info-grid">';
1395
+ html += `<div class="viewer-info-row">`;
1396
+ html += `<span class="viewer-info-label">Name:</span>`;
1397
+ html += `<span class="viewer-info-value">${escapeHtml(dirData.name)}</span>`;
1398
+ html += `</div>`;
1399
+ html += `<div class="viewer-info-row">`;
1400
+ html += `<span class="viewer-info-label">Subdirectories:</span>`;
1401
+ html += `<span class="viewer-info-value">${dirs}</span>`;
1402
+ html += `</div>`;
1403
+ html += `<div class="viewer-info-row">`;
1404
+ html += `<span class="viewer-info-label">Files:</span>`;
1405
+ html += `<span class="viewer-info-value">${files}</span>`;
1406
+ html += `</div>`;
1407
+ html += `<div class="viewer-info-row">`;
1408
+ html += `<span class="viewer-info-label">Total Items:</span>`;
1409
+ html += `<span class="viewer-info-value">${children.length}</span>`;
1410
+ html += `</div>`;
1411
+ html += '</div>';
1412
+ html += '</div>';
1413
+
1414
+ if (children.length > 0) {
1415
+ html += '<div class="viewer-section">';
1416
+ html += '<div class="viewer-section-title">Contents</div>';
1417
+ html += '<div class="dir-list">';
1418
+
1419
+ // Sort: directories first, then files
1420
+ const sortedChildren = [...children].sort((a, b) => {
1421
+ if (a.type === 'directory' && b.type !== 'directory') return -1;
1422
+ if (a.type !== 'directory' && b.type === 'directory') return 1;
1423
+ return a.name.localeCompare(b.name);
1424
+ });
1425
+
1426
+ sortedChildren.forEach(child => {
1427
+ const icon = child.type === 'directory' ? '📁' : '📄';
1428
+ const type = child.type === 'directory' ? 'dir' : 'file';
1429
+ const childData = JSON.stringify(child).replace(/"/g, '&quot;');
1430
+ const clickHandler = child.type === 'directory'
1431
+ ? `navigateToDirectory(${childData})`
1432
+ : `navigateToFile(${childData})`;
1433
+ html += `<div class="dir-list-item clickable" onclick="${clickHandler}">`;
1434
+ html += `<span class="dir-icon">${icon}</span>`;
1435
+ html += `<span class="dir-name">${escapeHtml(child.name)}</span>`;
1436
+ html += `<span class="dir-type">${type}</span>`;
1437
+ html += `<span class="dir-arrow">→</span>`;
1438
+ html += `</div>`;
1439
+ });
1440
+
1441
+ html += '</div>';
1442
+ html += '</div>';
1443
+ }
1444
+
1445
+ content.innerHTML = html;
1446
+
1447
+ // Hide section dropdown for directories (no code sections)
1448
+ populateSectionDropdown([]);
1449
+ }
1450
+
1451
+ function displayFileInfo(fileData, addToHistory = true) {
1452
+ openViewerPanel();
1453
+
1454
+ // Add to navigation history
1455
+ if (addToHistory) {
1456
+ addToNavHistory({type: 'file', data: fileData});
1457
+ }
1458
+
1459
+ const title = document.getElementById('viewer-title');
1460
+ const content = document.getElementById('viewer-content');
1461
+
1462
+ title.textContent = `📄 ${fileData.name}`;
1463
+
1464
+ // Get chunks
1465
+ const chunks = fileData.children || fileData._children || [];
1466
+
1467
+ let html = '';
1468
+
1469
+ // Navigation bar with breadcrumbs and back/forward
1470
+ html += renderNavigationBar(fileData);
1471
+
1472
+ html += '<div class="viewer-section">';
1473
+ html += '<div class="viewer-section-title">File Information</div>';
1474
+ html += '<div class="viewer-info-grid">';
1475
+ html += `<div class="viewer-info-row">`;
1476
+ html += `<span class="viewer-info-label">Name:</span>`;
1477
+ html += `<span class="viewer-info-value">${escapeHtml(fileData.name)}</span>`;
1478
+ html += `</div>`;
1479
+ html += `<div class="viewer-info-row">`;
1480
+ html += `<span class="viewer-info-label">Chunks:</span>`;
1481
+ html += `<span class="viewer-info-value">${chunks.length}</span>`;
1482
+ html += `</div>`;
1483
+ if (fileData.path) {
1484
+ html += `<div class="viewer-info-row">`;
1485
+ html += `<span class="viewer-info-label">Path:</span>`;
1486
+ html += `<span class="viewer-info-value" style="word-break: break-all;">${escapeHtml(fileData.path)}</span>`;
1487
+ html += `</div>`;
1488
+ }
1489
+ html += '</div>';
1490
+ html += '</div>';
1491
+
1492
+ if (chunks.length > 0) {
1493
+ html += '<div class="viewer-section">';
1494
+ html += '<div class="viewer-section-title">Code Chunks</div>';
1495
+ html += '<div class="chunk-list">';
1496
+
1497
+ chunks.forEach(chunk => {
1498
+ const icon = getChunkIcon(chunk.type);
1499
+ const chunkName = chunk.name || chunk.type || 'chunk';
1500
+ const lines = chunk.start_line && chunk.end_line
1501
+ ? `Lines ${chunk.start_line}-${chunk.end_line}`
1502
+ : 'Unknown lines';
1503
+
1504
+ html += `<div class="chunk-list-item" onclick="displayChunkContent(${JSON.stringify(chunk).replace(/"/g, '&quot;')})">`;
1505
+ html += `<span class="chunk-icon">${icon}</span>`;
1506
+ html += `<div class="chunk-info">`;
1507
+ html += `<div class="chunk-name">${escapeHtml(chunkName)}</div>`;
1508
+ html += `<div class="chunk-meta">${lines} • ${chunk.type || 'code'}</div>`;
1509
+ html += `</div>`;
1510
+ html += `</div>`;
1511
+ });
1512
+
1513
+ html += '</div>';
1514
+ html += '</div>';
1515
+ }
1516
+
1517
+ content.innerHTML = html;
1518
+
1519
+ // Hide section dropdown for files (no code sections in file view)
1520
+ populateSectionDropdown([]);
1521
+ }
1522
+
1523
+ function displayChunkContent(chunkData, addToHistory = true) {
1524
+ openViewerPanel();
1525
+
1526
+ // Add to navigation history
1527
+ if (addToHistory) {
1528
+ addToNavHistory({type: 'chunk', data: chunkData});
1529
+ }
1530
+
1531
+ const title = document.getElementById('viewer-title');
1532
+ const content = document.getElementById('viewer-content');
1533
+
1534
+ const chunkName = chunkData.name || chunkData.type || 'Chunk';
1535
+ title.textContent = `${getChunkIcon(chunkData.type)} ${chunkName}`;
1536
+
1537
+ let html = '';
1538
+
1539
+ // Navigation bar with breadcrumbs and back/forward
1540
+ html += renderNavigationBar(chunkData);
1541
+
1542
+ // Track sections for dropdown navigation
1543
+ const sections = [];
1544
+
1545
+ // === ORDER: Docstring (comments), Code, Metadata ===
1546
+
1547
+ // === 1. Docstring Section (Comments) ===
1548
+ if (chunkData.docstring) {
1549
+ sections.push({ id: 'docstring', label: '📖 Docstring' });
1550
+ html += '<div class="viewer-section" data-section="docstring">';
1551
+ html += '<div class="viewer-section-title">📖 Docstring</div>';
1552
+ html += `<div style="color: #8b949e; font-style: italic; padding: 8px 12px; background: #161b22; border-radius: 4px; white-space: pre-wrap;">${escapeHtml(chunkData.docstring)}</div>`;
1553
+ html += '</div>';
1554
+ }
1555
+
1556
+ // === 2. Source Code Section ===
1557
+ if (chunkData.content) {
1558
+ sections.push({ id: 'source-code', label: '📝 Source Code' });
1559
+ html += '<div class="viewer-section" data-section="source-code">';
1560
+ html += '<div class="viewer-section-title">📝 Source Code</div>';
1561
+ const langClass = getLanguageClass(chunkData.file_path);
1562
+ html += `<pre><code class="hljs${langClass ? ' language-' + langClass : ''}">${escapeHtml(chunkData.content)}</code></pre>`;
1563
+ html += '</div>';
1564
+ } else {
1565
+ html += '<p style="color: #8b949e; padding: 20px; text-align: center;">No content available for this chunk.</p>';
1566
+ }
1567
+
1568
+ // === 3. Metadata Section ===
1569
+ sections.push({ id: 'metadata', label: 'ℹ️ Metadata' });
1570
+ html += '<div class="viewer-section" data-section="metadata">';
1571
+ html += '<div class="viewer-section-title">ℹ️ Metadata</div>';
1572
+ html += '<div class="viewer-info-grid">';
1573
+
1574
+ // Basic info
1575
+ html += `<div class="viewer-info-row">`;
1576
+ html += `<span class="viewer-info-label">Name:</span>`;
1577
+ html += `<span class="viewer-info-value">${escapeHtml(chunkName)}</span>`;
1578
+ html += `</div>`;
1579
+
1580
+ html += `<div class="viewer-info-row">`;
1581
+ html += `<span class="viewer-info-label">Type:</span>`;
1582
+ html += `<span class="viewer-info-value">${escapeHtml(chunkData.type || 'code')}</span>`;
1583
+ html += `</div>`;
1584
+
1585
+ // File path (clickable - navigates to file node)
1586
+ if (chunkData.file_path) {
1587
+ const shortPath = chunkData.file_path.split('/').slice(-3).join('/');
1588
+ const escapedPath = escapeHtml(chunkData.file_path).replace(/'/g, "\\'");
1589
+ html += `<div class="viewer-info-row">`;
1590
+ html += `<span class="viewer-info-label">File:</span>`;
1591
+ html += `<span class="viewer-info-value clickable" onclick="navigateToFileByPath('${escapedPath}')" title="Click to navigate to file: ${escapeHtml(chunkData.file_path)}">.../${escapeHtml(shortPath)}</span>`;
1592
+ html += `</div>`;
1593
+ }
1594
+
1595
+ // Line numbers
1596
+ if (chunkData.start_line && chunkData.end_line) {
1597
+ html += `<div class="viewer-info-row">`;
1598
+ html += `<span class="viewer-info-label">Lines:</span>`;
1599
+ html += `<span class="viewer-info-value">${chunkData.start_line} - ${chunkData.end_line} (${chunkData.end_line - chunkData.start_line + 1} lines)</span>`;
1600
+ html += `</div>`;
1601
+ }
1602
+
1603
+ // Language
1604
+ if (chunkData.language) {
1605
+ html += `<div class="viewer-info-row">`;
1606
+ html += `<span class="viewer-info-label">Language:</span>`;
1607
+ html += `<span class="viewer-info-value">${escapeHtml(chunkData.language)}</span>`;
1608
+ html += `</div>`;
1609
+ }
1610
+
1611
+ // Complexity
1612
+ if (chunkData.complexity !== undefined && chunkData.complexity !== null) {
1613
+ const complexityColor = chunkData.complexity > 10 ? '#f85149' : chunkData.complexity > 5 ? '#d29922' : '#3fb950';
1614
+ html += `<div class="viewer-info-row">`;
1615
+ html += `<span class="viewer-info-label">Complexity:</span>`;
1616
+ html += `<span class="viewer-info-value" style="color: ${complexityColor}">${chunkData.complexity.toFixed(1)}</span>`;
1617
+ html += `</div>`;
1618
+ }
1619
+
1620
+ html += '</div>';
1621
+ html += '</div>';
1622
+
1623
+ // === 4. External Calls & Callers Section (Cross-file references) ===
1624
+ const chunkId = chunkData.id;
1625
+ const currentFilePath = chunkData.file_path;
1626
+
1627
+ if (chunkId) {
1628
+ // Find all caller relationships
1629
+ const allCallers = allLinks.filter(l => l.type === 'caller' && l.target === chunkId);
1630
+ const allCallees = allLinks.filter(l => l.type === 'caller' && l.source === chunkId);
1631
+
1632
+ // Separate external (different file) from local (same file) relationships
1633
+ // Use Maps to deduplicate by node.id
1634
+ const externalCallersMap = new Map();
1635
+ const localCallersMap = new Map();
1636
+ allCallers.forEach(link => {
1637
+ const callerNode = allNodes.find(n => n.id === link.source);
1638
+ if (callerNode) {
1639
+ if (callerNode.file_path !== currentFilePath) {
1640
+ if (!externalCallersMap.has(callerNode.id)) {
1641
+ externalCallersMap.set(callerNode.id, { link, node: callerNode });
1642
+ }
1643
+ } else {
1644
+ if (!localCallersMap.has(callerNode.id)) {
1645
+ localCallersMap.set(callerNode.id, { link, node: callerNode });
1646
+ }
1647
+ }
1648
+ }
1649
+ });
1650
+ const externalCallers = Array.from(externalCallersMap.values());
1651
+ const localCallers = Array.from(localCallersMap.values());
1652
+
1653
+ const externalCalleesMap = new Map();
1654
+ const localCalleesMap = new Map();
1655
+ allCallees.forEach(link => {
1656
+ const calleeNode = allNodes.find(n => n.id === link.target);
1657
+ if (calleeNode) {
1658
+ if (calleeNode.file_path !== currentFilePath) {
1659
+ if (!externalCalleesMap.has(calleeNode.id)) {
1660
+ externalCalleesMap.set(calleeNode.id, { link, node: calleeNode });
1661
+ }
1662
+ } else {
1663
+ if (!localCalleesMap.has(calleeNode.id)) {
1664
+ localCalleesMap.set(calleeNode.id, { link, node: calleeNode });
1665
+ }
1666
+ }
1667
+ }
1668
+ });
1669
+ const externalCallees = Array.from(externalCalleesMap.values());
1670
+ const localCallees = Array.from(localCalleesMap.values());
1671
+
1672
+ // === External Callers Section (functions from other files that call this) ===
1673
+ if (externalCallers.length > 0) {
1674
+ sections.push({ id: 'external-callers', label: '📥 External Callers' });
1675
+ html += '<div class="viewer-section" data-section="external-callers">';
1676
+ html += '<div class="viewer-section-title">📥 External Callers <span style="color: #8b949e; font-weight: normal;">(functions from other files calling this)</span></div>';
1677
+ html += '<div style="display: flex; flex-direction: column; gap: 6px;">';
1678
+ externalCallers.slice(0, 10).forEach(({ link, node }) => {
1679
+ const shortPath = node.file_path ? node.file_path.split('/').slice(-2).join('/') : '';
1680
+ html += `<div class="external-call-item" onclick="focusNodeInTree('${link.source}')" title="${escapeHtml(node.file_path || '')}">`;
1681
+ html += `<span class="external-call-icon">←</span>`;
1682
+ html += `<span class="external-call-name">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
1683
+ html += `<span class="external-call-path">${escapeHtml(shortPath)}</span>`;
1684
+ html += `</div>`;
1685
+ });
1686
+ if (externalCallers.length > 10) {
1687
+ html += `<div style="color: #8b949e; font-size: 11px; padding-left: 20px;">+${externalCallers.length - 10} more external callers</div>`;
1688
+ }
1689
+ html += '</div></div>';
1690
+ }
1691
+
1692
+ // === External Calls Section (functions in other files this calls) ===
1693
+ if (externalCallees.length > 0) {
1694
+ sections.push({ id: 'external-calls', label: '📤 External Calls' });
1695
+ html += '<div class="viewer-section" data-section="external-calls">';
1696
+ html += '<div class="viewer-section-title">📤 External Calls <span style="color: #8b949e; font-weight: normal;">(functions in other files this calls)</span></div>';
1697
+ html += '<div style="display: flex; flex-direction: column; gap: 6px;">';
1698
+ externalCallees.slice(0, 10).forEach(({ link, node }) => {
1699
+ const shortPath = node.file_path ? node.file_path.split('/').slice(-2).join('/') : '';
1700
+ html += `<div class="external-call-item" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(node.file_path || '')}">`;
1701
+ html += `<span class="external-call-icon">→</span>`;
1702
+ html += `<span class="external-call-name">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
1703
+ html += `<span class="external-call-path">${escapeHtml(shortPath)}</span>`;
1704
+ html += `</div>`;
1705
+ });
1706
+ if (externalCallees.length > 10) {
1707
+ html += `<div style="color: #8b949e; font-size: 11px; padding-left: 20px;">+${externalCallees.length - 10} more external calls</div>`;
1708
+ }
1709
+ html += '</div></div>';
1710
+ }
1711
+
1712
+ // === Local (Same-File) Relationships Section ===
1713
+ if (localCallers.length > 0 || localCallees.length > 0) {
1714
+ sections.push({ id: 'local-references', label: '🔗 Local References' });
1715
+ html += '<div class="viewer-section" data-section="local-references">';
1716
+ html += '<div class="viewer-section-title">🔗 Local References <span style="color: #8b949e; font-weight: normal;">(same file)</span></div>';
1717
+
1718
+ if (localCallers.length > 0) {
1719
+ html += '<div style="margin-bottom: 8px;">';
1720
+ html += '<div style="color: #58a6ff; font-size: 11px; margin-bottom: 4px;">Called by:</div>';
1721
+ html += '<div style="display: flex; flex-wrap: wrap; gap: 4px;">';
1722
+ localCallers.slice(0, 8).forEach(({ link, node }) => {
1723
+ html += `<span class="relationship-tag caller" onclick="focusNodeInTree('${link.source}')" title="${escapeHtml(node.name || '')}">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
1724
+ });
1725
+ if (localCallers.length > 8) {
1726
+ html += `<span style="color: #8b949e; font-size: 10px;">+${localCallers.length - 8} more</span>`;
1727
+ }
1728
+ html += '</div></div>';
1729
+ }
1730
+
1731
+ if (localCallees.length > 0) {
1732
+ html += '<div>';
1733
+ html += '<div style="color: #f0883e; font-size: 11px; margin-bottom: 4px;">Calls:</div>';
1734
+ html += '<div style="display: flex; flex-wrap: wrap; gap: 4px;">';
1735
+ localCallees.slice(0, 8).forEach(({ link, node }) => {
1736
+ html += `<span class="relationship-tag callee" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(node.name || '')}">${escapeHtml(node.name || node.id.substring(0, 8))}</span>`;
1737
+ });
1738
+ if (localCallees.length > 8) {
1739
+ html += `<span style="color: #8b949e; font-size: 10px;">+${localCallees.length - 8} more</span>`;
1740
+ }
1741
+ html += '</div></div>';
1742
+ }
1743
+
1744
+ html += '</div>';
1745
+ }
1746
+
1747
+ // === Semantically Similar Section ===
1748
+ const semanticLinks = allLinks.filter(l => l.type === 'semantic' && l.source === chunkId);
1749
+ if (semanticLinks.length > 0) {
1750
+ sections.push({ id: 'semantic', label: '🧠 Semantically Similar' });
1751
+ html += '<div class="viewer-section" data-section="semantic">';
1752
+ html += '<div class="viewer-section-title">🧠 Semantically Similar</div>';
1753
+ html += '<div style="display: flex; flex-direction: column; gap: 4px;">';
1754
+ semanticLinks.slice(0, 5).forEach(link => {
1755
+ const similarNode = allNodes.find(n => n.id === link.target);
1756
+ if (similarNode) {
1757
+ const similarity = (link.similarity * 100).toFixed(0);
1758
+ const label = similarNode.name || similarNode.id.substring(0, 8);
1759
+ html += `<div class="semantic-item" onclick="focusNodeInTree('${link.target}')" title="${escapeHtml(similarNode.file_path || '')}">`;
1760
+ html += `<span class="semantic-score">${similarity}%</span>`;
1761
+ html += `<span class="semantic-name">${escapeHtml(label)}</span>`;
1762
+ html += `<span class="semantic-type">${similarNode.type || ''}</span>`;
1763
+ html += `</div>`;
1764
+ }
1765
+ });
1766
+ html += '</div>';
1767
+ html += '</div>';
1768
+ }
1769
+ }
1770
+
1771
+ content.innerHTML = html;
1772
+
1773
+ // Apply syntax highlighting to code blocks
1774
+ content.querySelectorAll('pre code').forEach((block) => {
1775
+ if (typeof hljs !== 'undefined') {
1776
+ hljs.highlightElement(block);
1777
+ }
1778
+ });
1779
+
1780
+ // Populate section dropdown for navigation
1781
+ populateSectionDropdown(sections);
1782
+ }
1783
+
1784
+ // Focus on a node in the tree (expand path, scroll, highlight)
1785
+ function focusNodeInTree(nodeId) {
1786
+ console.log(`Focusing on node in tree: ${nodeId}`);
1787
+
1788
+ // Find the node in allNodes (the original data)
1789
+ const targetNodeData = allNodes.find(n => n.id === nodeId);
1790
+ if (!targetNodeData) {
1791
+ console.log(`Node ${nodeId} not found in allNodes`);
1792
+ return;
1793
+ }
1794
+
1795
+ // Find the path to this node in the tree structure
1796
+ // We need to find and expand all ancestors to make the node visible
1797
+ const pathToNode = findPathToNode(treeData, nodeId);
1798
+
1799
+ if (pathToNode.length > 0) {
1800
+ console.log(`Found path to node: ${pathToNode.map(n => n.name).join(' -> ')}`);
1801
+
1802
+ // Expand all nodes along the path (except the target node itself)
1803
+ pathToNode.slice(0, -1).forEach(node => {
1804
+ if (node._children) {
1805
+ // Node is collapsed, expand it
1806
+ console.log(`Expanding ${node.name} to reveal path`);
1807
+ node.children = node._children;
1808
+ node._children = null;
1809
+ }
1810
+ });
1811
+
1812
+ // Re-render the tree to show the expanded path
1813
+ renderVisualization();
1814
+
1815
+ // After render, scroll to and highlight the target node
1816
+ setTimeout(() => {
1817
+ highlightNodeInTree(nodeId);
1818
+ }, 100);
1819
+ } else {
1820
+ console.log(`Path to node ${nodeId} not found in tree - it may be orphaned`);
1821
+ }
1822
+
1823
+ // Display the content in the viewer panel
1824
+ if (chunkTypes.includes(targetNodeData.type)) {
1825
+ displayChunkContent(targetNodeData);
1826
+ } else if (targetNodeData.type === 'file') {
1827
+ displayFileInfo(targetNodeData);
1828
+ } else if (targetNodeData.type === 'directory') {
1829
+ displayDirectoryInfo(targetNodeData);
1830
+ }
1831
+ }
1832
+
1833
+ // Find path from root to a specific node by ID
1834
+ function findPathToNode(node, targetId, path = []) {
1835
+ if (!node) return [];
1836
+
1837
+ // Add current node to path
1838
+ const currentPath = [...path, node];
1839
+
1840
+ // Check if this is the target
1841
+ if (node.id === targetId) {
1842
+ return currentPath;
1843
+ }
1844
+
1845
+ // Check visible children
1846
+ if (node.children) {
1847
+ for (const child of node.children) {
1848
+ const result = findPathToNode(child, targetId, currentPath);
1849
+ if (result.length > 0) return result;
1850
+ }
1851
+ }
1852
+
1853
+ // Check hidden children
1854
+ if (node._children) {
1855
+ for (const child of node._children) {
1856
+ const result = findPathToNode(child, targetId, currentPath);
1857
+ if (result.length > 0) return result;
1858
+ }
1859
+ }
1860
+
1861
+ return [];
1862
+ }
1863
+
1864
+ // Highlight and scroll to a node in the rendered tree
1865
+ function highlightNodeInTree(nodeId) {
1866
+ // Remove any existing highlight
1867
+ d3.selectAll('.node-highlight').classed('node-highlight', false);
1868
+
1869
+ // Find and highlight the target node in the rendered SVG
1870
+ const svg = d3.select('#graph');
1871
+ const targetNode = svg.selectAll('.node')
1872
+ .filter(d => d.data.id === nodeId);
1873
+
1874
+ if (!targetNode.empty()) {
1875
+ // Add highlight class
1876
+ targetNode.classed('node-highlight', true);
1877
+
1878
+ // Pulse the node circle - scale up from current size
1879
+ targetNode.select('circle')
1880
+ .transition()
1881
+ .duration(200)
1882
+ .attr('r', d => getNodeRadius(d) * 1.5) // Grow 50%
1883
+ .transition()
1884
+ .duration(200)
1885
+ .attr('r', d => getNodeRadius(d) * 0.8) // Shrink 20%
1886
+ .transition()
1887
+ .duration(200)
1888
+ .attr('r', d => getNodeRadius(d)); // Return to normal
1889
+
1890
+ // Get the node's position for scrolling
1891
+ const nodeTransform = targetNode.attr('transform');
1892
+ const match = nodeTransform.match(/translate\\(([^,]+),([^)]+)\\)/);
1893
+ if (match) {
1894
+ const x = parseFloat(match[1]);
1895
+ const y = parseFloat(match[2]);
1896
+
1897
+ // Pan the view to center on this node
1898
+ const { width, height } = getViewportDimensions();
1899
+ const zoom = d3.zoom().on('zoom', () => {}); // Get current zoom
1900
+ const svg = d3.select('#graph');
1901
+
1902
+ // Calculate center offset
1903
+ const centerX = width / 2 - x;
1904
+ const centerY = height / 2 - y;
1905
+
1906
+ // Apply smooth transition to center on node
1907
+ svg.transition()
1908
+ .duration(500)
1909
+ .call(
1910
+ d3.zoom().transform,
1911
+ d3.zoomIdentity.translate(centerX, centerY)
1912
+ );
1913
+ }
1914
+
1915
+ console.log(`Highlighted node ${nodeId}`);
1916
+ } else {
1917
+ console.log(`Node ${nodeId} not found in rendered tree`);
1918
+ }
1919
+ }
1920
+
1921
+ // Legacy function for backward compatibility
1922
+ function focusNode(nodeId) {
1923
+ focusNodeInTree(nodeId);
1924
+ }
1925
+
1926
+ function getChunkIcon(chunkType) {
1927
+ const icons = {
1928
+ 'function': '⚡',
1929
+ 'class': '🏛️',
1930
+ 'method': '🔧',
1931
+ 'code': '📝',
1932
+ 'import': '📦',
1933
+ 'comment': '💬',
1934
+ 'docstring': '📖'
1935
+ };
1936
+ return icons[chunkType] || '📝';
1937
+ }
1938
+
1939
+ function escapeHtml(text) {
1940
+ const div = document.createElement('div');
1941
+ div.textContent = text;
1942
+ return div.innerHTML;
1943
+ }
1944
+
1945
+ // Get language class for highlight.js based on file extension
1946
+ function getLanguageClass(filePath) {
1947
+ if (!filePath) return '';
1948
+ const ext = filePath.split('.').pop().toLowerCase();
1949
+ const langMap = {
1950
+ 'py': 'python',
1951
+ 'js': 'javascript',
1952
+ 'ts': 'typescript',
1953
+ 'tsx': 'typescript',
1954
+ 'jsx': 'javascript',
1955
+ 'java': 'java',
1956
+ 'go': 'go',
1957
+ 'rs': 'rust',
1958
+ 'rb': 'ruby',
1959
+ 'php': 'php',
1960
+ 'c': 'c',
1961
+ 'cpp': 'cpp',
1962
+ 'cc': 'cpp',
1963
+ 'h': 'c',
1964
+ 'hpp': 'cpp',
1965
+ 'cs': 'csharp',
1966
+ 'swift': 'swift',
1967
+ 'kt': 'kotlin',
1968
+ 'scala': 'scala',
1969
+ 'sh': 'bash',
1970
+ 'bash': 'bash',
1971
+ 'zsh': 'bash',
1972
+ 'sql': 'sql',
1973
+ 'html': 'html',
1974
+ 'htm': 'html',
1975
+ 'css': 'css',
1976
+ 'scss': 'scss',
1977
+ 'less': 'less',
1978
+ 'json': 'json',
1979
+ 'yaml': 'yaml',
1980
+ 'yml': 'yaml',
1981
+ 'xml': 'xml',
1982
+ 'md': 'markdown',
1983
+ 'markdown': 'markdown',
1984
+ 'toml': 'ini',
1985
+ 'ini': 'ini',
1986
+ 'cfg': 'ini',
1987
+ 'lua': 'lua',
1988
+ 'r': 'r',
1989
+ 'dart': 'dart',
1990
+ 'ex': 'elixir',
1991
+ 'exs': 'elixir',
1992
+ 'erl': 'erlang',
1993
+ 'hs': 'haskell',
1994
+ 'clj': 'clojure',
1995
+ 'vim': 'vim',
1996
+ 'dockerfile': 'dockerfile'
1997
+ };
1998
+ return langMap[ext] || '';
1999
+ }
2000
+
2001
+ // ============================================================================
2002
+ // LAYOUT TOGGLE
2003
+ // ============================================================================
2004
+
2005
+ function toggleCallLines(show) {
2006
+ showCallLines = show;
2007
+ const lineGroup = d3.select('.external-lines-group');
2008
+ if (!lineGroup.empty()) {
2009
+ lineGroup.style('display', show ? 'block' : 'none');
2010
+ }
2011
+ console.log(`Call lines ${show ? 'shown' : 'hidden'}`);
2012
+ }
2013
+
2014
+ function toggleLayout() {
2015
+ const toggleCheckbox = document.getElementById('layout-toggle');
2016
+ const labels = document.querySelectorAll('.toggle-label');
2017
+
2018
+ // Update layout based on checkbox state
2019
+ currentLayout = toggleCheckbox.checked ? 'circular' : 'linear';
2020
+
2021
+ // Update label highlighting
2022
+ labels.forEach((label, index) => {
2023
+ if (index === 0) {
2024
+ // Linear label (left)
2025
+ label.classList.toggle('active', currentLayout === 'linear');
2026
+ } else {
2027
+ // Circular label (right)
2028
+ label.classList.toggle('active', currentLayout === 'circular');
2029
+ }
2030
+ });
2031
+
2032
+ console.log(`Layout switched to: ${currentLayout}`);
2033
+ renderVisualization();
2034
+ }
2035
+
2036
+ // ============================================================================
2037
+ // VIEWER PANEL CONTROLS
2038
+ // ============================================================================
2039
+
2040
+ let isViewerExpanded = false;
2041
+
2042
+ function openViewerPanel() {
2043
+ const panel = document.getElementById('viewer-panel');
2044
+ const container = document.getElementById('main-container');
2045
+
2046
+ if (!isViewerOpen) {
2047
+ panel.classList.add('open');
2048
+ container.classList.add('viewer-open');
2049
+ isViewerOpen = true;
2050
+
2051
+ // Re-render visualization to adjust to new viewport size
2052
+ setTimeout(() => {
2053
+ renderVisualization();
2054
+ }, 300); // Wait for transition
2055
+ }
2056
+ }
2057
+
2058
+ function closeViewerPanel() {
2059
+ const panel = document.getElementById('viewer-panel');
2060
+ const container = document.getElementById('main-container');
2061
+
2062
+ panel.classList.remove('open');
2063
+ panel.classList.remove('expanded');
2064
+ container.classList.remove('viewer-open');
2065
+ isViewerOpen = false;
2066
+ isViewerExpanded = false;
2067
+
2068
+ // Update icon
2069
+ const icon = document.getElementById('expand-icon');
2070
+ if (icon) icon.textContent = '⬅';
2071
+
2072
+ // Re-render visualization to adjust to new viewport size
2073
+ setTimeout(() => {
2074
+ renderVisualization();
2075
+ }, 300); // Wait for transition
2076
+ }
2077
+
2078
+ function toggleViewerExpand() {
2079
+ const panel = document.getElementById('viewer-panel');
2080
+ const icon = document.getElementById('expand-icon');
2081
+
2082
+ isViewerExpanded = !isViewerExpanded;
2083
+
2084
+ if (isViewerExpanded) {
2085
+ panel.classList.add('expanded');
2086
+ if (icon) icon.textContent = '➡';
2087
+ } else {
2088
+ panel.classList.remove('expanded');
2089
+ if (icon) icon.textContent = '⬅';
2090
+ }
2091
+
2092
+ // Don't re-render graph on expand - only affects panel width
2093
+ // Graph will adjust on close via closeViewerPanel()
2094
+ }
2095
+
2096
+ function jumpToSection(sectionId) {
2097
+ if (!sectionId) return;
2098
+
2099
+ const viewerContent = document.getElementById('viewer-content');
2100
+ const sectionElement = viewerContent.querySelector(`[data-section="${sectionId}"]`);
2101
+
2102
+ if (sectionElement) {
2103
+ sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
2104
+
2105
+ // Briefly highlight the section
2106
+ sectionElement.style.transition = 'background-color 0.3s';
2107
+ sectionElement.style.backgroundColor = 'rgba(88, 166, 255, 0.15)';
2108
+ setTimeout(() => {
2109
+ sectionElement.style.backgroundColor = '';
2110
+ }, 1000);
2111
+ }
2112
+
2113
+ // Reset dropdown to default
2114
+ const dropdown = document.getElementById('section-dropdown');
2115
+ if (dropdown) dropdown.value = '';
2116
+ }
2117
+
2118
+ function populateSectionDropdown(sections) {
2119
+ const dropdown = document.getElementById('section-dropdown');
2120
+ if (!dropdown) return;
2121
+
2122
+ // Clear existing options except the first one
2123
+ dropdown.innerHTML = '<option value="">Jump to section...</option>';
2124
+
2125
+ // Add section options
2126
+ sections.forEach(section => {
2127
+ const option = document.createElement('option');
2128
+ option.value = section.id;
2129
+ option.textContent = section.label;
2130
+ dropdown.appendChild(option);
2131
+ });
2132
+
2133
+ // Show/hide dropdown based on whether we have sections
2134
+ const sectionNav = document.getElementById('section-nav');
2135
+ if (sectionNav) {
2136
+ sectionNav.style.display = sections.length > 1 ? 'block' : 'none';
2137
+ }
2138
+ }
2139
+
2140
+ // ============================================================================
2141
+ // NAVIGATION FUNCTIONS
2142
+ // ============================================================================
2143
+
2144
+ function addToNavHistory(item) {
2145
+ // Remove any forward history when adding new item
2146
+ if (navigationIndex < navigationHistory.length - 1) {
2147
+ navigationHistory = navigationHistory.slice(0, navigationIndex + 1);
2148
+ }
2149
+ navigationHistory.push(item);
2150
+ navigationIndex = navigationHistory.length - 1;
2151
+ console.log(`Navigation history: ${navigationHistory.length} items, index: ${navigationIndex}`);
2152
+ }
2153
+
2154
+ function goBack() {
2155
+ if (navigationIndex > 0) {
2156
+ navigationIndex--;
2157
+ const item = navigationHistory[navigationIndex];
2158
+ console.log(`Going back to: ${item.type} - ${item.data.name}`);
2159
+ if (item.type === 'directory') {
2160
+ displayDirectoryInfo(item.data, false); // false = don't add to history
2161
+ focusNodeInTree(item.data.id);
2162
+ } else if (item.type === 'file') {
2163
+ displayFileInfo(item.data, false); // false = don't add to history
2164
+ focusNodeInTree(item.data.id);
2165
+ } else if (item.type === 'chunk') {
2166
+ displayChunkContent(item.data, false); // false = don't add to history
2167
+ focusNodeInTree(item.data.id);
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ function goForward() {
2173
+ if (navigationIndex < navigationHistory.length - 1) {
2174
+ navigationIndex++;
2175
+ const item = navigationHistory[navigationIndex];
2176
+ console.log(`Going forward to: ${item.type} - ${item.data.name}`);
2177
+ if (item.type === 'directory') {
2178
+ displayDirectoryInfo(item.data, false); // false = don't add to history
2179
+ focusNodeInTree(item.data.id);
2180
+ } else if (item.type === 'file') {
2181
+ displayFileInfo(item.data, false); // false = don't add to history
2182
+ focusNodeInTree(item.data.id);
2183
+ } else if (item.type === 'chunk') {
2184
+ displayChunkContent(item.data, false); // false = don't add to history
2185
+ focusNodeInTree(item.data.id);
2186
+ }
2187
+ }
2188
+ }
2189
+
2190
+ function navigateToDirectory(dirData) {
2191
+ console.log(`Navigating to directory: ${dirData.name}`);
2192
+ // Focus on the node in the tree (expand path and highlight)
2193
+ focusNodeInTree(dirData.id);
2194
+ }
2195
+
2196
+ function navigateToFile(fileData) {
2197
+ console.log(`Navigating to file: ${fileData.name}`);
2198
+ // Focus on the node in the tree (expand path and highlight)
2199
+ focusNodeInTree(fileData.id);
2200
+ }
2201
+
2202
+ // Navigate to a file by its file path (used when clicking on file paths in chunk metadata)
2203
+ function navigateToFileByPath(filePath) {
2204
+ console.log(`Navigating to file by path: ${filePath}`);
2205
+
2206
+ // Find the file node in allNodes that matches this path
2207
+ const fileNode = allNodes.find(n => {
2208
+ if (n.type !== 'file') return false;
2209
+ // Match against various path properties
2210
+ return n.path === filePath ||
2211
+ n.file_path === filePath ||
2212
+ (n.path && n.path.endsWith(filePath)) ||
2213
+ (n.file_path && n.file_path.endsWith(filePath)) ||
2214
+ filePath.endsWith(n.name);
2215
+ });
2216
+
2217
+ if (fileNode) {
2218
+ console.log(`Found file node: ${fileNode.name} (id: ${fileNode.id})`);
2219
+ focusNodeInTree(fileNode.id);
2220
+ } else {
2221
+ console.log(`File node not found for path: ${filePath}`);
2222
+ // Try to find by just the filename
2223
+ const fileName = filePath.split('/').pop();
2224
+ const fileByName = allNodes.find(n => n.type === 'file' && n.name === fileName);
2225
+ if (fileByName) {
2226
+ console.log(`Found file by name: ${fileByName.name}`);
2227
+ focusNodeInTree(fileByName.id);
2228
+ }
2229
+ }
2230
+ }
2231
+
2232
+ function renderNavigationBar(currentItem) {
2233
+ let html = '<div class="navigation-bar">';
2234
+
2235
+ // Back/Forward buttons
2236
+ const canGoBack = navigationIndex > 0;
2237
+ const canGoForward = navigationIndex < navigationHistory.length - 1;
2238
+
2239
+ html += `<button class="nav-btn ${canGoBack ? '' : 'disabled'}" onclick="goBack()" ${canGoBack ? '' : 'disabled'} title="Go Back">←</button>`;
2240
+ html += `<button class="nav-btn ${canGoForward ? '' : 'disabled'}" onclick="goForward()" ${canGoForward ? '' : 'disabled'} title="Go Forward">→</button>`;
2241
+
2242
+ // Breadcrumb trail
2243
+ html += '<div class="breadcrumb-trail">';
2244
+
2245
+ // Build breadcrumb from path
2246
+ if (currentItem && currentItem.id) {
2247
+ const path = findPathToNode(treeData, currentItem.id);
2248
+ path.forEach((node, index) => {
2249
+ const isLast = index === path.length - 1;
2250
+ const clickable = !isLast;
2251
+
2252
+ if (index > 0) {
2253
+ html += '<span class="breadcrumb-separator">/</span>';
2254
+ }
2255
+
2256
+ if (clickable) {
2257
+ html += `<span class="breadcrumb-item clickable" onclick="focusNodeInTree('${node.id}')">${escapeHtml(node.name)}</span>`;
2258
+ } else {
2259
+ html += `<span class="breadcrumb-item current">${escapeHtml(node.name)}</span>`;
2260
+ }
2261
+ });
2262
+ }
2263
+
2264
+ html += '</div>';
2265
+ html += '</div>';
2266
+
2267
+ return html;
2268
+ }
2269
+
2270
+ // ============================================================================
2271
+ // SEARCH FUNCTIONALITY
2272
+ // ============================================================================
2273
+
2274
+ let searchDebounceTimer = null;
2275
+ let searchResults = [];
2276
+ let selectedSearchIndex = -1;
2277
+
2278
+ function handleSearchInput(event) {
2279
+ const query = event.target.value.trim();
2280
+
2281
+ // Debounce search - wait 150ms after typing stops
2282
+ clearTimeout(searchDebounceTimer);
2283
+ searchDebounceTimer = setTimeout(() => {
2284
+ performSearch(query);
2285
+ }, 150);
2286
+ }
2287
+
2288
+ function handleSearchKeydown(event) {
2289
+ const resultsContainer = document.getElementById('search-results');
2290
+
2291
+ switch(event.key) {
2292
+ case 'ArrowDown':
2293
+ event.preventDefault();
2294
+ if (searchResults.length > 0) {
2295
+ selectedSearchIndex = Math.min(selectedSearchIndex + 1, searchResults.length - 1);
2296
+ updateSearchSelection();
2297
+ }
2298
+ break;
2299
+ case 'ArrowUp':
2300
+ event.preventDefault();
2301
+ if (searchResults.length > 0) {
2302
+ selectedSearchIndex = Math.max(selectedSearchIndex - 1, 0);
2303
+ updateSearchSelection();
2304
+ }
2305
+ break;
2306
+ case 'Enter':
2307
+ event.preventDefault();
2308
+ if (selectedSearchIndex >= 0 && selectedSearchIndex < searchResults.length) {
2309
+ selectSearchResult(searchResults[selectedSearchIndex]);
2310
+ }
2311
+ break;
2312
+ case 'Escape':
2313
+ closeSearchResults();
2314
+ document.getElementById('search-input').blur();
2315
+ break;
2316
+ }
2317
+ }
2318
+
2319
+ function performSearch(query) {
2320
+ const resultsContainer = document.getElementById('search-results');
2321
+
2322
+ if (!query || query.length < 2) {
2323
+ closeSearchResults();
2324
+ return;
2325
+ }
2326
+
2327
+ const lowerQuery = query.toLowerCase();
2328
+
2329
+ // Search through all nodes (directories, files, and chunks)
2330
+ searchResults = allNodes
2331
+ .filter(node => {
2332
+ // Match against name
2333
+ const nameMatch = node.name && node.name.toLowerCase().includes(lowerQuery);
2334
+ // Match against file path
2335
+ const pathMatch = node.file_path && node.file_path.toLowerCase().includes(lowerQuery);
2336
+ // Match against ID (useful for finding specific chunks)
2337
+ const idMatch = node.id && node.id.toLowerCase().includes(lowerQuery);
2338
+ return nameMatch || pathMatch || idMatch;
2339
+ })
2340
+ .slice(0, 20); // Limit to 20 results
2341
+
2342
+ // Sort results: exact matches first, then by type priority
2343
+ const typePriority = { 'directory': 1, 'file': 2, 'class': 3, 'function': 4, 'method': 5 };
2344
+ searchResults.sort((a, b) => {
2345
+ // Exact name match gets highest priority
2346
+ const aExact = a.name && a.name.toLowerCase() === lowerQuery ? 0 : 1;
2347
+ const bExact = b.name && b.name.toLowerCase() === lowerQuery ? 0 : 1;
2348
+ if (aExact !== bExact) return aExact - bExact;
2349
+
2350
+ // Then sort by type priority
2351
+ const aPriority = typePriority[a.type] || 10;
2352
+ const bPriority = typePriority[b.type] || 10;
2353
+ if (aPriority !== bPriority) return aPriority - bPriority;
2354
+
2355
+ // Finally sort alphabetically
2356
+ return (a.name || '').localeCompare(b.name || '');
2357
+ });
2358
+
2359
+ selectedSearchIndex = searchResults.length > 0 ? 0 : -1;
2360
+ renderSearchResults(query);
2361
+ }
2362
+
2363
+ function renderSearchResults(query) {
2364
+ const resultsContainer = document.getElementById('search-results');
2365
+
2366
+ if (searchResults.length === 0) {
2367
+ resultsContainer.innerHTML = '<div class="search-no-results">No results found</div>';
2368
+ resultsContainer.classList.add('visible');
2369
+ return;
2370
+ }
2371
+
2372
+ let html = '';
2373
+
2374
+ searchResults.forEach((node, index) => {
2375
+ const icon = getSearchResultIcon(node.type);
2376
+ const name = highlightMatch(node.name || node.id.substring(0, 20), query);
2377
+ const path = node.file_path ? node.file_path.split('/').slice(-3).join('/') : '';
2378
+ const type = node.type || 'unknown';
2379
+ const selected = index === selectedSearchIndex ? 'selected' : '';
2380
+
2381
+ html += `<div class="search-result-item ${selected}"
2382
+ data-index="${index}"
2383
+ onclick="selectSearchResultByIndex(${index})"
2384
+ onmouseenter="hoverSearchResult(${index})">`;
2385
+ html += `<span class="search-result-icon">${icon}</span>`;
2386
+ html += `<div class="search-result-info">`;
2387
+ html += `<div class="search-result-name">${name}</div>`;
2388
+ if (path) {
2389
+ html += `<div class="search-result-path">${escapeHtml(path)}</div>`;
2390
+ }
2391
+ html += `</div>`;
2392
+ html += `<span class="search-result-type">${type}</span>`;
2393
+ html += `</div>`;
2394
+ });
2395
+
2396
+ html += '<div class="search-hint">↑↓ Navigate • Enter Select • Esc Close</div>';
2397
+
2398
+ resultsContainer.innerHTML = html;
2399
+ resultsContainer.classList.add('visible');
2400
+ }
2401
+
2402
+ function getSearchResultIcon(type) {
2403
+ const icons = {
2404
+ 'directory': '📁',
2405
+ 'file': '📄',
2406
+ 'function': '⚡',
2407
+ 'class': '🏛️',
2408
+ 'method': '🔧',
2409
+ 'module': '📦',
2410
+ 'imports': '📦',
2411
+ 'text': '📝',
2412
+ 'code': '📝'
2413
+ };
2414
+ return icons[type] || '📄';
2415
+ }
2416
+
2417
+ function highlightMatch(text, query) {
2418
+ if (!text || !query) return escapeHtml(text || '');
2419
+
2420
+ const lowerText = text.toLowerCase();
2421
+ const lowerQuery = query.toLowerCase();
2422
+ const index = lowerText.indexOf(lowerQuery);
2423
+
2424
+ if (index === -1) return escapeHtml(text);
2425
+
2426
+ const before = text.substring(0, index);
2427
+ const match = text.substring(index, index + query.length);
2428
+ const after = text.substring(index + query.length);
2429
+
2430
+ return escapeHtml(before) + '<mark>' + escapeHtml(match) + '</mark>' + escapeHtml(after);
2431
+ }
2432
+
2433
+ function updateSearchSelection() {
2434
+ const items = document.querySelectorAll('.search-result-item');
2435
+ items.forEach((item, index) => {
2436
+ item.classList.toggle('selected', index === selectedSearchIndex);
2437
+ });
2438
+
2439
+ // Scroll selected item into view
2440
+ const selected = items[selectedSearchIndex];
2441
+ if (selected) {
2442
+ selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2443
+ }
2444
+ }
2445
+
2446
+ function hoverSearchResult(index) {
2447
+ selectedSearchIndex = index;
2448
+ updateSearchSelection();
2449
+ }
2450
+
2451
+ function selectSearchResultByIndex(index) {
2452
+ if (index >= 0 && index < searchResults.length) {
2453
+ selectSearchResult(searchResults[index]);
2454
+ }
2455
+ }
2456
+
2457
+ function selectSearchResult(node) {
2458
+ console.log(`Search selected: ${node.name} (${node.type})`);
2459
+
2460
+ // Close search dropdown
2461
+ closeSearchResults();
2462
+
2463
+ // Clear input
2464
+ document.getElementById('search-input').value = '';
2465
+
2466
+ // Focus on the node in the tree
2467
+ focusNodeInTree(node.id);
2468
+ }
2469
+
2470
+ function closeSearchResults() {
2471
+ const resultsContainer = document.getElementById('search-results');
2472
+ resultsContainer.classList.remove('visible');
2473
+ searchResults = [];
2474
+ selectedSearchIndex = -1;
2475
+ }
2476
+
2477
+ // ============================================================================
2478
+ // INITIALIZATION
2479
+ // ============================================================================
2480
+
2481
+ // Load data and initialize UI when page loads
2482
+ document.addEventListener('DOMContentLoaded', () => {
2483
+ console.log('=== PAGE INITIALIZATION ===');
2484
+ console.log('DOMContentLoaded event fired');
2485
+
2486
+ // Initialize toggle label highlighting
2487
+ const labels = document.querySelectorAll('.toggle-label');
2488
+ console.log(`Found ${labels.length} toggle labels`);
2489
+ if (labels[0]) {
2490
+ labels[0].classList.add('active');
2491
+ console.log('Activated first toggle label (linear mode)');
2492
+ }
2493
+
2494
+ // Close search results when clicking outside
2495
+ document.addEventListener('click', (event) => {
2496
+ const searchContainer = document.querySelector('.search-container');
2497
+ if (searchContainer && !searchContainer.contains(event.target)) {
2498
+ closeSearchResults();
2499
+ }
2500
+ });
2501
+
2502
+ // Load graph data
2503
+ console.log('Calling loadGraphData()...');
2504
+ loadGraphData();
2505
+ console.log('=== END PAGE INITIALIZATION ===');
2506
+ });
2507
+ """