mcp-vector-search 0.15.7__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.

Potentially problematic release.


This version of mcp-vector-search might be problematic. Click here for more details.

Files changed (86) hide show
  1. mcp_vector_search/__init__.py +10 -0
  2. mcp_vector_search/cli/__init__.py +1 -0
  3. mcp_vector_search/cli/commands/__init__.py +1 -0
  4. mcp_vector_search/cli/commands/auto_index.py +397 -0
  5. mcp_vector_search/cli/commands/chat.py +534 -0
  6. mcp_vector_search/cli/commands/config.py +393 -0
  7. mcp_vector_search/cli/commands/demo.py +358 -0
  8. mcp_vector_search/cli/commands/index.py +762 -0
  9. mcp_vector_search/cli/commands/init.py +658 -0
  10. mcp_vector_search/cli/commands/install.py +869 -0
  11. mcp_vector_search/cli/commands/install_old.py +700 -0
  12. mcp_vector_search/cli/commands/mcp.py +1254 -0
  13. mcp_vector_search/cli/commands/reset.py +393 -0
  14. mcp_vector_search/cli/commands/search.py +796 -0
  15. mcp_vector_search/cli/commands/setup.py +1133 -0
  16. mcp_vector_search/cli/commands/status.py +584 -0
  17. mcp_vector_search/cli/commands/uninstall.py +404 -0
  18. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  19. mcp_vector_search/cli/commands/visualize/cli.py +265 -0
  20. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  21. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  22. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +29 -0
  23. mcp_vector_search/cli/commands/visualize/graph_builder.py +709 -0
  24. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  25. mcp_vector_search/cli/commands/visualize/server.py +201 -0
  26. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  27. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  28. mcp_vector_search/cli/commands/visualize/templates/base.py +218 -0
  29. mcp_vector_search/cli/commands/visualize/templates/scripts.py +3670 -0
  30. mcp_vector_search/cli/commands/visualize/templates/styles.py +779 -0
  31. mcp_vector_search/cli/commands/visualize.py.original +2536 -0
  32. mcp_vector_search/cli/commands/watch.py +287 -0
  33. mcp_vector_search/cli/didyoumean.py +520 -0
  34. mcp_vector_search/cli/export.py +320 -0
  35. mcp_vector_search/cli/history.py +295 -0
  36. mcp_vector_search/cli/interactive.py +342 -0
  37. mcp_vector_search/cli/main.py +484 -0
  38. mcp_vector_search/cli/output.py +414 -0
  39. mcp_vector_search/cli/suggestions.py +375 -0
  40. mcp_vector_search/config/__init__.py +1 -0
  41. mcp_vector_search/config/constants.py +24 -0
  42. mcp_vector_search/config/defaults.py +200 -0
  43. mcp_vector_search/config/settings.py +146 -0
  44. mcp_vector_search/core/__init__.py +1 -0
  45. mcp_vector_search/core/auto_indexer.py +298 -0
  46. mcp_vector_search/core/config_utils.py +394 -0
  47. mcp_vector_search/core/connection_pool.py +360 -0
  48. mcp_vector_search/core/database.py +1237 -0
  49. mcp_vector_search/core/directory_index.py +318 -0
  50. mcp_vector_search/core/embeddings.py +294 -0
  51. mcp_vector_search/core/exceptions.py +89 -0
  52. mcp_vector_search/core/factory.py +318 -0
  53. mcp_vector_search/core/git_hooks.py +345 -0
  54. mcp_vector_search/core/indexer.py +1002 -0
  55. mcp_vector_search/core/llm_client.py +453 -0
  56. mcp_vector_search/core/models.py +294 -0
  57. mcp_vector_search/core/project.py +350 -0
  58. mcp_vector_search/core/scheduler.py +330 -0
  59. mcp_vector_search/core/search.py +952 -0
  60. mcp_vector_search/core/watcher.py +322 -0
  61. mcp_vector_search/mcp/__init__.py +5 -0
  62. mcp_vector_search/mcp/__main__.py +25 -0
  63. mcp_vector_search/mcp/server.py +752 -0
  64. mcp_vector_search/parsers/__init__.py +8 -0
  65. mcp_vector_search/parsers/base.py +296 -0
  66. mcp_vector_search/parsers/dart.py +605 -0
  67. mcp_vector_search/parsers/html.py +413 -0
  68. mcp_vector_search/parsers/javascript.py +643 -0
  69. mcp_vector_search/parsers/php.py +694 -0
  70. mcp_vector_search/parsers/python.py +502 -0
  71. mcp_vector_search/parsers/registry.py +223 -0
  72. mcp_vector_search/parsers/ruby.py +678 -0
  73. mcp_vector_search/parsers/text.py +186 -0
  74. mcp_vector_search/parsers/utils.py +265 -0
  75. mcp_vector_search/py.typed +1 -0
  76. mcp_vector_search/utils/__init__.py +42 -0
  77. mcp_vector_search/utils/gitignore.py +250 -0
  78. mcp_vector_search/utils/gitignore_updater.py +212 -0
  79. mcp_vector_search/utils/monorepo.py +339 -0
  80. mcp_vector_search/utils/timing.py +338 -0
  81. mcp_vector_search/utils/version.py +47 -0
  82. mcp_vector_search-0.15.7.dist-info/METADATA +884 -0
  83. mcp_vector_search-0.15.7.dist-info/RECORD +86 -0
  84. mcp_vector_search-0.15.7.dist-info/WHEEL +4 -0
  85. mcp_vector_search-0.15.7.dist-info/entry_points.txt +3 -0
  86. mcp_vector_search-0.15.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3670 @@
