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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +48 -1
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +35 -0
  7. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  8. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  9. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  10. mcp_vector_search/analysis/collectors/smells.py +325 -0
  11. mcp_vector_search/analysis/debt.py +516 -0
  12. mcp_vector_search/analysis/interpretation.py +685 -0
  13. mcp_vector_search/analysis/metrics.py +74 -1
  14. mcp_vector_search/analysis/reporters/__init__.py +3 -1
  15. mcp_vector_search/analysis/reporters/console.py +424 -0
  16. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  17. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  18. mcp_vector_search/analysis/storage/__init__.py +93 -0
  19. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  20. mcp_vector_search/analysis/storage/schema.py +245 -0
  21. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  22. mcp_vector_search/analysis/trends.py +308 -0
  23. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  24. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  25. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  26. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  27. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  28. mcp_vector_search/cli/commands/analyze.py +665 -11
  29. mcp_vector_search/cli/commands/chat.py +193 -0
  30. mcp_vector_search/cli/commands/index.py +600 -2
  31. mcp_vector_search/cli/commands/index_background.py +467 -0
  32. mcp_vector_search/cli/commands/search.py +194 -1
  33. mcp_vector_search/cli/commands/setup.py +64 -13
  34. mcp_vector_search/cli/commands/status.py +302 -3
  35. mcp_vector_search/cli/commands/visualize/cli.py +26 -10
  36. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +8 -4
  37. mcp_vector_search/cli/commands/visualize/graph_builder.py +167 -234
  38. mcp_vector_search/cli/commands/visualize/server.py +304 -15
  39. mcp_vector_search/cli/commands/visualize/templates/base.py +60 -6
  40. mcp_vector_search/cli/commands/visualize/templates/scripts.py +2100 -65
  41. mcp_vector_search/cli/commands/visualize/templates/styles.py +1297 -88
  42. mcp_vector_search/cli/didyoumean.py +5 -0
  43. mcp_vector_search/cli/main.py +16 -5
  44. mcp_vector_search/cli/output.py +134 -5
  45. mcp_vector_search/config/thresholds.py +89 -1
  46. mcp_vector_search/core/__init__.py +16 -0
  47. mcp_vector_search/core/database.py +39 -2
  48. mcp_vector_search/core/embeddings.py +24 -0
  49. mcp_vector_search/core/git.py +380 -0
  50. mcp_vector_search/core/indexer.py +445 -84
  51. mcp_vector_search/core/llm_client.py +9 -4
  52. mcp_vector_search/core/models.py +88 -1
  53. mcp_vector_search/core/relationships.py +473 -0
  54. mcp_vector_search/core/search.py +1 -1
  55. mcp_vector_search/mcp/server.py +795 -4
  56. mcp_vector_search/parsers/python.py +285 -5
  57. mcp_vector_search/utils/gitignore.py +0 -3
  58. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +3 -2
  59. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/RECORD +62 -39
  60. mcp_vector_search/cli/commands/visualize.py.original +0 -2536
  61. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +0 -0
  62. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +0 -0
  63. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -1,2536 +0,0 @@
