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.
- mcp_vector_search/__init__.py +10 -0
- mcp_vector_search/cli/__init__.py +1 -0
- mcp_vector_search/cli/commands/__init__.py +1 -0
- mcp_vector_search/cli/commands/auto_index.py +397 -0
- mcp_vector_search/cli/commands/chat.py +534 -0
- mcp_vector_search/cli/commands/config.py +393 -0
- mcp_vector_search/cli/commands/demo.py +358 -0
- mcp_vector_search/cli/commands/index.py +762 -0
- mcp_vector_search/cli/commands/init.py +658 -0
- mcp_vector_search/cli/commands/install.py +869 -0
- mcp_vector_search/cli/commands/install_old.py +700 -0
- mcp_vector_search/cli/commands/mcp.py +1254 -0
- mcp_vector_search/cli/commands/reset.py +393 -0
- mcp_vector_search/cli/commands/search.py +796 -0
- mcp_vector_search/cli/commands/setup.py +1133 -0
- mcp_vector_search/cli/commands/status.py +584 -0
- mcp_vector_search/cli/commands/uninstall.py +404 -0
- mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
- mcp_vector_search/cli/commands/visualize/cli.py +265 -0
- mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
- mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
- mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +29 -0
- mcp_vector_search/cli/commands/visualize/graph_builder.py +709 -0
- mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
- mcp_vector_search/cli/commands/visualize/server.py +201 -0
- mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
- mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
- mcp_vector_search/cli/commands/visualize/templates/base.py +218 -0
- mcp_vector_search/cli/commands/visualize/templates/scripts.py +3670 -0
- mcp_vector_search/cli/commands/visualize/templates/styles.py +779 -0
- mcp_vector_search/cli/commands/visualize.py.original +2536 -0
- mcp_vector_search/cli/commands/watch.py +287 -0
- mcp_vector_search/cli/didyoumean.py +520 -0
- mcp_vector_search/cli/export.py +320 -0
- mcp_vector_search/cli/history.py +295 -0
- mcp_vector_search/cli/interactive.py +342 -0
- mcp_vector_search/cli/main.py +484 -0
- mcp_vector_search/cli/output.py +414 -0
- mcp_vector_search/cli/suggestions.py +375 -0
- mcp_vector_search/config/__init__.py +1 -0
- mcp_vector_search/config/constants.py +24 -0
- mcp_vector_search/config/defaults.py +200 -0
- mcp_vector_search/config/settings.py +146 -0
- mcp_vector_search/core/__init__.py +1 -0
- mcp_vector_search/core/auto_indexer.py +298 -0
- mcp_vector_search/core/config_utils.py +394 -0
- mcp_vector_search/core/connection_pool.py +360 -0
- mcp_vector_search/core/database.py +1237 -0
- mcp_vector_search/core/directory_index.py +318 -0
- mcp_vector_search/core/embeddings.py +294 -0
- mcp_vector_search/core/exceptions.py +89 -0
- mcp_vector_search/core/factory.py +318 -0
- mcp_vector_search/core/git_hooks.py +345 -0
- mcp_vector_search/core/indexer.py +1002 -0
- mcp_vector_search/core/llm_client.py +453 -0
- mcp_vector_search/core/models.py +294 -0
- mcp_vector_search/core/project.py +350 -0
- mcp_vector_search/core/scheduler.py +330 -0
- mcp_vector_search/core/search.py +952 -0
- mcp_vector_search/core/watcher.py +322 -0
- mcp_vector_search/mcp/__init__.py +5 -0
- mcp_vector_search/mcp/__main__.py +25 -0
- mcp_vector_search/mcp/server.py +752 -0
- mcp_vector_search/parsers/__init__.py +8 -0
- mcp_vector_search/parsers/base.py +296 -0
- mcp_vector_search/parsers/dart.py +605 -0
- mcp_vector_search/parsers/html.py +413 -0
- mcp_vector_search/parsers/javascript.py +643 -0
- mcp_vector_search/parsers/php.py +694 -0
- mcp_vector_search/parsers/python.py +502 -0
- mcp_vector_search/parsers/registry.py +223 -0
- mcp_vector_search/parsers/ruby.py +678 -0
- mcp_vector_search/parsers/text.py +186 -0
- mcp_vector_search/parsers/utils.py +265 -0
- mcp_vector_search/py.typed +1 -0
- mcp_vector_search/utils/__init__.py +42 -0
- mcp_vector_search/utils/gitignore.py +250 -0
- mcp_vector_search/utils/gitignore_updater.py +212 -0
- mcp_vector_search/utils/monorepo.py +339 -0
- mcp_vector_search/utils/timing.py +338 -0
- mcp_vector_search/utils/version.py +47 -0
- mcp_vector_search-0.15.7.dist-info/METADATA +884 -0
- mcp_vector_search-0.15.7.dist-info/RECORD +86 -0
- mcp_vector_search-0.15.7.dist-info/WHEEL +4 -0
- mcp_vector_search-0.15.7.dist-info/entry_points.txt +3 -0
- 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, '&')
|
|
1445
|
+
.replace(/</g, '<')
|
|
1446
|
+
.replace(/>/g, '>');
|
|
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
|
+
)
|