1
+ """JavaScript code for the D3.js visualization.
2
+
3
+ This module contains all JavaScript functionality for the interactive code graph,
4
+ organized into logical sections for maintainability.
5
+ """
6
+
7
+
8
+ def get_d3_initialization() -> str:
9
+ """Get D3.js initialization and global variables.
10
+
11
+ Returns:
12
+ JavaScript string for D3.js setup
13
+ """
14
+ return """
15
+ const width = window.innerWidth;
16
+ const height = window.innerHeight;
17
+
18
+ // Create zoom behavior - allow more zoom out for larger nodes
19
+ const zoom = d3.zoom()
20
+ .scaleExtent([0.15, 4]) // Increased range from [0.1, 3] to allow better zoom out with larger nodes
21
+ .on("zoom", (event) => {
22
+ g.attr("transform", event.transform);
23
+ });
24
+
25
+ const svg = d3.select("#graph")
26
+ .attr("width", width)
27
+ .attr("height", height)
28
+ .call(zoom);
29
+
30
+ const g = svg.append("g");
31
+ const tooltip = d3.select("#tooltip");
32
+ let simulation;
33
+ let allNodes = [];
34
+ let allLinks = [];
35
+ let visibleNodes = new Set();
36
+ let collapsedNodes = new Set();
37
+ let highlightedNode = null;
38
+ let rootNodes = []; // Store root nodes for reset function
39
+ let isInitialOverview = true; // Track if we're in Phase 1 (initial overview) or Phase 2 (tree expansion)
40
+ let cy = null; // Cytoscape instance
41
+ let edgeFilters = {
42
+ containment: true,
43
+ calls: true,
44
+ imports: false,
45
+ semantic: false,
46
+ cycles: true
47
+ };
48
+ """
49
+
50
+
51
+ def get_file_type_functions() -> str:
52
+ """Get file type detection and icon functions.
53
+
54
+ Returns:
55
+ JavaScript string for file type handling
56
+ """
57
+ return """
58
+ // Get file extension from path
59
+ function getFileExtension(filePath) {
60
+ if (!filePath) return '';
61
+ const match = filePath.match(/\\.([^.]+)$/);
62
+ return match ? match[1].toLowerCase() : '';
63
+ }
64
+
65
+ // Get SVG icon path for file type
66
+ function getFileTypeIcon(node) {
67
+ if (node.type === 'directory') {
68
+ // Folder icon
69
+ return 'M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z';
70
+ }
71
+ if (node.type === 'file') {
72
+ const ext = getFileExtension(node.file_path);
73
+
74
+ // Python files
75
+ if (ext === 'py') {
76
+ return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z';
77
+ }
78
+ // JavaScript/TypeScript
79
+ if (ext === 'js' || ext === 'jsx' || ext === 'ts' || ext === 'tsx') {
80
+ return 'M3 3h18v18H3V3zm16 16V5H5v14h14zM7 7h2v2H7V7zm4 0h2v2h-2V7zm-4 4h2v2H7v-2zm4 0h6v2h-6v-2zm-4 4h10v2H7v-2z';
81
+ }
82
+ // Markdown
83
+ if (ext === 'md' || ext === 'markdown') {
84
+ return 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm10-10h-3v2h3v2h-3v2h3v2h-7V8h7v2z';
85
+ }
86
+ // JSON/YAML/Config files
87
+ if (ext === 'json' || ext === 'yaml' || ext === 'yml' || ext === 'toml' || ext === 'ini' || ext === 'conf') {
88
+ return 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm0 2l4 4h-4V4zM6 20V4h6v6h6v10H6zm4-4h4v2h-4v-2zm0-4h4v2h-4v-2z';
89
+ }
90
+ // Shell scripts
91
+ if (ext === 'sh' || ext === 'bash' || ext === 'zsh') {
92
+ return 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2zM6 10h8v2H6v-2z';
93
+ }
94
+ // Generic code file
95
+ return 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm0 2l4 4h-4V4zM6 20V4h6v6h6v10H6zm3-4h6v2H9v-2zm0-4h6v2H9v-2z';
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // Get color for file type icon
101
+ function getFileTypeColor(node) {
102
+ if (node.type === 'directory') return '#79c0ff';
103
+ if (node.type === 'file') {
104
+ const ext = getFileExtension(node.file_path);
105
+ if (ext === 'py') return '#3776ab'; // Python blue
106
+ if (ext === 'js' || ext === 'jsx') return '#f7df1e'; // JavaScript yellow
107
+ if (ext === 'ts' || ext === 'tsx') return '#3178c6'; // TypeScript blue
108
+ if (ext === 'md' || ext === 'markdown') return '#8b949e'; // Gray
109
+ if (ext === 'json' || ext === 'yaml' || ext === 'yml') return '#90a4ae'; // Config gray
110
+ if (ext === 'sh' || ext === 'bash' || ext === 'zsh') return '#4eaa25'; // Shell green
111
+ return '#58a6ff'; // Default file color
112
+ }
113
+ return null;
114
+ }
115
+ """
116
+
117
+
118
+ def get_spacing_calculation_functions() -> str:
119
+ """Get automatic spacing calculation functions.
120
+
121
+ Returns:
122
+ JavaScript string for spacing calculations
123
+
124
+ Design Decision: Adaptive spacing based on graph density
125
+
126
+ Rationale: Original 800px spacing caused nodes to go off-screen on typical displays.
127
+ Reduced to 300px to fit 5+ hierarchy levels on 1920px screens while maintaining readability.
128
+
129
+ Trade-offs:
130
+ - Adaptability: Auto-scales for mobile to 4K displays vs. fixed spacing
131
+ - Complexity: Requires calculation but prevents manual tuning per graph size
132
+ - Performance: Minimal overhead (O(1) calculation) vs. simplicity of hardcoded value
133
+
134
+ Alternatives Considered:
135
+ 1. Fixed spacing with manual overrides: Rejected - requires user intervention
136
+ 2. Viewport-only scaling: Rejected - doesn't account for node count
137
+ 3. Node-count-only scaling: Rejected - breaks on different screen sizes
138
+
139
+ Extension Points: Mode parameter ('tight', 'balanced', 'loose') allows future
140
+ customization. Bounds can be adjusted per graph size category if needed.
141
+
142
+ Performance:
143
+ - Time Complexity: O(1) - simple arithmetic operations
144
+ - Space Complexity: O(1) - no data structures allocated
145
+ - Expected Performance: <1ms per calculation on modern browsers
146
+
147
+ Error Handling:
148
+ - Zero nodes: Returns default 100px spacing
149
+ - Invalid mode: Falls back to 'balanced' mode
150
+ - Extreme viewports: Clamped by min/max bounds per size category
151
+ """
152
+ return """
153
+ // Calculate adaptive spacing based on graph density
154
+ function calculateAdaptiveSpacing(nodeCount, width, height, mode = 'balanced') {
155
+ if (nodeCount === 0) return 100; // Guard clause
156
+
157
+ const areaPerNode = (width * height) / nodeCount;
158
+ const baseSpacing = Math.sqrt(areaPerNode);
159
+
160
+ // Scale factors for different modes
161
+ const modeScales = {
162
+ 'tight': 0.4, // Dense packing
163
+ 'balanced': 0.6, // Good default
164
+ 'loose': 0.8 // More breathing room
165
+ };
166
+
167
+ const scaleFactor = modeScales[mode] || 0.6;
168
+ const calculatedSpacing = baseSpacing * scaleFactor;
169
+
170
+ // Bounds based on graph size
171
+ let minBound, maxBound;
172
+ if (nodeCount < 50) {
173
+ minBound = 150; maxBound = 400;
174
+ } else if (nodeCount < 500) {
175
+ minBound = 100; maxBound = 250;
176
+ } else {
177
+ minBound = 60; maxBound = 150;
178
+ }
179
+
180
+ return Math.max(minBound, Math.min(maxBound, calculatedSpacing));
181
+ }
182
+
183
+ // Calculate coordinated force parameters
184
+ function calculateForceParameters(nodeCount, width, height, spacing) {
185
+ const k = Math.sqrt(nodeCount / (width * height));
186
+
187
+ return {
188
+ linkDistance: Math.max(30, spacing * 0.25),
189
+ chargeStrength: -10 / k,
190
+ collideRadius: 30,
191
+ centerStrength: 0.05 + (0.1 * k),
192
+ radialStrength: 0.05 + (0.15 * k)
193
+ };
194
+ }
195
+ """
196
+
197
+
198
+ def get_loading_spinner_functions() -> str:
199
+ """Get loading spinner functions for async node operations.
200
+
201
+ Returns:
202
+ JavaScript string for loading spinner display
203
+
204
+ Usage Examples:
205
+ // Show spinner during async operation
206
+ showNodeLoading(nodeId);
207
+ try {
208
+ await fetchNodeData(nodeId);
209
+ } finally {
210
+ hideNodeLoading(nodeId);
211
+ }
212
+
213
+ Common Use Cases:
214
+ - Lazy-loading node data when expanding collapsed groups
215
+ - Fetching additional details from backend
216
+ - Loading file contents on demand
217
+ - Any async operation tied to a specific node
218
+
219
+ Error Case Handling:
220
+ - Missing nodeId: Silently returns (no error thrown)
221
+ - Invalid nodeId: Silently returns if node not found in DOM
222
+ - Multiple calls: Safe to call showNodeLoading multiple times (removes old spinner)
223
+
224
+ Performance:
225
+ - Time Complexity: O(n) where n = number of visible nodes (D3 selection filter)
226
+ - Space Complexity: O(1) - adds 2 SVG elements per node
227
+ - Animation: CSS-based, hardware-accelerated transform
228
+ """
229
+ return """
230
+ // Show loading spinner on a node
231
+ function showNodeLoading(nodeId) {
232
+ const node = svg.selectAll('.node')
233
+ .filter(d => d.id === nodeId);
234
+
235
+ if (node.empty()) return;
236
+
237
+ const nodeData = node.datum();
238
+ const x = nodeData.x || 0;
239
+ const y = nodeData.y || 0;
240
+ const radius = 30;
241
+
242
+ // Remove any existing spinner first
243
+ node.selectAll('.node-loading, .node-loading-overlay').remove();
244
+
245
+ // Add semi-transparent overlay
246
+ node.append('circle')
247
+ .attr('class', 'node-loading-overlay')
248
+ .attr('cx', x)
249
+ .attr('cy', y)
250
+ .attr('r', radius);
251
+
252
+ // Add spinning circle
253
+ node.append('circle')
254
+ .attr('class', 'node-loading')
255
+ .attr('cx', x)
256
+ .attr('cy', y)
257
+ .attr('r', radius * 0.7)
258
+ .style('transform-origin', `${x}px ${y}px`);
259
+ }
260
+
261
+ // Hide loading spinner from a node
262
+ function hideNodeLoading(nodeId) {
263
+ const node = svg.selectAll('.node')
264
+ .filter(d => d.id === nodeId);
265
+
266
+ node.selectAll('.node-loading, .node-loading-overlay').remove();
267
+ }
268
+ """
269
+
270
+
271
+ def get_graph_visualization_functions() -> str:
272
+ """Get main graph visualization functions.
273
+
274
+ Returns:
275
+ JavaScript string for graph rendering
276
+ """
277
+ return """
278
+ // Helper function to calculate complexity-based color shading
279
+ function getComplexityShade(baseColor, complexity) {
280
+ if (!complexity || complexity === 0) return baseColor;
281
+
282
+ // Convert hex to HSL for proper darkening
283
+ const rgb = d3.rgb(baseColor);
284
+ const hsl = d3.hsl(rgb);
285
+
286
+ // Reduce lightness based on complexity (darker = more complex)
287
+ // Complexity scale: 0-5 (low), 6-10 (medium), 11+ (high)
288
+ // Max reduction: 40% for very complex functions
289
+ const lightnessReduction = Math.min(complexity * 0.03, 0.4);
290
+ hsl.l = Math.max(hsl.l - lightnessReduction, 0.1); // Don't go too dark
291
+
292
+ return hsl.toString();
293
+ }
294
+
295
+ // Position ALL nodes in an adaptive initial layout
296
+ function positionNodesCompactly(nodes) {
297
+ const folders = nodes.filter(n => n.type === 'directory');
298
+ const outliers = nodes.filter(n => n.type !== 'directory');
299
+
300
+ // Calculate adaptive spacing for folders (grid layout)
301
+ if (folders.length > 0) {
302
+ const folderSpacing = calculateAdaptiveSpacing(folders.length, width, height, 'balanced');
303
+ const cols = Math.ceil(Math.sqrt(folders.length));
304
+ const startX = width / 2 - (cols * folderSpacing) / 2;
305
+ const startY = height / 2 - (Math.ceil(folders.length / cols) * folderSpacing) / 2;
306
+
307
+ folders.forEach((folder, i) => {
308
+ const col = i % cols;
309
+ const row = Math.floor(i / cols);
310
+ folder.x = startX + col * folderSpacing;
311
+ folder.y = startY + row * folderSpacing;
312
+ folder.fx = folder.x; // Fix position initially
313
+ folder.fy = folder.y;
314
+ });
315
+ }
316
+
317
+ // Calculate adaptive radius for outliers (spiral layout)
318
+ if (outliers.length > 0) {
319
+ const clusterRadius = calculateAdaptiveSpacing(outliers.length, width * 0.6, height * 0.6, 'tight') * 2;
320
+ outliers.forEach((node, i) => {
321
+ const angle = (i / outliers.length) * 2 * Math.PI;
322
+ const radius = clusterRadius * Math.sqrt(i / outliers.length);
323
+ node.x = width / 2 + radius * Math.cos(angle);
324
+ node.y = height / 2 + radius * Math.sin(angle);
325
+ });
326
+ }
327
+
328
+ // Release fixed folder positions after settling
329
+ setTimeout(() => {
330
+ folders.forEach(folder => {
331
+ folder.fx = null;
332
+ folder.fy = null;
333
+ });
334
+ }, 1000);
335
+ }
336
+
337
+ function visualizeGraph(data) {
338
+ g.selectAll("*").remove();
339
+
340
+ allNodes = data.nodes;
341
+ allLinks = data.links;
342
+
343
+ // Find root nodes - start with only top-level nodes
344
+ if (data.metadata && data.metadata.is_monorepo) {
345
+ // In monorepos, subproject nodes are roots
346
+ rootNodes = allNodes.filter(n => n.type === 'subproject');
347
+ } else {
348
+ // Regular projects: show root-level directories AND files
349
+ const dirNodes = allNodes.filter(n => n.type === 'directory');
350
+ const fileNodes = allNodes.filter(n => n.type === 'file');
351
+
352
+ // Find minimum depth for directories and files
353
+ const minDirDepth = dirNodes.length > 0
354
+ ? Math.min(...dirNodes.map(n => n.depth))
355
+ : Infinity;
356
+ const minFileDepth = fileNodes.length > 0
357
+ ? Math.min(...fileNodes.map(n => n.depth))
358
+ : Infinity;
359
+
360
+ // Include both root-level directories and root-level files
361
+ rootNodes = [
362
+ ...dirNodes.filter(n => n.depth === minDirDepth),
363
+ ...fileNodes.filter(n => n.depth === minFileDepth)
364
+ ];
365
+
366
+ // Fallback to all files if nothing found
367
+ if (rootNodes.length === 0) {
368
+ rootNodes = fileNodes;
369
+ }
370
+ }
371
+
372
+ // Start with only root nodes visible, all collapsed
373
+ visibleNodes = new Set(rootNodes.map(n => n.id));
374
+ collapsedNodes = new Set(rootNodes.map(n => n.id));
375
+ highlightedNode = null;
376
+
377
+ // Initial render
378
+ renderGraph();
379
+
380
+ // Position folders compactly (same as reset view)
381
+ const currentNodes = allNodes.filter(n => visibleNodes.has(n.id));
382
+ positionNodesCompactly(currentNodes);
383
+
384
+ // Zoom to fit (same as reset view)
385
+ setTimeout(() => {
386
+ zoomToFit(750);
387
+ }, 300); // Slightly longer delay for positioning
388
+ }
389
+
390
+ function renderGraph() {
391
+ const visibleNodesList = allNodes.filter(n => visibleNodes.has(n.id));
392
+ const filteredLinks = getFilteredLinks();
393
+ const visibleLinks = filteredLinks.filter(l =>
394
+ visibleNodes.has(l.source.id || l.source) &&
395
+ visibleNodes.has(l.target.id || l.target)
396
+ );
397
+
398
+ simulation = d3.forceSimulation(visibleNodesList)
399
+ .force("link", d3.forceLink(visibleLinks)
400
+ .id(d => d.id)
401
+ .distance(d => {
402
+ // MUCH shorter distances for compact packing
403
+ if (d.type === 'dir_containment' || d.type === 'dir_hierarchy') {
404
+ return 40; // Drastically reduced from 60
405
+ }
406
+ if (d.is_cycle) return 80; // Reduced from 120
407
+ if (d.type === 'semantic') return 100; // Reduced from 150
408
+ return 60; // Reduced from 90
409
+ })
410
+ .strength(d => {
411
+ // STRONGER links to pull nodes much closer
412
+ if (d.type === 'dir_containment' || d.type === 'dir_hierarchy') {
413
+ return 0.8; // Increased from 0.6
414
+ }
415
+ if (d.is_cycle) return 0.4; // Increased from 0.3
416
+ if (d.type === 'semantic') return 0.3; // Increased from 0.2
417
+ return 0.7; // Increased from 0.5
418
+ })
419
+ )
420
+ .force("charge", d3.forceManyBody()
421
+ .strength(d => {
422
+ // ULTRA-LOW repulsion for maximum clustering
423
+ if (d.type === 'directory') {
424
+ return -30; // FURTHER REDUCED: -50 → -30 (40% less)
425
+ }
426
+ return -60; // FURTHER REDUCED: -100 → -60 (40% less)
427
+ })
428
+ )
429
+ .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1)) // Explicit centering strength
430
+ .force("radial", d3.forceRadial(100, width / 2, height / 2)
431
+ .strength(d => {
432
+ // Pull non-folder nodes toward center
433
+ if (d.type === 'directory') {
434
+ return 0; // Don't affect folders
435
+ }
436
+ return 0.1; // Gentle pull toward center for other nodes
437
+ })
438
+ )
439
+ .force("collision", d3.forceCollide()
440
+ .radius(d => {
441
+ // Collision radius to prevent overlap
442
+ if (d.type === 'directory') return 30;
443
+ if (d.type === 'file') return 26;
444
+ return 24;
445
+ })
446
+ .strength(1.0) // Maximum collision strength to prevent overlap
447
+ )
448
+ .velocityDecay(0.6)
449
+ .alphaDecay(0.02)
450
+
451
+ g.selectAll("*").remove();
452
+
453
+ const link = g.append("g")
454
+ .selectAll("line")
455
+ .data(visibleLinks)
456
+ .join("line")
457
+ .attr("class", d => {
458
+ // Cycle links have highest priority
459
+ if (d.is_cycle) return "link cycle";
460
+ if (d.type === "dependency") return "link dependency";
461
+ if (d.type === "semantic") {
462
+ // Color based on similarity score
463
+ const sim = d.similarity || 0;
464
+ let simClass = "sim-very-low";
465
+ if (sim >= 0.8) simClass = "sim-high";
466
+ else if (sim >= 0.6) simClass = "sim-medium-high";
467
+ else if (sim >= 0.4) simClass = "sim-medium";
468
+ else if (sim >= 0.2) simClass = "sim-low";
469
+ return `link semantic ${simClass}`;
470
+ }
471
+ return "link";
472
+ })
473
+ .on("mouseover", showLinkTooltip)
474
+ .on("mouseout", hideTooltip);
475
+
476
+ const node = g.append("g")
477
+ .selectAll("g")
478
+ .data(visibleNodesList)
479
+ .join("g")
480
+ .attr("class", d => {
481
+ let classes = `node ${d.type}`;
482
+ if (highlightedNode && d.id === highlightedNode.id) {
483
+ classes += ' highlighted';
484
+ }
485
+ return classes;
486
+ })
487
+ .call(drag(simulation))
488
+ .on("click", handleNodeClick)
489
+ .on("mouseover", showTooltip)
490
+ .on("mouseout", hideTooltip);
491
+
492
+ // Add shapes based on node type
493
+ const isDocNode = d => ['docstring', 'comment'].includes(d.type);
494
+ const isFileOrDir = d => d.type === 'file' || d.type === 'directory';
495
+
496
+ // Add circles for regular code nodes (not files/dirs/docs)
497
+ node.filter(d => !isDocNode(d) && !isFileOrDir(d))
498
+ .append("circle")
499
+ .attr("r", d => {
500
+ if (d.type === 'subproject') return 28; // Increased from 24
501
+ // Increase base size and complexity multiplier
502
+ return d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18; // Was 12 + complexity * 2, max 28, default 15
503
+ })
504
+ .attr("stroke", d => {
505
+ // Check if node has incoming caller/imports edges (dead code detection)
506
+ const hasIncoming = allLinks.some(l =>
507
+ (l.target.id || l.target) === d.id &&
508
+ (l.type === 'caller' || l.type === 'imports')
509
+ );
510
+ if (!hasIncoming && (d.type === 'function' || d.type === 'class' || d.type === 'method')) {
511
+ // Check if it's not an entry point (main, test, cli files)
512
+ const isEntryPoint = d.file_path && (
513
+ d.file_path.includes('main.py') ||
514
+ d.file_path.includes('__main__.py') ||
515
+ d.file_path.includes('cli.py') ||
516
+ d.file_path.includes('test_')
517
+ );
518
+ if (!isEntryPoint) {
519
+ return "#ff6b6b"; // Red border for potentially dead code
520
+ }
521
+ }
522
+ return hasChildren(d) ? "#ffffff" : "none";
523
+ })
524
+ .attr("stroke-width", d => {
525
+ const hasIncoming = allLinks.some(l =>
526
+ (l.target.id || l.target) === d.id &&
527
+ (l.type === 'caller' || l.type === 'imports')
528
+ );
529
+ if (!hasIncoming && (d.type === 'function' || d.type === 'class' || d.type === 'method')) {
530
+ const isEntryPoint = d.file_path && (
531
+ d.file_path.includes('main.py') ||
532
+ d.file_path.includes('__main__.py') ||
533
+ d.file_path.includes('cli.py') ||
534
+ d.file_path.includes('test_')
535
+ );
536
+ if (!isEntryPoint) {
537
+ return 3; // Thicker red border
538
+ }
539
+ }
540
+ return hasChildren(d) ? 2 : 0;
541
+ })
542
+ .style("fill", d => {
543
+ const baseColor = d.color || null;
544
+ if (!baseColor) return null;
545
+ return getComplexityShade(baseColor, d.complexity);
546
+ });
547
+
548
+ // Add rectangles for document nodes
549
+ node.filter(d => isDocNode(d))
550
+ .append("rect")
551
+ .attr("width", d => {
552
+ const size = d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18; // Increased from 12/28/15
553
+ return size * 2;
554
+ })
555
+ .attr("height", d => {
556
+ const size = d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18; // Increased from 12/28/15
557
+ return size * 2;
558
+ })
559
+ .attr("x", d => {
560
+ const size = d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18; // Increased from 12/28/15
561
+ return -size;
562
+ })
563
+ .attr("y", d => {
564
+ const size = d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18; // Increased from 12/28/15
565
+ return -size;
566
+ })
567
+ .attr("rx", 2) // Rounded corners
568
+ .attr("ry", 2)
569
+ .attr("stroke", d => hasChildren(d) ? "#ffffff" : "none")
570
+ .attr("stroke-width", d => hasChildren(d) ? 2 : 0)
571
+ .style("fill", d => {
572
+ const baseColor = d.color || null;
573
+ if (!baseColor) return null;
574
+ return getComplexityShade(baseColor, d.complexity);
575
+ });
576
+
577
+ // Add SVG icons for file and directory nodes
578
+ node.filter(d => isFileOrDir(d))
579
+ .append("path")
580
+ .attr("class", "file-icon")
581
+ .attr("d", d => getFileTypeIcon(d))
582
+ .attr("transform", d => {
583
+ const scale = d.type === 'directory' ? 2.2 : 1.8; // Increased from 1.8/1.5
584
+ return `translate(-12, -12) scale(${scale})`;
585
+ })
586
+ .style("color", d => getFileTypeColor(d))
587
+ .attr("stroke", d => hasChildren(d) ? "#ffffff" : "none")
588
+ .attr("stroke-width", d => hasChildren(d) ? 1.5 : 0); // Slightly thicker stroke
589
+
590
+ // Add expand/collapse indicator - positioned to the left of label
591
+ node.filter(d => hasChildren(d))
592
+ .append("text")
593
+ .attr("class", "expand-indicator")
594
+ .attr("x", d => {
595
+ const iconRadius = d.type === 'directory' ? 22 : (d.type === 'file' ? 18 : 18); // Increased from 18/15/15
596
+ return iconRadius + 5; // Just right of the icon
597
+ })
598
+ .attr("y", 0)
599
+ .attr("dy", "0.6em")
600
+ .attr("text-anchor", "start")
601
+ .style("font-size", "15px") // Slightly larger from 14px
602
+ .style("font-weight", "bold")
603
+ .style("fill", "#ffffff")
604
+ .style("pointer-events", "none")
605
+ .text(d => collapsedNodes.has(d.id) ? "+" : "−");
606
+
607
+ // Add labels (show actual import statement for L1 nodes)
608
+ node.append("text")
609
+ .text(d => {
610
+ // L1 (depth 1) nodes are imports
611
+ if (d.depth === 1 && d.type !== 'directory' && d.type !== 'file') {
612
+ if (d.content) {
613
+ // Extract first line of import statement
614
+ const importLine = d.content.split('\\n')[0].trim();
615
+ // Truncate if too long (max 60 chars)
616
+ return importLine.length > 60 ? importLine.substring(0, 57) + '...' : importLine;
617
+ }
618
+ return d.name; // Fallback to name if no content
619
+ }
620
+ return d.name;
621
+ })
622
+ .attr("x", d => {
623
+ const iconRadius = d.type === 'directory' ? 22 : (d.type === 'file' ? 18 : 18); // Increased from 18/15/15
624
+ const hasExpand = hasChildren(d);
625
+ return iconRadius + 8 + (hasExpand ? 24 : 0); // Slightly more offset for larger indicator
626
+ })
627
+ .attr("y", 0)
628
+ .attr("dy", "0.6em")
629
+ .attr("text-anchor", "start")
630
+ .style("font-size", d => {
631
+ if (d.type === 'subproject') return "13px"; // Slightly larger from 12px
632
+ if (isFileOrDir(d)) return "12px"; // Larger from 11px
633
+ return "11px"; // Larger from 10px
634
+ });
635
+
636
+ simulation.on("tick", () => {
637
+ link
638
+ .attr("x1", d => d.source.x)
639
+ .attr("y1", d => d.source.y)
640
+ .attr("x2", d => d.target.x)
641
+ .attr("y2", d => d.target.y);
642
+
643
+ node.attr("transform", d => `translate(${d.x},${d.y})`);
644
+ });
645
+
646
+ updateStats({nodes: visibleNodesList, links: visibleLinks, metadata: {total_files: allNodes.length}});
647
+ }
648
+
649
+ function hasChildren(node) {
650
+ return allLinks.some(l => (l.source.id || l.source) === node.id);
651
+ }
652
+ """
653
+
654
+
655
+ def get_zoom_and_navigation_functions() -> str:
656
+ """Get zoom and navigation functions.
657
+
658
+ Returns:
659
+ JavaScript string for zoom and navigation
660
+ """
661
+ return """
662
+ // Zoom to fit all visible nodes with appropriate padding
663
+ function zoomToFit(duration = 750) {
664
+ const visibleNodesList = allNodes.filter(n => visibleNodes.has(n.id));
665
+ if (visibleNodesList.length === 0) return;
666
+
667
+ // Calculate bounding box of visible nodes
668
+ // Use MUCH more padding to ensure all nodes visible
669
+ const padding = 120;
670
+ let minX = Infinity, minY = Infinity;
671
+ let maxX = -Infinity, maxY = -Infinity;
672
+
673
+ visibleNodesList.forEach(d => {
674
+ if (d.x !== undefined && d.y !== undefined) {
675
+ minX = Math.min(minX, d.x);
676
+ minY = Math.min(minY, d.y);
677
+ maxX = Math.max(maxX, d.x);
678
+ maxY = Math.max(maxY, d.y);
679
+ }
680
+ });
681
+
682
+ // Add padding
683
+ minX -= padding;
684
+ minY -= padding;
685
+ maxX += padding;
686
+ maxY += padding;
687
+
688
+ const boxWidth = maxX - minX;
689
+ const boxHeight = maxY - minY;
690
+
691
+ // Calculate scale to fit ALL nodes in viewport
692
+ const scale = Math.min(
693
+ width / boxWidth,
694
+ height / boxHeight,
695
+ 2 // Max zoom level
696
+ ) * 0.5; // EVEN MORE ZOOM-OUT: 0.7 → 0.5 (29% more margin)
697
+
698
+ // Calculate center translation
699
+ const centerX = (minX + maxX) / 2;
700
+ const centerY = (minY + maxY) / 2;
701
+ const translateX = width / 2 - scale * centerX;
702
+ const translateY = height / 2 - scale * centerY;
703
+
704
+ // Apply zoom transform with animation
705
+ svg.transition()
706
+ .duration(duration)
707
+ .call(
708
+ zoom.transform,
709
+ d3.zoomIdentity
710
+ .translate(translateX, translateY)
711
+ .scale(scale)
712
+ );
713
+ }
714
+
715
+ function centerNode(node) {
716
+ // Get current transform to maintain zoom level
717
+ const transform = d3.zoomTransform(svg.node());
718
+
719
+ // Calculate translation to center the node in LEFT portion of viewport
720
+ // Position at 30% from left to avoid code pane on right side
721
+ const x = -node.x * transform.k + width * 0.3;
722
+ const y = -node.y * transform.k + height / 2;
723
+
724
+ // Apply smooth animation to center the node
725
+ svg.transition()
726
+ .duration(750)
727
+ .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(transform.k));
728
+ }
729
+
730
+ function resetView() {
731
+ // Reset to root level nodes only
732
+ visibleNodes = new Set(rootNodes.map(n => n.id));
733
+ collapsedNodes = new Set(rootNodes.map(n => n.id));
734
+ highlightedNode = null;
735
+
736
+ // Clean up non-visible objects
737
+ const pane = document.querySelector('.content-pane');
738
+ if (pane) {
739
+ pane.classList.remove('visible');
740
+ // Clear pane content to free memory
741
+ const content = pane.querySelector('.content-container');
742
+ const footer = pane.querySelector('.footer-container');
743
+ if (content) content.innerHTML = '';
744
+ if (footer) footer.innerHTML = '';
745
+ }
746
+
747
+ // Remove any highlighted states
748
+ d3.selectAll('.node circle, .node rect')
749
+ .classed('highlighted', false)
750
+ .classed('selected', false);
751
+
752
+ // Re-render graph
753
+ renderGraph();
754
+
755
+ // Position folders compactly after rendering
756
+ setTimeout(() => {
757
+ const currentNodes = allNodes.filter(n => visibleNodes.has(n.id));
758
+ positionNodesCompactly(currentNodes);
759
+ }, 100);
760
+
761
+ // Zoom to fit after positioning
762
+ setTimeout(() => {
763
+ zoomToFit(750);
764
+ }, 300);
765
+ }
766
+ """
767
+
768
+
769
+ def get_interaction_handlers() -> str:
770
+ """Get interaction handler functions (click, expand, collapse).
771
+
772
+ Returns:
773
+ JavaScript string for interaction handling
774
+ """
775
+ return """
776
+ function handleNodeClick(event, d) {
777
+ event.stopPropagation();
778
+
779
+ // Always show content pane when clicking any node
780
+ showContentPane(d);
781
+
782
+ // If node has children, also toggle expansion
783
+ if (hasChildren(d)) {
784
+ const wasCollapsed = collapsedNodes.has(d.id);
785
+ if (wasCollapsed) {
786
+ expandNode(d);
787
+ } else {
788
+ collapseNode(d);
789
+ }
790
+ renderGraph();
791
+
792
+ // After rendering and nodes have positions, zoom to fit ONLY visible nodes
793
+ if (!wasCollapsed) {
794
+ setTimeout(() => {
795
+ simulation.alphaTarget(0);
796
+ zoomToFit(750);
797
+ }, 200);
798
+ } else {
799
+ setTimeout(() => {
800
+ centerNode(d);
801
+ }, 200);
802
+ }
803
+ } else {
804
+ setTimeout(() => {
805
+ centerNode(d);
806
+ }, 100);
807
+ }
808
+ }
809
+
810
+ function expandNode(node) {
811
+ collapsedNodes.delete(node.id);
812
+
813
+ // Find direct children
814
+ const children = allLinks
815
+ .filter(l => (l.source.id || l.source) === node.id)
816
+ .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
817
+ .filter(n => n);
818
+
819
+ children.forEach(child => {
820
+ visibleNodes.add(child.id);
821
+ collapsedNodes.add(child.id); // Children start collapsed
822
+ });
823
+ }
824
+
825
+ function collapseNode(node) {
826
+ collapsedNodes.add(node.id);
827
+
828
+ // Hide all descendants recursively
829
+ function hideDescendants(parentId) {
830
+ const children = allLinks
831
+ .filter(l => (l.source.id || l.source) === parentId)
832
+ .map(l => l.target.id || l.target);
833
+
834
+ children.forEach(childId => {
835
+ visibleNodes.delete(childId);
836
+ collapsedNodes.delete(childId);
837
+ hideDescendants(childId);
838
+ });
839
+ }
840
+
841
+ hideDescendants(node.id);
842
+ }
843
+ """
844
+
845
+
846
+ def get_tooltip_logic() -> str:
847
+ """Get tooltip display logic with enhanced relationship types.
848
+
849
+ Returns:
850
+ JavaScript string for tooltip handling
851
+ """
852
+ return """
853
+ function showTooltip(event, d) {
854
+ // Extract first 2-3 lines of docstring for preview
855
+ let docPreview = '';
856
+ if (d.docstring) {
857
+ const lines = d.docstring.split('\\n').filter(l => l.trim());
858
+ const previewLines = lines.slice(0, 3).join(' ');
859
+ const truncated = previewLines.length > 150 ? previewLines.substring(0, 147) + '...' : previewLines;
860
+ docPreview = `<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">${truncated}</div>`;
861
+ }
862
+
863
+ tooltip
864
+ .style("display", "block")
865
+ .style("left", (event.pageX + 10) + "px")
866
+ .style("top", (event.pageY + 10) + "px")
867
+ .html(`
868
+ <div><strong>${d.name}</strong></div>
869
+ <div>Type: ${d.type}</div>
870
+ ${d.complexity ? `<div>Complexity: ${d.complexity.toFixed(1)}</div>` : ''}
871
+ ${d.start_line ? `<div>Lines: ${d.start_line}-${d.end_line}</div>` : ''}
872
+ <div>File: ${d.file_path}</div>
873
+ ${docPreview}
874
+ `);
875
+ }
876
+
877
+ function showLinkTooltip(event, d) {
878
+ const sourceName = allNodes.find(n => n.id === (d.source.id || d.source))?.name || 'Unknown';
879
+ const targetName = allNodes.find(n => n.id === (d.target.id || d.target))?.name || 'Unknown';
880
+
881
+ // Special tooltip for cycle links
882
+ if (d.is_cycle) {
883
+ tooltip
884
+ .style("display", "block")
885
+ .style("left", (event.pageX + 10) + "px")
886
+ .style("top", (event.pageY + 10) + "px")
887
+ .html(`
888
+ <div style="color: #ff4444;"><strong>⚠️ Circular Dependency Detected</strong></div>
889
+ <div style="margin-top: 8px;">Path: ${sourceName} → ${targetName}</div>
890
+ <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">
891
+ This indicates a circular call relationship that may lead to infinite recursion or tight coupling.
892
+ </div>
893
+ `);
894
+ return;
895
+ }
896
+
897
+ // Tooltip content based on link type
898
+ let typeLabel = '';
899
+ let typeDescription = '';
900
+ let extraInfo = '';
901
+
902
+ switch(d.type) {
903
+ case 'caller':
904
+ typeLabel = '📞 Function Call';
905
+ typeDescription = `${sourceName} calls ${targetName}`;
906
+ extraInfo = 'This is a direct function call relationship, the most common type of code dependency.';
907
+ break;
908
+ case 'semantic':
909
+ typeLabel = '🔗 Semantic Similarity';
910
+ typeDescription = `${(d.similarity * 100).toFixed(1)}% similar`;
911
+ extraInfo = `These code chunks have similar meaning or purpose based on their content.`;
912
+ break;
913
+ case 'imports':
914
+ typeLabel = '📦 Import Dependency';
915
+ typeDescription = `${sourceName} imports ${targetName}`;
916
+ extraInfo = 'This is an explicit import/dependency declaration.';
917
+ break;
918
+ case 'file_containment':
919
+ typeLabel = '📄 File Contains';
920
+ typeDescription = `${sourceName} contains ${targetName}`;
921
+ extraInfo = 'This file contains the code chunk or function.';
922
+ break;
923
+ case 'dir_containment':
924
+ typeLabel = '📁 Directory Contains';
925
+ typeDescription = `${sourceName} contains ${targetName}`;
926
+ extraInfo = 'This directory contains the file or subdirectory.';
927
+ break;
928
+ case 'dir_hierarchy':
929
+ typeLabel = '🗂️ Directory Hierarchy';
930
+ typeDescription = `${sourceName} → ${targetName}`;
931
+ extraInfo = 'Parent-child directory structure relationship.';
932
+ break;
933
+ case 'method':
934
+ typeLabel = '⚙️ Method Relationship';
935
+ typeDescription = `${sourceName} ↔ ${targetName}`;
936
+ extraInfo = 'Class method relationship.';
937
+ break;
938
+ case 'module':
939
+ typeLabel = '📚 Module Relationship';
940
+ typeDescription = `${sourceName} ↔ ${targetName}`;
941
+ extraInfo = 'Module-level relationship.';
942
+ break;
943
+ case 'dependency':
944
+ typeLabel = '🔀 Dependency';
945
+ typeDescription = `${sourceName} depends on ${targetName}`;
946
+ extraInfo = 'General code dependency relationship.';
947
+ break;
948
+ default:
949
+ typeLabel = `🔗 ${d.type || 'Unknown'}`;
950
+ typeDescription = `${sourceName} → ${targetName}`;
951
+ extraInfo = 'Code relationship.';
952
+ }
953
+
954
+ tooltip
955
+ .style("display", "block")
956
+ .style("left", (event.pageX + 10) + "px")
957
+ .style("top", (event.pageY + 10) + "px")
958
+ .html(`
959
+ <div><strong>${typeLabel}</strong></div>
960
+ <div style="margin-top: 4px;">${typeDescription}</div>
961
+ <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">
962
+ ${extraInfo}
963
+ </div>
964
+ `);
965
+ }
966
+
967
+ function hideTooltip() {
968
+ tooltip.style("display", "none");
969
+ }
970
+ """
971
+
972
+
973
+ def get_drag_and_stats_functions() -> str:
974
+ """Get drag behavior and stats update functions.
975
+
976
+ Returns:
977
+ JavaScript string for drag and stats
978
+ """
979
+ return """
980
+ function drag(simulation) {
981
+ function dragstarted(event) {
982
+ if (!event.active) simulation.alphaTarget(0.3).restart();
983
+ event.subject.fx = event.subject.x;
984
+ event.subject.fy = event.subject.y;
985
+ }
986
+
987
+ function dragged(event) {
988
+ event.subject.fx = event.x;
989
+ event.subject.fy = event.y;
990
+ }
991
+
992
+ function dragended(event) {
993
+ if (!event.active) simulation.alphaTarget(0);
994
+ event.subject.fx = null;
995
+ event.subject.fy = null;
996
+ }
997
+
998
+ return d3.drag()
999
+ .on("start", dragstarted)
1000
+ .on("drag", dragged)
1001
+ .on("end", dragended);
1002
+ }
1003
+
1004
+ function updateStats(data) {
1005
+ const stats = d3.select("#stats");
1006
+ stats.html(`
1007
+ <div>Nodes: ${data.nodes.length}</div>
1008
+ <div>Links: ${data.links.length}</div>
1009
+ ${data.metadata ? `<div>Files: ${data.metadata.total_files || 'N/A'}</div>` : ''}
1010
+ ${data.metadata && data.metadata.is_monorepo ? `<div>Monorepo: ${data.metadata.subprojects.length} subprojects</div>` : ''}
1011
+ `);
1012
+
1013
+ // Show subproject legend if monorepo
1014
+ if (data.metadata && data.metadata.is_monorepo && data.metadata.subprojects.length > 0) {
1015
+ const subprojectsLegend = d3.select("#subprojects-legend");
1016
+ const subprojectsList = d3.select("#subprojects-list");
1017
+
1018
+ subprojectsLegend.style("display", "block");
1019
+
1020
+ // Get subproject nodes with colors
1021
+ const subprojectNodes = allNodes.filter(n => n.type === 'subproject');
1022
+
1023
+ subprojectsList.html(
1024
+ subprojectNodes.map(sp =>
1025
+ `<div class="legend-item">
1026
+ <span class="legend-color" style="background: ${sp.color};"></span> ${sp.name}
1027
+ </div>`
1028
+ ).join('')
1029
+ );
1030
+ }
1031
+ }
1032
+ """
1033
+
1034
+
1035
+ def get_breadcrumb_functions() -> str:
1036
+ """Get breadcrumb navigation functions for file/directory paths.
1037
+
1038
+ Returns:
1039
+ JavaScript string for breadcrumb generation and navigation
1040
+ """
1041
+ return """
1042
+ // Generate breadcrumb navigation for a file/directory path
1043
+ function generateBreadcrumbs(node) {
1044
+ if (!node.file_path) return '';
1045
+
1046
+ // File paths are already relative to project root, use them directly
1047
+ const nodePath = node.file_path;
1048
+ const segments = nodePath.split('/').filter(s => s.length > 0);
1049
+
1050
+ if (segments.length === 0) return '';
1051
+
1052
+ let breadcrumbHTML = '<div class="breadcrumb-nav">';
1053
+ breadcrumbHTML += '<span class="breadcrumb-root" onclick="navigateToRoot()">🏠 Root</span>';
1054
+
1055
+ let currentPath = '';
1056
+ segments.forEach((segment, index) => {
1057
+ currentPath += (currentPath ? '/' : '') + segment;
1058
+ const isLast = (index === segments.length - 1);
1059
+
1060
+ breadcrumbHTML += ' <span class="breadcrumb-separator">/</span> ';
1061
+
1062
+ if (!isLast) {
1063
+ // Parent directories are clickable
1064
+ breadcrumbHTML += `<span class="breadcrumb-link" onclick="navigateToBreadcrumb('${currentPath}')">${segment}</span>`;
1065
+ } else {
1066
+ // Current file/directory is not clickable (highlighted)
1067
+ breadcrumbHTML += `<span class="breadcrumb-current">${segment}</span>`;
1068
+ }
1069
+ });
1070
+
1071
+ breadcrumbHTML += '</div>';
1072
+ return breadcrumbHTML;
1073
+ }
1074
+
1075
+ // Navigate to a breadcrumb link (find and highlight the node)
1076
+ function navigateToBreadcrumb(path) {
1077
+ // Try to find the directory node by path
1078
+ const targetNode = allNodes.find(n => n.file_path === path || n.dir_path === path);
1079
+
1080
+ if (targetNode) {
1081
+ navigateToNode(targetNode);
1082
+ }
1083
+ }
1084
+
1085
+ // Navigate to project root
1086
+ function navigateToRoot() {
1087
+ resetView();
1088
+ }
1089
+ """
1090
+
1091
+
1092
+ def get_code_chunks_functions() -> str:
1093
+ """Get code chunks display functions for file viewer.
1094
+
1095
+ Returns:
1096
+ JavaScript string for code chunks navigation
1097
+
1098
+ Design Decision: Clickable code chunks section for file detail pane
1099
+
1100
+ Rationale: Users need quick navigation to specific functions/classes within files.
1101
+ Selected list-based UI with line ranges and type badges for clarity.
1102
+
1103
+ Trade-offs:
1104
+ - Performance: O(n) filtering per file vs. pre-indexing (chose simplicity)
1105
+ - UX: Shows all chunks vs. grouped by type (chose comprehensive view)
1106
+ - Visual: Icons vs. badges (chose both for maximum clarity)
1107
+
1108
+ Alternatives Considered:
1109
+ 1. Tree structure (functions under classes): Rejected - adds complexity
1110
+ 2. Grouped by type: Rejected - line number order more intuitive
1111
+ 3. Separate tab: Rejected - want chunks visible by default
1112
+
1113
+ Extension Points: Can add filtering by chunk_type, search box, or grouping later.
1114
+ """
1115
+ return """
1116
+ // Get all code chunks for a given file
1117
+ function getCodeChunksForFile(filePath) {
1118
+ if (!filePath) return [];
1119
+
1120
+ const chunks = allNodes.filter(n =>
1121
+ n.type === 'code' ||
1122
+ (n.file_path === filePath &&
1123
+ ['function', 'class', 'method'].includes(n.type))
1124
+ ).filter(n => n.file_path === filePath || n.parent_file === filePath);
1125
+
1126
+ // Sort by start_line
1127
+ return chunks.sort((a, b) =>
1128
+ (a.start_line || 0) - (b.start_line || 0)
1129
+ );
1130
+ }
1131
+
1132
+ // Generate HTML for code chunks section
1133
+ function generateCodeChunksSection(filePath) {
1134
+ const chunks = getCodeChunksForFile(filePath);
1135
+
1136
+ if (chunks.length === 0) {
1137
+ return ''; // No chunks, don't show section
1138
+ }
1139
+
1140
+ let html = '<div class="code-chunks-section">';
1141
+ html += '<h4 class="section-header">Code Chunks (' + chunks.length + ')</h4>';
1142
+ html += '<div class="code-chunks-list">';
1143
+
1144
+ chunks.forEach(chunk => {
1145
+ const icon = getChunkIcon(chunk.type);
1146
+ const lineRange = chunk.start_line ?
1147
+ ` <span class="line-range">L${chunk.start_line}-${chunk.end_line || chunk.start_line}</span>` :
1148
+ '';
1149
+
1150
+ html += `
1151
+ <div class="code-chunk-item" data-type="${chunk.type}" onclick="navigateToChunk('${chunk.id}')">
1152
+ <span class="chunk-icon">${icon}</span>
1153
+ <span class="chunk-name">${escapeHtml(chunk.name || 'unnamed')}</span>
1154
+ ${lineRange}
1155
+ <span class="chunk-type">${chunk.type || 'code'}</span>
1156
+ </div>
1157
+ `;
1158
+ });
1159
+
1160
+ html += '</div></div>';
1161
+ return html;
1162
+ }
1163
+
1164
+ // Get icon for chunk type
1165
+ function getChunkIcon(chunkType) {
1166
+ const icons = {
1167
+ 'function': '⚡',
1168
+ 'class': '📦',
1169
+ 'method': '🔧',
1170
+ 'variable': '📊',
1171
+ 'import': '📥',
1172
+ 'export': '📤',
1173
+ 'code': '📄'
1174
+ };
1175
+ return icons[chunkType] || '📄';
1176
+ }
1177
+
1178
+ // Navigate to a code chunk (highlight and show details)
1179
+ function navigateToChunk(chunkId) {
1180
+ const chunk = allNodes.find(n => n.id === chunkId);
1181
+ if (chunk) {
1182
+ navigateToNode(chunk);
1183
+ }
1184
+ }
1185
+
1186
+ // Navigate to a file by path (reload parent file view from code chunk)
1187
+ function navigateToFile(filePath) {
1188
+ const fileNode = allNodes.find(n => n.file_path === filePath && n.type === 'file');
1189
+ if (fileNode) {
1190
+ navigateToNode(fileNode);
1191
+ }
1192
+ }
1193
+ """
1194
+
1195
+
1196
+ def get_content_pane_functions() -> str:
1197
+ """Get content pane display functions.
1198
+
1199
+ Returns:
1200
+ JavaScript string for content pane
1201
+ """
1202
+ return """
1203
+ function showContentPane(node, addToHistory = true) {
1204
+ // Add to navigation stack if requested
1205
+ if (addToHistory) {
1206
+ viewStack.push(node.id);
1207
+ }
1208
+
1209
+ // Highlight the node
1210
+ highlightedNode = node;
1211
+ renderGraph();
1212
+
1213
+ // Populate content pane
1214
+ const pane = document.getElementById('content-pane');
1215
+ const title = document.getElementById('pane-title');
1216
+ const meta = document.getElementById('pane-meta');
1217
+ const content = document.getElementById('pane-content');
1218
+ const footer = document.getElementById('pane-footer');
1219
+
1220
+ // Generate and inject breadcrumbs at the top
1221
+ const breadcrumbs = generateBreadcrumbs(node);
1222
+
1223
+ // Set title with actual import statement for L1 nodes
1224
+ if (node.depth === 1 && node.type !== 'directory' && node.type !== 'file') {
1225
+ if (node.content) {
1226
+ const importLine = node.content.split('\\n')[0].trim();
1227
+ title.innerHTML = breadcrumbs + importLine;
1228
+ } else {
1229
+ title.innerHTML = breadcrumbs + `Import: ${node.name}`;
1230
+ }
1231
+ } else {
1232
+ title.innerHTML = breadcrumbs + node.name;
1233
+ }
1234
+
1235
+ // Set metadata
1236
+ meta.textContent = node.type;
1237
+
1238
+ // Build footer with annotations
1239
+ let footerHtml = '';
1240
+ if (node.language) {
1241
+ footerHtml += `<span class="footer-item"><span class="footer-label">Language:</span> ${node.language}</span>`;
1242
+ }
1243
+ footerHtml += `<span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>`;
1244
+
1245
+ if (node.start_line !== undefined && node.end_line !== undefined) {
1246
+ const totalLines = node.end_line - node.start_line + 1;
1247
+
1248
+ // Build line info string with optional non-doc code lines
1249
+ let lineInfo = `${node.start_line}-${node.end_line} (${totalLines} lines`;
1250
+
1251
+ // Add non-documentation code lines if available
1252
+ if (node.non_doc_lines !== undefined && node.non_doc_lines > 0) {
1253
+ lineInfo += `, ${node.non_doc_lines} code`;
1254
+ }
1255
+
1256
+ lineInfo += ')';
1257
+
1258
+ if (node.type === 'function' || node.type === 'class' || node.type === 'method') {
1259
+ footerHtml += `<span class="footer-item"><span class="footer-label">Lines:</span> ${lineInfo}</span>`;
1260
+ } else if (node.type === 'file') {
1261
+ footerHtml += `<span class="footer-item"><span class="footer-label">File Lines:</span> ${totalLines}</span>`;
1262
+ } else {
1263
+ footerHtml += `<span class="footer-item"><span class="footer-label">Location:</span> ${lineInfo}</span>`;
1264
+ }
1265
+
1266
+ if (node.complexity && node.complexity > 0) {
1267
+ footerHtml += `<span class="footer-item"><span class="footer-label">Complexity:</span> ${node.complexity}</span>`;
1268
+ }
1269
+ }
1270
+
1271
+ footer.innerHTML = footerHtml;
1272
+
1273
+ // Display content based on node type
1274
+ if (node.type === 'directory') {
1275
+ showDirectoryContents(node, content, footer);
1276
+ } else if (node.type === 'file') {
1277
+ showFileContents(node, content);
1278
+ } else if (node.depth === 1 && node.type !== 'directory' && node.type !== 'file') {
1279
+ showImportDetails(node, content);
1280
+ } else {
1281
+ showCodeContent(node, content);
1282
+ }
1283
+
1284
+ pane.classList.add('visible');
1285
+ }
1286
+
1287
+ function showDirectoryContents(node, container, footer) {
1288
+ const children = allLinks
1289
+ .filter(l => (l.source.id || l.source) === node.id)
1290
+ .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
1291
+ .filter(n => n);
1292
+
1293
+ if (children.length === 0) {
1294
+ container.innerHTML = '<p style="color: #8b949e;">Empty directory</p>';
1295
+ footer.innerHTML = `<span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>`;
1296
+ return;
1297
+ }
1298
+
1299
+ const files = children.filter(n => n.type === 'file');
1300
+ const subdirs = children.filter(n => n.type === 'directory');
1301
+ const chunks = children.filter(n => n.type !== 'file' && n.type !== 'directory');
1302
+
1303
+ let html = '<ul class="directory-list">';
1304
+
1305
+ subdirs.forEach(child => {
1306
+ html += `
1307
+ <li data-node-id="${child.id}">
1308
+ <span class="item-icon">📁</span>
1309
+ ${child.name}
1310
+ </li>
1311
+ `;
1312
+ });
1313
+
1314
+ files.forEach(child => {
1315
+ html += `
1316
+ <li data-node-id="${child.id}">
1317
+ <span class="item-icon">📄</span>
1318
+ ${child.name}
1319
+ </li>
1320
+ `;
1321
+ });
1322
+
1323
+ chunks.forEach(child => {
1324
+ const icon = child.type === 'class' ? '🔷' : child.type === 'function' ? '⚡' : '📝';
1325
+ html += `
1326
+ <li data-node-id="${child.id}">
1327
+ <span class="item-icon">${icon}</span>
1328
+ ${child.name}
1329
+ </li>
1330
+ `;
1331
+ });
1332
+
1333
+ html += '</ul>';
1334
+ container.innerHTML = html;
1335
+
1336
+ const listItems = container.querySelectorAll('.directory-list li');
1337
+ listItems.forEach(item => {
1338
+ item.addEventListener('click', () => {
1339
+ const nodeId = item.getAttribute('data-node-id');
1340
+ const childNode = allNodes.find(n => n.id === nodeId);
1341
+ if (childNode) {
1342
+ showContentPane(childNode);
1343
+ }
1344
+ });
1345
+ });
1346
+
1347
+ footer.innerHTML = `
1348
+ <span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>
1349
+ <span class="footer-item"><span class="footer-label">Total:</span> ${children.length} items (${subdirs.length} directories, ${files.length} files, ${chunks.length} code chunks)</span>
1350
+ `;
1351
+ }
1352
+
1353
+ function showFileContents(node, container) {
1354
+ const fileChunks = allLinks
1355
+ .filter(l => (l.source.id || l.source) === node.id)
1356
+ .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
1357
+ .filter(n => n);
1358
+
1359
+ if (fileChunks.length === 0) {
1360
+ container.innerHTML = '<p style="color: #8b949e;">No code chunks found in this file</p>';
1361
+ return;
1362
+ }
1363
+
1364
+ const sortedChunks = fileChunks
1365
+ .filter(c => c.content)
1366
+ .sort((a, b) => a.start_line - b.start_line);
1367
+
1368
+ if (sortedChunks.length === 0) {
1369
+ container.innerHTML = '<p style="color: #8b949e;">File content not available</p>';
1370
+ return;
1371
+ }
1372
+
1373
+ const fullContent = sortedChunks.map(c => c.content).join('\\n\\n');
1374
+
1375
+ // Generate code chunks section HTML
1376
+ const codeChunksHtml = generateCodeChunksSection(node.file_path || node.id);
1377
+
1378
+ container.innerHTML = `
1379
+ ${codeChunksHtml}
1380
+ <p style="color: #8b949e; font-size: 11px; margin-bottom: 12px; ${codeChunksHtml ? 'margin-top: 20px;' : ''}">
1381
+ Contains ${fileChunks.length} code chunks
1382
+ </p>
1383
+ <pre><code>${escapeHtml(fullContent)}</code></pre>
1384
+ `;
1385
+ }
1386
+
1387
+ function showImportDetails(node, container) {
1388
+ const importHtml = `
1389
+ <div class="import-details">
1390
+ ${node.content ? `
1391
+ <div style="margin-bottom: 16px;">
1392
+ <div class="detail-label" style="margin-bottom: 8px;">Import Statement:</div>
1393
+ <pre><code>${escapeHtml(node.content)}</code></pre>
1394
+ </div>
1395
+ ` : '<p style="color: #8b949e;">No import content available</p>'}
1396
+ </div>
1397
+ `;
1398
+
1399
+ container.innerHTML = importHtml;
1400
+ }
1401
+
1402
+ function parseDocstring(docstring) {
1403
+ if (!docstring) return { brief: '', sections: {} };
1404
+
1405
+ const lines = docstring.split('\\n');
1406
+ const sections = {};
1407
+ let currentSection = 'brief';
1408
+ let currentContent = [];
1409
+
1410
+ for (let line of lines) {
1411
+ const trimmed = line.trim();
1412
+ const sectionMatch = trimmed.match(/^(Args?|Returns?|Yields?|Raises?|Note|Notes|Example|Examples|See Also|Docs?|Parameters?):?$/i);
1413
+
1414
+ if (sectionMatch) {
1415
+ if (currentContent.length > 0) {
1416
+ sections[currentSection] = currentContent.join('\\n').trim();
1417
+ }
1418
+ currentSection = sectionMatch[1].toLowerCase();
1419
+ currentContent = [];
1420
+ } else {
1421
+ currentContent.push(line);
1422
+ }
1423
+ }
1424
+
1425
+ if (currentContent.length > 0) {
1426
+ sections[currentSection] = currentContent.join('\\n').trim();
1427
+ }
1428
+
1429
+ return { brief: sections.brief || '', sections };
1430
+ }
1431
+
1432
+ // Create linkable code with Python primitives bolded
1433
+ function createLinkableCode(code, currentNodeId) {
1434
+ // Build map of available nodes (functions, classes, methods)
1435
+ const nodeMap = new Map();
1436
+ allNodes.forEach(node => {
1437
+ if (node.type === 'function' || node.type === 'class' || node.type === 'method') {
1438
+ nodeMap.set(node.name, node.id);
1439
+ }
1440
+ });
1441
+
1442
+ // Escape HTML first
1443
+ let html = code
1444
+ .replace(/&/g, '&amp;')
1445
+ .replace(/</g, '&lt;')
1446
+ .replace(/>/g, '&gt;');
1447
+
1448
+ // Find and link function/class references
1449
+ // Match: word followed by '(' or preceded by 'class '
1450
+ const identifierRegex = /\\b([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?=\\()|(?<=class\\s+)([a-zA-Z_][a-zA-Z0-9_]*)/g;
1451
+
1452
+ // Collect matches to avoid overlapping replacements
1453
+ const matches = [];
1454
+ let match;
1455
+ while ((match = identifierRegex.exec(code)) !== null) {
1456
+ const name = match[1] || match[2];
1457
+ if (nodeMap.has(name) && nodeMap.get(name) !== currentNodeId) {
1458
+ matches.push({
1459
+ index: match.index,
1460
+ length: name.length,
1461
+ name: name,
1462
+ nodeId: nodeMap.get(name)
1463
+ });
1464
+ }
1465
+ }
1466
+
1467
+ // Apply replacements in reverse order to preserve indices
1468
+ matches.reverse().forEach(m => {
1469
+ const before = html.substring(0, m.index);
1470
+ const linkText = html.substring(m.index, m.index + m.length);
1471
+ const after = html.substring(m.index + m.length);
1472
+
1473
+ html = before +
1474
+ `<span class="code-link" data-node-id="${m.nodeId}" title="Jump to ${m.name}">${linkText}</span>` +
1475
+ after;
1476
+ });
1477
+
1478
+ // Apply primitive bolding AFTER creating links
1479
+ html = boldPythonPrimitives(html);
1480
+
1481
+ return html;
1482
+ }
1483
+
1484
+ // Bold Python primitives (keywords and built-ins)
1485
+ function boldPythonPrimitives(html) {
1486
+ // Python keywords
1487
+ const keywords = [
1488
+ 'def', 'class', 'if', 'else', 'elif', 'for', 'while', 'return',
1489
+ 'import', 'from', 'try', 'except', 'finally', 'with', 'as',
1490
+ 'async', 'await', 'yield', 'lambda', 'pass', 'break', 'continue',
1491
+ 'raise', 'assert', 'del', 'global', 'nonlocal', 'is', 'in',
1492
+ 'and', 'or', 'not'
1493
+ ];
1494
+
1495
+ // Built-in types and functions
1496
+ const builtins = [
1497
+ 'str', 'int', 'float', 'bool', 'list', 'dict', 'set', 'tuple',
1498
+ 'None', 'True', 'False', 'type', 'len', 'range', 'enumerate',
1499
+ 'zip', 'map', 'filter', 'sorted', 'reversed', 'any', 'all',
1500
+ 'isinstance', 'issubclass', 'hasattr', 'getattr', 'setattr', 'print'
1501
+ ];
1502
+
1503
+ // Combine all primitives
1504
+ const allPrimitives = [...keywords, ...builtins];
1505
+
1506
+ // Create regex pattern: \b(keyword1|keyword2|...)\b
1507
+ // Use word boundaries to avoid matching parts of identifiers
1508
+ const pattern = new RegExp(`\\\\b(${allPrimitives.join('|')})\\\\b`, 'g');
1509
+
1510
+ // Replace with bold version
1511
+ // Important: Skip content inside existing HTML tags (like code-link spans)
1512
+ const result = html.replace(pattern, (match) => {
1513
+ return `<strong style="color: #ff7b72; font-weight: 600;">${match}</strong>`;
1514
+ });
1515
+
1516
+ return result;
1517
+ }
1518
+
1519
+ function showCodeContent(node, container) {
1520
+ let html = '';
1521
+
1522
+ const docInfo = parseDocstring(node.docstring);
1523
+
1524
+ if (docInfo.brief && docInfo.brief.trim()) {
1525
+ html += `
1526
+ <div style="margin-bottom: 16px; padding: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 6px;">
1527
+ <div style="font-size: 11px; color: #8b949e; margin-bottom: 8px; font-weight: 600;">DESCRIPTION</div>
1528
+ <pre style="margin: 0; padding: 0; background: transparent; border: none; white-space: pre-wrap;"><code>${escapeHtml(docInfo.brief)}</code></pre>
1529
+ </div>
1530
+ `;
1531
+ }
1532
+
1533
+ if (node.content) {
1534
+ // Use linkable code with primitives bolding
1535
+ const linkedCode = createLinkableCode(node.content, node.id);
1536
+ html += `<pre><code>${linkedCode}</code></pre>`;
1537
+ } else {
1538
+ html += '<p style="color: #8b949e;">No content available</p>';
1539
+ }
1540
+
1541
+ container.innerHTML = html;
1542
+
1543
+ // Add click handler for code links
1544
+ const codeLinks = container.querySelectorAll('.code-link');
1545
+ codeLinks.forEach(link => {
1546
+ link.addEventListener('click', (e) => {
1547
+ e.preventDefault();
1548
+ const nodeId = link.getAttribute('data-node-id');
1549
+ const targetNode = allNodes.find(n => n.id === nodeId);
1550
+ if (targetNode) {
1551
+ navigateToNode(targetNode);
1552
+ }
1553
+ });
1554
+ });
1555
+
1556
+ const footer = document.getElementById('pane-footer');
1557
+ let footerHtml = '';
1558
+
1559
+ if (node.language) {
1560
+ footerHtml += `<div class="footer-item"><span class="footer-label">Language:</span> <span class="footer-value">${node.language}</span></div>`;
1561
+ }
1562
+ footerHtml += `<div class="footer-item"><span class="footer-label">File:</span> <a href="#" class="file-path-link" onclick="navigateToFile('${node.file_path}'); return false;" style="color: #58a6ff; text-decoration: none; cursor: pointer;">${node.file_path}</a></div>`;
1563
+ if (node.start_line) {
1564
+ footerHtml += `<div class="footer-item"><span class="footer-label">Lines:</span> <span class="footer-value">${node.start_line}-${node.end_line}</span></div>`;
1565
+ }
1566
+
1567
+ if (node.callers && node.callers.length > 0) {
1568
+ footerHtml += `<div class="footer-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d;">`;
1569
+ footerHtml += `<span class="footer-label">Called By:</span><br/>`;
1570
+ node.callers.forEach(caller => {
1571
+ const fileName = caller.file.split('/').pop();
1572
+ const callerDisplay = `${fileName}::${caller.name}`;
1573
+ footerHtml += `<span class="footer-value" style="display: block; margin-left: 8px; margin-top: 4px;">
1574
+ <a href="#" class="caller-link" data-chunk-id="${caller.chunk_id}" style="color: #58a6ff; text-decoration: none; cursor: pointer;">
1575
+ • ${escapeHtml(callerDisplay)}
1576
+ </a>
1577
+ </span>`;
1578
+ });
1579
+ footerHtml += `</div>`;
1580
+ } else if (node.type === 'function' || node.type === 'method' || node.type === 'class') {
1581
+ footerHtml += `<div class="footer-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d;">`;
1582
+ footerHtml += `<span class="footer-label">Called By:</span> <span class="footer-value" style="font-style: italic; color: #6e7681;">(No external callers found)</span>`;
1583
+ footerHtml += `</div>`;
1584
+ }
1585
+
1586
+ const sectionLabels = {
1587
+ 'docs': 'Docs', 'doc': 'Docs',
1588
+ 'args': 'Args', 'arg': 'Args',
1589
+ 'parameters': 'Args', 'parameter': 'Args',
1590
+ 'returns': 'Returns', 'return': 'Returns',
1591
+ 'yields': 'Yields', 'yield': 'Yields',
1592
+ 'raises': 'Raises', 'raise': 'Raises',
1593
+ 'note': 'Note', 'notes': 'Note',
1594
+ 'example': 'Example', 'examples': 'Example',
1595
+ };
1596
+
1597
+ for (let [key, content] of Object.entries(docInfo.sections)) {
1598
+ if (key === 'brief') continue;
1599
+
1600
+ const label = sectionLabels[key] || key.charAt(0).toUpperCase() + key.slice(1);
1601
+ const truncated = content.length > 200 ? content.substring(0, 197) + '...' : content;
1602
+
1603
+ footerHtml += `<div class="footer-item"><span class="footer-label">${label}:</span> <span class="footer-value">${escapeHtml(truncated)}</span></div>`;
1604
+ }
1605
+
1606
+ footer.innerHTML = footerHtml;
1607
+
1608
+ const callerLinks = footer.querySelectorAll('.caller-link');
1609
+ callerLinks.forEach(link => {
1610
+ link.addEventListener('click', (e) => {
1611
+ e.preventDefault();
1612
+ const chunkId = link.getAttribute('data-chunk-id');
1613
+ const callerNode = allNodes.find(n => n.id === chunkId);
1614
+ if (callerNode) {
1615
+ navigateToNode(callerNode);
1616
+ }
1617
+ });
1618
+ });
1619
+ }
1620
+
1621
+ function escapeHtml(text) {
1622
+ const div = document.createElement('div');
1623
+ div.textContent = text;
1624
+ return div.innerHTML;
1625
+ }
1626
+
1627
+ function navigateToNode(targetNode) {
1628
+ if (!visibleNodes.has(targetNode.id)) {
1629
+ expandParentsToNode(targetNode);
1630
+ renderGraph();
1631
+ }
1632
+
1633
+ showContentPane(targetNode);
1634
+
1635
+ setTimeout(() => {
1636
+ if (targetNode.x !== undefined && targetNode.y !== undefined) {
1637
+ const scale = 1.5;
1638
+ const translateX = width * 0.3 - scale * targetNode.x;
1639
+ const translateY = height / 2 - scale * targetNode.y;
1640
+
1641
+ svg.transition()
1642
+ .duration(750)
1643
+ .call(
1644
+ zoom.transform,
1645
+ d3.zoomIdentity
1646
+ .translate(translateX, translateY)
1647
+ .scale(scale)
1648
+ );
1649
+ }
1650
+ }, 200);
1651
+ }
1652
+
1653
+ function expandParentsToNode(targetNode) {
1654
+ const path = [];
1655
+ let current = targetNode;
1656
+
1657
+ while (current) {
1658
+ path.unshift(current);
1659
+ const parentLink = allLinks.find(l =>
1660
+ (l.target.id || l.target) === current.id &&
1661
+ (l.type !== 'semantic' && l.type !== 'dependency')
1662
+ );
1663
+ if (parentLink) {
1664
+ const parentId = parentLink.source.id || parentLink.source;
1665
+ current = allNodes.find(n => n.id === parentId);
1666
+ } else {
1667
+ break;
1668
+ }
1669
+ }
1670
+
1671
+ path.forEach(node => {
1672
+ if (!visibleNodes.has(node.id)) {
1673
+ visibleNodes.add(node.id);
1674
+ }
1675
+ if (collapsedNodes.has(node.id)) {
1676
+ expandNode(node);
1677
+ }
1678
+ });
1679
+ }
1680
+
1681
+ function closeContentPane() {
1682
+ const pane = document.getElementById('content-pane');
1683
+ pane.classList.remove('visible');
1684
+
1685
+ highlightedNode = null;
1686
+ renderGraph();
1687
+ }
1688
+ """
1689
+
1690
+
1691
+ def get_navigation_stack_logic() -> str:
1692
+ """Get navigation stack for back/forward functionality.
1693
+
1694
+ Returns:
1695
+ JavaScript string for navigation stack management
1696
+ """
1697
+ return """
1698
+ // Navigation stack for back/forward functionality
1699
+ const viewStack = {
1700
+ stack: [],
1701
+ currentIndex: -1,
1702
+
1703
+ push(chunkId) {
1704
+ // Don't add duplicates if clicking same node
1705
+ if (this.stack.length > 0 && this.stack[this.currentIndex] === chunkId) {
1706
+ return;
1707
+ }
1708
+
1709
+ // Remove forward history if we're not at the end
1710
+ this.stack = this.stack.slice(0, this.currentIndex + 1);
1711
+ this.stack.push(chunkId);
1712
+ this.currentIndex++;
1713
+ this.updateButtons();
1714
+ },
1715
+
1716
+ canGoBack() {
1717
+ return this.currentIndex > 0;
1718
+ },
1719
+
1720
+ canGoForward() {
1721
+ return this.currentIndex < this.stack.length - 1;
1722
+ },
1723
+
1724
+ back() {
1725
+ if (this.canGoBack()) {
1726
+ this.currentIndex--;
1727
+ this.updateButtons();
1728
+ return this.stack[this.currentIndex];
1729
+ }
1730
+ return null;
1731
+ },
1732
+
1733
+ forward() {
1734
+ if (this.canGoForward()) {
1735
+ this.currentIndex++;
1736
+ this.updateButtons();
1737
+ return this.stack[this.currentIndex];
1738
+ }
1739
+ return null;
1740
+ },
1741
+
1742
+ updateButtons() {
1743
+ const backBtn = document.getElementById('navBack');
1744
+ const forwardBtn = document.getElementById('navForward');
1745
+ const positionSpan = document.getElementById('navPosition');
1746
+
1747
+ if (backBtn) backBtn.disabled = !this.canGoBack();
1748
+ if (forwardBtn) forwardBtn.disabled = !this.canGoForward();
1749
+ if (positionSpan && this.stack.length > 0) {
1750
+ positionSpan.textContent = `${this.currentIndex + 1} of ${this.stack.length}`;
1751
+ }
1752
+ },
1753
+
1754
+ clear() {
1755
+ this.stack = [];
1756
+ this.currentIndex = -1;
1757
+ this.updateButtons();
1758
+ }
1759
+ };
1760
+
1761
+ // Add keyboard shortcuts for navigation
1762
+ document.addEventListener('keydown', (e) => {
1763
+ if (e.altKey && e.key === 'ArrowLeft') {
1764
+ e.preventDefault();
1765
+ const chunkId = viewStack.back();
1766
+ if (chunkId) {
1767
+ const node = allNodes.find(n => n.id === chunkId);
1768
+ if (node) showContentPane(node, false); // false = don't add to history
1769
+ }
1770
+ } else if (e.altKey && e.key === 'ArrowRight') {
1771
+ e.preventDefault();
1772
+ const chunkId = viewStack.forward();
1773
+ if (chunkId) {
1774
+ const node = allNodes.find(n => n.id === chunkId);
1775
+ if (node) showContentPane(node, false);
1776
+ }
1777
+ }
1778
+ });
1779
+
1780
+ // Add click handlers for navigation buttons
1781
+ document.addEventListener('DOMContentLoaded', () => {
1782
+ const backBtn = document.getElementById('navBack');
1783
+ const forwardBtn = document.getElementById('navForward');
1784
+
1785
+ if (backBtn) {
1786
+ backBtn.addEventListener('click', () => {
1787
+ const chunkId = viewStack.back();
1788
+ if (chunkId) {
1789
+ const node = allNodes.find(n => n.id === chunkId);
1790
+ if (node) showContentPane(node, false);
1791
+ }
1792
+ });
1793
+ }
1794
+
1795
+ if (forwardBtn) {
1796
+ forwardBtn.addEventListener('click', () => {
1797
+ const chunkId = viewStack.forward();
1798
+ if (chunkId) {
1799
+ const node = allNodes.find(n => n.id === chunkId);
1800
+ if (node) showContentPane(node, false);
1801
+ }
1802
+ });
1803
+ }
1804
+ });
1805
+ """
1806
+
1807
+
1808
+ def get_layout_switching_logic() -> str:
1809
+ """Get layout switching functionality for Dagre/Force/Circle layouts.
1810
+
1811
+ Returns:
1812
+ JavaScript string for layout switching
1813
+ """
1814
+ return """
1815
+ // Filter edges based on current filter settings
1816
+ function getFilteredLinks() {
1817
+ return allLinks.filter(link => {
1818
+ const linkType = link.type || 'unknown';
1819
+
1820
+ // Containment edges
1821
+ if (linkType === 'dir_containment' || linkType === 'dir_hierarchy' || linkType === 'file_containment') {
1822
+ return edgeFilters.containment;
1823
+ }
1824
+
1825
+ // Call edges
1826
+ if (linkType === 'caller') {
1827
+ return edgeFilters.calls;
1828
+ }
1829
+
1830
+ // Import edges
1831
+ if (linkType === 'imports') {
1832
+ return edgeFilters.imports;
1833
+ }
1834
+
1835
+ // Semantic edges
1836
+ if (linkType === 'semantic') {
1837
+ return edgeFilters.semantic;
1838
+ }
1839
+
1840
+ // Cycle edges
1841
+ if (link.is_cycle) {
1842
+ return edgeFilters.cycles;
1843
+ }
1844
+
1845
+ // Default: show other edge types
1846
+ return true;
1847
+ });
1848
+ }
1849
+
1850
+ // Switch to Cytoscape layout (Dagre or Circle)
1851
+ function switchToCytoscapeLayout(layoutName) {
1852
+ // Note: This is legacy code for old visualization architecture
1853
+ // V2.0 uses tree-based layouts with automatic phase transitions
1854
+
1855
+ // Hide D3 SVG
1856
+ svg.style('display', 'none');
1857
+
1858
+ // Create Cytoscape container if doesn't exist
1859
+ let cyContainer = document.getElementById('cy-container');
1860
+ if (!cyContainer) {
1861
+ cyContainer = document.createElement('div');
1862
+ cyContainer.id = 'cy-container';
1863
+ cyContainer.style.width = '100vw';
1864
+ cyContainer.style.height = '100vh';
1865
+ cyContainer.style.position = 'absolute';
1866
+ cyContainer.style.top = '0';
1867
+ cyContainer.style.left = '0';
1868
+ document.body.insertBefore(cyContainer, document.body.firstChild);
1869
+ }
1870
+ cyContainer.style.display = 'block';
1871
+
1872
+ // Get visible nodes and filtered links
1873
+ const visibleNodesList = allNodes.filter(n => visibleNodes.has(n.id));
1874
+ const filteredLinks = getFilteredLinks();
1875
+ const visibleLinks = filteredLinks.filter(l =>
1876
+ visibleNodes.has(l.source.id || l.source) &&
1877
+ visibleNodes.has(l.target.id || l.target)
1878
+ );
1879
+
1880
+ // Convert to Cytoscape format
1881
+ const cyElements = [];
1882
+
1883
+ // Add nodes
1884
+ visibleNodesList.forEach(node => {
1885
+ cyElements.push({
1886
+ data: {
1887
+ id: node.id,
1888
+ label: node.name,
1889
+ nodeType: node.type,
1890
+ color: node.color,
1891
+ ...node
1892
+ }
1893
+ });
1894
+ });
1895
+
1896
+ // Add edges
1897
+ visibleLinks.forEach(link => {
1898
+ const sourceId = link.source.id || link.source;
1899
+ const targetId = link.target.id || link.target;
1900
+ cyElements.push({
1901
+ data: {
1902
+ ...link,
1903
+ source: sourceId,
1904
+ target: targetId,
1905
+ linkType: link.type,
1906
+ isCycle: link.is_cycle
1907
+ }
1908
+ });
1909
+ });
1910
+
1911
+ // Initialize or update Cytoscape
1912
+ if (cy) {
1913
+ cy.destroy();
1914
+ }
1915
+
1916
+ cy = cytoscape({
1917
+ container: cyContainer,
1918
+ elements: cyElements,
1919
+ style: [
1920
+ {
1921
+ selector: 'node',
1922
+ style: {
1923
+ 'label': 'data(label)',
1924
+ 'background-color': 'data(color)',
1925
+ 'color': '#c9d1d9',
1926
+ 'font-size': '11px',
1927
+ 'text-valign': 'center',
1928
+ 'text-halign': 'right',
1929
+ 'text-margin-x': '5px',
1930
+ 'width': d => d.data('type') === 'directory' ? 35 : 25,
1931
+ 'height': d => d.data('type') === 'directory' ? 35 : 25,
1932
+ }
1933
+ },
1934
+ {
1935
+ selector: 'edge',
1936
+ style: {
1937
+ 'width': 2,
1938
+ 'line-color': '#30363d',
1939
+ 'target-arrow-color': '#30363d',
1940
+ 'target-arrow-shape': 'triangle',
1941
+ 'curve-style': 'bezier'
1942
+ }
1943
+ },
1944
+ {
1945
+ selector: 'edge[isCycle]',
1946
+ style: {
1947
+ 'line-color': '#ff4444',
1948
+ 'width': 3,
1949
+ 'line-style': 'dashed'
1950
+ }
1951
+ }
1952
+ ],
1953
+ layout: {
1954
+ name: layoutName === 'dagre' ? 'dagre' : 'circle',
1955
+ rankDir: 'TB',
1956
+ rankSep: 150,
1957
+ nodeSep: 80,
1958
+ ranker: 'network-simplex',
1959
+ spacingFactor: 1.2
1960
+ }
1961
+ });
1962
+
1963
+ // Add click handler
1964
+ cy.on('tap', 'node', function(evt) {
1965
+ const nodeData = evt.target.data();
1966
+ const node = allNodes.find(n => n.id === nodeData.id);
1967
+ if (node) {
1968
+ showContentPane(node);
1969
+ }
1970
+ });
1971
+ }
1972
+
1973
+ // Switch to D3 force-directed layout
1974
+ function switchToForceLayout() {
1975
+ // Note: This is legacy code for old visualization architecture
1976
+ // V2.0 uses tree-based layouts with automatic phase transitions
1977
+
1978
+ // Hide Cytoscape
1979
+ const cyContainer = document.getElementById('cy-container');
1980
+ if (cyContainer) {
1981
+ cyContainer.style.display = 'none';
1982
+ }
1983
+
1984
+ // Show D3 SVG
1985
+ svg.style('display', 'block');
1986
+
1987
+ // Re-render with D3
1988
+ renderGraph();
1989
+ }
1990
+
1991
+ // Handle layout selector change
1992
+ document.addEventListener('DOMContentLoaded', () => {
1993
+ const layoutSelector = document.getElementById('layoutSelector');
1994
+ if (layoutSelector) {
1995
+ layoutSelector.addEventListener('change', (e) => {
1996
+ const layout = e.target.value;
1997
+ if (layout === 'force') {
1998
+ switchToForceLayout();
1999
+ } else if (layout === 'dagre' || layout === 'circle') {
2000
+ switchToCytoscapeLayout(layout);
2001
+ }
2002
+ });
2003
+ }
2004
+
2005
+ // Handle edge filter checkboxes
2006
+ const filterCheckboxes = {
2007
+ 'filter-containment': 'containment',
2008
+ 'filter-calls': 'calls',
2009
+ 'filter-imports': 'imports',
2010
+ 'filter-semantic': 'semantic',
2011
+ 'filter-cycles': 'cycles'
2012
+ };
2013
+
2014
+ Object.entries(filterCheckboxes).forEach(([id, filterKey]) => {
2015
+ const checkbox = document.getElementById(id);
2016
+ if (checkbox) {
2017
+ checkbox.addEventListener('change', (e) => {
2018
+ edgeFilters[filterKey] = e.target.checked;
2019
+ // Re-render with new filters
2020
+ // Note: V2.0 uses automatic layout based on view mode
2021
+ if (typeof renderGraphV2 === 'function') {
2022
+ renderGraphV2(); // V2.0 rendering
2023
+ } else {
2024
+ renderGraph(); // Legacy fallback
2025
+ }
2026
+ });
2027
+ }
2028
+ });
2029
+ });
2030
+ """
2031
+
2032
+
2033
+ def get_data_loading_logic() -> str:
2034
+ """Get data loading logic with streaming JSON parser.
2035
+
2036
+ Returns:
2037
+ JavaScript string for data loading
2038
+
2039
+ Design Decision: Streaming JSON with chunked transfer and incremental parsing
2040
+
2041
+ Rationale: Safari's JSON.parse() crashes with 6.3MB files. Selected streaming
2042
+ approach to download in chunks and parse incrementally, avoiding browser memory
2043
+ limits and parser crashes.
2044
+
2045
+ Trade-offs:
2046
+ - Memory: Constant memory usage vs. loading entire file
2047
+ - Complexity: Custom streaming parser vs. simple JSON.parse()
2048
+ - Performance: Slightly slower but prevents crashes
2049
+
2050
+ Alternatives Considered:
2051
+ 1. Web Workers for parsing: Rejected - still requires full JSON in memory
2052
+ 2. IndexedDB caching: Rejected - doesn't solve initial load problem
2053
+ 3. MessagePack binary: Rejected - requires backend changes
2054
+
2055
+ Error Handling:
2056
+ - Network errors: Show retry button with clear error message
2057
+ - Timeout: 60s timeout with abort controller
2058
+ - Parse errors: Log to console and show user-friendly message
2059
+ - Incomplete data: Validate nodes/links exist before rendering
2060
+
2061
+ Performance:
2062
+ - Transfer: Shows progress 0-50% during download
2063
+ - Parse: Shows progress 50-100% during JSON parsing
2064
+ - Expected: <10s for 6.3MB file on localhost
2065
+ - Memory: <100MB peak usage during load
2066
+ """
2067
+ return """
2068
+ // Streaming JSON loader to handle large files without crashing Safari
2069
+ async function loadGraphDataStreaming() {
2070
+ const progressBar = document.getElementById('progress-bar');
2071
+ const progressText = document.getElementById('progress-text');
2072
+
2073
+ try {
2074
+ // Fetch from streaming endpoint
2075
+ const response = await fetch('/api/graph-data');
2076
+
2077
+ if (!response.ok) {
2078
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2079
+ }
2080
+
2081
+ const contentLength = response.headers.get('content-length');
2082
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
2083
+ let loaded = 0;
2084
+
2085
+ if (total > 0) {
2086
+ const sizeMB = (total / (1024 * 1024)).toFixed(1);
2087
+ progressText.textContent = `Downloading ${sizeMB}MB...`;
2088
+ }
2089
+
2090
+ // Stream download with progress tracking
2091
+ const reader = response.body.getReader();
2092
+ const decoder = new TextDecoder();
2093
+ let buffer = '';
2094
+
2095
+ while (true) {
2096
+ const {done, value} = await reader.read();
2097
+
2098
+ if (done) break;
2099
+
2100
+ loaded += value.byteLength;
2101
+
2102
+ // Update progress (0-50% for transfer)
2103
+ if (total > 0) {
2104
+ const transferPercent = Math.round((loaded / total) * 50);
2105
+ progressBar.style.width = transferPercent + '%';
2106
+ const loadedMB = (loaded / (1024 * 1024)).toFixed(1);
2107
+ const totalMB = (total / (1024 * 1024)).toFixed(1);
2108
+ progressText.textContent = `Downloaded ${loadedMB}MB / ${totalMB}MB (${transferPercent}%)`;
2109
+ } else {
2110
+ const loadedMB = (loaded / (1024 * 1024)).toFixed(1);
2111
+ progressText.textContent = `Downloaded ${loadedMB}MB...`;
2112
+ }
2113
+
2114
+ // Accumulate chunks into buffer
2115
+ buffer += decoder.decode(value, {stream: true});
2116
+ }
2117
+
2118
+ // Transfer complete, now parse
2119
+ progressBar.style.width = '50%';
2120
+ progressText.textContent = 'Parsing JSON data...';
2121
+
2122
+ // Parse JSON (this is still the bottleneck, but at least we streamed the download)
2123
+ // Future optimization: Implement incremental JSON parser if needed
2124
+ const data = JSON.parse(buffer);
2125
+
2126
+ // Parsing complete
2127
+ progressBar.style.width = '100%';
2128
+ progressText.textContent = 'Complete!';
2129
+
2130
+ return data;
2131
+
2132
+ } catch (error) {
2133
+ console.error('Streaming load error:', error);
2134
+ throw error;
2135
+ }
2136
+ }
2137
+
2138
+ // Auto-load graph data on page load with streaming support
2139
+ window.addEventListener('DOMContentLoaded', () => {
2140
+ const loadingEl = document.getElementById('loading');
2141
+
2142
+ // Show initial loading message
2143
+ loadingEl.innerHTML = '<label style="color: #58a6ff;"><span class="spinner"></span>Loading graph data...</label><br>' +
2144
+ '<div style="margin-top: 8px; background: #21262d; border-radius: 4px; height: 20px; width: 250px; position: relative; overflow: hidden;">' +
2145
+ '<div id="progress-bar" style="background: #238636; height: 100%; width: 0%; transition: width 0.3s;"></div>' +
2146
+ '</div>' +
2147
+ '<small id="progress-text" style="color: #8b949e; margin-top: 4px; display: block;">Connecting...</small>';
2148
+
2149
+ // Create abort controller for timeout
2150
+ const controller = new AbortController();
2151
+ const timeout = setTimeout(() => controller.abort(), 60000); // 60s timeout
2152
+
2153
+ // Use streaming loader
2154
+ loadGraphDataStreaming()
2155
+ .then(data => {
2156
+ clearTimeout(timeout);
2157
+ loadingEl.innerHTML = '<label style="color: #238636;">✓ Graph loaded successfully</label>';
2158
+ setTimeout(() => loadingEl.style.display = 'none', 2000);
2159
+
2160
+ // Initialize V2.0 two-phase visualization system
2161
+ // Phase 1: Vertical list of root nodes (overview)
2162
+ // Phase 2: Tree expansion on click (rightward)
2163
+ initializeVisualizationV2(data);
2164
+
2165
+ console.log('[Data Load] V2 visualization initialized - Phase 1 active (vertical list)');
2166
+ })
2167
+ .catch(err => {
2168
+ clearTimeout(timeout);
2169
+
2170
+ let errorMsg = err.message;
2171
+ if (err.name === 'AbortError') {
2172
+ errorMsg = 'Loading timeout - file may be too large or server unresponsive';
2173
+ }
2174
+
2175
+ loadingEl.innerHTML = `<label style="color: #f85149;">✗ Failed to load graph data</label><br>` +
2176
+ `<small style="color: #8b949e;">${errorMsg}</small><br>` +
2177
+ `<button onclick="location.reload()" style="margin-top: 8px; padding: 6px 12px; background: #238636; border: none; border-radius: 6px; color: white; cursor: pointer;">Retry</button><br>` +
2178
+ `<small style="color: #8b949e; margin-top: 4px; display: block;">Or run: mcp-vector-search visualize export</small>`;
2179
+ console.error("Failed to load graph:", err);
2180
+ });
2181
+ });
2182
+
2183
+ // Reset view button event handler - return to Phase 1 (vertical list)
2184
+ document.getElementById('reset-view-btn').addEventListener('click', () => {
2185
+ resetToListViewV2();
2186
+ });
2187
+ """
2188
+
2189
+
2190
+ def get_state_management() -> str:
2191
+ """Get visualization V2.0 state management JavaScript.
2192
+
2193
+ Implements the VisualizationStateManager class for hierarchical
2194
+ list-based navigation with expansion paths and sibling exclusivity.
2195
+
2196
+ Returns:
2197
+ JavaScript string for state management
2198
+ """
2199
+ return """
2200
+ /**
2201
+ * Visualization State Manager for V2.0 Architecture
2202
+ *
2203
+ * Manages expansion paths, node visibility, and view modes.
2204
+ * Enforces sibling exclusivity: only one child expanded per depth.
2205
+ *
2206
+ * Two-Phase Prescriptive Approach:
2207
+ * Phase 1 (tree_root): Initial overview - vertical list of root nodes, all collapsed, NO edges
2208
+ * Phase 2 (tree_expanded/file_detail): Tree navigation - rightward expansion with dagre-style hierarchy
2209
+ *
2210
+ * View Modes (corresponds to phases):
2211
+ * - tree_root: Phase 1 - Vertical list of root nodes, NO edges shown
2212
+ * - tree_expanded: Phase 2 - Rightward tree expansion of directories, NO edges shown
2213
+ * - file_detail: Phase 2 - File with AST chunks, function call edges shown
2214
+ *
2215
+ * Design Decision: Prescriptive (non-configurable) phase transition
2216
+ *
2217
+ * The first click on any node automatically transitions from Phase 1 to Phase 2.
2218
+ * This is a fixed behavior with no user configuration - reduces cognitive load
2219
+ * and provides consistent, predictable interaction patterns.
2220
+ *
2221
+ * Reference: docs/development/VISUALIZATION_ARCHITECTURE_V2.md
2222
+ */
2223
+ class VisualizationStateManager {
2224
+ constructor(initialState = null) {
2225
+ // View mode: "tree_root", "tree_expanded", or "file_detail"
2226
+ this.viewMode = initialState?.view_mode || "tree_root";
2227
+
2228
+ // Handle old view mode names (backward compatibility)
2229
+ if (this.viewMode === "list") this.viewMode = "tree_root";
2230
+ if (this.viewMode === "directory_fan") this.viewMode = "tree_expanded";
2231
+ if (this.viewMode === "file_fan") this.viewMode = "file_detail";
2232
+
2233
+ // Expansion path: ordered array of expanded node IDs (root to current)
2234
+ this.expansionPath = initialState?.expansion_path || [];
2235
+
2236
+ // Node states: map of node_id -> {expanded, visible, children_visible}
2237
+ this.nodeStates = new Map();
2238
+
2239
+ // Visible edges: set of [source_id, target_id] tuples
2240
+ this.visibleEdges = new Set();
2241
+
2242
+ // Event listeners for state changes
2243
+ this.listeners = [];
2244
+
2245
+ // Initialize from initial state if provided
2246
+ if (initialState?.node_states) {
2247
+ for (const [nodeId, state] of Object.entries(initialState.node_states)) {
2248
+ this.nodeStates.set(nodeId, {
2249
+ expanded: state.expanded || false,
2250
+ visible: state.visible || true,
2251
+ childrenVisible: state.children_visible || false,
2252
+ positionOverride: state.position_override || null
2253
+ });
2254
+ }
2255
+ }
2256
+
2257
+ console.log('[StateManager] Initialized with mode:', this.viewMode);
2258
+ }
2259
+
2260
+ /**
2261
+ * Get or create node state
2262
+ */
2263
+ _getOrCreateState(nodeId) {
2264
+ if (!this.nodeStates.has(nodeId)) {
2265
+ this.nodeStates.set(nodeId, {
2266
+ expanded: false,
2267
+ visible: true,
2268
+ childrenVisible: false,
2269
+ positionOverride: null
2270
+ });
2271
+ }
2272
+ return this.nodeStates.get(nodeId);
2273
+ }
2274
+
2275
+ /**
2276
+ * Expand a node (directory or file)
2277
+ *
2278
+ * Enforces sibling exclusivity: if another sibling is expanded
2279
+ * at the same depth, it is collapsed first.
2280
+ */
2281
+ expandNode(nodeId, nodeType, children = []) {
2282
+ console.log(`[StateManager] Expanding ${nodeType} node:`, nodeId, 'with', children.length, 'children');
2283
+
2284
+ const nodeState = this._getOrCreateState(nodeId);
2285
+
2286
+ // Calculate depth
2287
+ const depth = this.expansionPath.length;
2288
+
2289
+ // Sibling exclusivity: check if another sibling is expanded at this depth
2290
+ if (depth < this.expansionPath.length) {
2291
+ const oldSibling = this.expansionPath[depth];
2292
+ if (oldSibling !== nodeId) {
2293
+ console.log(`[StateManager] Sibling exclusivity: collapsing ${oldSibling}`);
2294
+ // Collapse old path from this depth onward
2295
+ const nodesToCollapse = this.expansionPath.slice(depth);
2296
+ this.expansionPath = this.expansionPath.slice(0, depth);
2297
+ for (const oldNode of nodesToCollapse) {
2298
+ this._collapseNodeInternal(oldNode);
2299
+ }
2300
+ }
2301
+ }
2302
+
2303
+ // Phase 2 Transition: Hide sibling nodes when expanding at root level
2304
+ // This prevents duplicate rendering of root nodes when transitioning from Phase 1 to Phase 2
2305
+ if (depth === 0) {
2306
+ console.log('[StateManager] Phase 1->2 transition: Hiding non-expanded root siblings');
2307
+ // Hide all nodes except the one being expanded and its children
2308
+ for (const [siblingId, siblingState] of this.nodeStates.entries()) {
2309
+ if (siblingId !== nodeId && siblingState.visible && !this.expansionPath.includes(siblingId)) {
2310
+ // This is a sibling root node - hide it during Phase 2 tree expansion
2311
+ siblingState.visible = false;
2312
+ console.log(`[StateManager] Hiding root sibling: ${siblingId}`);
2313
+ }
2314
+ }
2315
+ }
2316
+
2317
+ // Mark node as expanded
2318
+ nodeState.expanded = true;
2319
+ nodeState.childrenVisible = true;
2320
+
2321
+ // Add to expansion path
2322
+ if (!this.expansionPath.includes(nodeId)) {
2323
+ this.expansionPath.push(nodeId);
2324
+ }
2325
+
2326
+ // Make children visible
2327
+ for (const childId of children) {
2328
+ const childState = this._getOrCreateState(childId);
2329
+ childState.visible = true;
2330
+ }
2331
+
2332
+ // Update view mode
2333
+ if (nodeType === 'directory') {
2334
+ this.viewMode = 'tree_expanded';
2335
+ } else if (nodeType === 'file') {
2336
+ this.viewMode = 'file_detail';
2337
+ }
2338
+
2339
+ console.log('[StateManager] Expansion path:', this.expansionPath.join(' > '));
2340
+ console.log('[StateManager] View mode:', this.viewMode);
2341
+
2342
+ // Notify listeners
2343
+ this._notifyListeners();
2344
+ }
2345
+
2346
+ /**
2347
+ * Internal collapse (without path manipulation)
2348
+ */
2349
+ _collapseNodeInternal(nodeId) {
2350
+ const nodeState = this.nodeStates.get(nodeId);
2351
+ if (!nodeState) return;
2352
+
2353
+ nodeState.expanded = false;
2354
+ nodeState.childrenVisible = false;
2355
+ }
2356
+
2357
+ /**
2358
+ * Collapse a node and hide all descendants
2359
+ */
2360
+ collapseNode(nodeId) {
2361
+ console.log('[StateManager] Collapsing node:', nodeId);
2362
+
2363
+ // Remove from expansion path
2364
+ const pathIndex = this.expansionPath.indexOf(nodeId);
2365
+ if (pathIndex !== -1) {
2366
+ this.expansionPath = this.expansionPath.slice(0, pathIndex);
2367
+ }
2368
+
2369
+ // Mark as collapsed
2370
+ this._collapseNodeInternal(nodeId);
2371
+
2372
+ // Update view mode if path is empty
2373
+ if (this.expansionPath.length === 0) {
2374
+ this.viewMode = 'tree_root';
2375
+ console.log('[StateManager] Collapsed to root, switching to TREE_ROOT view - restoring root siblings');
2376
+
2377
+ // Restore visibility of all root nodes when returning to Phase 1
2378
+ // This reverses the hiding done in expandNode() at depth 0
2379
+ this._showAllRootNodes();
2380
+ }
2381
+
2382
+ // Notify listeners
2383
+ this._notifyListeners();
2384
+ }
2385
+
2386
+ /**
2387
+ * Show all root-level nodes (used when returning to Phase 1)
2388
+ */
2389
+ _showAllRootNodes() {
2390
+ // Find and show all root nodes (nodes with no parent containment links)
2391
+ // This is determined by checking allLinks, which is available globally
2392
+ if (typeof allLinks !== 'undefined' && typeof allNodes !== 'undefined') {
2393
+ for (const node of allNodes) {
2394
+ const hasParent = allLinks.some(link => {
2395
+ const targetId = link.target.id || link.target;
2396
+ const linkType = link.type;
2397
+ return targetId === node.id &&
2398
+ (linkType === 'dir_containment' ||
2399
+ linkType === 'file_containment' ||
2400
+ linkType === 'dir_hierarchy');
2401
+ });
2402
+
2403
+ if (!hasParent) {
2404
+ // This is a root node - make it visible
2405
+ const nodeState = this._getOrCreateState(node.id);
2406
+ nodeState.visible = true;
2407
+ nodeState.expanded = false;
2408
+ nodeState.childrenVisible = false;
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2413
+
2414
+ /**
2415
+ * Reset state to initial list view
2416
+ */
2417
+ reset() {
2418
+ console.log('[StateManager] Resetting to initial state');
2419
+
2420
+ // Collapse all nodes in expansion path
2421
+ const nodesToCollapse = [...this.expansionPath];
2422
+ for (const nodeId of nodesToCollapse) {
2423
+ this._collapseNodeInternal(nodeId);
2424
+ }
2425
+
2426
+ // Clear expansion path
2427
+ this.expansionPath = [];
2428
+
2429
+ // Reset view mode to tree_root
2430
+ this.viewMode = 'tree_root';
2431
+
2432
+ // Restore visibility of all root nodes (Phase 1 state)
2433
+ this._showAllRootNodes();
2434
+
2435
+ // Notify listeners
2436
+ this._notifyListeners();
2437
+ }
2438
+
2439
+ /**
2440
+ * Get list of visible node IDs
2441
+ */
2442
+ getVisibleNodes() {
2443
+ const visible = [];
2444
+ for (const [nodeId, state] of this.nodeStates.entries()) {
2445
+ if (state.visible) {
2446
+ visible.push(nodeId);
2447
+ }
2448
+ }
2449
+ return visible;
2450
+ }
2451
+
2452
+ /**
2453
+ * Get visible edges (AST calls only in FILE_FAN mode)
2454
+ */
2455
+ getVisibleEdges() {
2456
+ return Array.from(this.visibleEdges);
2457
+ }
2458
+
2459
+ /**
2460
+ * Subscribe to state changes
2461
+ */
2462
+ subscribe(listener) {
2463
+ this.listeners.push(listener);
2464
+ }
2465
+
2466
+ /**
2467
+ * Notify all listeners of state change
2468
+ */
2469
+ _notifyListeners() {
2470
+ for (const listener of this.listeners) {
2471
+ listener(this.toDict());
2472
+ }
2473
+ }
2474
+
2475
+ /**
2476
+ * Serialize state to plain object
2477
+ */
2478
+ toDict() {
2479
+ const nodeStatesObj = {};
2480
+ for (const [nodeId, state] of this.nodeStates.entries()) {
2481
+ nodeStatesObj[nodeId] = {
2482
+ expanded: state.expanded,
2483
+ visible: state.visible,
2484
+ children_visible: state.childrenVisible,
2485
+ position_override: state.positionOverride
2486
+ };
2487
+ }
2488
+
2489
+ return {
2490
+ view_mode: this.viewMode,
2491
+ expansion_path: [...this.expansionPath],
2492
+ visible_nodes: this.getVisibleNodes(),
2493
+ visible_edges: this.getVisibleEdges(),
2494
+ node_states: nodeStatesObj
2495
+ };
2496
+ }
2497
+ }
2498
+
2499
+ // Global state manager instance (initialized in visualizeGraph)
2500
+ let stateManager = null;
2501
+ """
2502
+
2503
+
2504
+ def get_layout_algorithms_v2() -> str:
2505
+ """Get V2.0 layout algorithms (list and fan layouts).
2506
+
2507
+ Returns:
2508
+ JavaScript string for layout calculation functions
2509
+ """
2510
+ return """
2511
+ /**
2512
+ * Calculate vertical list layout positions for nodes.
2513
+ *
2514
+ * Positions nodes in a vertical list with fixed spacing,
2515
+ * sorted alphabetically with directories before files.
2516
+ *
2517
+ * @param {Array} nodes - Array of node objects
2518
+ * @param {Number} canvasWidth - SVG viewport width
2519
+ * @param {Number} canvasHeight - SVG viewport height
2520
+ * @returns {Map} Map of nodeId -> {x, y} positions
2521
+ */
2522
+ function calculateListLayout(nodes, canvasWidth, canvasHeight) {
2523
+ if (!nodes || nodes.length === 0) {
2524
+ console.debug('[Layout] No nodes to layout');
2525
+ return new Map();
2526
+ }
2527
+
2528
+ // Sort alphabetically (directories first, then files)
2529
+ const sortedNodes = nodes.slice().sort((a, b) => {
2530
+ // Directories first
2531
+ const aIsDir = a.type === 'directory' ? 0 : 1;
2532
+ const bIsDir = b.type === 'directory' ? 0 : 1;
2533
+ if (aIsDir !== bIsDir) return aIsDir - bIsDir;
2534
+
2535
+ // Then alphabetical by name
2536
+ const aName = (a.name || '').toLowerCase();
2537
+ const bName = (b.name || '').toLowerCase();
2538
+ return aName.localeCompare(bName);
2539
+ });
2540
+
2541
+ // Layout parameters for Phase 1 (Initial Overview) - Grid Layout
2542
+ const cellWidth = 250; // Horizontal space per node (250px for adequate label space)
2543
+ const cellHeight = 150; // Vertical space per node (150px spacing)
2544
+ const startX = 100; // Left margin (100px as per requirements)
2545
+ const startY = 100; // Top margin (100px as per requirements, fixed not centered)
2546
+
2547
+ // Calculate grid dimensions (roughly square layout)
2548
+ const columnsPerRow = Math.ceil(Math.sqrt(sortedNodes.length));
2549
+ const totalRows = Math.ceil(sortedNodes.length / columnsPerRow);
2550
+ const totalWidth = columnsPerRow * cellWidth;
2551
+ const totalHeight = totalRows * cellHeight;
2552
+
2553
+ // Calculate positions in grid
2554
+ const positions = new Map();
2555
+ sortedNodes.forEach((node, i) => {
2556
+ if (!node.id) {
2557
+ console.warn('[Layout] Node missing id:', node);
2558
+ return;
2559
+ }
2560
+
2561
+ const col = i % columnsPerRow;
2562
+ const row = Math.floor(i / columnsPerRow);
2563
+ const xPosition = startX + (col * cellWidth);
2564
+ const yPosition = startY + (row * cellHeight);
2565
+ positions.set(node.id, { x: xPosition, y: yPosition });
2566
+ });
2567
+
2568
+ console.debug(
2569
+ `[Layout] Grid: ${positions.size} nodes, ` +
2570
+ `${columnsPerRow} cols × ${totalRows} rows, ` +
2571
+ `size=${totalWidth}×${totalHeight}px, start=(${startX},${startY})`
2572
+ );
2573
+
2574
+ return positions;
2575
+ }
2576
+
2577
+ /**
2578
+ * Calculate horizontal fan layout positions for child nodes.
2579
+ *
2580
+ * Arranges children in a 180° arc (horizontal fan) from parent node.
2581
+ * Radius adapts to child count (200-400px range).
2582
+ *
2583
+ * @param {Object} parentPos - {x, y} coordinates of parent
2584
+ * @param {Array} children - Array of child node objects
2585
+ * @param {Number} canvasWidth - SVG viewport width
2586
+ * @param {Number} canvasHeight - SVG viewport height
2587
+ * @returns {Map} Map of childId -> {x, y} positions
2588
+ */
2589
+ function calculateFanLayout(parentPos, children, canvasWidth, canvasHeight) {
2590
+ if (!children || children.length === 0) {
2591
+ console.debug('[Layout] No children to layout in fan');
2592
+ return new Map();
2593
+ }
2594
+
2595
+ const parentX = parentPos.x;
2596
+ const parentY = parentPos.y;
2597
+
2598
+ // Calculate adaptive radius based on child count
2599
+ const baseRadius = 200; // Minimum radius
2600
+ const maxRadius = 400; // Maximum radius
2601
+ const spacingPerChild = 60; // Horizontal space per child
2602
+
2603
+ // Arc length = radius * π (for 180° arc)
2604
+ // We want: arc_length >= num_children * spacingPerChild
2605
+ // Therefore: radius >= (num_children * spacingPerChild) / π
2606
+ const calculatedRadius = (children.length * spacingPerChild) / Math.PI;
2607
+ const radius = Math.max(baseRadius, Math.min(calculatedRadius, maxRadius));
2608
+
2609
+ // Horizontal fan: 180° arc from left to right
2610
+ const startAngle = Math.PI; // Left (180°)
2611
+ const endAngle = 0; // Right (0°)
2612
+ const angleRange = startAngle - endAngle;
2613
+
2614
+ // Sort children (directories first, then alphabetical)
2615
+ const sortedChildren = children.slice().sort((a, b) => {
2616
+ const aIsDir = a.type === 'directory' ? 0 : 1;
2617
+ const bIsDir = b.type === 'directory' ? 0 : 1;
2618
+ if (aIsDir !== bIsDir) return aIsDir - bIsDir;
2619
+
2620
+ const aName = (a.name || '').toLowerCase();
2621
+ const bName = (b.name || '').toLowerCase();
2622
+ return aName.localeCompare(bName);
2623
+ });
2624
+
2625
+ // Calculate positions
2626
+ const positions = new Map();
2627
+ const numChildren = sortedChildren.length;
2628
+
2629
+ sortedChildren.forEach((child, i) => {
2630
+ if (!child.id) {
2631
+ console.warn('[Layout] Child missing id:', child);
2632
+ return;
2633
+ }
2634
+
2635
+ // Calculate angle for this child
2636
+ let angle;
2637
+ if (numChildren === 1) {
2638
+ // Single child: center of arc (90°)
2639
+ angle = Math.PI / 2;
2640
+ } else {
2641
+ // Distribute evenly across arc
2642
+ const progress = i / (numChildren - 1);
2643
+ angle = startAngle - (progress * angleRange);
2644
+ }
2645
+
2646
+ // Convert polar to cartesian coordinates
2647
+ const x = parentX + radius * Math.cos(angle);
2648
+ const y = parentY + radius * Math.sin(angle);
2649
+
2650
+ positions.set(child.id, { x, y });
2651
+ });
2652
+
2653
+ console.debug(
2654
+ `[Layout] Fan: ${positions.size} children, ` +
2655
+ `radius=${radius.toFixed(1)}px, ` +
2656
+ `arc=${(angleRange * 180 / Math.PI).toFixed(0)}°`
2657
+ );
2658
+
2659
+ return positions;
2660
+ }
2661
+
2662
+ /**
2663
+ * Calculate tree layout for directory navigation (rightward expansion).
2664
+ *
2665
+ * Arranges children vertically to the right of parent node,
2666
+ * creating a hierarchical tree structure similar to file explorers.
2667
+ *
2668
+ * NEW: Supports hierarchical depth indication via horizontal offset.
2669
+ * Each level can be positioned at a consistent horizontal distance,
2670
+ * creating a clear visual hierarchy.
2671
+ *
2672
+ * Design Decision: Tree layout for directory navigation
2673
+ *
2674
+ * Rationale: Selected rightward tree layout to match familiar file explorer
2675
+ * UX (Finder, Explorer). Provides clear parent-child relationships and
2676
+ * efficient use of horizontal space for deep hierarchies.
2677
+ *
2678
+ * Trade-offs:
2679
+ * - Clarity: Clear hierarchical structure vs. fan's compact radial layout
2680
+ * - Space: Grows rightward (scrollable) vs. fan's fixed radius
2681
+ * - Familiarity: Matches file explorer metaphor vs. novel visualization
2682
+ *
2683
+ * @param {Object} parentPos - {x, y} coordinates of parent
2684
+ * @param {Array} children - Array of child node objects
2685
+ * @param {Number} canvasWidth - SVG viewport width
2686
+ * @param {Number} canvasHeight - SVG viewport height
2687
+ * @param {Number} depth - Optional depth level for hierarchical spacing
2688
+ * @returns {Map} Map of childId -> {x, y} positions
2689
+ */
2690
+ function calculateTreeLayout(parentPos, children, canvasWidth, canvasHeight, depth = 1) {
2691
+ if (!children || children.length === 0) {
2692
+ console.debug('[Layout] No children for tree layout');
2693
+ return new Map();
2694
+ }
2695
+
2696
+ const parentX = parentPos.x;
2697
+ const parentY = parentPos.y;
2698
+
2699
+ // Tree layout parameters
2700
+ const horizontalOffset = 300; // Fixed horizontal spacing from parent (reduced from 800 to fit on screen)
2701
+ const verticalSpacing = 100; // Vertical spacing between children (increased from 50 for better readability)
2702
+
2703
+ // Sort children (directories first, then alphabetical)
2704
+ const sortedChildren = children.slice().sort((a, b) => {
2705
+ const aIsDir = a.type === 'directory' ? 0 : 1;
2706
+ const bIsDir = b.type === 'directory' ? 0 : 1;
2707
+ if (aIsDir !== bIsDir) return aIsDir - bIsDir;
2708
+
2709
+ const aName = (a.name || '').toLowerCase();
2710
+ const bName = (b.name || '').toLowerCase();
2711
+ return aName.localeCompare(bName);
2712
+ });
2713
+
2714
+ // Calculate vertical centering
2715
+ const totalHeight = sortedChildren.length * verticalSpacing;
2716
+ const startY = parentY - (totalHeight / 2);
2717
+
2718
+ // Calculate positions
2719
+ const positions = new Map();
2720
+ sortedChildren.forEach((child, i) => {
2721
+ if (!child.id) {
2722
+ console.warn('[Layout] Child missing id:', child);
2723
+ return;
2724
+ }
2725
+
2726
+ const x = parentX + horizontalOffset;
2727
+ const y = startY + (i * verticalSpacing);
2728
+
2729
+ positions.set(child.id, { x, y });
2730
+ });
2731
+
2732
+ console.debug(
2733
+ `[Layout] Tree: ${positions.size} children, ` +
2734
+ `offset=${horizontalOffset}px, spacing=${verticalSpacing}px, depth=${depth}`
2735
+ );
2736
+
2737
+ return positions;
2738
+ }
2739
+
2740
+ /**
2741
+ * Calculate hybrid layout for file detail view.
2742
+ *
2743
+ * Combines vertical tree positioning for AST chunks with
2744
+ * force-directed layout for function call relationships.
2745
+ *
2746
+ * Design Decision: Vertical tree + function call edges
2747
+ *
2748
+ * Rationale: AST chunks within a file have natural top-to-bottom order
2749
+ * (by line number). Vertical tree preserves this order while function
2750
+ * call edges show actual code dependencies.
2751
+ *
2752
+ * Trade-offs:
2753
+ * - Readability: Preserves code order vs. force layout's organic grouping
2754
+ * - Performance: Simple O(n) tree vs. O(n²) force simulation
2755
+ * - Edges: Shows only AST calls (clear) vs. all relationships (cluttered)
2756
+ *
2757
+ * @param {Object} parentPos - {x, y} coordinates of parent file node
2758
+ * @param {Array} chunks - Array of AST chunk node objects
2759
+ * @param {Array} edges - Array of function call edges
2760
+ * @param {Number} canvasWidth - SVG viewport width
2761
+ * @param {Number} canvasHeight - SVG viewport height
2762
+ * @returns {Map} Map of chunkId -> {x, y} positions
2763
+ */
2764
+ function calculateHybridCodeLayout(parentPos, chunks, edges, canvasWidth, canvasHeight) {
2765
+ if (!chunks || chunks.length === 0) {
2766
+ console.debug('[Layout] No chunks for hybrid code layout');
2767
+ return new Map();
2768
+ }
2769
+
2770
+ // Use tree layout for initial positioning (preserves code order)
2771
+ // For file detail view, we show chunks in vertical order
2772
+ const positions = calculateTreeLayout(parentPos, chunks, canvasWidth, canvasHeight);
2773
+
2774
+ // Note: Force-directed refinement can be added later if needed
2775
+ // For now, simple tree layout preserves line number order
2776
+
2777
+ console.debug(
2778
+ `[Layout] Hybrid code: ${positions.size} chunks positioned in tree layout`
2779
+ );
2780
+
2781
+ return positions;
2782
+ }
2783
+
2784
+ /**
2785
+ * @deprecated Use calculateTreeLayout instead
2786
+ * Legacy function name for backward compatibility
2787
+ */
2788
+ function calculateCompactFolderLayout(parentPos, children, canvasWidth, canvasHeight) {
2789
+ return calculateTreeLayout(parentPos, children, canvasWidth, canvasHeight);
2790
+ }
2791
+ """
2792
+
2793
+
2794
+ def get_interaction_handlers_v2() -> str:
2795
+ """Get V2.0 interaction handlers (expand, collapse, click).
2796
+
2797
+ Returns:
2798
+ JavaScript string for interaction handling
2799
+ """
2800
+ return """
2801
+ /**
2802
+ * Handle node click events for V2.0 navigation.
2803
+ *
2804
+ * Behavior (Two-Phase Prescriptive Approach):
2805
+ * - Phase 1 (Initial Overview): Circle/grid layout with root-level nodes only, all collapsed
2806
+ * - Phase 2 (Tree Navigation): Dagre vertical tree layout with rightward expansion
2807
+ *
2808
+ * On first click, automatically transitions from Phase 1 to Phase 2.
2809
+ *
2810
+ * Node Behavior:
2811
+ * - Directory: Expand/collapse with rightward tree layout
2812
+ * - File: Expand/collapse AST chunks with tree layout + call edges
2813
+ * - AST Chunk: Show in content pane, no expansion
2814
+ *
2815
+ * Design Decision: Automatic phase transition on first interaction
2816
+ *
2817
+ * Rationale: Users start with high-level overview (Phase 1), then drill down
2818
+ * into specific areas (Phase 2). The transition is automatic and prescriptive
2819
+ * - no user configuration needed.
2820
+ *
2821
+ * Trade-offs:
2822
+ * - Simplicity: Fixed behavior vs. user choice (removes cognitive load)
2823
+ * - Discoverability: Automatic transition vs. explicit control
2824
+ * - Consistency: Predictable behavior vs. flexible customization
2825
+ */
2826
+ function handleNodeClickV2(event, nodeData) {
2827
+ event.stopPropagation();
2828
+
2829
+ const node = allNodes.find(n => n.id === nodeData.id);
2830
+ if (!node) {
2831
+ console.warn('[Click] Node not found:', nodeData.id);
2832
+ return;
2833
+ }
2834
+
2835
+ console.log('[Click] Node clicked:', node.type, node.name);
2836
+
2837
+ // PHASE TRANSITION: First click transitions from Phase 1 to Phase 2
2838
+ if (isInitialOverview && (node.type === 'directory' || node.type === 'file')) {
2839
+ console.log('[Phase Transition] Switching from Phase 1 (overview) to Phase 2 (tree expansion)');
2840
+ isInitialOverview = false;
2841
+ // The layout will automatically change when expandNodeV2 updates viewMode to 'tree_expanded'
2842
+ }
2843
+
2844
+ // Always show content pane
2845
+ showContentPane(node);
2846
+
2847
+ // Handle expansion based on node type
2848
+ if (node.type === 'directory' || node.type === 'file') {
2849
+ if (!stateManager) {
2850
+ console.error('[Click] State manager not initialized');
2851
+ return;
2852
+ }
2853
+
2854
+ const isExpanded = stateManager.nodeStates.get(node.id)?.expanded || false;
2855
+
2856
+ if (isExpanded) {
2857
+ // Collapse node
2858
+ collapseNodeV2(node.id);
2859
+ } else {
2860
+ // Expand node (this will trigger layout change to tree)
2861
+ expandNodeV2(node.id, node.type);
2862
+ }
2863
+ }
2864
+ // AST chunks (function, class, method) don't expand
2865
+ }
2866
+
2867
+ /**
2868
+ * Get immediate children of a given node (ONE level only).
2869
+ *
2870
+ * Returns only direct children, not recursive descendants.
2871
+ * This enables on-demand expansion where each click reveals one level.
2872
+ *
2873
+ * Design Decision: Lazy expansion (one level at a time)
2874
+ *
2875
+ * Rationale: Users build mental model incrementally by expanding one level
2876
+ * at a time. Pre-expanding entire subtrees is overwhelming and defeats
2877
+ * the purpose of interactive exploration.
2878
+ *
2879
+ * @param {String} parentId - ID of parent node
2880
+ * @param {Array} links - All graph links
2881
+ * @param {Array} nodes - All graph nodes
2882
+ * @returns {Array} Array of immediate child node objects
2883
+ */
2884
+ function getImmediateChildren(parentId, links, nodes) {
2885
+ const children = links
2886
+ .filter(link => {
2887
+ const sourceId = link.source.id || link.source;
2888
+ const linkType = link.type;
2889
+ return sourceId === parentId &&
2890
+ (linkType === 'dir_containment' ||
2891
+ linkType === 'file_containment' ||
2892
+ linkType === 'dir_hierarchy');
2893
+ })
2894
+ .map(link => {
2895
+ const childId = link.target.id || link.target;
2896
+ const childNode = nodes.find(n => n.id === childId);
2897
+ return childNode;
2898
+ })
2899
+ .filter(n => n);
2900
+
2901
+ console.log(`[getImmediateChildren] Found ${children.length} immediate children for node ${parentId}`);
2902
+ return children;
2903
+ }
2904
+
2905
+ /**
2906
+ * Expand a node (directory or file) in V2.0 mode.
2907
+ *
2908
+ * ON-DEMAND EXPANSION: Shows ONLY immediate children (one level)
2909
+ * - Directories: Show immediate subdirectories and files
2910
+ * - Files: Show immediate AST chunks
2911
+ *
2912
+ * Design Decision: One level at a time (lazy expansion)
2913
+ *
2914
+ * Rationale: Users explore incrementally, building mental model level by level.
2915
+ * Pre-expanding entire trees causes cognitive overload and defeats the purpose
2916
+ * of interactive visualization.
2917
+ *
2918
+ * Triggers state update and re-render with radial animation.
2919
+ */
2920
+ function expandNodeV2(nodeId, nodeType) {
2921
+ if (!stateManager) {
2922
+ console.error('[Expand] State manager not initialized');
2923
+ return;
2924
+ }
2925
+
2926
+ const node = allNodes.find(n => n.id === nodeId);
2927
+ if (!node) {
2928
+ console.warn('[Expand] Node not found:', nodeId);
2929
+ return;
2930
+ }
2931
+
2932
+ // ON-DEMAND: Get ONLY immediate children (one level)
2933
+ const children = getImmediateChildren(nodeId, allLinks, allNodes);
2934
+ const childIds = children.map(c => c.id);
2935
+
2936
+ console.log(`[Expand] ${nodeType} - showing ${childIds.length} immediate children only (on-demand expansion)`);
2937
+
2938
+ // Update state
2939
+ stateManager.expandNode(nodeId, nodeType, childIds);
2940
+
2941
+ // Re-render with radial animation
2942
+ renderGraphV2();
2943
+ }
2944
+
2945
+ /**
2946
+ * Collapse a node and hide all its descendants.
2947
+ */
2948
+ function collapseNodeV2(nodeId) {
2949
+ if (!stateManager) {
2950
+ console.error('[Collapse] State manager not initialized');
2951
+ return;
2952
+ }
2953
+
2954
+ console.log('[Collapse] Collapsing node:', nodeId);
2955
+
2956
+ // Update state (recursively hides descendants)
2957
+ stateManager.collapseNode(nodeId, allNodes);
2958
+
2959
+ // Re-render with animation
2960
+ renderGraphV2();
2961
+ }
2962
+
2963
+ /**
2964
+ * Reset to initial list view (Phase 1).
2965
+ */
2966
+ function resetToListViewV2() {
2967
+ if (!stateManager) {
2968
+ console.error('[Reset] State manager not initialized');
2969
+ return;
2970
+ }
2971
+
2972
+ console.log('[Reset] Resetting to Phase 1 (initial overview)');
2973
+
2974
+ // Reset to Phase 1
2975
+ isInitialOverview = true;
2976
+
2977
+ // Collapse all nodes
2978
+ stateManager.reset();
2979
+
2980
+ // Clear selection
2981
+ highlightedNode = null;
2982
+
2983
+ // Close content pane
2984
+ closeContentPane();
2985
+
2986
+ // Re-render
2987
+ renderGraphV2();
2988
+ }
2989
+
2990
+ /**
2991
+ * Navigate to a node in the expansion path (breadcrumb click).
2992
+ */
2993
+ function navigateToNodeInPath(nodeId) {
2994
+ if (!stateManager) {
2995
+ console.error('[Navigate] State manager not initialized');
2996
+ return;
2997
+ }
2998
+
2999
+ const pathIndex = stateManager.expansionPath.indexOf(nodeId);
3000
+ if (pathIndex === -1) {
3001
+ console.warn('[Navigate] Node not in expansion path:', nodeId);
3002
+ return;
3003
+ }
3004
+
3005
+ console.log('[Navigate] Navigating to node in path:', nodeId);
3006
+
3007
+ // Collapse all nodes after this one in the path
3008
+ const nodesToCollapse = stateManager.expansionPath.slice(pathIndex + 1);
3009
+ nodesToCollapse.forEach(id => collapseNodeV2(id));
3010
+
3011
+ // Show the node in content pane
3012
+ const node = allNodes.find(n => n.id === nodeId);
3013
+ if (node) {
3014
+ showContentPane(node);
3015
+ }
3016
+ }
3017
+
3018
+ /**
3019
+ * Initialize V2.0 visualization with two-phase prescriptive layout.
3020
+ *
3021
+ * Phase 1 (Initial State):
3022
+ * - Show only root nodes (nodes with no incoming containment edges)
3023
+ * - Vertical list layout at x=100, y spaced every 100px starting at y=100
3024
+ * - All nodes collapsed, NO edges visible
3025
+ * - viewMode: 'tree_root'
3026
+ *
3027
+ * Design Decision: Prescriptive initialization
3028
+ *
3029
+ * Rationale: Always start in Phase 1 (overview) to provide consistent,
3030
+ * predictable initial state. Users can explore from this clean starting point.
3031
+ */
3032
+ function initializeVisualizationV2(data) {
3033
+ console.log('[Init V2] Starting two-phase visualization initialization');
3034
+
3035
+ // Store global data
3036
+ allNodes = data.nodes;
3037
+ allLinks = data.links;
3038
+
3039
+ // Find root nodes - nodes with no incoming containment edges
3040
+ // Phase 1: Only show directories and files (exclude code chunks)
3041
+ // This prevents hundreds of functions/classes from cluttering the initial view
3042
+ const rootNodesList = allNodes.filter(n => {
3043
+ // Filter by type: only structural elements (directories, files, subprojects)
3044
+ // This prevents hundreds of functions/classes from cluttering the initial view
3045
+ const isDirectoryOrFile = n.type === 'directory' || n.type === 'file' || n.type === 'subproject';
3046
+ if (!isDirectoryOrFile) {
3047
+ return false;
3048
+ }
3049
+
3050
+ // Check if node has a parent containment edge
3051
+ const hasParent = allLinks.some(link => {
3052
+ const targetId = link.target.id || link.target;
3053
+ return targetId === n.id &&
3054
+ (link.type === 'dir_containment' ||
3055
+ link.type === 'file_containment' ||
3056
+ link.type === 'dir_hierarchy');
3057
+ });
3058
+ return !hasParent;
3059
+ });
3060
+
3061
+ console.log('[Init V2] Found', rootNodesList.length, 'root nodes');
3062
+
3063
+ // Debug: Log node type distribution for verification
3064
+ const nodeTypeCounts = {};
3065
+ rootNodesList.forEach(n => {
3066
+ nodeTypeCounts[n.type] = (nodeTypeCounts[n.type] || 0) + 1;
3067
+ });
3068
+ console.log('[Init V2] Root node types:', nodeTypeCounts);
3069
+
3070
+ // Debug: Warn if any non-structural nodes slipped through
3071
+ const nonStructural = rootNodesList.filter(n =>
3072
+ n.type !== 'directory' && n.type !== 'file' && n.type !== 'subproject'
3073
+ );
3074
+ if (nonStructural.length > 0) {
3075
+ console.warn('[Init V2] WARNING: Non-structural nodes in root list:',
3076
+ nonStructural.map(n => `${n.id} (${n.type})`));
3077
+ }
3078
+
3079
+ // Store root nodes globally
3080
+ rootNodes = rootNodesList;
3081
+
3082
+ // Initialize state manager in Phase 1 (tree_root mode)
3083
+ stateManager = new VisualizationStateManager({
3084
+ view_mode: 'tree_root',
3085
+ expansion_path: [],
3086
+ node_states: {}
3087
+ });
3088
+
3089
+ // Initialize all root nodes as visible and collapsed
3090
+ for (const rootNode of rootNodesList) {
3091
+ stateManager.nodeStates.set(rootNode.id, {
3092
+ expanded: false,
3093
+ visible: true,
3094
+ childrenVisible: false,
3095
+ positionOverride: null
3096
+ });
3097
+ }
3098
+
3099
+ // All non-root nodes start invisible
3100
+ for (const node of allNodes) {
3101
+ if (!stateManager.nodeStates.has(node.id)) {
3102
+ stateManager.nodeStates.set(node.id, {
3103
+ expanded: false,
3104
+ visible: false,
3105
+ childrenVisible: false,
3106
+ positionOverride: null
3107
+ });
3108
+ }
3109
+ }
3110
+
3111
+ // Set initial overview flag
3112
+ isInitialOverview = true;
3113
+
3114
+ console.log('[Init V2] State manager initialized');
3115
+ console.log('[Init V2] View mode:', stateManager.viewMode);
3116
+ console.log('[Init V2] Visible nodes:', stateManager.getVisibleNodes().length);
3117
+
3118
+ // Render Phase 1: vertical list of root nodes, no edges
3119
+ renderGraphV2(750);
3120
+
3121
+ console.log('[Init V2] Phase 1 rendering complete - vertical list with', rootNodesList.length, 'root nodes');
3122
+ }
3123
+ """
3124
+
3125
+
3126
+ def get_rendering_v2() -> str:
3127
+ """Get V2.0 rendering functions with transitions.
3128
+
3129
+ Returns:
3130
+ JavaScript string for D3.js rendering with animations
3131
+ """
3132
+ return """
3133
+ /**
3134
+ * Main rendering function for V2.0 with transition animations.
3135
+ *
3136
+ * Two-Phase Prescriptive Layout:
3137
+ * - Phase 1 (tree_root): Vertical list layout with root-level nodes only, all collapsed
3138
+ * - Phase 2 (tree_expanded/file_detail): Rightward tree expansion with dagre-style hierarchy
3139
+ *
3140
+ * Renders visible nodes with smooth 750ms transitions between layouts.
3141
+ *
3142
+ * Design Decision: Automatic layout selection based on view mode
3143
+ *
3144
+ * The layout automatically adapts to the current phase:
3145
+ * - Phase 1 uses simple vertical list (clear overview)
3146
+ * - Phase 2 uses tree layout (rightward expansion for deep hierarchies)
3147
+ */
3148
+ function renderGraphV2(duration = 750) {
3149
+ if (!stateManager) {
3150
+ console.error('[Render] State manager not initialized');
3151
+ return;
3152
+ }
3153
+
3154
+ console.log('[Render] Rendering graph, mode:', stateManager.viewMode, 'phase:', isInitialOverview ? 'Phase 1 (overview)' : 'Phase 2 (tree)');
3155
+
3156
+ // 1. Get visible nodes
3157
+ const visibleNodeIds = stateManager.getVisibleNodes();
3158
+ const visibleNodesList = visibleNodeIds
3159
+ .map(id => allNodes.find(n => n.id === id))
3160
+ .filter(n => n);
3161
+
3162
+ console.log('[Render] Visible nodes:', visibleNodesList.length);
3163
+
3164
+ // 2. Calculate layout positions (Two-Phase Prescriptive)
3165
+ const positions = new Map();
3166
+
3167
+ if (stateManager.viewMode === 'tree_root') {
3168
+ // PHASE 1: Vertical list layout for root nodes only (initial overview)
3169
+ const listPos = calculateListLayout(visibleNodesList, width, height);
3170
+ listPos.forEach((pos, nodeId) => positions.set(nodeId, pos));
3171
+
3172
+ console.debug('[Render] PHASE 1 (tree_root): Vertical list with', positions.size, 'root nodes');
3173
+ } else if (stateManager.viewMode === 'tree_expanded' || stateManager.viewMode === 'file_detail') {
3174
+ // PHASE 2: Tree layout with rightward expansion (after first click)
3175
+ // NEW: Hierarchical tree layout for ALL visible descendants
3176
+
3177
+ // Get the root expanded node (first in expansion path)
3178
+ const rootExpandedId = stateManager.expansionPath[0];
3179
+ if (!rootExpandedId) {
3180
+ console.warn('[Render] No expanded node in path');
3181
+ return;
3182
+ }
3183
+
3184
+ const rootExpandedNode = allNodes.find(n => n.id === rootExpandedId);
3185
+ if (!rootExpandedNode) {
3186
+ console.warn('[Render] Root expanded node not found:', rootExpandedId);
3187
+ return;
3188
+ }
3189
+
3190
+ // Position the root expanded node using list layout
3191
+ const rootNodes = allNodes.filter(n => {
3192
+ const isDirectoryOrFile = n.type === 'directory' || n.type === 'file' || n.type === 'subproject';
3193
+ if (!isDirectoryOrFile) return false;
3194
+
3195
+ const parentLinks = allLinks.filter(l =>
3196
+ (l.target.id || l.target) === n.id &&
3197
+ (l.type === 'dir_containment' || l.type === 'file_containment')
3198
+ );
3199
+ return parentLinks.length === 0;
3200
+ });
3201
+ const listPos = calculateListLayout(rootNodes, width, height);
3202
+ const rootPos = listPos.get(rootExpandedId);
3203
+ if (rootPos) {
3204
+ positions.set(rootExpandedId, rootPos);
3205
+ } else {
3206
+ positions.set(rootExpandedId, { x: 100, y: height / 2 });
3207
+ }
3208
+
3209
+ // Build hierarchical tree layout for ALL visible descendants
3210
+ // Use BFS to position nodes level by level
3211
+ const positioned = new Set([rootExpandedId]);
3212
+ const queue = [rootExpandedId];
3213
+
3214
+ while (queue.length > 0) {
3215
+ const parentId = queue.shift();
3216
+ const parentPos = positions.get(parentId);
3217
+ if (!parentPos) continue;
3218
+
3219
+ // Find visible children of this parent
3220
+ const children = allLinks
3221
+ .filter(link => {
3222
+ const sourceId = link.source.id || link.source;
3223
+ const linkType = link.type;
3224
+ return sourceId === parentId &&
3225
+ (linkType === 'dir_containment' ||
3226
+ linkType === 'file_containment' ||
3227
+ linkType === 'dir_hierarchy');
3228
+ })
3229
+ .map(link => {
3230
+ const targetId = link.target.id || link.target;
3231
+ return allNodes.find(n => n.id === targetId);
3232
+ })
3233
+ .filter(n => n && visibleNodeIds.includes(n.id) && !positioned.has(n.id));
3234
+
3235
+ if (children.length > 0) {
3236
+ // Calculate tree layout for these children
3237
+ const treePos = calculateTreeLayout(parentPos, children, width, height);
3238
+ treePos.forEach((pos, childId) => {
3239
+ positions.set(childId, pos);
3240
+ positioned.add(childId);
3241
+ // Add to queue to position their children
3242
+ queue.push(childId);
3243
+ });
3244
+ }
3245
+ }
3246
+
3247
+ console.debug(
3248
+ `[Render] ${stateManager.viewMode.toUpperCase()}: ` +
3249
+ `Hierarchical tree layout with ${positions.size} nodes, ` +
3250
+ `depth ${stateManager.expansionPath.length}`
3251
+ );
3252
+ }
3253
+
3254
+ console.log('[Render] Calculated positions for', positions.size, 'nodes');
3255
+
3256
+ // 3. Filter edges
3257
+ const visibleLinks = getFilteredLinksForCurrentViewV2();
3258
+
3259
+ console.log('[Render] Visible links:', visibleLinks.length);
3260
+
3261
+ // 4. D3 rendering with transitions
3262
+
3263
+ // --- LINKS ---
3264
+ const linkSelection = g.selectAll('.link')
3265
+ .data(visibleLinks, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
3266
+
3267
+ // ENTER: New links
3268
+ linkSelection.enter()
3269
+ .append('line')
3270
+ .attr('class', d => `link ${d.type}`)
3271
+ .attr('x1', d => {
3272
+ const sourceId = d.source.id || d.source;
3273
+ const pos = positions.get(sourceId);
3274
+ return pos ? pos.x : (d.source.x || 0);
3275
+ })
3276
+ .attr('y1', d => {
3277
+ const sourceId = d.source.id || d.source;
3278
+ const pos = positions.get(sourceId);
3279
+ return pos ? pos.y : (d.source.y || 0);
3280
+ })
3281
+ .attr('x2', d => {
3282
+ const targetId = d.target.id || d.target;
3283
+ const pos = positions.get(targetId);
3284
+ return pos ? pos.x : (d.target.x || 0);
3285
+ })
3286
+ .attr('y2', d => {
3287
+ const targetId = d.target.id || d.target;
3288
+ const pos = positions.get(targetId);
3289
+ return pos ? pos.y : (d.target.y || 0);
3290
+ })
3291
+ .style('opacity', 0)
3292
+ .transition()
3293
+ .duration(duration)
3294
+ .style('opacity', 1);
3295
+
3296
+ // UPDATE: Existing links
3297
+ linkSelection.transition()
3298
+ .duration(duration)
3299
+ .attr('x1', d => {
3300
+ const sourceId = d.source.id || d.source;
3301
+ const pos = positions.get(sourceId);
3302
+ return pos ? pos.x : (d.source.x || 0);
3303
+ })
3304
+ .attr('y1', d => {
3305
+ const sourceId = d.source.id || d.source;
3306
+ const pos = positions.get(sourceId);
3307
+ return pos ? pos.y : (d.source.y || 0);
3308
+ })
3309
+ .attr('x2', d => {
3310
+ const targetId = d.target.id || d.target;
3311
+ const pos = positions.get(targetId);
3312
+ return pos ? pos.x : (d.target.x || 0);
3313
+ })
3314
+ .attr('y2', d => {
3315
+ const targetId = d.target.id || d.target;
3316
+ const pos = positions.get(targetId);
3317
+ return pos ? pos.y : (d.target.y || 0);
3318
+ });
3319
+
3320
+ // EXIT: Remove links
3321
+ linkSelection.exit()
3322
+ .transition()
3323
+ .duration(duration)
3324
+ .style('opacity', 0)
3325
+ .remove();
3326
+
3327
+ // --- NODES ---
3328
+ const nodeSelection = g.selectAll('.node')
3329
+ .data(visibleNodesList, d => d.id);
3330
+
3331
+ // ENTER: New nodes
3332
+ const nodeEnter = nodeSelection.enter()
3333
+ .append('g')
3334
+ .attr('class', d => `node ${d.type}`)
3335
+ .attr('transform', d => {
3336
+ // Start at calculated position or center
3337
+ const pos = positions.get(d.id);
3338
+ if (pos) {
3339
+ return `translate(${pos.x}, ${pos.y})`;
3340
+ }
3341
+ return `translate(${width / 2}, ${height / 2})`;
3342
+ })
3343
+ .style('opacity', 0)
3344
+ .on('click', handleNodeClickV2)
3345
+ .on('mouseover', (event, d) => showTooltip(event, d))
3346
+ .on('mouseout', () => hideTooltip());
3347
+
3348
+ // Add node visuals (reuse existing rendering functions)
3349
+ addNodeVisuals(nodeEnter);
3350
+
3351
+ // Fade in new nodes
3352
+ nodeEnter.transition()
3353
+ .duration(duration)
3354
+ .style('opacity', 1);
3355
+
3356
+ // UPDATE: Existing nodes with transition
3357
+ nodeSelection.transition()
3358
+ .duration(duration)
3359
+ .attr('transform', d => {
3360
+ const pos = positions.get(d.id);
3361
+ if (pos) {
3362
+ // Update stored position for force layout compatibility
3363
+ d.x = pos.x;
3364
+ d.y = pos.y;
3365
+ return `translate(${pos.x}, ${pos.y})`;
3366
+ }
3367
+ return `translate(${d.x || width / 2}, ${d.y || height / 2})`;
3368
+ });
3369
+
3370
+ // Update expand/collapse indicators
3371
+ nodeSelection.selectAll('.expand-indicator')
3372
+ .text(d => {
3373
+ if (!hasChildren(d)) return '';
3374
+ const state = stateManager.nodeStates.get(d.id);
3375
+ return state?.expanded ? '−' : '+';
3376
+ });
3377
+
3378
+ // EXIT: Remove nodes
3379
+ nodeSelection.exit()
3380
+ .transition()
3381
+ .duration(duration)
3382
+ .style('opacity', 0)
3383
+ .remove();
3384
+
3385
+ // 5. Post-render updates
3386
+ updateBreadcrumbsV2();
3387
+ updateStats();
3388
+ }
3389
+
3390
+ /**
3391
+ * Filter links for current view mode (V2.0).
3392
+ *
3393
+ * Rules (Tree-based):
3394
+ * - TREE_ROOT mode: NO edges shown (vertical list only)
3395
+ * - TREE_EXPANDED mode: NO edges shown (directory tree only)
3396
+ * - FILE_DETAIL mode: Only AST call edges within expanded file
3397
+ *
3398
+ * Design Decision: No edges during navigation
3399
+ *
3400
+ * Rationale: Edges are hidden during directory navigation to reduce
3401
+ * visual clutter and maintain focus on hierarchy. Only function call
3402
+ * edges are shown in file detail view where they provide value.
3403
+ *
3404
+ * Error Handling:
3405
+ * - Returns empty array if state manager not initialized
3406
+ * - Returns empty array if no file expanded in FILE_DETAIL mode
3407
+ * - Filters out edges where source or target nodes are not visible
3408
+ */
3409
+ function getFilteredLinksForCurrentViewV2() {
3410
+ if (!stateManager) {
3411
+ console.warn('[EdgeFilter] State manager not initialized');
3412
+ return [];
3413
+ }
3414
+
3415
+ // No edges in tree_root mode (initial overview - just list of folders)
3416
+ if (stateManager.viewMode === 'tree_root') {
3417
+ return [];
3418
+ }
3419
+
3420
+ // TREE_EXPANDED mode: Show containment edges between visible nodes
3421
+ if (stateManager.viewMode === 'tree_expanded') {
3422
+ const visibleNodeIds = stateManager.getVisibleNodes();
3423
+
3424
+ // Show only containment edges (dir_containment, file_containment, dir_hierarchy)
3425
+ const filteredLinks = allLinks.filter(link => {
3426
+ const linkType = link.type;
3427
+ const sourceId = link.source.id || link.source;
3428
+ const targetId = link.target.id || link.target;
3429
+
3430
+ // Must be containment relationship
3431
+ const isContainment = linkType === 'dir_containment' ||
3432
+ linkType === 'file_containment' ||
3433
+ linkType === 'dir_hierarchy';
3434
+
3435
+ if (!isContainment) return false;
3436
+
3437
+ // Both nodes must be visible
3438
+ return visibleNodeIds.includes(sourceId) && visibleNodeIds.includes(targetId);
3439
+ });
3440
+
3441
+ console.debug(
3442
+ `[EdgeFilter] TREE_EXPANDED mode: ${filteredLinks.length} containment edges`
3443
+ );
3444
+
3445
+ return filteredLinks;
3446
+ }
3447
+
3448
+ // FILE_DETAIL mode: Show AST call edges within file
3449
+ if (stateManager.viewMode === 'file_detail') {
3450
+ // Find expanded file in path
3451
+ const expandedFileId = stateManager.expansionPath.find(nodeId => {
3452
+ const node = allNodes.find(n => n.id === nodeId);
3453
+ return node && node.type === 'file';
3454
+ });
3455
+
3456
+ if (!expandedFileId) {
3457
+ console.debug('[EdgeFilter] No file expanded in FILE_DETAIL mode');
3458
+ return [];
3459
+ }
3460
+
3461
+ const expandedFile = allNodes.find(n => n.id === expandedFileId);
3462
+ if (!expandedFile) {
3463
+ console.warn('[EdgeFilter] Expanded file node not found:', expandedFileId);
3464
+ return [];
3465
+ }
3466
+
3467
+ // Show only caller edges within this file
3468
+ const filteredLinks = allLinks.filter(link => {
3469
+ // Must be caller relationship
3470
+ if (link.type !== 'caller') return false;
3471
+
3472
+ // Both source and target must be AST chunks of the expanded file
3473
+ const sourceId = link.source.id || link.source;
3474
+ const targetId = link.target.id || link.target;
3475
+
3476
+ const source = allNodes.find(n => n.id === sourceId);
3477
+ const target = allNodes.find(n => n.id === targetId);
3478
+
3479
+ if (!source || !target) return false;
3480
+
3481
+ // Both must be in the same file and visible
3482
+ return source.file_path === expandedFile.file_path &&
3483
+ target.file_path === expandedFile.file_path &&
3484
+ stateManager.getVisibleNodes().includes(sourceId) &&
3485
+ stateManager.getVisibleNodes().includes(targetId);
3486
+ });
3487
+
3488
+ console.debug(
3489
+ `[EdgeFilter] FILE_DETAIL mode: ${filteredLinks.length} call edges ` +
3490
+ `in file ${expandedFile.name}`
3491
+ );
3492
+
3493
+ return filteredLinks;
3494
+ }
3495
+
3496
+ // Unknown view mode
3497
+ console.warn('[EdgeFilter] Unknown view mode:', stateManager.viewMode);
3498
+ return [];
3499
+ }
3500
+
3501
+ /**
3502
+ * Update breadcrumbs for V2.0 navigation.
3503
+ */
3504
+ function updateBreadcrumbsV2() {
3505
+ if (!stateManager) return;
3506
+
3507
+ const breadcrumbEl = document.querySelector('.breadcrumb-nav');
3508
+ if (!breadcrumbEl) return;
3509
+
3510
+ const parts = ['<span class="breadcrumb-root" onclick="resetToListViewV2()" style="cursor:pointer;">🏠 Root</span>'];
3511
+
3512
+ stateManager.expansionPath.forEach((nodeId, index) => {
3513
+ const node = allNodes.find(n => n.id === nodeId);
3514
+ if (!node) return;
3515
+
3516
+ const isLast = (index === stateManager.expansionPath.length - 1);
3517
+
3518
+ parts.push(' / ');
3519
+
3520
+ if (isLast) {
3521
+ // Current node: not clickable, highlighted
3522
+ parts.push(`<span class="breadcrumb-current" style="color: #ffffff; font-weight: 600;">${escapeHtml(node.name)}</span>`);
3523
+ } else {
3524
+ // Parent nodes: clickable
3525
+ parts.push(
3526
+ `<span class="breadcrumb-link" onclick="navigateToNodeInPath('${node.id}')" ` +
3527
+ `style="color: #58a6ff; cursor: pointer; text-decoration: none;">` +
3528
+ `${escapeHtml(node.name)}</span>`
3529
+ );
3530
+ }
3531
+ });
3532
+
3533
+ breadcrumbEl.innerHTML = parts.join('');
3534
+ }
3535
+
3536
+ /**
3537
+ * Helper function to add node visuals (reused from existing code).
3538
+ */
3539
+ function addNodeVisuals(nodeEnter) {
3540
+ // This function should call existing node rendering logic
3541
+ // For now, we'll add basic shapes
3542
+
3543
+ // Add circles for code nodes
3544
+ nodeEnter.filter(d => !isFileOrDir(d) && !isDocNode(d))
3545
+ .append('circle')
3546
+ .attr('r', d => d.complexity ? Math.min(15 + d.complexity * 2.5, 32) : 18)
3547
+ .style('fill', d => d.color || '#58a6ff')
3548
+ .attr('stroke', d => hasChildren(d) ? '#ffffff' : 'none')
3549
+ .attr('stroke-width', d => hasChildren(d) ? 2 : 0);
3550
+
3551
+ // Add SVG icons for file and directory nodes
3552
+ nodeEnter.filter(d => isFileOrDir(d))
3553
+ .append('path')
3554
+ .attr('class', 'file-icon')
3555
+ .attr('d', d => getFileTypeIcon(d))
3556
+ .attr('transform', d => {
3557
+ const scale = d.type === 'directory' ? 2.2 : 1.8;
3558
+ return `translate(-12, -12) scale(${scale})`;
3559
+ })
3560
+ .style('color', d => getFileTypeColor(d))
3561
+ .attr('stroke', d => hasChildren(d) ? '#ffffff' : 'none')
3562
+ .attr('stroke-width', d => hasChildren(d) ? 1.5 : 0);
3563
+
3564
+ // Add expand/collapse indicator
3565
+ nodeEnter.filter(d => hasChildren(d))
3566
+ .append('text')
3567
+ .attr('class', 'expand-indicator')
3568
+ .attr('x', d => {
3569
+ const iconRadius = d.type === 'directory' ? 22 : 18;
3570
+ return iconRadius + 5;
3571
+ })
3572
+ .attr('y', 0)
3573
+ .attr('dy', '0.6em')
3574
+ .attr('text-anchor', 'start')
3575
+ .style('fill', '#ffffff')
3576
+ .style('font-size', '16px')
3577
+ .style('font-weight', 'bold')
3578
+ .style('pointer-events', 'none')
3579
+ .text(d => {
3580
+ if (!stateManager) return '+';
3581
+ const state = stateManager.nodeStates.get(d.id);
3582
+ return state?.expanded ? '−' : '+';
3583
+ });
3584
+
3585
+ // Add labels
3586
+ nodeEnter.append('text')
3587
+ .attr('class', 'node-label')
3588
+ .attr('x', d => {
3589
+ if (isFileOrDir(d)) {
3590
+ const iconRadius = d.type === 'directory' ? 22 : 18;
3591
+ return iconRadius + 25; // After icon and indicator
3592
+ }
3593
+ return 0;
3594
+ })
3595
+ .attr('y', d => isFileOrDir(d) ? 0 : 0)
3596
+ .attr('dy', d => isFileOrDir(d) ? '0.35em' : '2.5em')
3597
+ .attr('text-anchor', d => isFileOrDir(d) ? 'start' : 'middle')
3598
+ .style('fill', '#ffffff')
3599
+ .style('font-size', '14px')
3600
+ .style('pointer-events', 'none')
3601
+ .text(d => d.name || 'Unknown');
3602
+ }
3603
+
3604
+ /**
3605
+ * Helper function to check if node is file or directory.
3606
+ */
3607
+ function isFileOrDir(node) {
3608
+ return node.type === 'file' || node.type === 'directory';
3609
+ }
3610
+
3611
+ /**
3612
+ * Helper function to check if node is a document node.
3613
+ */
3614
+ function isDocNode(node) {
3615
+ return node.type === 'document' || node.type === 'section';
3616
+ }
3617
+
3618
+ /**
3619
+ * Helper function to check if node has children.
3620
+ */
3621
+ function hasChildren(node) {
3622
+ return allLinks.some(link => {
3623
+ const sourceId = link.source.id || link.source;
3624
+ return sourceId === node.id &&
3625
+ (link.type === 'dir_containment' ||
3626
+ link.type === 'file_containment' ||
3627
+ link.type === 'dir_hierarchy');
3628
+ });
3629
+ }
3630
+
3631
+ /**
3632
+ * Helper function to escape HTML in strings.
3633
+ */
3634
+ function escapeHtml(text) {
3635
+ const div = document.createElement('div');
3636
+ div.textContent = text;
3637
+ return div.innerHTML;
3638
+ }
3639
+ """
3640
+
3641
+
3642
+ def get_all_scripts() -> str:
3643
+ """Get all JavaScript code combined.
3644
+
3645
+ Returns:
3646
+ Complete JavaScript string for the visualization
3647
+ """
3648
+ return "".join(
3649
+ [
3650
+ get_d3_initialization(),
3651
+ get_state_management(), # NEW: V2.0 state management
3652
+ get_layout_algorithms_v2(), # NEW: V2.0 layout algorithms
3653
+ get_interaction_handlers_v2(), # NEW: V2.0 interaction handlers
3654
+ get_rendering_v2(), # NEW: V2.0 rendering with transitions
3655
+ get_file_type_functions(),
3656
+ get_spacing_calculation_functions(),
3657
+ get_loading_spinner_functions(),
3658
+ get_navigation_stack_logic(),
3659
+ get_layout_switching_logic(),
3660
+ get_graph_visualization_functions(),
3661
+ get_zoom_and_navigation_functions(),
3662
+ get_interaction_handlers(),
3663
+ get_tooltip_logic(),
3664
+ get_drag_and_stats_functions(),
3665
+ get_breadcrumb_functions(),
3666
+ get_code_chunks_functions(),
3667
+ get_content_pane_functions(),
3668
+ get_data_loading_logic(),
3669
+ ]
3670
+ )