1
- """Visualization commands for MCP Vector Search."""
2
-
3
- import asyncio
4
- import json
5
- import shutil
6
- from pathlib import Path
7
-
8
- import typer
9
- from loguru import logger
10
- from rich.console import Console
11
- from rich.panel import Panel
12
-
13
- from ...core.database import ChromaVectorDatabase
14
- from ...core.embeddings import create_embedding_function
15
- from ...core.project import ProjectManager
16
-
17
- app = typer.Typer(
18
- help="Visualize code chunk relationships",
19
- no_args_is_help=True,
20
- )
21
- console = Console()
22
-
23
-
24
- @app.command()
25
- def export(
26
- output: Path = typer.Option(
27
- Path("chunk-graph.json"),
28
- "--output",
29
- "-o",
30
- help="Output file for chunk relationship data",
31
- ),
32
- file_path: str | None = typer.Option(
33
- None,
34
- "--file",
35
- "-f",
36
- help="Export only chunks from specific file (supports wildcards)",
37
- ),
38
- code_only: bool = typer.Option(
39
- False,
40
- "--code-only",
41
- help="Exclude documentation chunks (text, comment, docstring)",
42
- ),
43
- ) -> None:
44
- """Export chunk relationships as JSON for D3.js visualization.
45
-
46
- Examples:
47
- # Export all chunks
48
- mcp-vector-search visualize export
49
-
50
- # Export from specific file
51
- mcp-vector-search visualize export --file src/main.py
52
-
53
- # Custom output location
54
- mcp-vector-search visualize export -o graph.json
55
-
56
- # Export only code chunks (exclude documentation)
57
- mcp-vector-search visualize export --code-only
58
- """
59
- asyncio.run(_export_chunks(output, file_path, code_only))
60
-
61
-
62
- async def _export_chunks(
63
- output: Path, file_filter: str | None, code_only: bool = False
64
- ) -> None:
65
- """Export chunk relationship data.
66
-
67
- Args:
68
- output: Path to output JSON file
69
- file_filter: Optional file pattern to filter chunks
70
- code_only: If True, exclude documentation chunks (text, comment, docstring)
71
- """
72
- try:
73
- # Load project
74
- project_manager = ProjectManager(Path.cwd())
75
-
76
- if not project_manager.is_initialized():
77
- console.print(
78
- "[red]Project not initialized. Run 'mcp-vector-search init' first.[/red]"
79
- )
80
- raise typer.Exit(1)
81
-
82
- config = project_manager.load_config()
83
-
84
- # Get database
85
- embedding_function, _ = create_embedding_function(config.embedding_model)
86
- database = ChromaVectorDatabase(
87
- persist_directory=config.index_path,
88
- embedding_function=embedding_function,
89
- )
90
- await database.initialize()
91
-
92
- # Get all chunks with metadata
93
- console.print("[cyan]Fetching chunks from database...[/cyan]")
94
- chunks = await database.get_all_chunks()
95
-
96
- # Store database reference for semantic search
97
- # We'll pass it in metadata for the visualization
98
- graph_database = database
99
-
100
- if len(chunks) == 0:
101
- console.print(
102
- "[yellow]No chunks found in index. Run 'mcp-vector-search index' first.[/yellow]"
103
- )
104
- raise typer.Exit(1)
105
-
106
- console.print(f"[green]✓[/green] Retrieved {len(chunks)} chunks")
107
-
108
- # Apply file filter if specified
109
- if file_filter:
110
- from fnmatch import fnmatch
111
-
112
- chunks = [c for c in chunks if fnmatch(str(c.file_path), file_filter)]
113
- console.print(
114
- f"[cyan]Filtered to {len(chunks)} chunks matching '{file_filter}'[/cyan]"
115
- )
116
-
117
- # Apply code-only filter if requested
118
- if code_only:
119
- original_count = len(chunks)
120
- chunks = [
121
- c
122
- for c in chunks
123
- if c.chunk_type not in ["text", "comment", "docstring"]
124
- ]
125
- filtered_count = len(chunks)
126
- console.print(
127
- f"[dim]Filtered out {original_count - filtered_count} documentation chunks "
128
- f"({original_count} → {filtered_count} chunks)[/dim]"
129
- )
130
-
131
- # Collect subprojects for monorepo support
132
- subprojects = {}
133
- for chunk in chunks:
134
- if chunk.subproject_name and chunk.subproject_name not in subprojects:
135
- subprojects[chunk.subproject_name] = {
136
- "name": chunk.subproject_name,
137
- "path": chunk.subproject_path,
138
- "color": _get_subproject_color(
139
- chunk.subproject_name, len(subprojects)
140
- ),
141
- }
142
-
143
- # Build graph data structure
144
- nodes = []
145
- links = []
146
- chunk_id_map = {} # Map chunk IDs to array indices
147
- file_nodes = {} # Track file nodes by path
148
- dir_nodes = {} # Track directory nodes by path
149
-
150
- # Add subproject root nodes for monorepos
151
- if subprojects:
152
- console.print(
153
- f"[cyan]Detected monorepo with {len(subprojects)} subprojects[/cyan]"
154
- )
155
- for sp_name, sp_data in subprojects.items():
156
- node = {
157
- "id": f"subproject_{sp_name}",
158
- "name": sp_name,
159
- "type": "subproject",
160
- "file_path": sp_data["path"] or "",
161
- "start_line": 0,
162
- "end_line": 0,
163
- "complexity": 0,
164
- "color": sp_data["color"],
165
- "depth": 0,
166
- }
167
- nodes.append(node)
168
-
169
- # Load directory index for enhanced directory metadata
170
- console.print("[cyan]Loading directory index...[/cyan]")
171
- from ...core.directory_index import DirectoryIndex
172
-
173
- dir_index_path = (
174
- project_manager.project_root / ".mcp-vector-search" / "directory_index.json"
175
- )
176
- dir_index = DirectoryIndex(dir_index_path)
177
- dir_index.load()
178
-
179
- # Create directory nodes from directory index
180
- console.print(
181
- f"[green]✓[/green] Loaded {len(dir_index.directories)} directories"
182
- )
183
- for dir_path_str, directory in dir_index.directories.items():
184
- dir_id = f"dir_{hash(dir_path_str) & 0xFFFFFFFF:08x}"
185
- dir_nodes[dir_path_str] = {
186
- "id": dir_id,
187
- "name": directory.name,
188
- "type": "directory",
189
- "file_path": dir_path_str,
190
- "start_line": 0,
191
- "end_line": 0,
192
- "complexity": 0,
193
- "depth": directory.depth,
194
- "dir_path": dir_path_str,
195
- "file_count": directory.file_count,
196
- "subdirectory_count": directory.subdirectory_count,
197
- "total_chunks": directory.total_chunks,
198
- "languages": directory.languages or {},
199
- "is_package": directory.is_package,
200
- "last_modified": directory.last_modified,
201
- }
202
-
203
- # Create file nodes from chunks
204
- for chunk in chunks:
205
- file_path_str = str(chunk.file_path)
206
- file_path = Path(file_path_str)
207
-
208
- # Create file node with parent directory reference
209
- if file_path_str not in file_nodes:
210
- file_id = f"file_{hash(file_path_str) & 0xFFFFFFFF:08x}"
211
-
212
- # Convert absolute path to relative path for parent directory lookup
213
- try:
214
- relative_file_path = file_path.relative_to(
215
- project_manager.project_root
216
- )
217
- parent_dir = relative_file_path.parent
218
- # Use relative path for parent directory (matches directory_index)
219
- parent_dir_str = (
220
- str(parent_dir) if parent_dir != Path(".") else None
221
- )
222
- except ValueError:
223
- # File is outside project root
224
- parent_dir_str = None
225
-
226
- # Look up parent directory ID from dir_nodes (must match exactly)
227
- parent_dir_id = None
228
- if parent_dir_str and parent_dir_str in dir_nodes:
229
- parent_dir_id = dir_nodes[parent_dir_str]["id"]
230
-
231
- file_nodes[file_path_str] = {
232
- "id": file_id,
233
- "name": file_path.name,
234
- "type": "file",
235
- "file_path": file_path_str,
236
- "start_line": 0,
237
- "end_line": 0,
238
- "complexity": 0,
239
- "depth": len(file_path.parts) - 1,
240
- "parent_dir_id": parent_dir_id,
241
- "parent_dir_path": parent_dir_str,
242
- }
243
-
244
- # Add directory nodes to graph
245
- for dir_node in dir_nodes.values():
246
- nodes.append(dir_node)
247
-
248
- # Add file nodes to graph
249
- for file_node in file_nodes.values():
250
- nodes.append(file_node)
251
-
252
- # Compute semantic relationships for code chunks
253
- console.print("[cyan]Computing semantic relationships...[/cyan]")
254
- code_chunks = [
255
- c for c in chunks if c.chunk_type in ["function", "method", "class"]
256
- ]
257
- semantic_links = []
258
-
259
- # Pre-compute top 5 semantic relationships for each code chunk
260
- for i, chunk in enumerate(code_chunks):
261
- if i % 20 == 0: # Progress indicator every 20 chunks
262
- console.print(f"[dim]Processed {i}/{len(code_chunks)} chunks[/dim]")
263
-
264
- try:
265
- # Search for similar chunks using the chunk's content
266
- similar_results = await graph_database.search(
267
- query=chunk.content[:500], # Use first 500 chars for query
268
- limit=6, # Get 6 (exclude self = 5)
269
- similarity_threshold=0.3, # Lower threshold to catch more relationships
270
- )
271
-
272
- # Filter out self and create semantic links
273
- for result in similar_results:
274
- # Construct target chunk_id from file_path and line numbers
275
- # This matches the chunk ID construction in the database
276
- target_chunk = next(
277
- (
278
- c
279
- for c in chunks
280
- if str(c.file_path) == str(result.file_path)
281
- and c.start_line == result.start_line
282
- and c.end_line == result.end_line
283
- ),
284
- None,
285
- )
286
-
287
- if not target_chunk:
288
- continue
289
-
290
- target_chunk_id = target_chunk.chunk_id or target_chunk.id
291
-
292
- # Skip self-references
293
- if target_chunk_id == (chunk.chunk_id or chunk.id):
294
- continue
295
-
296
- # Add semantic link with similarity score (use similarity_score from SearchResult)
297
- if result.similarity_score >= 0.2:
298
- semantic_links.append(
299
- {
300
- "source": chunk.chunk_id or chunk.id,
301
- "target": target_chunk_id,
302
- "type": "semantic",
303
- "similarity": result.similarity_score,
304
- }
305
- )
306
-
307
- # Only keep top 5
308
- if (
309
- len(
310
- [
311
- link
312
- for link in semantic_links
313
- if link["source"] == (chunk.chunk_id or chunk.id)
314
- ]
315
- )
316
- >= 5
317
- ):
318
- break
319
-
320
- except Exception as e:
321
- logger.debug(
322
- f"Failed to compute semantic relationships for {chunk.chunk_id}: {e}"
323
- )
324
- continue
325
-
326
- console.print(
327
- f"[green]✓[/green] Computed {len(semantic_links)} semantic relationships"
328
- )
329
-
330
- # Compute external caller relationships
331
- console.print("[cyan]Computing external caller relationships...[/cyan]")
332
- caller_map = {} # Map chunk_id -> list of caller info
333
-
334
- for chunk in code_chunks:
335
- chunk_id = chunk.chunk_id or chunk.id
336
- file_path = str(chunk.file_path)
337
- function_name = chunk.function_name or chunk.class_name
338
-
339
- if not function_name:
340
- continue
341
-
342
- # Search for other chunks that reference this function/class name
343
- for other_chunk in chunks:
344
- other_file_path = str(other_chunk.file_path)
345
-
346
- # Only track EXTERNAL callers (different file)
347
- if other_file_path == file_path:
348
- continue
349
-
350
- # Check if the other chunk's content mentions this function/class
351
- if function_name in other_chunk.content:
352
- other_chunk_id = other_chunk.chunk_id or other_chunk.id
353
- other_name = (
354
- other_chunk.function_name
355
- or other_chunk.class_name
356
- or f"L{other_chunk.start_line}"
357
- )
358
-
359
- if chunk_id not in caller_map:
360
- caller_map[chunk_id] = []
361
-
362
- # Store caller information
363
- caller_map[chunk_id].append(
364
- {
365
- "file": other_file_path,
366
- "chunk_id": other_chunk_id,
367
- "name": other_name,
368
- "type": other_chunk.chunk_type,
369
- }
370
- )
371
-
372
- # Count total caller relationships
373
- total_callers = sum(len(callers) for callers in caller_map.values())
374
- console.print(
375
- f"[green]✓[/green] Found {total_callers} external caller relationships"
376
- )
377
-
378
- # Detect circular dependencies in caller relationships
379
- console.print("[cyan]Detecting circular dependencies...[/cyan]")
380
-
381
- def detect_cycles(nodes_list, links_list):
382
- """Detect cycles in the call graph using DFS.
383
-
384
- Returns:
385
- List of cycles found, where each cycle is a list of node IDs in the cycle path.
386
- """
387
- cycles_found = []
388
- visited = set()
389
-
390
- def dfs(node_id, path, path_set):
391
- """DFS traversal to detect cycles.
392
-
393
- Args:
394
- node_id: Current node ID being visited
395
- path: List of node IDs in current path (for cycle reconstruction)
396
- path_set: Set of node IDs in current path (for O(1) cycle detection)
397
-
398
- Returns:
399
- True if cycle detected in this path
400
- """
401
- if node_id in path_set:
402
- # Found a cycle! Record the cycle path
403
- cycle_start = path.index(node_id)
404
- cycle_nodes = path[cycle_start:]
405
- cycles_found.append(cycle_nodes)
406
- return True
407
-
408
- if node_id in visited:
409
- return False
410
-
411
- path.append(node_id)
412
- path_set.add(node_id)
413
- visited.add(node_id)
414
-
415
- # Follow caller links (external callers create directed edges)
416
- if node_id in caller_map:
417
- for caller_info in caller_map[node_id]:
418
- caller_id = caller_info["chunk_id"]
419
- dfs(caller_id, path, path_set)
420
-
421
- path.pop()
422
- path_set.remove(node_id)
423
- return False
424
-
425
- # Run DFS from each unvisited node
426
- for node in nodes_list:
427
- if node.chunk_id or node.id not in visited:
428
- chunk_id = node.chunk_id or node.id
429
- dfs(chunk_id, [], set())
430
-
431
- return cycles_found
432
-
433
- # Detect cycles
434
- cycles = detect_cycles(chunks, [])
435
-
436
- # Mark cycle links
437
- cycle_links = []
438
- if cycles:
439
- console.print(
440
- f"[yellow]⚠ Found {len(cycles)} circular dependencies[/yellow]"
441
- )
442
-
443
- # For each cycle, create links marking the cycle
444
- for cycle in cycles:
445
- # Create links for the cycle path: A → B → C → A
446
- for i in range(len(cycle)):
447
- source = cycle[i]
448
- target = cycle[(i + 1) % len(cycle)] # Wrap around to form cycle
449
- cycle_links.append(
450
- {
451
- "source": source,
452
- "target": target,
453
- "type": "caller",
454
- "is_cycle": True,
455
- }
456
- )
457
- else:
458
- console.print("[green]✓[/green] No circular dependencies detected")
459
-
460
- # Add chunk nodes
461
- for chunk in chunks:
462
- chunk_id = chunk.chunk_id or chunk.id
463
- node = {
464
- "id": chunk_id,
465
- "name": chunk.function_name
466
- or chunk.class_name
467
- or f"L{chunk.start_line}",
468
- "type": chunk.chunk_type,
469
- "file_path": str(chunk.file_path),
470
- "start_line": chunk.start_line,
471
- "end_line": chunk.end_line,
472
- "complexity": chunk.complexity_score,
473
- "parent_id": chunk.parent_chunk_id,
474
- "depth": chunk.chunk_depth,
475
- "content": chunk.content, # Add content for code viewer
476
- "docstring": chunk.docstring,
477
- "language": chunk.language,
478
- }
479
-
480
- # Add caller information if available
481
- if chunk_id in caller_map:
482
- node["callers"] = caller_map[chunk_id]
483
-
484
- # Add subproject info for monorepos
485
- if chunk.subproject_name:
486
- node["subproject"] = chunk.subproject_name
487
- node["color"] = subprojects[chunk.subproject_name]["color"]
488
-
489
- nodes.append(node)
490
- chunk_id_map[node["id"]] = len(nodes) - 1
491
-
492
- # Link directories to their parent directories (hierarchical structure)
493
- for dir_path_str, dir_info in dir_index.directories.items():
494
- if dir_info.parent_path:
495
- parent_path_str = str(dir_info.parent_path)
496
- if parent_path_str in dir_nodes:
497
- parent_dir_id = f"dir_{hash(parent_path_str) & 0xFFFFFFFF:08x}"
498
- child_dir_id = f"dir_{hash(dir_path_str) & 0xFFFFFFFF:08x}"
499
- links.append(
500
- {
501
- "source": parent_dir_id,
502
- "target": child_dir_id,
503
- "type": "dir_hierarchy",
504
- }
505
- )
506
-
507
- # Link directories to subprojects in monorepos (simple flat structure)
508
- if subprojects:
509
- for dir_path_str, dir_node in dir_nodes.items():
510
- for sp_name, sp_data in subprojects.items():
511
- if dir_path_str.startswith(sp_data.get("path", "")):
512
- links.append(
513
- {
514
- "source": f"subproject_{sp_name}",
515
- "target": dir_node["id"],
516
- "type": "dir_containment",
517
- }
518
- )
519
- break
520
-
521
- # Link files to their parent directories
522
- for _file_path_str, file_node in file_nodes.items():
523
- if file_node.get("parent_dir_id"):
524
- links.append(
525
- {
526
- "source": file_node["parent_dir_id"],
527
- "target": file_node["id"],
528
- "type": "dir_containment",
529
- }
530
- )
531
-
532
- # Build hierarchical links from parent-child relationships
533
- for chunk in chunks:
534
- chunk_id = chunk.chunk_id or chunk.id
535
- file_path = str(chunk.file_path)
536
-
537
- # Link chunk to its file node if it has no parent (top-level chunks)
538
- if not chunk.parent_chunk_id and file_path in file_nodes:
539
- links.append(
540
- {
541
- "source": file_nodes[file_path]["id"],
542
- "target": chunk_id,
543
- "type": "file_containment",
544
- }
545
- )
546
-
547
- # Link to subproject root if in monorepo
548
- if chunk.subproject_name and not chunk.parent_chunk_id:
549
- links.append(
550
- {
551
- "source": f"subproject_{chunk.subproject_name}",
552
- "target": chunk_id,
553
- }
554
- )
555
-
556
- # Link to parent chunk
557
- if chunk.parent_chunk_id and chunk.parent_chunk_id in chunk_id_map:
558
- links.append(
559
- {
560
- "source": chunk.parent_chunk_id,
561
- "target": chunk_id,
562
- }
563
- )
564
-
565
- # Add semantic relationship links
566
- links.extend(semantic_links)
567
-
568
- # Add cycle links
569
- links.extend(cycle_links)
570
-
571
- # Parse inter-project dependencies for monorepos
572
- if subprojects:
573
- console.print("[cyan]Parsing inter-project dependencies...[/cyan]")
574
- dep_links = _parse_project_dependencies(
575
- project_manager.project_root, subprojects
576
- )
577
- links.extend(dep_links)
578
- if dep_links:
579
- console.print(
580
- f"[green]✓[/green] Found {len(dep_links)} inter-project dependencies"
581
- )
582
-
583
- # Get stats
584
- stats = await database.get_stats()
585
-
586
- # Build final graph data
587
- graph_data = {
588
- "nodes": nodes,
589
- "links": links,
590
- "metadata": {
591
- "total_chunks": len(chunks),
592
- "total_files": stats.total_files,
593
- "languages": stats.languages,
594
- "is_monorepo": len(subprojects) > 0,
595
- "subprojects": list(subprojects.keys()) if subprojects else [],
596
- },
597
- }
598
-
599
- # Write to file
600
- output.parent.mkdir(parents=True, exist_ok=True)
601
- with open(output, "w") as f:
602
- json.dump(graph_data, f, indent=2)
603
-
604
- await database.close()
605
-
606
- console.print()
607
- cycle_warning = f"[yellow]Cycles: {len(cycles)} ⚠️[/yellow]\n" if cycles else ""
608
- console.print(
609
- Panel.fit(
610
- f"[green]✓[/green] Exported graph data to [cyan]{output}[/cyan]\n\n"
611
- f"Nodes: {len(graph_data['nodes'])}\n"
612
- f"Links: {len(graph_data['links'])}\n"
613
- f"{cycle_warning}"
614
- f"{'Subprojects: ' + str(len(subprojects)) if subprojects else ''}\n\n"
615
- f"[dim]Next: Run 'mcp-vector-search visualize serve' to view[/dim]",
616
- title="Export Complete",
617
- border_style="green",
618
- )
619
- )
620
-
621
- except Exception as e:
622
- logger.error(f"Export failed: {e}")
623
- console.print(f"[red]✗ Export failed: {e}[/red]")
624
- raise typer.Exit(1)
625
-
626
-
627
- def _get_subproject_color(subproject_name: str, index: int) -> str:
628
- """Get a consistent color for a subproject."""
629
- # Color palette for subprojects (GitHub-style colors)
630
- colors = [
631
- "#238636", # Green
632
- "#1f6feb", # Blue
633
- "#d29922", # Yellow
634
- "#8957e5", # Purple
635
- "#da3633", # Red
636
- "#bf8700", # Orange
637
- "#1a7f37", # Dark green
638
- "#0969da", # Dark blue
639
- ]
640
- return colors[index % len(colors)]
641
-
642
-
643
- def _parse_project_dependencies(project_root: Path, subprojects: dict) -> list[dict]:
644
- """Parse package.json files to find inter-project dependencies.
645
-
646
- Args:
647
- project_root: Root directory of the monorepo
648
- subprojects: Dictionary of subproject information
649
-
650
- Returns:
651
- List of dependency links between subprojects
652
- """
653
- dependency_links = []
654
-
655
- for sp_name, sp_data in subprojects.items():
656
- package_json = project_root / sp_data["path"] / "package.json"
657
-
658
- if not package_json.exists():
659
- continue
660
-
661
- try:
662
- with open(package_json) as f:
663
- package_data = json.load(f)
664
-
665
- # Check all dependency types
666
- all_deps = {}
667
- for dep_type in ["dependencies", "devDependencies", "peerDependencies"]:
668
- if dep_type in package_data:
669
- all_deps.update(package_data[dep_type])
670
-
671
- # Find dependencies on other subprojects
672
- for dep_name in all_deps.keys():
673
- # Check if this dependency is another subproject
674
- for other_sp_name in subprojects.keys():
675
- if other_sp_name != sp_name and dep_name == other_sp_name:
676
- # Found inter-project dependency
677
- dependency_links.append(
678
- {
679
- "source": f"subproject_{sp_name}",
680
- "target": f"subproject_{other_sp_name}",
681
- "type": "dependency",
682
- }
683
- )
684
-
685
- except Exception as e:
686
- logger.debug(f"Failed to parse {package_json}: {e}")
687
- continue
688
-
689
- return dependency_links
690
-
691
-
692
- @app.command()
693
- def serve(
694
- port: int = typer.Option(
695
- 8080, "--port", "-p", help="Port for visualization server"
696
- ),
697
- graph_file: Path = typer.Option(
698
- Path("chunk-graph.json"),
699
- "--graph",
700
- "-g",
701
- help="Graph JSON file to visualize",
702
- ),
703
- code_only: bool = typer.Option(
704
- False,
705
- "--code-only",
706
- help="Exclude documentation chunks (text, comment, docstring)",
707
- ),
708
- ) -> None:
709
- """Start local HTTP server for D3.js visualization.
710
-
711
- Examples:
712
- # Start server on default port 8080
713
- mcp-vector-search visualize serve
714
-
715
- # Custom port
716
- mcp-vector-search visualize serve --port 3000
717
-
718
- # Custom graph file
719
- mcp-vector-search visualize serve --graph my-graph.json
720
-
721
- # Serve with code-only filter
722
- mcp-vector-search visualize serve --code-only
723
- """
724
- import http.server
725
- import os
726
- import socket
727
- import socketserver
728
- import webbrowser
729
-
730
- # Find free port in range 8080-8099
731
- def find_free_port(start_port: int = 8080, end_port: int = 8099) -> int:
732
- """Find a free port in the given range."""
733
- for test_port in range(start_port, end_port + 1):
734
- try:
735
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
736
- s.bind(("", test_port))
737
- return test_port
738
- except OSError:
739
- continue
740
- raise OSError(f"No free ports available in range {start_port}-{end_port}")
741
-
742
- # Use specified port or find free one
743
- if port == 8080: # Default port, try to find free one
744
- try:
745
- port = find_free_port(8080, 8099)
746
- except OSError as e:
747
- console.print(f"[red]✗ {e}[/red]")
748
- raise typer.Exit(1)
749
-
750
- # Get visualization directory - use project-local storage
751
- project_manager = ProjectManager(Path.cwd())
752
- if not project_manager.is_initialized():
753
- console.print(
754
- "[red]Project not initialized. Run 'mcp-vector-search init' first.[/red]"
755
- )
756
- raise typer.Exit(1)
757
-
758
- viz_dir = project_manager.project_root / ".mcp-vector-search" / "visualization"
759
-
760
- if not viz_dir.exists():
761
- console.print(
762
- f"[yellow]Visualization directory not found. Creating at {viz_dir}...[/yellow]"
763
- )
764
- viz_dir.mkdir(parents=True, exist_ok=True)
765
-
766
- # Always ensure index.html exists (regenerate if missing)
767
- html_file = viz_dir / "index.html"
768
- if not html_file.exists():
769
- console.print("[yellow]Creating visualization HTML file...[/yellow]")
770
- _create_visualization_html(html_file)
771
-
772
- # Check if we need to regenerate the graph file
773
- needs_regeneration = not graph_file.exists() or code_only
774
-
775
- if graph_file.exists() and not needs_regeneration:
776
- # Use existing unfiltered file
777
- dest = viz_dir / "chunk-graph.json"
778
- shutil.copy(graph_file, dest)
779
- console.print(f"[green]✓[/green] Copied graph data to {dest}")
780
- else:
781
- # Generate new file (with filter if requested)
782
- if graph_file.exists() and code_only:
783
- console.print(
784
- "[yellow]Regenerating filtered graph data (--code-only)...[/yellow]"
785
- )
786
- elif not graph_file.exists():
787
- console.print(
788
- f"[yellow]Graph file {graph_file} not found. Generating it now...[/yellow]"
789
- )
790
-
791
- asyncio.run(_export_chunks(graph_file, None, code_only))
792
- console.print()
793
-
794
- # Copy the newly generated graph to visualization directory
795
- if graph_file.exists():
796
- dest = viz_dir / "chunk-graph.json"
797
- shutil.copy(graph_file, dest)
798
- console.print(f"[green]✓[/green] Copied graph data to {dest}")
799
-
800
- # Change to visualization directory
801
- os.chdir(viz_dir)
802
-
803
- # Start server
804
- handler = http.server.SimpleHTTPRequestHandler
805
- try:
806
- with socketserver.TCPServer(("", port), handler) as httpd:
807
- url = f"http://localhost:{port}"
808
- console.print()
809
- console.print(
810
- Panel.fit(
811
- f"[green]✓[/green] Visualization server running\n\n"
812
- f"URL: [cyan]{url}[/cyan]\n"
813
- f"Directory: [dim]{viz_dir}[/dim]\n\n"
814
- f"[dim]Press Ctrl+C to stop[/dim]",
815
- title="Server Started",
816
- border_style="green",
817
- )
818
- )
819
-
820
- # Open browser
821
- webbrowser.open(url)
822
-
823
- try:
824
- httpd.serve_forever()
825
- except KeyboardInterrupt:
826
- console.print("\n[yellow]Stopping server...[/yellow]")
827
-
828
- except OSError as e:
829
- if "Address already in use" in str(e):
830
- console.print(
831
- f"[red]✗ Port {port} is already in use. Try a different port with --port[/red]"
832
- )
833
- else:
834
- console.print(f"[red]✗ Server error: {e}[/red]")
835
- raise typer.Exit(1)
836
-
837
-
838
- def _create_visualization_html(html_file: Path) -> None:
839
- """Create the D3.js visualization HTML file."""
840
- html_content = """<!DOCTYPE html>
841
- <html>
842
- <head>
843
- <meta charset="utf-8">
844
- <title>Code Chunk Relationship Graph</title>
845
- <script src="https://d3js.org/d3.v7.min.js"></script>
846
- <style>
847
- body {
848
- margin: 0;
849
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
850
- background: #0d1117;
851
- color: #c9d1d9;
852
- overflow: hidden;
853
- }
854
-
855
- #controls {
856
- position: absolute;
857
- top: 20px;
858
- left: 20px;
859
- background: rgba(13, 17, 23, 0.95);
860
- border: 1px solid #30363d;
861
- border-radius: 6px;
862
- padding: 16px;
863
- min-width: 250px;
864
- max-height: 80vh;
865
- overflow-y: auto;
866
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
867
- }
868
-
869
- h1 { margin: 0 0 16px 0; font-size: 18px; }
870
- h3 { margin: 16px 0 8px 0; font-size: 14px; color: #8b949e; }
871
-
872
- .control-group {
873
- margin-bottom: 12px;
874
- }
875
-
876
- label {
877
- display: block;
878
- margin-bottom: 4px;
879
- font-size: 12px;
880
- color: #8b949e;
881
- }
882
-
883
- input[type="file"] {
884
- width: 100%;
885
- padding: 6px;
886
- background: #161b22;
887
- border: 1px solid #30363d;
888
- border-radius: 6px;
889
- color: #c9d1d9;
890
- font-size: 12px;
891
- }
892
-
893
- .legend {
894
- font-size: 12px;
895
- }
896
-
897
- .legend-item {
898
- margin: 4px 0;
899
- display: flex;
900
- align-items: center;
901
- }
902
-
903
- .legend-color {
904
- width: 12px;
905
- height: 12px;
906
- border-radius: 50%;
907
- margin-right: 8px;
908
- }
909
-
910
- #graph {
911
- width: 100vw;
912
- height: 100vh;
913
- }
914
-
915
- .node circle {
916
- cursor: pointer;
917
- stroke: #c9d1d9;
918
- stroke-width: 2px;
919
- pointer-events: all;
920
- }
921
-
922
- .node.module circle { fill: #238636; }
923
- .node.class circle { fill: #1f6feb; }
924
- .node.function circle { fill: #d29922; }
925
- .node.method circle { fill: #8957e5; }
926
- .node.code circle { fill: #6e7681; }
927
- .node.file circle {
928
- fill: none;
929
- stroke: #58a6ff;
930
- stroke-width: 2px;
931
- stroke-dasharray: 5,3;
932
- opacity: 0.6;
933
- }
934
- .node.directory circle {
935
- fill: none;
936
- stroke: #79c0ff;
937
- stroke-width: 2px;
938
- stroke-dasharray: 3,3;
939
- opacity: 0.5;
940
- }
941
- .node.subproject circle { fill: #da3633; stroke-width: 3px; }
942
-
943
- /* Non-code document nodes - squares */
944
- .node.docstring rect { fill: #8b949e; }
945
- .node.comment rect { fill: #6e7681; }
946
- .node rect {
947
- cursor: pointer;
948
- stroke: #c9d1d9;
949
- stroke-width: 2px;
950
- pointer-events: all;
951
- }
952
-
953
- /* File type icon styling */
954
- .node path.file-icon {
955
- fill: currentColor;
956
- stroke: none;
957
- pointer-events: all;
958
- cursor: pointer;
959
- }
960
-
961
- .node text {
962
- font-size: 14px;
963
- fill: #c9d1d9;
964
- text-anchor: middle;
965
- pointer-events: none;
966
- user-select: none;
967
- }
968
-
969
- .link {
970
- stroke: #30363d;
971
- stroke-opacity: 0.6;
972
- stroke-width: 1.5px;
973
- }
974
-
975
- .link.dependency {
976
- stroke: #d29922;
977
- stroke-opacity: 0.8;
978
- stroke-width: 2px;
979
- stroke-dasharray: 5,5;
980
- }
981
-
982
- /* Semantic relationship links - colored by similarity */
983
- .link.semantic {
984
- stroke-opacity: 0.7;
985
- stroke-dasharray: 4,4;
986
- }
987
-
988
- .link.semantic.sim-high { stroke: #00ff00; stroke-width: 4px; }
989
- .link.semantic.sim-medium-high { stroke: #88ff00; stroke-width: 3px; }
990
- .link.semantic.sim-medium { stroke: #ffff00; stroke-width: 2.5px; }
991
- .link.semantic.sim-low { stroke: #ffaa00; stroke-width: 2px; }
992
- .link.semantic.sim-very-low { stroke: #ff0000; stroke-width: 1.5px; }
993
-
994
- /* Circular dependency links - highest visual priority */
995
- .link.cycle {
996
- stroke: #ff4444 !important;
997
- stroke-width: 3px !important;
998
- stroke-dasharray: 8, 4;
999
- stroke-opacity: 0.8;
1000
- animation: pulse-cycle 2s infinite;
1001
- }
1002
-
1003
- @keyframes pulse-cycle {
1004
- 0%, 100% { stroke-opacity: 0.8; }
1005
- 50% { stroke-opacity: 1.0; }
1006
- }
1007
-
1008
- .tooltip {
1009
- position: absolute;
1010
- padding: 12px;
1011
- background: rgba(13, 17, 23, 0.95);
1012
- border: 1px solid #30363d;
1013
- border-radius: 6px;
1014
- pointer-events: none;
1015
- display: none;
1016
- font-size: 12px;
1017
- max-width: 300px;
1018
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1019
- }
1020
-
1021
- .stats {
1022
- margin-top: 16px;
1023
- padding-top: 16px;
1024
- border-top: 1px solid #30363d;
1025
- font-size: 12px;
1026
- color: #8b949e;
1027
- }
1028
-
1029
- #content-pane {
1030
- position: fixed;
1031
- top: 0;
1032
- right: 0;
1033
- width: 600px;
1034
- height: 100vh;
1035
- background: rgba(13, 17, 23, 0.98);
1036
- border-left: 1px solid #30363d;
1037
- overflow-y: auto;
1038
- box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
1039
- transform: translateX(100%);
1040
- transition: transform 0.3s ease-in-out;
1041
- z-index: 1000;
1042
- }
1043
-
1044
- #content-pane.visible {
1045
- transform: translateX(0);
1046
- }
1047
-
1048
- #content-pane .pane-header {
1049
- position: sticky;
1050
- top: 0;
1051
- background: rgba(13, 17, 23, 0.98);
1052
- padding: 20px;
1053
- border-bottom: 1px solid #30363d;
1054
- z-index: 1;
1055
- }
1056
-
1057
- #content-pane .pane-title {
1058
- font-size: 16px;
1059
- font-weight: bold;
1060
- color: #58a6ff;
1061
- margin-bottom: 8px;
1062
- padding-right: 30px;
1063
- }
1064
-
1065
- #content-pane .pane-meta {
1066
- font-size: 12px;
1067
- color: #8b949e;
1068
- }
1069
-
1070
- #content-pane .pane-footer {
1071
- position: sticky;
1072
- bottom: 0;
1073
- background: rgba(13, 17, 23, 0.98);
1074
- padding: 16px 20px;
1075
- border-top: 1px solid #30363d;
1076
- font-size: 11px;
1077
- color: #8b949e;
1078
- z-index: 1;
1079
- }
1080
-
1081
- #content-pane .pane-footer .footer-item {
1082
- display: block;
1083
- margin-bottom: 8px;
1084
- }
1085
-
1086
- #content-pane .pane-footer .footer-label {
1087
- color: #c9d1d9;
1088
- font-weight: 600;
1089
- margin-right: 4px;
1090
- }
1091
-
1092
- #content-pane .pane-footer .footer-value {
1093
- color: #8b949e;
1094
- }
1095
-
1096
- #content-pane .collapse-btn {
1097
- position: absolute;
1098
- top: 20px;
1099
- right: 20px;
1100
- cursor: pointer;
1101
- color: #8b949e;
1102
- font-size: 24px;
1103
- line-height: 1;
1104
- background: none;
1105
- border: none;
1106
- padding: 0;
1107
- transition: color 0.2s;
1108
- }
1109
-
1110
- #content-pane .collapse-btn:hover {
1111
- color: #c9d1d9;
1112
- }
1113
-
1114
- #content-pane .pane-content {
1115
- padding: 20px;
1116
- }
1117
-
1118
- #content-pane pre {
1119
- margin: 0;
1120
- padding: 16px;
1121
- background: #0d1117;
1122
- border: 1px solid #30363d;
1123
- border-radius: 6px;
1124
- overflow-x: auto;
1125
- font-size: 12px;
1126
- line-height: 1.6;
1127
- }
1128
-
1129
- #content-pane code {
1130
- color: #c9d1d9;
1131
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1132
- }
1133
-
1134
- #content-pane .directory-list {
1135
- list-style: none;
1136
- padding: 0;
1137
- margin: 0;
1138
- }
1139
-
1140
- #content-pane .directory-list li {
1141
- padding: 8px 12px;
1142
- margin: 4px 0;
1143
- background: #161b22;
1144
- border: 1px solid #30363d;
1145
- border-radius: 4px;
1146
- font-size: 12px;
1147
- display: flex;
1148
- align-items: center;
1149
- cursor: pointer;
1150
- transition: background-color 0.2s;
1151
- }
1152
-
1153
- #content-pane .directory-list li:hover {
1154
- background-color: rgba(255, 255, 255, 0.1);
1155
- }
1156
-
1157
- #content-pane .directory-list .item-icon {
1158
- margin-right: 8px;
1159
- font-size: 14px;
1160
- }
1161
-
1162
- #content-pane .directory-list .item-type {
1163
- margin-left: auto;
1164
- padding-left: 12px;
1165
- font-size: 10px;
1166
- color: #8b949e;
1167
- }
1168
-
1169
- #content-pane .import-details {
1170
- background: #161b22;
1171
- border: 1px solid #30363d;
1172
- border-radius: 6px;
1173
- padding: 16px;
1174
- }
1175
-
1176
- #content-pane .import-details .import-statement {
1177
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1178
- font-size: 12px;
1179
- color: #79c0ff;
1180
- margin-bottom: 12px;
1181
- }
1182
-
1183
- #content-pane .import-details .detail-row {
1184
- font-size: 11px;
1185
- color: #8b949e;
1186
- margin: 4px 0;
1187
- }
1188
-
1189
- #content-pane .import-details .detail-label {
1190
- color: #c9d1d9;
1191
- font-weight: 600;
1192
- }
1193
-
1194
- .node.highlighted circle,
1195
- .node.highlighted rect {
1196
- stroke: #f0e68c;
1197
- stroke-width: 3px;
1198
- filter: drop-shadow(0 0 8px #f0e68c);
1199
- }
1200
-
1201
- .caller-link {
1202
- color: #58a6ff;
1203
- text-decoration: none;
1204
- cursor: pointer;
1205
- transition: color 0.2s;
1206
- }
1207
-
1208
- .caller-link:hover {
1209
- color: #79c0ff;
1210
- text-decoration: underline;
1211
- }
1212
-
1213
- #reset-view-btn {
1214
- position: fixed;
1215
- top: 20px;
1216
- right: 460px;
1217
- padding: 8px 16px;
1218
- background: #21262d;
1219
- border: 1px solid #30363d;
1220
- border-radius: 6px;
1221
- color: #c9d1d9;
1222
- font-size: 14px;
1223
- cursor: pointer;
1224
- display: flex;
1225
- align-items: center;
1226
- gap: 8px;
1227
- z-index: 100;
1228
- transition: all 0.2s;
1229
- }
1230
-
1231
- #reset-view-btn:hover {
1232
- background: #30363d;
1233
- border-color: #58a6ff;
1234
- transform: translateY(-1px);
1235
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
1236
- }
1237
-
1238
- /* Loading spinner animation */
1239
- @keyframes spin {
1240
- 0% { transform: rotate(0deg); }
1241
- 100% { transform: rotate(360deg); }
1242
- }
1243
-
1244
- .spinner {
1245
- display: inline-block;
1246
- width: 20px;
1247
- height: 20px;
1248
- border: 3px solid #30363d;
1249
- border-top-color: #58a6ff;
1250
- border-radius: 50%;
1251
- animation: spin 0.8s linear infinite;
1252
- margin-right: 8px;
1253
- vertical-align: middle;
1254
- }
1255
- </style>
1256
- </head>
1257
- <body>
1258
- <div id="controls">
1259
- <h1>🔍 Code Graph</h1>
1260
-
1261
- <div class="control-group" id="loading">
1262
- <label>⏳ Loading graph data...</label>
1263
- </div>
1264
-
1265
- <h3>Legend</h3>
1266
- <div class="legend">
1267
- <div class="legend-item">
1268
- <span class="legend-color" style="background: #da3633;"></span> Subproject
1269
- </div>
1270
- <div class="legend-item">
1271
- 📁 Directory
1272
- </div>
1273
- <div class="legend-item">
1274
- 📄 File (.py 🐍 .js/.ts 📜 .md 📝 .json/.yaml ⚙️ .sh 💻)
1275
- </div>
1276
- <div class="legend-item">
1277
- <span class="legend-color" style="background: #238636;"></span> Module
1278
- </div>
1279
- <div class="legend-item">
1280
- <span class="legend-color" style="background: #1f6feb;"></span> Class
1281
- </div>
1282
- <div class="legend-item">
1283
- <span class="legend-color" style="background: #d29922;"></span> Function
1284
- </div>
1285
- <div class="legend-item">
1286
- <span class="legend-color" style="background: #8957e5;"></span> Method
1287
- </div>
1288
- <div class="legend-item">
1289
- <span class="legend-color" style="background: #6e7681;"></span> Code
1290
- </div>
1291
- <div class="legend-item" style="font-style: italic; color: #79c0ff;">
1292
- <span class="legend-color" style="background: #6e7681;"></span> Import (L1)
1293
- </div>
1294
- <div class="legend-item">
1295
- <span class="legend-color" style="background: #8b949e; border-radius: 2px;"></span> Docstring ▢
1296
- </div>
1297
- <div class="legend-item">
1298
- <span class="legend-color" style="background: #6e7681; border-radius: 2px;"></span> Comment ▢
1299
- </div>
1300
- </div>
1301
-
1302
- <h3>Relationships</h3>
1303
- <div class="legend">
1304
- <div class="legend-item" style="color: #ff4444;">
1305
- ⚠️ Circular Dependency (red pulsing)
1306
- </div>
1307
- <div class="legend-item" style="color: #00ff00;">
1308
- — Semantic (green-yellow gradient)
1309
- </div>
1310
- <div class="legend-item" style="color: #30363d;">
1311
- — Structural (gray)
1312
- </div>
1313
- </div>
1314
-
1315
- <div id="subprojects-legend" style="display: none;">
1316
- <h3>Subprojects</h3>
1317
- <div class="legend" id="subprojects-list"></div>
1318
- </div>
1319
-
1320
- <div class="stats" id="stats"></div>
1321
- </div>
1322
-
1323
- <svg id="graph"></svg>
1324
- <div id="tooltip" class="tooltip"></div>
1325
-
1326
- <button id="reset-view-btn" title="Reset to home view">
1327
- <span style="font-size: 18px;">🏠</span>
1328
- <span>Reset View</span>
1329
- </button>
1330
-
1331
- <div id="content-pane">
1332
- <div class="pane-header">
1333
- <button class="collapse-btn" onclick="closeContentPane()">×</button>
1334
- <div class="pane-title" id="pane-title"></div>
1335
- <div class="pane-meta" id="pane-meta"></div>
1336
- </div>
1337
- <div class="pane-content" id="pane-content"></div>
1338
- <div class="pane-footer" id="pane-footer"></div>
1339
- </div>
1340
-
1341
- <script>
1342
- const width = window.innerWidth;
1343
- const height = window.innerHeight;
1344
-
1345
- // Create zoom behavior
1346
- const zoom = d3.zoom().on("zoom", (event) => {
1347
- g.attr("transform", event.transform);
1348
- });
1349
-
1350
- const svg = d3.select("#graph")
1351
- .attr("width", width)
1352
- .attr("height", height)
1353
- .call(zoom);
1354
-
1355
- const g = svg.append("g");
1356
- const tooltip = d3.select("#tooltip");
1357
- let simulation;
1358
- let allNodes = [];
1359
- let allLinks = [];
1360
- let visibleNodes = new Set();
1361
- let collapsedNodes = new Set();
1362
- let highlightedNode = null;
1363
- let rootNodes = []; // NEW: Store root nodes for reset function
1364
-
1365
- // Get file extension from path
1366
- function getFileExtension(filePath) {
1367
- if (!filePath) return '';
1368
- const match = filePath.match(/\\.([^.]+)$/);
1369
- return match ? match[1].toLowerCase() : '';
1370
- }
1371
-
1372
- // Get SVG icon path for file type
1373
- function getFileTypeIcon(node) {
1374
- if (node.type === 'directory') {
1375
- // Folder icon
1376
- 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';
1377
- }
1378
- if (node.type === 'file') {
1379
- const ext = getFileExtension(node.file_path);
1380
-
1381
- // Python files
1382
- if (ext === 'py') {
1383
- 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';
1384
- }
1385
- // JavaScript/TypeScript
1386
- if (ext === 'js' || ext === 'jsx' || ext === 'ts' || ext === 'tsx') {
1387
- return 'M3 3h18v18H3V3zm16 16V5H5v14h14zM7 7h2v2H7V7zm4 0h2v2h-2V7zm-4 4h2v2H7v-2zm4 0h6v2h-6v-2zm-4 4h10v2H7v-2z';
1388
- }
1389
- // Markdown
1390
- if (ext === 'md' || ext === 'markdown') {
1391
- 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';
1392
- }
1393
- // JSON/YAML/Config files
1394
- if (ext === 'json' || ext === 'yaml' || ext === 'yml' || ext === 'toml' || ext === 'ini' || ext === 'conf') {
1395
- 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';
1396
- }
1397
- // Shell scripts
1398
- if (ext === 'sh' || ext === 'bash' || ext === 'zsh') {
1399
- 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';
1400
- }
1401
- // Generic code file
1402
- 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';
1403
- }
1404
- return null;
1405
- }
1406
-
1407
- // Get color for file type icon
1408
- function getFileTypeColor(node) {
1409
- if (node.type === 'directory') return '#79c0ff';
1410
- if (node.type === 'file') {
1411
- const ext = getFileExtension(node.file_path);
1412
- if (ext === 'py') return '#3776ab'; // Python blue
1413
- if (ext === 'js' || ext === 'jsx') return '#f7df1e'; // JavaScript yellow
1414
- if (ext === 'ts' || ext === 'tsx') return '#3178c6'; // TypeScript blue
1415
- if (ext === 'md' || ext === 'markdown') return '#8b949e'; // Gray
1416
- if (ext === 'json' || ext === 'yaml' || ext === 'yml') return '#90a4ae'; // Config gray
1417
- if (ext === 'sh' || ext === 'bash' || ext === 'zsh') return '#4eaa25'; // Shell green
1418
- return '#58a6ff'; // Default file color
1419
- }
1420
- return null;
1421
- }
1422
-
1423
- function visualizeGraph(data) {
1424
- g.selectAll("*").remove();
1425
-
1426
- allNodes = data.nodes;
1427
- allLinks = data.links;
1428
-
1429
- // Find root nodes - start with only top-level nodes
1430
- let rootNodes;
1431
- if (data.metadata && data.metadata.is_monorepo) {
1432
- // In monorepos, subproject nodes are roots
1433
- rootNodes = allNodes.filter(n => n.type === 'subproject');
1434
- } else {
1435
- // Regular projects: show root-level directories AND files
1436
- const dirNodes = allNodes.filter(n => n.type === 'directory');
1437
- const fileNodes = allNodes.filter(n => n.type === 'file');
1438
-
1439
- // Find minimum depth for directories and files
1440
- const minDirDepth = dirNodes.length > 0
1441
- ? Math.min(...dirNodes.map(n => n.depth))
1442
- : Infinity;
1443
- const minFileDepth = fileNodes.length > 0
1444
- ? Math.min(...fileNodes.map(n => n.depth))
1445
- : Infinity;
1446
-
1447
- // Include both root-level directories and root-level files
1448
- rootNodes = [
1449
- ...dirNodes.filter(n => n.depth === minDirDepth),
1450
- ...fileNodes.filter(n => n.depth === minFileDepth)
1451
- ];
1452
-
1453
- // Fallback to all files if nothing found
1454
- if (rootNodes.length === 0) {
1455
- rootNodes = fileNodes;
1456
- }
1457
- }
1458
-
1459
- // Start with only root nodes visible, all collapsed
1460
- rootNodes = rootNodes; // Store for reset function
1461
- visibleNodes = new Set(rootNodes.map(n => n.id));
1462
- collapsedNodes = new Set(rootNodes.map(n => n.id));
1463
-
1464
- renderGraph();
1465
- }
1466
-
1467
- function renderGraph() {
1468
- const visibleNodesList = allNodes.filter(n => visibleNodes.has(n.id));
1469
- const visibleLinks = allLinks.filter(l =>
1470
- visibleNodes.has(l.source.id || l.source) &&
1471
- visibleNodes.has(l.target.id || l.target)
1472
- );
1473
-
1474
- simulation = d3.forceSimulation(visibleNodesList)
1475
- .force("link", d3.forceLink(visibleLinks).id(d => d.id).distance(100))
1476
- .force("charge", d3.forceManyBody().strength(d => {
1477
- // Check if node has any connections
1478
- const hasConnections = allLinks.some(link =>
1479
- link.source.id === d.id || link.target.id === d.id
1480
- );
1481
- // Connected nodes: spread out more (-200)
1482
- // Unconnected nodes: cluster together (-50)
1483
- return hasConnections ? -200 : -50;
1484
- }))
1485
- .force("center", d3.forceCenter(width / 2, height / 2).strength(0.15))
1486
- .force("collision", d3.forceCollide().radius(35));
1487
-
1488
- g.selectAll("*").remove();
1489
-
1490
- const link = g.append("g")
1491
- .selectAll("line")
1492
- .data(visibleLinks)
1493
- .join("line")
1494
- .attr("class", d => {
1495
- // Cycle links have highest priority
1496
- if (d.is_cycle) return "link cycle";
1497
- if (d.type === "dependency") return "link dependency";
1498
- if (d.type === "semantic") {
1499
- // Color based on similarity score
1500
- const sim = d.similarity || 0;
1501
- let simClass = "sim-very-low";
1502
- if (sim >= 0.8) simClass = "sim-high";
1503
- else if (sim >= 0.6) simClass = "sim-medium-high";
1504
- else if (sim >= 0.4) simClass = "sim-medium";
1505
- else if (sim >= 0.2) simClass = "sim-low";
1506
- return `link semantic ${simClass}`;
1507
- }
1508
- return "link";
1509
- })
1510
- .on("mouseover", showLinkTooltip)
1511
- .on("mouseout", hideTooltip);
1512
-
1513
- const node = g.append("g")
1514
- .selectAll("g")
1515
- .data(visibleNodesList)
1516
- .join("g")
1517
- .attr("class", d => {
1518
- let classes = `node ${d.type}`;
1519
- if (highlightedNode && d.id === highlightedNode.id) {
1520
- classes += ' highlighted';
1521
- }
1522
- return classes;
1523
- })
1524
- .call(drag(simulation))
1525
- .on("click", handleNodeClick)
1526
- .on("mouseover", showTooltip)
1527
- .on("mouseout", hideTooltip);
1528
-
1529
- // Add shapes based on node type
1530
- const isDocNode = d => ['docstring', 'comment'].includes(d.type);
1531
- const isFileOrDir = d => d.type === 'file' || d.type === 'directory';
1532
-
1533
- // Add circles for regular code nodes (not files/dirs/docs)
1534
- node.filter(d => !isDocNode(d) && !isFileOrDir(d))
1535
- .append("circle")
1536
- .attr("r", d => {
1537
- if (d.type === 'subproject') return 24;
1538
- return d.complexity ? Math.min(12 + d.complexity * 2, 28) : 15;
1539
- })
1540
- .attr("stroke", d => {
1541
- // Check if node has incoming caller/imports edges (dead code detection)
1542
- const hasIncoming = allLinks.some(l =>
1543
- (l.target.id || l.target) === d.id &&
1544
- (l.type === 'caller' || l.type === 'imports')
1545
- );
1546
- if (!hasIncoming && (d.type === 'function' || d.type === 'class' || d.type === 'method')) {
1547
- // Check if it's not an entry point (main, test, cli files)
1548
- const isEntryPoint = d.file_path && (
1549
- d.file_path.includes('main.py') ||
1550
- d.file_path.includes('__main__.py') ||
1551
- d.file_path.includes('cli.py') ||
1552
- d.file_path.includes('test_')
1553
- );
1554
- if (!isEntryPoint) {
1555
- return "#ff6b6b"; // Red border for potentially dead code
1556
- }
1557
- }
1558
- return hasChildren(d) ? "#ffffff" : "none";
1559
- })
1560
- .attr("stroke-width", d => {
1561
- const hasIncoming = allLinks.some(l =>
1562
- (l.target.id || l.target) === d.id &&
1563
- (l.type === 'caller' || l.type === 'imports')
1564
- );
1565
- if (!hasIncoming && (d.type === 'function' || d.type === 'class' || d.type === 'method')) {
1566
- const isEntryPoint = d.file_path && (
1567
- d.file_path.includes('main.py') ||
1568
- d.file_path.includes('__main__.py') ||
1569
- d.file_path.includes('cli.py') ||
1570
- d.file_path.includes('test_')
1571
- );
1572
- if (!isEntryPoint) {
1573
- return 3; // Thicker red border
1574
- }
1575
- }
1576
- return hasChildren(d) ? 2 : 0;
1577
- })
1578
- .style("fill", d => d.color || null); // Use custom color if available
1579
-
1580
- // Add rectangles for document nodes
1581
- node.filter(d => isDocNode(d))
1582
- .append("rect")
1583
- .attr("width", d => {
1584
- const size = d.complexity ? Math.min(12 + d.complexity * 2, 28) : 15;
1585
- return size * 2;
1586
- })
1587
- .attr("height", d => {
1588
- const size = d.complexity ? Math.min(12 + d.complexity * 2, 28) : 15;
1589
- return size * 2;
1590
- })
1591
- .attr("x", d => {
1592
- const size = d.complexity ? Math.min(12 + d.complexity * 2, 28) : 15;
1593
- return -size;
1594
- })
1595
- .attr("y", d => {
1596
- const size = d.complexity ? Math.min(12 + d.complexity * 2, 28) : 15;
1597
- return -size;
1598
- })
1599
- .attr("rx", 2) // Rounded corners
1600
- .attr("ry", 2)
1601
- .attr("stroke", d => hasChildren(d) ? "#ffffff" : "none")
1602
- .attr("stroke-width", d => hasChildren(d) ? 2 : 0)
1603
- .style("fill", d => d.color || null);
1604
-
1605
- // Add SVG icons for file and directory nodes
1606
- node.filter(d => isFileOrDir(d))
1607
- .append("path")
1608
- .attr("class", "file-icon")
1609
- .attr("d", d => getFileTypeIcon(d))
1610
- .attr("transform", d => {
1611
- const scale = d.type === 'directory' ? 1.8 : 1.5;
1612
- return `translate(-12, -12) scale(${scale})`;
1613
- })
1614
- .style("color", d => getFileTypeColor(d))
1615
- .attr("stroke", d => hasChildren(d) ? "#ffffff" : "none")
1616
- .attr("stroke-width", d => hasChildren(d) ? 1 : 0);
1617
-
1618
- // Add expand/collapse indicator - positioned to the left of label
1619
- node.filter(d => hasChildren(d))
1620
- .append("text")
1621
- .attr("class", "expand-indicator")
1622
- .attr("x", d => {
1623
- const iconRadius = d.type === 'directory' ? 18 : (d.type === 'file' ? 15 : 15);
1624
- return iconRadius + 5; // Just right of the icon (slightly more spacing)
1625
- })
1626
- .attr("y", 0) // Vertically center with icon
1627
- .attr("dy", "0.6em") // Fine-tune vertical centering (shifted down)
1628
- .attr("text-anchor", "start")
1629
- .style("font-size", "14px")
1630
- .style("font-weight", "bold")
1631
- .style("fill", "#ffffff")
1632
- .style("pointer-events", "none")
1633
- .text(d => collapsedNodes.has(d.id) ? "+" : "−");
1634
-
1635
- // Add labels (show actual import statement for L1 nodes)
1636
- node.append("text")
1637
- .text(d => {
1638
- // L1 (depth 1) nodes are imports
1639
- if (d.depth === 1 && d.type !== 'directory' && d.type !== 'file') {
1640
- if (d.content) {
1641
- // Extract first line of import statement
1642
- const importLine = d.content.split('\\n')[0].trim();
1643
- // Truncate if too long (max 60 chars)
1644
- return importLine.length > 60 ? importLine.substring(0, 57) + '...' : importLine;
1645
- }
1646
- return d.name; // Fallback to name if no content
1647
- }
1648
- return d.name;
1649
- })
1650
- .attr("x", d => {
1651
- const iconRadius = d.type === 'directory' ? 18 : (d.type === 'file' ? 15 : 15);
1652
- const hasExpand = hasChildren(d);
1653
- // Position after icon, plus expand indicator width if present (increased spacing)
1654
- return iconRadius + 8 + (hasExpand ? 22 : 0);
1655
- })
1656
- .attr("y", 0) // Vertically center with icon
1657
- .attr("dy", "0.6em") // Fine-tune vertical centering (shifted down to match expand indicator)
1658
- .attr("text-anchor", "start");
1659
-
1660
- simulation.on("tick", () => {
1661
- link
1662
- .attr("x1", d => d.source.x)
1663
- .attr("y1", d => d.source.y)
1664
- .attr("x2", d => d.target.x)
1665
- .attr("y2", d => d.target.y);
1666
-
1667
- node.attr("transform", d => `translate(${d.x},${d.y})`);
1668
- });
1669
-
1670
- updateStats({nodes: visibleNodesList, links: visibleLinks, metadata: {total_files: allNodes.length}});
1671
- }
1672
-
1673
- function hasChildren(node) {
1674
- return allLinks.some(l => (l.source.id || l.source) === node.id);
1675
- }
1676
-
1677
- // Zoom to fit all visible nodes
1678
- function zoomToFit(duration = 750) {
1679
- const visibleNodesList = allNodes.filter(n => visibleNodes.has(n.id));
1680
- if (visibleNodesList.length === 0) return;
1681
-
1682
- // Calculate bounding box of visible nodes
1683
- const padding = 100;
1684
- let minX = Infinity, minY = Infinity;
1685
- let maxX = -Infinity, maxY = -Infinity;
1686
-
1687
- visibleNodesList.forEach(d => {
1688
- if (d.x !== undefined && d.y !== undefined) {
1689
- minX = Math.min(minX, d.x);
1690
- minY = Math.min(minY, d.y);
1691
- maxX = Math.max(maxX, d.x);
1692
- maxY = Math.max(maxY, d.y);
1693
- }
1694
- });
1695
-
1696
- // Add padding
1697
- minX -= padding;
1698
- minY -= padding;
1699
- maxX += padding;
1700
- maxY += padding;
1701
-
1702
- const boxWidth = maxX - minX;
1703
- const boxHeight = maxY - minY;
1704
-
1705
- // Calculate scale to fit
1706
- const scale = Math.min(
1707
- width / boxWidth,
1708
- height / boxHeight,
1709
- 2 // Max zoom level
1710
- ) * 0.9; // Add 10% margin
1711
-
1712
- // Calculate center translation
1713
- const centerX = (minX + maxX) / 2;
1714
- const centerY = (minY + maxY) / 2;
1715
- const translateX = width / 2 - scale * centerX;
1716
- const translateY = height / 2 - scale * centerY;
1717
-
1718
- // Apply zoom transform with animation
1719
- svg.transition()
1720
- .duration(duration)
1721
- .call(
1722
- zoom.transform,
1723
- d3.zoomIdentity
1724
- .translate(translateX, translateY)
1725
- .scale(scale)
1726
- );
1727
- }
1728
-
1729
- function centerNode(node) {
1730
- // Get current transform to maintain zoom level
1731
- const transform = d3.zoomTransform(svg.node());
1732
-
1733
- // Calculate translation to center the node in LEFT portion of viewport
1734
- // Position at 30% from left to avoid code pane on right side
1735
- const x = -node.x * transform.k + width * 0.3;
1736
- const y = -node.y * transform.k + height / 2;
1737
-
1738
- // Apply smooth animation to center the node
1739
- svg.transition()
1740
- .duration(750)
1741
- .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(transform.k));
1742
- }
1743
-
1744
- function resetView() {
1745
- // Reset to root level nodes only
1746
- visibleNodes = new Set(rootNodes.map(n => n.id));
1747
- collapsedNodes = new Set(rootNodes.map(n => n.id));
1748
- highlightedNode = null;
1749
-
1750
- // Re-render graph
1751
- renderGraph();
1752
-
1753
- // Zoom to fit after rendering
1754
- setTimeout(() => {
1755
- zoomToFit(750);
1756
- }, 200);
1757
- }
1758
-
1759
- function handleNodeClick(event, d) {
1760
- event.stopPropagation();
1761
-
1762
- // Always show content pane when clicking any node
1763
- showContentPane(d);
1764
-
1765
- // If node has children, also toggle expansion
1766
- if (hasChildren(d)) {
1767
- const wasCollapsed = collapsedNodes.has(d.id);
1768
- if (wasCollapsed) {
1769
- expandNode(d);
1770
- } else {
1771
- collapseNode(d);
1772
- }
1773
- renderGraph();
1774
-
1775
- // After rendering and nodes have positions, zoom to fit ONLY visible nodes
1776
- // Use a small delay to ensure D3 simulation has updated positions
1777
- if (!wasCollapsed) {
1778
- // Wait for simulation to stabilize before zooming
1779
- setTimeout(() => {
1780
- // Stop simulation to get final positions
1781
- simulation.alphaTarget(0);
1782
- zoomToFit(750);
1783
- }, 200);
1784
- } else {
1785
- // For expansion, center the clicked node after a delay
1786
- setTimeout(() => {
1787
- centerNode(d);
1788
- }, 200);
1789
- }
1790
- } else {
1791
- // For nodes without children, center immediately after a small delay
1792
- setTimeout(() => {
1793
- centerNode(d);
1794
- }, 100);
1795
- }
1796
- }
1797
-
1798
- function expandNode(node) {
1799
- collapsedNodes.delete(node.id);
1800
-
1801
- // Find direct children
1802
- const children = allLinks
1803
- .filter(l => (l.source.id || l.source) === node.id)
1804
- .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
1805
- .filter(n => n);
1806
-
1807
- children.forEach(child => {
1808
- visibleNodes.add(child.id);
1809
- collapsedNodes.add(child.id); // Children start collapsed
1810
- });
1811
- }
1812
-
1813
- function collapseNode(node) {
1814
- collapsedNodes.add(node.id);
1815
-
1816
- // Hide all descendants recursively
1817
- function hideDescendants(parentId) {
1818
- const children = allLinks
1819
- .filter(l => (l.source.id || l.source) === parentId)
1820
- .map(l => l.target.id || l.target);
1821
-
1822
- children.forEach(childId => {
1823
- visibleNodes.delete(childId);
1824
- collapsedNodes.delete(childId);
1825
- hideDescendants(childId);
1826
- });
1827
- }
1828
-
1829
- hideDescendants(node.id);
1830
- }
1831
-
1832
- function showTooltip(event, d) {
1833
- // Extract first 2-3 lines of docstring for preview
1834
- let docPreview = '';
1835
- if (d.docstring) {
1836
- const lines = d.docstring.split('\\n').filter(l => l.trim());
1837
- const previewLines = lines.slice(0, 3).join(' ');
1838
- const truncated = previewLines.length > 150 ? previewLines.substring(0, 147) + '...' : previewLines;
1839
- docPreview = `<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">${truncated}</div>`;
1840
- }
1841
-
1842
- tooltip
1843
- .style("display", "block")
1844
- .style("left", (event.pageX + 10) + "px")
1845
- .style("top", (event.pageY + 10) + "px")
1846
- .html(`
1847
- <div><strong>${d.name}</strong></div>
1848
- <div>Type: ${d.type}</div>
1849
- ${d.complexity ? `<div>Complexity: ${d.complexity.toFixed(1)}</div>` : ''}
1850
- ${d.start_line ? `<div>Lines: ${d.start_line}-${d.end_line}</div>` : ''}
1851
- <div>File: ${d.file_path}</div>
1852
- ${docPreview}
1853
- `);
1854
- }
1855
-
1856
- function showLinkTooltip(event, d) {
1857
- const sourceName = allNodes.find(n => n.id === (d.source.id || d.source))?.name || 'Unknown';
1858
- const targetName = allNodes.find(n => n.id === (d.target.id || d.target))?.name || 'Unknown';
1859
-
1860
- // Special tooltip for cycle links
1861
- if (d.is_cycle) {
1862
- tooltip
1863
- .style("display", "block")
1864
- .style("left", (event.pageX + 10) + "px")
1865
- .style("top", (event.pageY + 10) + "px")
1866
- .html(`
1867
- <div style="color: #ff4444;"><strong>⚠️ Circular Dependency Detected</strong></div>
1868
- <div style="margin-top: 8px;">Path: ${sourceName} → ${targetName}</div>
1869
- <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">
1870
- This indicates a circular call relationship that may lead to infinite recursion or tight coupling.
1871
- </div>
1872
- `);
1873
- return;
1874
- }
1875
-
1876
- // Tooltip content based on link type
1877
- let typeLabel = '';
1878
- let typeDescription = '';
1879
- let extraInfo = '';
1880
-
1881
- switch(d.type) {
1882
- case 'caller':
1883
- typeLabel = '📞 Function Call';
1884
- typeDescription = `${sourceName} calls ${targetName}`;
1885
- extraInfo = 'This is a direct function call relationship, the most common type of code dependency.';
1886
- break;
1887
- case 'semantic':
1888
- typeLabel = '🔗 Semantic Similarity';
1889
- typeDescription = `${(d.similarity * 100).toFixed(1)}% similar`;
1890
- extraInfo = `These code chunks have similar meaning or purpose based on their content.`;
1891
- break;
1892
- case 'imports':
1893
- typeLabel = '📦 Import Dependency';
1894
- typeDescription = `${sourceName} imports ${targetName}`;
1895
- extraInfo = 'This is an explicit import/dependency declaration.';
1896
- break;
1897
- case 'file_containment':
1898
- typeLabel = '📄 File Contains';
1899
- typeDescription = `${sourceName} contains ${targetName}`;
1900
- extraInfo = 'This file contains the code chunk or function.';
1901
- break;
1902
- case 'dir_containment':
1903
- typeLabel = '📁 Directory Contains';
1904
- typeDescription = `${sourceName} contains ${targetName}`;
1905
- extraInfo = 'This directory contains the file or subdirectory.';
1906
- break;
1907
- case 'dir_hierarchy':
1908
- typeLabel = '🗂️ Directory Hierarchy';
1909
- typeDescription = `${sourceName} → ${targetName}`;
1910
- extraInfo = 'Parent-child directory structure relationship.';
1911
- break;
1912
- case 'method':
1913
- typeLabel = '⚙️ Method Relationship';
1914
- typeDescription = `${sourceName} ↔ ${targetName}`;
1915
- extraInfo = 'Class method relationship.';
1916
- break;
1917
- case 'module':
1918
- typeLabel = '📚 Module Relationship';
1919
- typeDescription = `${sourceName} ↔ ${targetName}`;
1920
- extraInfo = 'Module-level relationship.';
1921
- break;
1922
- case 'dependency':
1923
- typeLabel = '🔀 Dependency';
1924
- typeDescription = `${sourceName} depends on ${targetName}`;
1925
- extraInfo = 'General code dependency relationship.';
1926
- break;
1927
- default:
1928
- typeLabel = `🔗 ${d.type || 'Unknown'}`;
1929
- typeDescription = `${sourceName} → ${targetName}`;
1930
- extraInfo = 'Code relationship.';
1931
- }
1932
-
1933
- tooltip
1934
- .style("display", "block")
1935
- .style("left", (event.pageX + 10) + "px")
1936
- .style("top", (event.pageY + 10) + "px")
1937
- .html(`
1938
- <div><strong>${typeLabel}</strong></div>
1939
- <div style="margin-top: 4px;">${typeDescription}</div>
1940
- <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d; font-size: 11px; color: #8b949e; font-style: italic;">
1941
- ${extraInfo}
1942
- </div>
1943
- `);
1944
- }
1945
-
1946
- function hideTooltip() {
1947
- tooltip.style("display", "none");
1948
- }
1949
-
1950
- function drag(simulation) {
1951
- function dragstarted(event) {
1952
- if (!event.active) simulation.alphaTarget(0.3).restart();
1953
- event.subject.fx = event.subject.x;
1954
- event.subject.fy = event.subject.y;
1955
- }
1956
-
1957
- function dragged(event) {
1958
- event.subject.fx = event.x;
1959
- event.subject.fy = event.y;
1960
- }
1961
-
1962
- function dragended(event) {
1963
- if (!event.active) simulation.alphaTarget(0);
1964
- event.subject.fx = null;
1965
- event.subject.fy = null;
1966
- }
1967
-
1968
- return d3.drag()
1969
- .on("start", dragstarted)
1970
- .on("drag", dragged)
1971
- .on("end", dragended);
1972
- }
1973
-
1974
- function updateStats(data) {
1975
- const stats = d3.select("#stats");
1976
- stats.html(`
1977
- <div>Nodes: ${data.nodes.length}</div>
1978
- <div>Links: ${data.links.length}</div>
1979
- ${data.metadata ? `<div>Files: ${data.metadata.total_files || 'N/A'}</div>` : ''}
1980
- ${data.metadata && data.metadata.is_monorepo ? `<div>Monorepo: ${data.metadata.subprojects.length} subprojects</div>` : ''}
1981
- `);
1982
-
1983
- // Show subproject legend if monorepo
1984
- if (data.metadata && data.metadata.is_monorepo && data.metadata.subprojects.length > 0) {
1985
- const subprojectsLegend = d3.select("#subprojects-legend");
1986
- const subprojectsList = d3.select("#subprojects-list");
1987
-
1988
- subprojectsLegend.style("display", "block");
1989
-
1990
- // Get subproject nodes with colors
1991
- const subprojectNodes = allNodes.filter(n => n.type === 'subproject');
1992
-
1993
- subprojectsList.html(
1994
- subprojectNodes.map(sp =>
1995
- `<div class="legend-item">
1996
- <span class="legend-color" style="background: ${sp.color};"></span> ${sp.name}
1997
- </div>`
1998
- ).join('')
1999
- );
2000
- }
2001
- }
2002
-
2003
- function showContentPane(node) {
2004
- // Highlight the node
2005
- highlightedNode = node;
2006
- renderGraph();
2007
-
2008
- // Populate content pane
2009
- const pane = document.getElementById('content-pane');
2010
- const title = document.getElementById('pane-title');
2011
- const meta = document.getElementById('pane-meta');
2012
- const content = document.getElementById('pane-content');
2013
- const footer = document.getElementById('pane-footer');
2014
-
2015
- // Set title with actual import statement for L1 nodes
2016
- if (node.depth === 1 && node.type !== 'directory' && node.type !== 'file') {
2017
- if (node.content) {
2018
- const importLine = node.content.split('\\n')[0].trim();
2019
- title.textContent = importLine;
2020
- } else {
2021
- title.textContent = `Import: ${node.name}`;
2022
- }
2023
- } else {
2024
- title.textContent = node.name;
2025
- }
2026
-
2027
- // Set metadata (type only in header)
2028
- meta.textContent = node.type;
2029
-
2030
- // Build footer with annotations
2031
- let footerHtml = '';
2032
- if (node.language) {
2033
- footerHtml += `<span class="footer-item"><span class="footer-label">Language:</span> ${node.language}</span>`;
2034
- }
2035
- footerHtml += `<span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>`;
2036
-
2037
- // Add line information and complexity
2038
- if (node.start_line !== undefined && node.end_line !== undefined) {
2039
- const totalLines = node.end_line - node.start_line + 1;
2040
-
2041
- if (node.type === 'function' || node.type === 'class' || node.type === 'method') {
2042
- // For functions/classes: show function lines
2043
- footerHtml += `<span class="footer-item"><span class="footer-label">Lines:</span> ${node.start_line}-${node.end_line} (${totalLines} lines)</span>`;
2044
- } else if (node.type === 'file') {
2045
- // For files: show file lines
2046
- footerHtml += `<span class="footer-item"><span class="footer-label">File Lines:</span> ${totalLines}</span>`;
2047
- } else {
2048
- // For other types: show location
2049
- footerHtml += `<span class="footer-item"><span class="footer-label">Location:</span> Lines ${node.start_line}-${node.end_line}</span>`;
2050
- }
2051
-
2052
- // Add cyclomatic complexity if available and > 0
2053
- if (node.complexity && node.complexity > 0) {
2054
- footerHtml += `<span class="footer-item"><span class="footer-label">Complexity:</span> ${node.complexity}</span>`;
2055
- }
2056
- }
2057
-
2058
- footer.innerHTML = footerHtml;
2059
-
2060
- // Display content based on node type
2061
- if (node.type === 'directory') {
2062
- showDirectoryContents(node, content, footer);
2063
- } else if (node.type === 'file') {
2064
- showFileContents(node, content);
2065
- } else if (node.depth === 1 && node.type !== 'directory' && node.type !== 'file') {
2066
- // L1 nodes are imports
2067
- showImportDetails(node, content);
2068
- } else {
2069
- // Class, function, method, code nodes
2070
- showCodeContent(node, content);
2071
- }
2072
-
2073
- pane.classList.add('visible');
2074
- }
2075
-
2076
- function showDirectoryContents(node, container, footer) {
2077
- // Find all direct children of this directory
2078
- const children = allLinks
2079
- .filter(l => (l.source.id || l.source) === node.id)
2080
- .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
2081
- .filter(n => n);
2082
-
2083
- if (children.length === 0) {
2084
- container.innerHTML = '<p style="color: #8b949e;">Empty directory</p>';
2085
- // Update footer with file path only
2086
- footer.innerHTML = `<span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>`;
2087
- return;
2088
- }
2089
-
2090
- // Group by type
2091
- const files = children.filter(n => n.type === 'file');
2092
- const subdirs = children.filter(n => n.type === 'directory');
2093
- const chunks = children.filter(n => n.type !== 'file' && n.type !== 'directory');
2094
-
2095
- let html = '<ul class="directory-list">';
2096
-
2097
- // Show subdirectories first
2098
- subdirs.forEach(child => {
2099
- html += `
2100
- <li data-node-id="${child.id}">
2101
- <span class="item-icon">📁</span>
2102
- ${child.name}
2103
- </li>
2104
- `;
2105
- });
2106
-
2107
- // Then files
2108
- files.forEach(child => {
2109
- html += `
2110
- <li data-node-id="${child.id}">
2111
- <span class="item-icon">📄</span>
2112
- ${child.name}
2113
- </li>
2114
- `;
2115
- });
2116
-
2117
- // Then code chunks
2118
- chunks.forEach(child => {
2119
- const icon = child.type === 'class' ? '🔷' : child.type === 'function' ? '⚡' : '📝';
2120
- html += `
2121
- <li data-node-id="${child.id}">
2122
- <span class="item-icon">${icon}</span>
2123
- ${child.name}
2124
- </li>
2125
- `;
2126
- });
2127
-
2128
- html += '</ul>';
2129
-
2130
- container.innerHTML = html;
2131
-
2132
- // Add click handlers to list items
2133
- const listItems = container.querySelectorAll('.directory-list li');
2134
- listItems.forEach(item => {
2135
- item.addEventListener('click', () => {
2136
- const nodeId = item.getAttribute('data-node-id');
2137
- const childNode = allNodes.find(n => n.id === nodeId);
2138
- if (childNode) {
2139
- showContentPane(childNode);
2140
- }
2141
- });
2142
- });
2143
-
2144
- // Update footer with file path and summary
2145
- footer.innerHTML = `
2146
- <span class="footer-item"><span class="footer-label">File:</span> ${node.file_path}</span>
2147
- <span class="footer-item"><span class="footer-label">Total:</span> ${children.length} items (${subdirs.length} directories, ${files.length} files, ${chunks.length} code chunks)</span>
2148
- `;
2149
- }
2150
-
2151
- function showFileContents(node, container) {
2152
- // Find all chunks in this file
2153
- const fileChunks = allLinks
2154
- .filter(l => (l.source.id || l.source) === node.id)
2155
- .map(l => allNodes.find(n => n.id === (l.target.id || l.target)))
2156
- .filter(n => n);
2157
-
2158
- if (fileChunks.length === 0) {
2159
- container.innerHTML = '<p style="color: #8b949e;">No code chunks found in this file</p>';
2160
- return;
2161
- }
2162
-
2163
- // Collect all content from chunks and sort by line number
2164
- const sortedChunks = fileChunks
2165
- .filter(c => c.content)
2166
- .sort((a, b) => a.start_line - b.start_line);
2167
-
2168
- if (sortedChunks.length === 0) {
2169
- container.innerHTML = '<p style="color: #8b949e;">File content not available</p>';
2170
- return;
2171
- }
2172
-
2173
- // Combine all chunks to show full file
2174
- const fullContent = sortedChunks.map(c => c.content).join('\\n\\n');
2175
-
2176
- container.innerHTML = `
2177
- <p style="color: #8b949e; font-size: 11px; margin-bottom: 12px;">
2178
- Contains ${fileChunks.length} code chunks
2179
- </p>
2180
- <pre><code>${escapeHtml(fullContent)}</code></pre>
2181
- `;
2182
- }
2183
-
2184
- function showImportDetails(node, container) {
2185
- // L1 nodes are import statements - show import content prominently
2186
- // Note: File, Location, and Language are now in the footer
2187
- const importHtml = `
2188
- <div class="import-details">
2189
- ${node.content ? `
2190
- <div style="margin-bottom: 16px;">
2191
- <div class="detail-label" style="margin-bottom: 8px;">Import Statement:</div>
2192
- <pre><code>${escapeHtml(node.content)}</code></pre>
2193
- </div>
2194
- ` : '<p style="color: #8b949e;">No import content available</p>'}
2195
- </div>
2196
- `;
2197
-
2198
- container.innerHTML = importHtml;
2199
- }
2200
-
2201
- // Parse docstring sections (Args, Returns, Raises, etc.)
2202
- function parseDocstring(docstring) {
2203
- if (!docstring) return { brief: '', sections: {} };
2204
-
2205
- const lines = docstring.split('\\n');
2206
- const sections = {};
2207
- let currentSection = 'brief';
2208
- let currentContent = [];
2209
-
2210
- for (let line of lines) {
2211
- const trimmed = line.trim();
2212
- // Check for section headers (Args:, Returns:, Raises:, etc.)
2213
- const sectionMatch = trimmed.match(/^(Args?|Returns?|Yields?|Raises?|Note|Notes|Example|Examples|See Also|Docs?|Parameters?):?$/i);
2214
-
2215
- if (sectionMatch) {
2216
- // Save previous section
2217
- if (currentContent.length > 0) {
2218
- sections[currentSection] = currentContent.join('\\n').trim();
2219
- }
2220
- // Start new section
2221
- currentSection = sectionMatch[1].toLowerCase();
2222
- currentContent = [];
2223
- } else {
2224
- currentContent.push(line);
2225
- }
2226
- }
2227
-
2228
- // Save last section
2229
- if (currentContent.length > 0) {
2230
- sections[currentSection] = currentContent.join('\\n').trim();
2231
- }
2232
-
2233
- return { brief: sections.brief || '', sections };
2234
- }
2235
-
2236
- function showCodeContent(node, container) {
2237
- // Show code for function, class, method, or code chunks
2238
- let html = '';
2239
-
2240
- // Parse docstring to extract sections
2241
- const docInfo = parseDocstring(node.docstring);
2242
-
2243
- // Show brief description (non-sectioned part) in content area
2244
- if (docInfo.brief && docInfo.brief.trim()) {
2245
- html += `
2246
- <div style="margin-bottom: 16px; padding: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 6px;">
2247
- <div style="font-size: 11px; color: #8b949e; margin-bottom: 8px; font-weight: 600;">DESCRIPTION</div>
2248
- <pre style="margin: 0; padding: 0; background: transparent; border: none; white-space: pre-wrap;"><code>${escapeHtml(docInfo.brief)}</code></pre>
2249
- </div>
2250
- `;
2251
- }
2252
-
2253
- if (node.content) {
2254
- html += `<pre><code>${escapeHtml(node.content)}</code></pre>`;
2255
- } else {
2256
- html += '<p style="color: #8b949e;">No content available</p>';
2257
- }
2258
-
2259
- container.innerHTML = html;
2260
-
2261
- // Update footer with docstring sections
2262
- const footer = document.getElementById('pane-footer');
2263
- let footerHtml = '';
2264
-
2265
- // Add existing footer items
2266
- if (node.language) {
2267
- footerHtml += `<div class="footer-item"><span class="footer-label">Language:</span> <span class="footer-value">${node.language}</span></div>`;
2268
- }
2269
- footerHtml += `<div class="footer-item"><span class="footer-label">File:</span> <span class="footer-value">${node.file_path}</span></div>`;
2270
- if (node.start_line) {
2271
- footerHtml += `<div class="footer-item"><span class="footer-label">Lines:</span> <span class="footer-value">${node.start_line}-${node.end_line}</span></div>`;
2272
- }
2273
-
2274
- // Add "Called By" section for external callers
2275
- if (node.callers && node.callers.length > 0) {
2276
- footerHtml += `<div class="footer-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d;">`;
2277
- footerHtml += `<span class="footer-label">Called By:</span><br/>`;
2278
- node.callers.forEach(caller => {
2279
- const fileName = caller.file.split('/').pop();
2280
- const callerDisplay = `${fileName}::${caller.name}`;
2281
- footerHtml += `<span class="footer-value" style="display: block; margin-left: 8px; margin-top: 4px;">
2282
- <a href="#" class="caller-link" data-chunk-id="${caller.chunk_id}" style="color: #58a6ff; text-decoration: none; cursor: pointer;">
2283
- • ${escapeHtml(callerDisplay)}
2284
- </a>
2285
- </span>`;
2286
- });
2287
- footerHtml += `</div>`;
2288
- } else if (node.type === 'function' || node.type === 'method' || node.type === 'class') {
2289
- // Only show "no callers" message for callable entities
2290
- footerHtml += `<div class="footer-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #30363d;">`;
2291
- footerHtml += `<span class="footer-label">Called By:</span> <span class="footer-value" style="font-style: italic; color: #6e7681;">(No external callers found)</span>`;
2292
- footerHtml += `</div>`;
2293
- }
2294
-
2295
- // Add docstring sections to footer
2296
- const sectionLabels = {
2297
- 'docs': 'Docs',
2298
- 'doc': 'Docs',
2299
- 'args': 'Args',
2300
- 'arg': 'Args',
2301
- 'parameters': 'Args',
2302
- 'parameter': 'Args',
2303
- 'returns': 'Returns',
2304
- 'return': 'Returns',
2305
- 'yields': 'Yields',
2306
- 'yield': 'Yields',
2307
- 'raises': 'Raises',
2308
- 'raise': 'Raises',
2309
- 'note': 'Note',
2310
- 'notes': 'Note',
2311
- 'example': 'Example',
2312
- 'examples': 'Example',
2313
- };
2314
-
2315
- for (let [key, content] of Object.entries(docInfo.sections)) {
2316
- if (key === 'brief') continue; // Already shown above
2317
-
2318
- const label = sectionLabels[key] || key.charAt(0).toUpperCase() + key.slice(1);
2319
- // Truncate long sections for footer
2320
- const truncated = content.length > 200 ? content.substring(0, 197) + '...' : content;
2321
-
2322
- footerHtml += `<div class="footer-item"><span class="footer-label">${label}:</span> <span class="footer-value">${escapeHtml(truncated)}</span></div>`;
2323
- }
2324
-
2325
- footer.innerHTML = footerHtml;
2326
-
2327
- // Add click handlers to caller links
2328
- const callerLinks = footer.querySelectorAll('.caller-link');
2329
- callerLinks.forEach(link => {
2330
- link.addEventListener('click', (e) => {
2331
- e.preventDefault();
2332
- const chunkId = link.getAttribute('data-chunk-id');
2333
- const callerNode = allNodes.find(n => n.id === chunkId);
2334
- if (callerNode) {
2335
- // Navigate to the caller node
2336
- navigateToNode(callerNode);
2337
- }
2338
- });
2339
- });
2340
- }
2341
-
2342
- function escapeHtml(text) {
2343
- const div = document.createElement('div');
2344
- div.textContent = text;
2345
- return div.innerHTML;
2346
- }
2347
-
2348
- function navigateToNode(targetNode) {
2349
- // Ensure the node is visible in the graph
2350
- if (!visibleNodes.has(targetNode.id)) {
2351
- // Expand parent nodes to make this node visible
2352
- expandParentsToNode(targetNode);
2353
- renderGraph();
2354
- }
2355
-
2356
- // Show the content pane for this node
2357
- showContentPane(targetNode);
2358
-
2359
- // Zoom to the target node
2360
- setTimeout(() => {
2361
- // Find the node's position
2362
- if (targetNode.x !== undefined && targetNode.y !== undefined) {
2363
- const scale = 1.5; // Zoom level
2364
- // Position at 30% from left to avoid code pane on right side
2365
- const translateX = width * 0.3 - scale * targetNode.x;
2366
- const translateY = height / 2 - scale * targetNode.y;
2367
-
2368
- svg.transition()
2369
- .duration(750)
2370
- .call(
2371
- zoom.transform,
2372
- d3.zoomIdentity
2373
- .translate(translateX, translateY)
2374
- .scale(scale)
2375
- );
2376
- }
2377
- }, 200);
2378
- }
2379
-
2380
- function expandParentsToNode(targetNode) {
2381
- // Build a path from root to target node
2382
- const path = [];
2383
- let current = targetNode;
2384
-
2385
- while (current) {
2386
- path.unshift(current);
2387
- // Find parent
2388
- const parentLink = allLinks.find(l =>
2389
- (l.target.id || l.target) === current.id &&
2390
- (l.type !== 'semantic' && l.type !== 'dependency')
2391
- );
2392
- if (parentLink) {
2393
- const parentId = parentLink.source.id || parentLink.source;
2394
- current = allNodes.find(n => n.id === parentId);
2395
- } else {
2396
- break;
2397
- }
2398
- }
2399
-
2400
- // Expand all nodes in the path
2401
- path.forEach(node => {
2402
- if (!visibleNodes.has(node.id)) {
2403
- visibleNodes.add(node.id);
2404
- }
2405
- if (collapsedNodes.has(node.id)) {
2406
- expandNode(node);
2407
- }
2408
- });
2409
- }
2410
-
2411
- function closeContentPane() {
2412
- const pane = document.getElementById('content-pane');
2413
- pane.classList.remove('visible');
2414
-
2415
- // Remove highlight
2416
- highlightedNode = null;
2417
- renderGraph();
2418
- }
2419
-
2420
- // Auto-load graph data on page load with progress tracking
2421
- window.addEventListener('DOMContentLoaded', () => {
2422
- const loadingEl = document.getElementById('loading');
2423
-
2424
- // Show initial loading message
2425
- loadingEl.innerHTML = '<label style="color: #58a6ff;"><span class="spinner"></span>Loading graph data...</label><br>' +
2426
- '<div style="margin-top: 8px; background: #21262d; border-radius: 4px; height: 20px; width: 250px; position: relative; overflow: hidden;">' +
2427
- '<div id="progress-bar" style="background: #238636; height: 100%; width: 0%; transition: width 0.3s;"></div>' +
2428
- '</div>' +
2429
- '<small id="progress-text" style="color: #8b949e; margin-top: 4px; display: block;">Connecting...</small>';
2430
-
2431
- // Create abort controller for timeout
2432
- const controller = new AbortController();
2433
- const timeout = setTimeout(() => controller.abort(), 60000); // 60s timeout for large files
2434
-
2435
- fetch("chunk-graph.json", { signal: controller.signal })
2436
- .then(response => {
2437
- if (!response.ok) {
2438
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2439
- }
2440
-
2441
- const contentLength = response.headers.get('content-length');
2442
- const total = contentLength ? parseInt(contentLength, 10) : 0;
2443
- let loaded = 0;
2444
-
2445
- const progressBar = document.getElementById('progress-bar');
2446
- const progressText = document.getElementById('progress-text');
2447
-
2448
- // Update progress text based on file size
2449
- if (total > 0) {
2450
- const sizeMB = (total / (1024 * 1024)).toFixed(1);
2451
- progressText.textContent = `Downloading ${sizeMB}MB...`;
2452
- } else {
2453
- progressText.textContent = 'Downloading...';
2454
- }
2455
-
2456
- // Create a new response with progress tracking
2457
- const reader = response.body.getReader();
2458
- const stream = new ReadableStream({
2459
- start(controller) {
2460
- function push() {
2461
- reader.read().then(({ done, value }) => {
2462
- if (done) {
2463
- controller.close();
2464
- return;
2465
- }
2466
-
2467
- loaded += value.byteLength;
2468
-
2469
- // Update progress bar
2470
- if (total > 0) {
2471
- const percent = Math.round((loaded / total) * 100);
2472
- progressBar.style.width = percent + '%';
2473
- const loadedMB = (loaded / (1024 * 1024)).toFixed(1);
2474
- const totalMB = (total / (1024 * 1024)).toFixed(1);
2475
- progressText.textContent = `Downloaded ${loadedMB}MB / ${totalMB}MB (${percent}%)`;
2476
- } else {
2477
- const loadedMB = (loaded / (1024 * 1024)).toFixed(1);
2478
- progressText.textContent = `Downloaded ${loadedMB}MB...`;
2479
- }
2480
-
2481
- controller.enqueue(value);
2482
- push();
2483
- }).catch(err => {
2484
- console.error('Stream reading error:', err);
2485
- controller.error(err);
2486
- });
2487
- }
2488
- push();
2489
- }
2490
- });
2491
-
2492
- return new Response(stream);
2493
- })
2494
- .then(response => {
2495
- clearTimeout(timeout);
2496
-
2497
- // Update UI for parsing phase
2498
- const progressText = document.getElementById('progress-text');
2499
- const progressBar = document.getElementById('progress-bar');
2500
- progressBar.style.width = '100%';
2501
- progressText.textContent = 'Parsing JSON data...';
2502
-
2503
- // Parse JSON (this may take time for large files)
2504
- return response.json();
2505
- })
2506
- .then(data => {
2507
- clearTimeout(timeout);
2508
- loadingEl.innerHTML = '<label style="color: #238636;">✓ Graph loaded successfully</label>';
2509
- setTimeout(() => loadingEl.style.display = 'none', 2000);
2510
- visualizeGraph(data);
2511
- })
2512
- .catch(err => {
2513
- clearTimeout(timeout);
2514
-
2515
- let errorMsg = err.message;
2516
- if (err.name === 'AbortError') {
2517
- errorMsg = 'Loading timeout - file may be too large or server unresponsive';
2518
- }
2519
-
2520
- loadingEl.innerHTML = `<label style="color: #f85149;">✗ Failed to load graph data</label><br>` +
2521
- `<small style="color: #8b949e;">${errorMsg}</small><br>` +
2522
- `<small style="color: #8b949e;">Run: mcp-vector-search visualize export</small>`;
2523
- console.error("Failed to load graph:", err);
2524
- });
2525
- });
2526
-
2527
- // Reset view button event handler
2528
- document.getElementById('reset-view-btn').addEventListener('click', () => {
2529
- resetView();
2530
- });
2531
- </script>
2532
- </body>
2533
- </html>"""
2534
-
2535
- with open(html_file, "w") as f:
2536
- f.write(html_content)