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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +111 -0
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +74 -0
  7. mcp_vector_search/analysis/collectors/base.py +164 -0
  8. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  9. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  10. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  11. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  12. mcp_vector_search/analysis/collectors/smells.py +325 -0
  13. mcp_vector_search/analysis/debt.py +516 -0
  14. mcp_vector_search/analysis/interpretation.py +685 -0
  15. mcp_vector_search/analysis/metrics.py +414 -0
  16. mcp_vector_search/analysis/reporters/__init__.py +7 -0
  17. mcp_vector_search/analysis/reporters/console.py +646 -0
  18. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  19. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  20. mcp_vector_search/analysis/storage/__init__.py +93 -0
  21. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  22. mcp_vector_search/analysis/storage/schema.py +245 -0
  23. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  24. mcp_vector_search/analysis/trends.py +308 -0
  25. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  26. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  27. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  28. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  29. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  30. mcp_vector_search/cli/commands/analyze.py +1062 -0
  31. mcp_vector_search/cli/commands/chat.py +1455 -0
  32. mcp_vector_search/cli/commands/index.py +621 -5
  33. mcp_vector_search/cli/commands/index_background.py +467 -0
  34. mcp_vector_search/cli/commands/init.py +13 -0
  35. mcp_vector_search/cli/commands/install.py +597 -335
  36. mcp_vector_search/cli/commands/install_old.py +8 -4
  37. mcp_vector_search/cli/commands/mcp.py +78 -6
  38. mcp_vector_search/cli/commands/reset.py +68 -26
  39. mcp_vector_search/cli/commands/search.py +224 -8
  40. mcp_vector_search/cli/commands/setup.py +1184 -0
  41. mcp_vector_search/cli/commands/status.py +339 -5
  42. mcp_vector_search/cli/commands/uninstall.py +276 -357
  43. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  44. mcp_vector_search/cli/commands/visualize/cli.py +292 -0
  45. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  46. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  47. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
  48. mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
  49. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  50. mcp_vector_search/cli/commands/visualize/server.py +600 -0
  51. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  52. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  53. mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
  54. mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
  55. mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
  56. mcp_vector_search/cli/didyoumean.py +27 -2
  57. mcp_vector_search/cli/main.py +127 -160
  58. mcp_vector_search/cli/output.py +158 -13
  59. mcp_vector_search/config/__init__.py +4 -0
  60. mcp_vector_search/config/default_thresholds.yaml +52 -0
  61. mcp_vector_search/config/settings.py +12 -0
  62. mcp_vector_search/config/thresholds.py +273 -0
  63. mcp_vector_search/core/__init__.py +16 -0
  64. mcp_vector_search/core/auto_indexer.py +3 -3
  65. mcp_vector_search/core/boilerplate.py +186 -0
  66. mcp_vector_search/core/config_utils.py +394 -0
  67. mcp_vector_search/core/database.py +406 -94
  68. mcp_vector_search/core/embeddings.py +24 -0
  69. mcp_vector_search/core/exceptions.py +11 -0
  70. mcp_vector_search/core/git.py +380 -0
  71. mcp_vector_search/core/git_hooks.py +4 -4
  72. mcp_vector_search/core/indexer.py +632 -54
  73. mcp_vector_search/core/llm_client.py +756 -0
  74. mcp_vector_search/core/models.py +91 -1
  75. mcp_vector_search/core/project.py +17 -0
  76. mcp_vector_search/core/relationships.py +473 -0
  77. mcp_vector_search/core/scheduler.py +11 -11
  78. mcp_vector_search/core/search.py +179 -29
  79. mcp_vector_search/mcp/server.py +819 -9
  80. mcp_vector_search/parsers/python.py +285 -5
  81. mcp_vector_search/utils/__init__.py +2 -0
  82. mcp_vector_search/utils/gitignore.py +0 -3
  83. mcp_vector_search/utils/gitignore_updater.py +212 -0
  84. mcp_vector_search/utils/monorepo.py +66 -4
  85. mcp_vector_search/utils/timing.py +10 -6
  86. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
  87. mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
  88. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
  89. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
  90. mcp_vector_search/cli/commands/visualize.py +0 -1467
  91. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  92. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2895 @@
1
+ """HTML standalone report generator for structural code analysis results.
2
+
3
+ This module provides the HTMLReportGenerator class that creates self-contained,
4
+ interactive HTML reports from analysis data. Reports include:
5
+ - Interactive charts using Chart.js
6
+ - Responsive design for mobile/desktop
7
+ - Code syntax highlighting via Highlight.js
8
+ - Embedded CSS and JavaScript (only CDN for libraries)
9
+ - Grade-based color coding and visualizations
10
+
11
+ The generated HTML files are fully self-contained except for CDN dependencies,
12
+ making them easy to share and view without additional infrastructure.
13
+
14
+ Example:
15
+ >>> from pathlib import Path
16
+ >>> from mcp_vector_search.analysis.visualizer import JSONExporter, HTMLReportGenerator
17
+ >>>
18
+ >>> # Export analysis to schema format
19
+ >>> exporter = JSONExporter(project_root=Path("/path/to/project"))
20
+ >>> export = exporter.export(project_metrics)
21
+ >>>
22
+ >>> # Generate HTML report
23
+ >>> html_gen = HTMLReportGenerator(title="My Project Analysis")
24
+ >>> html_output = html_gen.generate(export)
25
+ >>>
26
+ >>> # Or write directly to file
27
+ >>> html_path = html_gen.generate_to_file(export, Path("report.html"))
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from pathlib import Path
34
+
35
+ from .d3_data import transform_for_d3
36
+ from .schemas import AnalysisExport
37
+
38
+
39
+ class HTMLReportGenerator:
40
+ """Generates standalone HTML reports from analysis data.
41
+
42
+ Creates self-contained HTML files with embedded styles and scripts,
43
+ using CDN links only for Chart.js (visualizations) and Highlight.js
44
+ (syntax highlighting).
45
+
46
+ Attributes:
47
+ title: Report title displayed in header and <title> tag
48
+ """
49
+
50
+ # CDN URLs for external libraries
51
+ CHART_JS_CDN = "https://cdn.jsdelivr.net/npm/chart.js"
52
+ HIGHLIGHT_JS_CDN = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0"
53
+ D3_JS_CDN = "https://d3js.org/d3.v7.min.js"
54
+
55
+ def __init__(self, title: str = "Code Analysis Report"):
56
+ """Initialize HTML report generator.
57
+
58
+ Args:
59
+ title: Title for the report (default: "Code Analysis Report")
60
+ """
61
+ self.title = title
62
+
63
+ def generate(self, export: AnalysisExport) -> str:
64
+ """Generate complete HTML report as a string.
65
+
66
+ Args:
67
+ export: Analysis export data in schema format
68
+
69
+ Returns:
70
+ Complete HTML document as string
71
+ """
72
+ return f"""<!DOCTYPE html>
73
+ <html lang="en">
74
+ <head>
75
+ <meta charset="UTF-8">
76
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
77
+ <title>{self.title}</title>
78
+ <script src="{self.CHART_JS_CDN}"></script>
79
+ <script src="{self.D3_JS_CDN}"></script>
80
+ <link rel="stylesheet" href="{self.HIGHLIGHT_JS_CDN}/styles/github.min.css">
81
+ <script src="{self.HIGHLIGHT_JS_CDN}/highlight.min.js"></script>
82
+ {self._generate_styles()}
83
+ </head>
84
+ <body>
85
+ <a href="#main-content" class="skip-link">Skip to main content</a>
86
+ <div class="a11y-controls" role="toolbar" aria-label="Accessibility controls">
87
+ <button id="toggle-contrast" class="a11y-button" aria-pressed="false" title="Toggle high contrast mode">
88
+ High Contrast
89
+ </button>
90
+ <button id="toggle-reduced-motion" class="a11y-button" aria-pressed="false" title="Toggle reduced motion preference">
91
+ Reduce Motion
92
+ </button>
93
+ </div>
94
+ <div id="main-content">
95
+ {self._generate_header(export)}
96
+ {self._generate_summary_section(export)}
97
+ {self._generate_d3_graph_section(export)}
98
+ {self._generate_complexity_chart(export)}
99
+ {self._generate_grade_distribution(export)}
100
+ {self._generate_smells_section(export)}
101
+ {self._generate_files_table(export)}
102
+ {self._generate_dependencies_section(export)}
103
+ {self._generate_trends_section(export)}
104
+ {self._generate_footer(export)}
105
+ </div><!-- #main-content -->
106
+ {self._generate_scripts(export)}
107
+ </body>
108
+ </html>"""
109
+
110
+ def generate_to_file(self, export: AnalysisExport, output_path: Path) -> Path:
111
+ """Generate HTML report and write to file.
112
+
113
+ Args:
114
+ export: Analysis export data
115
+ output_path: Path where HTML file will be written
116
+
117
+ Returns:
118
+ Path to the created HTML file
119
+ """
120
+ html = self.generate(export)
121
+ output_path.parent.mkdir(parents=True, exist_ok=True)
122
+ output_path.write_text(html, encoding="utf-8")
123
+ return output_path
124
+
125
+ def _generate_styles(self) -> str:
126
+ """Generate embedded CSS styles.
127
+
128
+ Returns:
129
+ <style> block with complete CSS
130
+ """
131
+ return """<style>
132
+ :root {
133
+ --primary: #3b82f6;
134
+ --success: #22c55e;
135
+ --warning: #f59e0b;
136
+ --danger: #ef4444;
137
+ --gray-50: #f9fafb;
138
+ --gray-100: #f3f4f6;
139
+ --gray-200: #e5e7eb;
140
+ --gray-700: #374151;
141
+ --gray-900: #111827;
142
+ }
143
+
144
+ /* High Contrast Mode */
145
+ body.high-contrast {
146
+ --primary: #0056b3;
147
+ --gray-50: #ffffff;
148
+ --gray-100: #e0e0e0;
149
+ --gray-200: #c0c0c0;
150
+ --gray-700: #000000;
151
+ --gray-900: #000000;
152
+ background: #ffffff;
153
+ color: #000000;
154
+ }
155
+
156
+ body.high-contrast .card {
157
+ border: 2px solid #000000;
158
+ }
159
+
160
+ body.high-contrast .node circle {
161
+ stroke: #000000 !important;
162
+ stroke-width: 2px !important;
163
+ }
164
+
165
+ /* Reduced Motion */
166
+ @media (prefers-reduced-motion: reduce) {
167
+ *,
168
+ *::before,
169
+ *::after {
170
+ animation-duration: 0.01ms !important;
171
+ animation-iteration-count: 1 !important;
172
+ transition-duration: 0.01ms !important;
173
+ }
174
+ }
175
+
176
+ * { box-sizing: border-box; margin: 0; padding: 0; }
177
+
178
+ body {
179
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
180
+ line-height: 1.6;
181
+ color: var(--gray-900);
182
+ background: var(--gray-50);
183
+ padding: 2rem;
184
+ }
185
+
186
+ .container { max-width: 1200px; margin: 0 auto; }
187
+
188
+ header {
189
+ background: linear-gradient(135deg, var(--primary), #1d4ed8);
190
+ color: white;
191
+ padding: 2rem;
192
+ border-radius: 12px;
193
+ margin-bottom: 2rem;
194
+ }
195
+
196
+ h1 { font-size: 2rem; margin-bottom: 0.5rem; }
197
+ h2 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--gray-700); }
198
+ h3 { font-size: 1.25rem; margin-bottom: 0.75rem; }
199
+
200
+ .card {
201
+ background: white;
202
+ border-radius: 12px;
203
+ padding: 1.5rem;
204
+ margin-bottom: 1.5rem;
205
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
206
+ }
207
+
208
+ .stats-grid {
209
+ display: grid;
210
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
211
+ gap: 1rem;
212
+ }
213
+
214
+ .stat-card {
215
+ background: var(--gray-100);
216
+ padding: 1rem;
217
+ border-radius: 8px;
218
+ text-align: center;
219
+ }
220
+
221
+ .stat-value { font-size: 2rem; font-weight: bold; color: var(--primary); }
222
+ .stat-label { font-size: 0.875rem; color: var(--gray-700); }
223
+
224
+ .grade-badge {
225
+ display: inline-block;
226
+ padding: 0.25rem 0.75rem;
227
+ border-radius: 9999px;
228
+ font-weight: 600;
229
+ font-size: 0.875rem;
230
+ }
231
+
232
+ .grade-a { background: #dcfce7; color: #166534; }
233
+ .grade-b { background: #dbeafe; color: #1e40af; }
234
+ .grade-c { background: #fef3c7; color: #92400e; }
235
+ .grade-d { background: #fed7aa; color: #9a3412; }
236
+ .grade-f { background: #fecaca; color: #991b1b; }
237
+
238
+ table {
239
+ width: 100%;
240
+ border-collapse: collapse;
241
+ margin-top: 1rem;
242
+ }
243
+
244
+ th, td {
245
+ padding: 0.75rem;
246
+ text-align: left;
247
+ border-bottom: 1px solid var(--gray-200);
248
+ }
249
+
250
+ th { background: var(--gray-100); font-weight: 600; }
251
+
252
+ .chart-container {
253
+ position: relative;
254
+ height: 300px;
255
+ margin: 1rem 0;
256
+ }
257
+
258
+ .health-bar {
259
+ height: 8px;
260
+ background: var(--gray-200);
261
+ border-radius: 4px;
262
+ overflow: hidden;
263
+ }
264
+
265
+ .health-fill {
266
+ height: 100%;
267
+ border-radius: 4px;
268
+ transition: width 0.3s;
269
+ }
270
+
271
+ footer {
272
+ text-align: center;
273
+ padding: 2rem;
274
+ color: var(--gray-700);
275
+ font-size: 0.875rem;
276
+ }
277
+
278
+ /* Accessibility Controls */
279
+ .a11y-controls {
280
+ position: fixed;
281
+ top: 1rem;
282
+ right: 1rem;
283
+ display: flex;
284
+ gap: 0.5rem;
285
+ z-index: 10000;
286
+ }
287
+
288
+ .a11y-button {
289
+ padding: 0.5rem 1rem;
290
+ background: white;
291
+ border: 2px solid var(--gray-200);
292
+ border-radius: 6px;
293
+ cursor: pointer;
294
+ font-size: 0.875rem;
295
+ font-weight: 600;
296
+ transition: all 0.2s;
297
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
298
+ }
299
+
300
+ .a11y-button:hover {
301
+ background: var(--gray-100);
302
+ border-color: var(--primary);
303
+ }
304
+
305
+ .a11y-button:focus {
306
+ outline: 3px solid var(--primary);
307
+ outline-offset: 2px;
308
+ }
309
+
310
+ .a11y-button.active {
311
+ background: var(--primary);
312
+ color: white;
313
+ border-color: var(--primary);
314
+ }
315
+
316
+ /* Skip Link */
317
+ .skip-link {
318
+ position: absolute;
319
+ top: -40px;
320
+ left: 0;
321
+ background: var(--primary);
322
+ color: white;
323
+ padding: 0.5rem 1rem;
324
+ text-decoration: none;
325
+ border-radius: 0 0 4px 0;
326
+ z-index: 10001;
327
+ }
328
+
329
+ .skip-link:focus {
330
+ top: 0;
331
+ }
332
+
333
+ /* Export Controls */
334
+ .export-controls {
335
+ display: flex;
336
+ gap: 0.5rem;
337
+ margin-top: 1rem;
338
+ padding-top: 1rem;
339
+ border-top: 1px solid var(--gray-200);
340
+ }
341
+
342
+ .export-button {
343
+ padding: 0.5rem 1rem;
344
+ background: var(--primary);
345
+ color: white;
346
+ border: none;
347
+ border-radius: 6px;
348
+ font-size: 0.875rem;
349
+ font-weight: 600;
350
+ cursor: pointer;
351
+ transition: all 0.2s;
352
+ }
353
+
354
+ .export-button:hover {
355
+ background: #1d4ed8;
356
+ }
357
+
358
+ .export-button:active {
359
+ transform: translateY(1px);
360
+ }
361
+
362
+ .export-button:disabled {
363
+ opacity: 0.5;
364
+ cursor: not-allowed;
365
+ }
366
+
367
+ /* Focus trap indicator */
368
+ .focus-trapped {
369
+ outline: 3px solid var(--primary);
370
+ outline-offset: 2px;
371
+ }
372
+
373
+ /* Screen reader only */
374
+ .sr-only {
375
+ position: absolute;
376
+ width: 1px;
377
+ height: 1px;
378
+ padding: 0;
379
+ margin: -1px;
380
+ overflow: hidden;
381
+ clip: rect(0, 0, 0, 0);
382
+ white-space: nowrap;
383
+ border-width: 0;
384
+ }
385
+
386
+ /* Loading/Error States */
387
+ .error-message {
388
+ background: #fef2f2;
389
+ border: 2px solid #fca5a5;
390
+ border-radius: 8px;
391
+ padding: 1rem;
392
+ margin: 1rem 0;
393
+ color: #991b1b;
394
+ }
395
+
396
+ .loading-spinner {
397
+ display: inline-block;
398
+ width: 20px;
399
+ height: 20px;
400
+ border: 3px solid var(--gray-200);
401
+ border-top-color: var(--primary);
402
+ border-radius: 50%;
403
+ animation: spin 1s linear infinite;
404
+ }
405
+
406
+ @keyframes spin {
407
+ to { transform: rotate(360deg); }
408
+ }
409
+
410
+ @media (max-width: 768px) {
411
+ body { padding: 1rem; }
412
+ .stats-grid { grid-template-columns: 1fr; }
413
+ h1 { font-size: 1.5rem; }
414
+ .a11y-controls {
415
+ position: relative;
416
+ top: auto;
417
+ right: auto;
418
+ margin-bottom: 1rem;
419
+ flex-wrap: wrap;
420
+ }
421
+ }
422
+
423
+ /* D3 Graph Styles */
424
+ .graph-dashboard-container {
425
+ position: relative;
426
+ width: 100%;
427
+ margin-bottom: 1rem;
428
+ }
429
+
430
+ #d3-graph-container {
431
+ position: relative;
432
+ overflow: hidden;
433
+ background: #fafafa;
434
+ height: 700px;
435
+ border: 1px solid #e5e7eb;
436
+ border-radius: 8px;
437
+ }
438
+
439
+ #d3-graph {
440
+ width: 100%;
441
+ height: 100%;
442
+ }
443
+
444
+ /* Stats Toggle Button */
445
+ .stats-toggle-button {
446
+ position: absolute;
447
+ top: 1rem;
448
+ right: 1rem;
449
+ z-index: 1000;
450
+ padding: 0.5rem 1rem;
451
+ background: white;
452
+ border: 2px solid var(--gray-200);
453
+ border-radius: 6px;
454
+ cursor: pointer;
455
+ font-size: 0.875rem;
456
+ font-weight: 600;
457
+ transition: all 0.2s;
458
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
459
+ }
460
+
461
+ .stats-toggle-button:hover {
462
+ background: var(--gray-100);
463
+ border-color: var(--primary);
464
+ }
465
+
466
+ .stats-toggle-button:focus {
467
+ outline: 3px solid var(--primary);
468
+ outline-offset: 2px;
469
+ }
470
+
471
+ .stats-toggle-button.active {
472
+ background: var(--primary);
473
+ color: white;
474
+ border-color: var(--primary);
475
+ }
476
+
477
+ /* Filter Controls Panel */
478
+ .filter-controls {
479
+ background: white;
480
+ border: 1px solid #e5e7eb;
481
+ border-radius: 8px;
482
+ padding: 1rem;
483
+ margin-bottom: 1rem;
484
+ }
485
+
486
+ .filter-row {
487
+ display: grid;
488
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
489
+ gap: 1rem;
490
+ margin-bottom: 0.75rem;
491
+ }
492
+
493
+ .filter-group {
494
+ display: flex;
495
+ flex-direction: column;
496
+ gap: 0.5rem;
497
+ }
498
+
499
+ .filter-label {
500
+ font-weight: 600;
501
+ font-size: 0.875rem;
502
+ color: var(--gray-700);
503
+ }
504
+
505
+ .filter-checkboxes {
506
+ display: flex;
507
+ flex-wrap: wrap;
508
+ gap: 0.75rem;
509
+ }
510
+
511
+ .filter-checkbox-label {
512
+ display: flex;
513
+ align-items: center;
514
+ gap: 0.25rem;
515
+ font-size: 0.875rem;
516
+ cursor: pointer;
517
+ }
518
+
519
+ .filter-checkbox-label input[type="checkbox"] {
520
+ cursor: pointer;
521
+ }
522
+
523
+ .filter-input {
524
+ padding: 0.5rem;
525
+ border: 1px solid var(--gray-200);
526
+ border-radius: 6px;
527
+ font-size: 0.875rem;
528
+ font-family: inherit;
529
+ }
530
+
531
+ .filter-input:focus {
532
+ outline: none;
533
+ border-color: var(--primary);
534
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
535
+ }
536
+
537
+ .filter-select {
538
+ padding: 0.5rem;
539
+ border: 1px solid var(--gray-200);
540
+ border-radius: 6px;
541
+ font-size: 0.875rem;
542
+ background: white;
543
+ cursor: pointer;
544
+ }
545
+
546
+ .filter-select:focus {
547
+ outline: none;
548
+ border-color: var(--primary);
549
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
550
+ }
551
+
552
+ .filter-button {
553
+ padding: 0.5rem 1rem;
554
+ background: var(--gray-100);
555
+ border: 1px solid var(--gray-200);
556
+ border-radius: 6px;
557
+ font-size: 0.875rem;
558
+ font-weight: 600;
559
+ cursor: pointer;
560
+ transition: all 0.2s;
561
+ }
562
+
563
+ .filter-button:hover {
564
+ background: var(--gray-200);
565
+ }
566
+
567
+ .filter-button:active {
568
+ transform: translateY(1px);
569
+ }
570
+
571
+ .filter-actions {
572
+ display: flex;
573
+ gap: 0.5rem;
574
+ }
575
+
576
+ /* Node filtering states */
577
+ .node-filtered {
578
+ opacity: 0.1 !important;
579
+ transition: opacity 0.3s;
580
+ }
581
+
582
+ .link-filtered {
583
+ opacity: 0.05 !important;
584
+ stroke: var(--gray-200) !important;
585
+ transition: opacity 0.3s, stroke 0.3s;
586
+ }
587
+
588
+ /* Search highlight */
589
+ .node-search-highlight {
590
+ filter: drop-shadow(0 0 6px #fbbf24) !important;
591
+ }
592
+
593
+ /* Keyboard focus */
594
+ .node-focused {
595
+ filter: drop-shadow(0 0 8px var(--primary)) !important;
596
+ }
597
+
598
+ .node circle:focus {
599
+ outline: 2px solid var(--primary);
600
+ outline-offset: 4px;
601
+ }
602
+
603
+ /* Dashboard Panels */
604
+ .dashboard-panels {
605
+ position: fixed;
606
+ top: 0;
607
+ right: -400px;
608
+ width: 400px;
609
+ height: 100vh;
610
+ background: white;
611
+ box-shadow: -2px 0 8px rgba(0,0,0,0.1);
612
+ z-index: 999;
613
+ transition: right 0.3s ease-in-out;
614
+ overflow-y: auto;
615
+ padding: 1rem;
616
+ display: flex;
617
+ flex-direction: column;
618
+ gap: 1rem;
619
+ }
620
+
621
+ .dashboard-panels.visible {
622
+ right: 0;
623
+ }
624
+
625
+ .dashboard-panel {
626
+ background: white;
627
+ border: 1px solid #e5e7eb;
628
+ border-radius: 8px;
629
+ padding: 1rem;
630
+ overflow-y: auto;
631
+ }
632
+
633
+ .dashboard-panel h3 {
634
+ font-size: 1rem;
635
+ font-weight: 600;
636
+ margin-bottom: 0.75rem;
637
+ color: var(--gray-900);
638
+ }
639
+
640
+ .panel-stat {
641
+ display: flex;
642
+ justify-content: space-between;
643
+ padding: 0.5rem 0;
644
+ border-bottom: 1px solid var(--gray-100);
645
+ }
646
+
647
+ .panel-stat:last-child {
648
+ border-bottom: none;
649
+ }
650
+
651
+ .panel-stat-label {
652
+ color: var(--gray-700);
653
+ font-size: 0.875rem;
654
+ }
655
+
656
+ .panel-stat-value {
657
+ font-weight: 600;
658
+ color: var(--gray-900);
659
+ }
660
+
661
+ .smell-badge-small {
662
+ display: inline-block;
663
+ padding: 0.125rem 0.5rem;
664
+ border-radius: 9999px;
665
+ font-size: 0.75rem;
666
+ font-weight: 600;
667
+ }
668
+
669
+ .smell-badge-error {
670
+ background: #fecaca;
671
+ color: #991b1b;
672
+ }
673
+
674
+ .smell-badge-warning {
675
+ background: #fed7aa;
676
+ color: #9a3412;
677
+ }
678
+
679
+ .smell-badge-info {
680
+ background: #dbeafe;
681
+ color: #1e40af;
682
+ }
683
+
684
+ .panel-list {
685
+ list-style: none;
686
+ padding: 0;
687
+ margin: 0;
688
+ }
689
+
690
+ .panel-list-item {
691
+ padding: 0.5rem 0;
692
+ border-bottom: 1px solid var(--gray-100);
693
+ font-size: 0.875rem;
694
+ }
695
+
696
+ .panel-list-item:last-child {
697
+ border-bottom: none;
698
+ }
699
+
700
+ #node-detail-panel.hidden {
701
+ display: none;
702
+ }
703
+
704
+ .detail-header {
705
+ background: var(--gray-100);
706
+ padding: 0.75rem;
707
+ border-radius: 6px;
708
+ margin-bottom: 0.75rem;
709
+ }
710
+
711
+ .detail-header-title {
712
+ font-weight: 600;
713
+ color: var(--gray-900);
714
+ margin-bottom: 0.25rem;
715
+ }
716
+
717
+ .detail-header-path {
718
+ font-size: 0.75rem;
719
+ color: var(--gray-600);
720
+ font-family: 'Monaco', 'Courier New', monospace;
721
+ }
722
+
723
+ .detail-section {
724
+ margin-bottom: 1rem;
725
+ }
726
+
727
+ .detail-section-title {
728
+ font-weight: 600;
729
+ font-size: 0.875rem;
730
+ color: var(--gray-700);
731
+ margin-bottom: 0.5rem;
732
+ }
733
+
734
+ .circular-warning {
735
+ color: #dc2626;
736
+ font-weight: 600;
737
+ }
738
+
739
+ /* Node styles - complexity shading (darker = more complex) */
740
+ .node-complexity-low { fill: #f3f4f6; }
741
+ .node-complexity-moderate { fill: #9ca3af; }
742
+ .node-complexity-high { fill: #4b5563; }
743
+ .node-complexity-very-high { fill: #1f2937; }
744
+ .node-complexity-critical { fill: #111827; }
745
+
746
+ /* Node borders - smell severity (redder = worse) */
747
+ .smell-none { stroke: #e5e7eb; stroke-width: 1px; }
748
+ .smell-info { stroke: #fca5a5; stroke-width: 2px; }
749
+ .smell-warning { stroke: #f87171; stroke-width: 3px; }
750
+ .smell-error { stroke: #ef4444; stroke-width: 4px; }
751
+ .smell-critical { stroke: #dc2626; stroke-width: 5px; filter: drop-shadow(0 0 4px #dc2626); }
752
+
753
+ /* Edge styles */
754
+ .link {
755
+ fill: none;
756
+ stroke-opacity: 0.6;
757
+ }
758
+
759
+ .link-circular {
760
+ fill: none;
761
+ stroke: #dc2626;
762
+ stroke-opacity: 0.8;
763
+ animation: pulse 2s infinite;
764
+ }
765
+
766
+ @keyframes pulse {
767
+ 0%, 100% { stroke-opacity: 0.8; }
768
+ 50% { stroke-opacity: 0.3; }
769
+ }
770
+
771
+ /* Arrowhead marker */
772
+ .arrowhead {
773
+ fill: #64748b;
774
+ }
775
+
776
+ .arrowhead-circular {
777
+ fill: #dc2626;
778
+ }
779
+
780
+ /* Module cluster hulls */
781
+ .module-hull {
782
+ fill-opacity: 0.1;
783
+ stroke-width: 2px;
784
+ stroke-opacity: 0.4;
785
+ }
786
+
787
+ /* Node hover effects */
788
+ .node-dimmed {
789
+ opacity: 0.2;
790
+ transition: opacity 0.3s;
791
+ }
792
+
793
+ .node-highlighted {
794
+ opacity: 1;
795
+ transition: opacity 0.3s;
796
+ }
797
+
798
+ .link-dimmed {
799
+ opacity: 0.1;
800
+ transition: opacity 0.3s;
801
+ }
802
+
803
+ .link-highlighted {
804
+ opacity: 1;
805
+ transition: opacity 0.3s;
806
+ }
807
+
808
+ /* Node labels */
809
+ .node-label {
810
+ font-size: 10px;
811
+ fill: #374151;
812
+ text-anchor: middle;
813
+ pointer-events: none;
814
+ }
815
+
816
+ /* Tooltip */
817
+ .d3-tooltip {
818
+ position: absolute;
819
+ background: white;
820
+ border: 2px solid #e5e7eb;
821
+ border-radius: 8px;
822
+ padding: 12px;
823
+ font-size: 12px;
824
+ box-shadow: 0 10px 25px rgba(0,0,0,0.15);
825
+ pointer-events: none;
826
+ z-index: 1000;
827
+ max-width: 320px;
828
+ opacity: 0;
829
+ transition: opacity 0.2s;
830
+ }
831
+
832
+ .tooltip-header {
833
+ font-weight: 700;
834
+ font-size: 13px;
835
+ color: var(--gray-900);
836
+ margin-bottom: 6px;
837
+ }
838
+
839
+ .tooltip-subtitle {
840
+ color: var(--gray-600);
841
+ font-size: 11px;
842
+ margin-bottom: 10px;
843
+ }
844
+
845
+ .tooltip-section {
846
+ margin-top: 8px;
847
+ padding-top: 8px;
848
+ border-top: 1px solid #f3f4f6;
849
+ }
850
+
851
+ .tooltip-metric {
852
+ display: flex;
853
+ justify-content: space-between;
854
+ margin: 4px 0;
855
+ font-size: 11px;
856
+ }
857
+
858
+ .tooltip-metric-label {
859
+ color: var(--gray-600);
860
+ }
861
+
862
+ .tooltip-metric-value {
863
+ font-weight: 600;
864
+ color: var(--gray-900);
865
+ }
866
+
867
+ .tooltip-bar {
868
+ width: 100%;
869
+ height: 6px;
870
+ background: #f3f4f6;
871
+ border-radius: 3px;
872
+ overflow: hidden;
873
+ margin-top: 4px;
874
+ }
875
+
876
+ .tooltip-bar-fill {
877
+ height: 100%;
878
+ border-radius: 3px;
879
+ transition: width 0.3s;
880
+ }
881
+
882
+ .tooltip-badge {
883
+ display: inline-block;
884
+ padding: 2px 6px;
885
+ border-radius: 4px;
886
+ font-size: 10px;
887
+ font-weight: 600;
888
+ margin-left: 4px;
889
+ }
890
+
891
+ /* Legend - Overlay in Upper Left */
892
+ .d3-legend-container {
893
+ position: absolute;
894
+ top: 1rem;
895
+ left: 1rem;
896
+ z-index: 900;
897
+ max-width: 300px;
898
+ }
899
+
900
+ .d3-legend {
901
+ background: rgba(255, 255, 255, 0.95);
902
+ border: 1px solid #e5e7eb;
903
+ border-radius: 8px;
904
+ padding: 12px;
905
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
906
+ backdrop-filter: blur(4px);
907
+ }
908
+
909
+ .legend-section {
910
+ display: flex;
911
+ flex-direction: column;
912
+ gap: 6px;
913
+ margin-bottom: 12px;
914
+ }
915
+
916
+ .legend-section:last-child {
917
+ margin-bottom: 0;
918
+ }
919
+
920
+ .legend-title {
921
+ font-weight: 600;
922
+ font-size: 11px;
923
+ color: var(--gray-700);
924
+ margin-bottom: 4px;
925
+ text-transform: uppercase;
926
+ letter-spacing: 0.5px;
927
+ }
928
+
929
+ .legend-item {
930
+ display: flex;
931
+ align-items: center;
932
+ gap: 8px;
933
+ font-size: 11px;
934
+ color: var(--gray-700);
935
+ }
936
+
937
+ .legend-circle {
938
+ width: 14px;
939
+ height: 14px;
940
+ border-radius: 50%;
941
+ flex-shrink: 0;
942
+ }
943
+
944
+ .legend-line {
945
+ width: 20px;
946
+ height: 2px;
947
+ flex-shrink: 0;
948
+ }
949
+
950
+ /* Collapsible Legend */
951
+ .legend-toggle {
952
+ background: white;
953
+ border: 1px solid #e5e7eb;
954
+ padding: 0.5rem 0.75rem;
955
+ border-radius: 6px;
956
+ cursor: pointer;
957
+ font-weight: 600;
958
+ font-size: 0.75rem;
959
+ color: var(--gray-700);
960
+ margin-bottom: 0.5rem;
961
+ width: 100%;
962
+ text-align: left;
963
+ display: flex;
964
+ justify-content: space-between;
965
+ align-items: center;
966
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
967
+ }
968
+
969
+ .legend-toggle:hover {
970
+ background: var(--gray-50);
971
+ border-color: var(--primary);
972
+ }
973
+
974
+ .legend-content.collapsed {
975
+ display: none;
976
+ }
977
+
978
+ .count-badge {
979
+ background: var(--gray-700);
980
+ color: white;
981
+ padding: 0.125rem 0.5rem;
982
+ border-radius: 9999px;
983
+ font-size: 0.75rem;
984
+ margin-left: 0.5rem;
985
+ }
986
+
987
+ @media (max-width: 768px) {
988
+ #d3-graph-container { height: 500px; }
989
+ .d3-legend-container {
990
+ max-width: 250px;
991
+ }
992
+ .dashboard-panels {
993
+ width: 100%;
994
+ right: -100%;
995
+ }
996
+ .stats-toggle-button {
997
+ font-size: 0.75rem;
998
+ padding: 0.4rem 0.8rem;
999
+ }
1000
+ }
1001
+ </style>"""
1002
+
1003
+ def _generate_header(self, export: AnalysisExport) -> str:
1004
+ """Generate report header with metadata.
1005
+
1006
+ Args:
1007
+ export: Analysis export containing metadata
1008
+
1009
+ Returns:
1010
+ HTML header section
1011
+ """
1012
+ meta = export.metadata
1013
+ git_info = ""
1014
+ if meta.git_commit:
1015
+ git_info = (
1016
+ f"<p>Git: {meta.git_branch or 'unknown'} @ {meta.git_commit[:8]}</p>"
1017
+ )
1018
+
1019
+ return f"""<div class="container">
1020
+ <header>
1021
+ <h1>📊 {self.title}</h1>
1022
+ <p>Generated: {meta.generated_at.strftime("%Y-%m-%d %H:%M:%S")}</p>
1023
+ <p>Project: {meta.project_root}</p>
1024
+ {git_info}
1025
+ </header>"""
1026
+
1027
+ def _generate_summary_section(self, export: AnalysisExport) -> str:
1028
+ """Generate summary statistics cards.
1029
+
1030
+ Args:
1031
+ export: Analysis export with summary data
1032
+
1033
+ Returns:
1034
+ HTML summary section
1035
+ """
1036
+ s = export.summary
1037
+ return f"""<section class="card">
1038
+ <h2>📈 Project Summary</h2>
1039
+ <div class="stats-grid">
1040
+ <div class="stat-card">
1041
+ <div class="stat-value">{s.total_files:,}</div>
1042
+ <div class="stat-label">Files</div>
1043
+ </div>
1044
+ <div class="stat-card">
1045
+ <div class="stat-value">{s.total_functions:,}</div>
1046
+ <div class="stat-label">Functions</div>
1047
+ </div>
1048
+ <div class="stat-card">
1049
+ <div class="stat-value">{s.total_classes:,}</div>
1050
+ <div class="stat-label">Classes</div>
1051
+ </div>
1052
+ <div class="stat-card">
1053
+ <div class="stat-value">{s.total_lines:,}</div>
1054
+ <div class="stat-label">Lines of Code</div>
1055
+ </div>
1056
+ <div class="stat-card">
1057
+ <div class="stat-value">{s.avg_complexity:.1f}</div>
1058
+ <div class="stat-label">Avg Complexity</div>
1059
+ </div>
1060
+ <div class="stat-card">
1061
+ <div class="stat-value">{s.total_smells:,}</div>
1062
+ <div class="stat-label">Code Smells</div>
1063
+ </div>
1064
+ </div>
1065
+ </section>"""
1066
+
1067
+ def _generate_d3_graph_section(self, export: AnalysisExport) -> str:
1068
+ """Generate D3.js interactive dependency graph section.
1069
+
1070
+ Args:
1071
+ export: Analysis export with files and dependencies
1072
+
1073
+ Returns:
1074
+ HTML section with D3 graph container and legend
1075
+ """
1076
+ # Transform data for D3
1077
+ d3_data = transform_for_d3(export)
1078
+ d3_json = json.dumps(d3_data)
1079
+ summary = d3_data["summary"]
1080
+
1081
+ # Generate filter controls HTML
1082
+ filter_controls_html = self._generate_filter_controls(d3_data)
1083
+
1084
+ # Generate summary panel HTML
1085
+ summary_panel_html = self._generate_summary_panel(summary)
1086
+
1087
+ # Generate detail panel HTML (initially hidden)
1088
+ detail_panel_html = self._generate_detail_panel()
1089
+
1090
+ # Generate legend HTML with counts
1091
+ legend_html = self._generate_legend_with_counts(summary)
1092
+
1093
+ return f"""<section class="card" id="graph-section">
1094
+ <h2>🔗 Interactive Dependency Graph</h2>
1095
+ <p style="color: var(--gray-700); margin-bottom: 1rem;">
1096
+ Explore file dependencies with interactive visualization. Node size reflects lines of code,
1097
+ fill color shows complexity (darker = more complex), and border color indicates code smells
1098
+ (redder = more severe). Click nodes for details, drag to rearrange, zoom and pan to explore.
1099
+ Use filters below to focus on specific aspects.
1100
+ </p>
1101
+ <!-- Screen reader announcements -->
1102
+ <div id="sr-announcements" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
1103
+ {filter_controls_html}
1104
+ <div class="graph-dashboard-container">
1105
+ <!-- Stats Toggle Button in Upper Right -->
1106
+ <button
1107
+ id="stats-toggle"
1108
+ class="stats-toggle-button"
1109
+ onclick="toggleStatsPanel()"
1110
+ aria-label="Toggle statistics panel"
1111
+ aria-expanded="false"
1112
+ >
1113
+ 📊 Show Stats
1114
+ </button>
1115
+
1116
+ <!-- Main Graph Container -->
1117
+ <div id="d3-graph-container" role="img" aria-label="Interactive dependency graph visualization">
1118
+ <!-- Legend Overlay in Upper Left -->
1119
+ {legend_html}
1120
+
1121
+ <svg id="d3-graph" aria-label="Dependency graph"></svg>
1122
+ <div id="graph-error" class="error-message" style="display: none;" role="alert">
1123
+ <strong>Error:</strong> <span id="graph-error-message"></span>
1124
+ </div>
1125
+ <div id="graph-loading" style="display: none; text-align: center; padding: 2rem;">
1126
+ <div class="loading-spinner"></div>
1127
+ <p style="margin-top: 1rem; color: var(--gray-600);">Loading visualization...</p>
1128
+ </div>
1129
+ </div>
1130
+
1131
+ <!-- Stats Panel (Hidden by Default, Slides in from Right) -->
1132
+ <div id="stats-panel-container" class="dashboard-panels" aria-hidden="true">
1133
+ <button
1134
+ class="stats-toggle-button"
1135
+ onclick="toggleStatsPanel()"
1136
+ aria-label="Close statistics panel"
1137
+ style="position: relative; top: 0; right: 0; margin-bottom: 1rem;"
1138
+ >
1139
+ ✕ Hide Stats
1140
+ </button>
1141
+ {summary_panel_html}
1142
+ {detail_panel_html}
1143
+ </div>
1144
+ </div>
1145
+ <script id="d3-graph-data" type="application/json">{d3_json}</script>
1146
+ </section>"""
1147
+
1148
+ def _generate_filter_controls(self, d3_data: dict) -> str:
1149
+ """Generate filter controls panel for the graph.
1150
+
1151
+ Args:
1152
+ d3_data: D3 graph data with nodes and modules
1153
+
1154
+ Returns:
1155
+ HTML for filter controls panel
1156
+ """
1157
+ # Get unique modules
1158
+ modules = set()
1159
+ for node in d3_data.get("nodes", []):
1160
+ if node.get("module"):
1161
+ modules.add(node["module"])
1162
+ modules = sorted(modules)
1163
+
1164
+ module_options = "".join(
1165
+ f'<option value="{module}">{module}</option>' for module in modules
1166
+ )
1167
+
1168
+ return f"""<div class="filter-controls">
1169
+ <div class="filter-row">
1170
+ <div class="filter-group">
1171
+ <label class="filter-label">Complexity Grade</label>
1172
+ <div class="filter-checkboxes">
1173
+ <label class="filter-checkbox-label">
1174
+ <input type="checkbox" id="filter-grade-a" value="A" checked>
1175
+ <span>A (0-5)</span>
1176
+ </label>
1177
+ <label class="filter-checkbox-label">
1178
+ <input type="checkbox" id="filter-grade-b" value="B" checked>
1179
+ <span>B (6-10)</span>
1180
+ </label>
1181
+ <label class="filter-checkbox-label">
1182
+ <input type="checkbox" id="filter-grade-c" value="C" checked>
1183
+ <span>C (11-20)</span>
1184
+ </label>
1185
+ <label class="filter-checkbox-label">
1186
+ <input type="checkbox" id="filter-grade-d" value="D" checked>
1187
+ <span>D (21-30)</span>
1188
+ </label>
1189
+ <label class="filter-checkbox-label">
1190
+ <input type="checkbox" id="filter-grade-f" value="F" checked>
1191
+ <span>F (31+)</span>
1192
+ </label>
1193
+ </div>
1194
+ </div>
1195
+ <div class="filter-group">
1196
+ <label class="filter-label">Code Smells</label>
1197
+ <div class="filter-checkboxes">
1198
+ <label class="filter-checkbox-label">
1199
+ <input type="checkbox" id="filter-smell-none" value="none" checked>
1200
+ <span>None</span>
1201
+ </label>
1202
+ <label class="filter-checkbox-label">
1203
+ <input type="checkbox" id="filter-smell-info" value="info" checked>
1204
+ <span>Info</span>
1205
+ </label>
1206
+ <label class="filter-checkbox-label">
1207
+ <input type="checkbox" id="filter-smell-warning" value="warning" checked>
1208
+ <span>Warning</span>
1209
+ </label>
1210
+ <label class="filter-checkbox-label">
1211
+ <input type="checkbox" id="filter-smell-error" value="error" checked>
1212
+ <span>Error</span>
1213
+ </label>
1214
+ </div>
1215
+ </div>
1216
+ </div>
1217
+ <div class="filter-row">
1218
+ <div class="filter-group">
1219
+ <label class="filter-label" for="filter-module">Module Filter</label>
1220
+ <select id="filter-module" class="filter-select" multiple size="3">
1221
+ <option value="" selected>All Modules</option>
1222
+ {module_options}
1223
+ </select>
1224
+ </div>
1225
+ <div class="filter-group">
1226
+ <label class="filter-label" for="filter-search">Search Files</label>
1227
+ <input
1228
+ type="text"
1229
+ id="filter-search"
1230
+ class="filter-input"
1231
+ placeholder="Search by file name..."
1232
+ autocomplete="off"
1233
+ >
1234
+ </div>
1235
+ <div class="filter-group" style="justify-content: flex-end;">
1236
+ <label class="filter-label" style="visibility: hidden;">Actions</label>
1237
+ <div class="filter-actions">
1238
+ <button class="filter-button" onclick="resetFilters()">
1239
+ Reset Filters
1240
+ </button>
1241
+ <button class="filter-button" onclick="clearSelection()">
1242
+ Clear Selection
1243
+ </button>
1244
+ </div>
1245
+ </div>
1246
+ </div>
1247
+ <div class="export-controls">
1248
+ <button class="export-button" onclick="exportAsPNG()" aria-label="Export graph as PNG image">
1249
+ 📥 Export PNG
1250
+ </button>
1251
+ <button class="export-button" onclick="exportAsSVG()" aria-label="Export graph as SVG vector image">
1252
+ 📥 Export SVG
1253
+ </button>
1254
+ <button class="export-button" onclick="copyShareLink()" aria-label="Copy shareable link with current filters">
1255
+ 🔗 Copy Link
1256
+ </button>
1257
+ </div>
1258
+ </div>"""
1259
+
1260
+ def _generate_summary_panel(self, summary: dict) -> str:
1261
+ """Generate the metrics summary panel.
1262
+
1263
+ Args:
1264
+ summary: Summary statistics dictionary
1265
+
1266
+ Returns:
1267
+ HTML for the summary panel
1268
+ """
1269
+ grade_class = f"grade-{summary['complexity_grade'].lower()}"
1270
+ circular_class = (
1271
+ "circular-warning" if summary["circular_dependencies"] > 0 else ""
1272
+ )
1273
+
1274
+ return f"""<div class="dashboard-panel" id="summary-panel">
1275
+ <h3>📊 Project Metrics</h3>
1276
+ <div class="panel-stat">
1277
+ <span class="panel-stat-label">Total Files</span>
1278
+ <span class="panel-stat-value">{summary["total_files"]:,}</span>
1279
+ </div>
1280
+ <div class="panel-stat">
1281
+ <span class="panel-stat-label">Total Functions</span>
1282
+ <span class="panel-stat-value">{summary["total_functions"]:,}</span>
1283
+ </div>
1284
+ <div class="panel-stat">
1285
+ <span class="panel-stat-label">Total Classes</span>
1286
+ <span class="panel-stat-value">{summary["total_classes"]:,}</span>
1287
+ </div>
1288
+ <div class="panel-stat">
1289
+ <span class="panel-stat-label">Total LOC</span>
1290
+ <span class="panel-stat-value">{summary["total_lines"]:,}</span>
1291
+ </div>
1292
+ <div class="panel-stat">
1293
+ <span class="panel-stat-label">Average Complexity</span>
1294
+ <span class="panel-stat-value">
1295
+ {summary["avg_complexity"]:.1f}
1296
+ <span class="grade-badge {grade_class}">{summary["complexity_grade"]}</span>
1297
+ </span>
1298
+ </div>
1299
+ <hr style="margin: 0.75rem 0; border: none; border-top: 1px solid var(--gray-200);">
1300
+ <div class="detail-section-title">Code Smells</div>
1301
+ <div class="panel-stat">
1302
+ <span class="panel-stat-label">Total Smells</span>
1303
+ <span class="panel-stat-value">{summary["total_smells"]:,}</span>
1304
+ </div>
1305
+ <div class="panel-stat">
1306
+ <span class="panel-stat-label">Errors</span>
1307
+ <span class="panel-stat-value">
1308
+ <span class="smell-badge-small smell-badge-error">{summary["error_count"]}</span>
1309
+ </span>
1310
+ </div>
1311
+ <div class="panel-stat">
1312
+ <span class="panel-stat-label">Warnings</span>
1313
+ <span class="panel-stat-value">
1314
+ <span class="smell-badge-small smell-badge-warning">{summary["warning_count"]}</span>
1315
+ </span>
1316
+ </div>
1317
+ <div class="panel-stat">
1318
+ <span class="panel-stat-label">Info</span>
1319
+ <span class="panel-stat-value">
1320
+ <span class="smell-badge-small smell-badge-info">{summary["info_count"]}</span>
1321
+ </span>
1322
+ </div>
1323
+ <hr style="margin: 0.75rem 0; border: none; border-top: 1px solid var(--gray-200);">
1324
+ <div class="panel-stat">
1325
+ <span class="panel-stat-label">LOC Range</span>
1326
+ <span class="panel-stat-value">{summary["min_loc"]} - {summary["max_loc"]}</span>
1327
+ </div>
1328
+ <div class="panel-stat">
1329
+ <span class="panel-stat-label">Median LOC</span>
1330
+ <span class="panel-stat-value">{summary["median_loc"]}</span>
1331
+ </div>
1332
+ <div class="panel-stat">
1333
+ <span class="panel-stat-label">Circular Dependencies</span>
1334
+ <span class="panel-stat-value {circular_class}">{summary["circular_dependencies"]}</span>
1335
+ </div>
1336
+ </div>"""
1337
+
1338
+ def _generate_detail_panel(self) -> str:
1339
+ """Generate the node detail panel (initially hidden).
1340
+
1341
+ Returns:
1342
+ HTML for the detail panel
1343
+ """
1344
+ return """<div class="dashboard-panel hidden" id="node-detail-panel" role="dialog" aria-labelledby="detail-panel-title">
1345
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
1346
+ <h3 id="detail-panel-title">📄 Node Details</h3>
1347
+ <button class="detail-close-button filter-button" aria-label="Close detail panel" style="padding: 0.25rem 0.5rem;">✕</button>
1348
+ </div>
1349
+ <div id="node-detail-content">
1350
+ <p style="color: var(--gray-600); font-size: 0.875rem; text-align: center; padding: 2rem 0;">
1351
+ Click a node to view details
1352
+ </p>
1353
+ </div>
1354
+ </div>"""
1355
+
1356
+ def _generate_legend_with_counts(self, summary: dict) -> str:
1357
+ """Generate collapsible legend with node counts.
1358
+
1359
+ Args:
1360
+ summary: Summary statistics with distribution data
1361
+
1362
+ Returns:
1363
+ HTML for the legend section
1364
+ """
1365
+ complexity_dist = summary["complexity_distribution"]
1366
+ smell_dist = summary["smell_distribution"]
1367
+
1368
+ return f"""<div class="d3-legend-container">
1369
+ <button class="legend-toggle" onclick="toggleLegend()">
1370
+ <span>Legend</span>
1371
+ <span id="legend-toggle-icon">▼</span>
1372
+ </button>
1373
+ <div class="d3-legend legend-content" id="legend-content">
1374
+ <div class="legend-section">
1375
+ <div class="legend-title">Complexity (Fill)</div>
1376
+ <div class="legend-item">
1377
+ <div class="legend-circle" style="background: #f3f4f6; border: 1px solid #e5e7eb;"></div>
1378
+ <span>0-5 (Low)<span class="count-badge">{complexity_dist["low"]}</span></span>
1379
+ </div>
1380
+ <div class="legend-item">
1381
+ <div class="legend-circle" style="background: #9ca3af;"></div>
1382
+ <span>6-10 (Moderate)<span class="count-badge">{complexity_dist["moderate"]}</span></span>
1383
+ </div>
1384
+ <div class="legend-item">
1385
+ <div class="legend-circle" style="background: #4b5563;"></div>
1386
+ <span>11-20 (High)<span class="count-badge">{complexity_dist["high"]}</span></span>
1387
+ </div>
1388
+ <div class="legend-item">
1389
+ <div class="legend-circle" style="background: #1f2937;"></div>
1390
+ <span>21-30 (Very High)<span class="count-badge">{complexity_dist["very_high"]}</span></span>
1391
+ </div>
1392
+ <div class="legend-item">
1393
+ <div class="legend-circle" style="background: #111827;"></div>
1394
+ <span>31+ (Critical)<span class="count-badge">{complexity_dist["critical"]}</span></span>
1395
+ </div>
1396
+ </div>
1397
+ <div class="legend-section">
1398
+ <div class="legend-title">Code Smells (Border)</div>
1399
+ <div class="legend-item">
1400
+ <div class="legend-circle" style="background: white; border: 1px solid #e5e7eb;"></div>
1401
+ <span>None<span class="count-badge">{smell_dist["none"]}</span></span>
1402
+ </div>
1403
+ <div class="legend-item">
1404
+ <div class="legend-circle" style="background: white; border: 2px solid #fca5a5;"></div>
1405
+ <span>Info<span class="count-badge">{smell_dist["info"]}</span></span>
1406
+ </div>
1407
+ <div class="legend-item">
1408
+ <div class="legend-circle" style="background: white; border: 3px solid #f87171;"></div>
1409
+ <span>Warning<span class="count-badge">{smell_dist["warning"]}</span></span>
1410
+ </div>
1411
+ <div class="legend-item">
1412
+ <div class="legend-circle" style="background: white; border: 4px solid #ef4444;"></div>
1413
+ <span>Error<span class="count-badge">{smell_dist["error"]}</span></span>
1414
+ </div>
1415
+ </div>
1416
+ <div class="legend-section">
1417
+ <div class="legend-title">Dependencies (Edges)</div>
1418
+ <div class="legend-item">
1419
+ <div class="legend-line" style="background: #64748b;"></div>
1420
+ <span>Normal</span>
1421
+ </div>
1422
+ <div class="legend-item">
1423
+ <div class="legend-line" style="background: #dc2626;"></div>
1424
+ <span>Circular</span>
1425
+ </div>
1426
+ </div>
1427
+ <div class="legend-section">
1428
+ <div class="legend-title">Size</div>
1429
+ <div class="legend-item">
1430
+ <div class="legend-circle" style="width: 8px; height: 8px; background: #9ca3af;"></div>
1431
+ <span>Fewer lines</span>
1432
+ </div>
1433
+ <div class="legend-item">
1434
+ <div class="legend-circle" style="width: 16px; height: 16px; background: #9ca3af;"></div>
1435
+ <span>More lines</span>
1436
+ </div>
1437
+ </div>
1438
+ </div>
1439
+ </div>"""
1440
+
1441
+ def _generate_complexity_chart(self, export: AnalysisExport) -> str:
1442
+ """Generate complexity distribution chart placeholder.
1443
+
1444
+ Args:
1445
+ export: Analysis export data
1446
+
1447
+ Returns:
1448
+ HTML section with canvas for Chart.js
1449
+ """
1450
+ return """<section class="card">
1451
+ <h2>📊 Complexity Distribution</h2>
1452
+ <div class="chart-container">
1453
+ <canvas id="complexityChart"></canvas>
1454
+ </div>
1455
+ </section>"""
1456
+
1457
+ def _generate_grade_distribution(self, export: AnalysisExport) -> str:
1458
+ """Generate grade distribution table.
1459
+
1460
+ Args:
1461
+ export: Analysis export with file data
1462
+
1463
+ Returns:
1464
+ HTML section with grade breakdown
1465
+ """
1466
+ # Calculate grades from files
1467
+ grades = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
1468
+ for f in export.files:
1469
+ grade = self._get_grade(f.cognitive_complexity)
1470
+ grades[grade] += 1
1471
+
1472
+ total = sum(grades.values()) or 1
1473
+
1474
+ rows = []
1475
+ for grade, count in grades.items():
1476
+ pct = (count / total) * 100
1477
+ rows.append(
1478
+ f"""<tr>
1479
+ <td><span class="grade-badge grade-{grade.lower()}">{grade}</span></td>
1480
+ <td>{count:,}</td>
1481
+ <td>{pct:.1f}%</td>
1482
+ </tr>"""
1483
+ )
1484
+
1485
+ return f"""<section class="card">
1486
+ <h2>🎯 Grade Distribution</h2>
1487
+ <table>
1488
+ <thead>
1489
+ <tr><th>Grade</th><th>Count</th><th>Percentage</th></tr>
1490
+ </thead>
1491
+ <tbody>{"".join(rows)}</tbody>
1492
+ </table>
1493
+ </section>"""
1494
+
1495
+ def _generate_smells_section(self, export: AnalysisExport) -> str:
1496
+ """Generate code smells section.
1497
+
1498
+ Args:
1499
+ export: Analysis export with smell data
1500
+
1501
+ Returns:
1502
+ HTML section with top code smells
1503
+ """
1504
+ all_smells = []
1505
+ for f in export.files:
1506
+ for smell in f.smells:
1507
+ all_smells.append((f.path, smell))
1508
+
1509
+ # Sort by severity
1510
+ severity_order = {"error": 0, "warning": 1, "info": 2}
1511
+ all_smells.sort(key=lambda x: severity_order.get(x[1].severity, 3))
1512
+
1513
+ # Limit to top 20
1514
+ top_smells = all_smells[:20]
1515
+
1516
+ if not top_smells:
1517
+ return """<section class="card">
1518
+ <h2>🔍 Code Smells</h2>
1519
+ <p>No code smells detected! 🎉</p>
1520
+ </section>"""
1521
+
1522
+ rows = []
1523
+ for path, smell in top_smells:
1524
+ severity_class = {
1525
+ "error": "grade-f",
1526
+ "warning": "grade-d",
1527
+ "info": "grade-b",
1528
+ }.get(smell.severity, "")
1529
+ rows.append(
1530
+ f"""<tr>
1531
+ <td><span class="grade-badge {severity_class}">{smell.severity}</span></td>
1532
+ <td>{smell.smell_type}</td>
1533
+ <td>{path}:{smell.line}</td>
1534
+ <td>{smell.message}</td>
1535
+ </tr>"""
1536
+ )
1537
+
1538
+ return f"""<section class="card">
1539
+ <h2>🔍 Code Smells ({export.summary.total_smells:,} total)</h2>
1540
+ <table>
1541
+ <thead>
1542
+ <tr><th>Severity</th><th>Type</th><th>Location</th><th>Message</th></tr>
1543
+ </thead>
1544
+ <tbody>{"".join(rows)}</tbody>
1545
+ </table>
1546
+ </section>"""
1547
+
1548
+ def _generate_files_table(self, export: AnalysisExport) -> str:
1549
+ """Generate files table sorted by complexity.
1550
+
1551
+ Args:
1552
+ export: Analysis export with file data
1553
+
1554
+ Returns:
1555
+ HTML section with top files by complexity
1556
+ """
1557
+ # Sort by complexity descending, limit to top 20
1558
+ sorted_files = sorted(
1559
+ export.files, key=lambda f: f.cognitive_complexity, reverse=True
1560
+ )[:20]
1561
+
1562
+ rows = []
1563
+ for f in sorted_files:
1564
+ grade = self._get_grade(f.cognitive_complexity)
1565
+ rows.append(
1566
+ f"""<tr>
1567
+ <td>{f.path}</td>
1568
+ <td><span class="grade-badge grade-{grade.lower()}">{grade}</span></td>
1569
+ <td>{f.cognitive_complexity}</td>
1570
+ <td>{f.cyclomatic_complexity}</td>
1571
+ <td>{f.lines_of_code:,}</td>
1572
+ <td>{f.function_count}</td>
1573
+ <td>{len(f.smells)}</td>
1574
+ </tr>"""
1575
+ )
1576
+
1577
+ return f"""<section class="card">
1578
+ <h2>📁 Top Files by Complexity</h2>
1579
+ <table>
1580
+ <thead>
1581
+ <tr>
1582
+ <th>File</th>
1583
+ <th>Grade</th>
1584
+ <th>Cognitive</th>
1585
+ <th>Cyclomatic</th>
1586
+ <th>LOC</th>
1587
+ <th>Functions</th>
1588
+ <th>Smells</th>
1589
+ </tr>
1590
+ </thead>
1591
+ <tbody>{"".join(rows)}</tbody>
1592
+ </table>
1593
+ </section>"""
1594
+
1595
+ def _generate_dependencies_section(self, export: AnalysisExport) -> str:
1596
+ """Generate dependencies section.
1597
+
1598
+ Args:
1599
+ export: Analysis export with dependency data
1600
+
1601
+ Returns:
1602
+ HTML section with dependency analysis (empty if no data)
1603
+ """
1604
+ if not export.dependencies:
1605
+ return ""
1606
+
1607
+ deps = export.dependencies
1608
+
1609
+ circular_html = ""
1610
+ if deps.circular_dependencies:
1611
+ cycles = []
1612
+ for cycle in deps.circular_dependencies[:10]:
1613
+ cycles.append(f"<li>{' → '.join(cycle.cycle)}</li>")
1614
+ circular_html = f"""
1615
+ <h3>⚠️ Circular Dependencies ({len(deps.circular_dependencies)})</h3>
1616
+ <ul>{"".join(cycles)}</ul>"""
1617
+
1618
+ return f"""<section class="card">
1619
+ <h2>🔗 Dependencies</h2>
1620
+ <div class="stats-grid">
1621
+ <div class="stat-card">
1622
+ <div class="stat-value">{len(deps.edges):,}</div>
1623
+ <div class="stat-label">Total Imports</div>
1624
+ </div>
1625
+ <div class="stat-card">
1626
+ <div class="stat-value">{len(deps.circular_dependencies)}</div>
1627
+ <div class="stat-label">Circular Dependencies</div>
1628
+ </div>
1629
+ </div>
1630
+ {circular_html}
1631
+ </section>"""
1632
+
1633
+ def _generate_trends_section(self, export: AnalysisExport) -> str:
1634
+ """Generate trends section.
1635
+
1636
+ Args:
1637
+ export: Analysis export with trend data
1638
+
1639
+ Returns:
1640
+ HTML section with trends (empty if no data)
1641
+ """
1642
+ if not export.trends or not export.trends.metrics:
1643
+ return ""
1644
+
1645
+ rows = []
1646
+ for trend in export.trends.metrics:
1647
+ direction_icon = {
1648
+ "improving": "📈",
1649
+ "worsening": "📉",
1650
+ "stable": "➡️",
1651
+ }.get(trend.trend_direction, "➡️")
1652
+
1653
+ change = (
1654
+ f"{trend.change_percent:+.1f}%"
1655
+ if trend.change_percent is not None
1656
+ else "N/A"
1657
+ )
1658
+
1659
+ rows.append(
1660
+ f"""<tr>
1661
+ <td>{trend.metric_name}</td>
1662
+ <td>{trend.current_value:.1f}</td>
1663
+ <td>{change}</td>
1664
+ <td>{direction_icon} {trend.trend_direction}</td>
1665
+ </tr>"""
1666
+ )
1667
+
1668
+ return f"""<section class="card">
1669
+ <h2>📈 Trends</h2>
1670
+ <table>
1671
+ <thead>
1672
+ <tr><th>Metric</th><th>Current</th><th>Change</th><th>Trend</th></tr>
1673
+ </thead>
1674
+ <tbody>{"".join(rows)}</tbody>
1675
+ </table>
1676
+ </section>"""
1677
+
1678
+ def _generate_footer(self, export: AnalysisExport) -> str:
1679
+ """Generate report footer.
1680
+
1681
+ Args:
1682
+ export: Analysis export with metadata
1683
+
1684
+ Returns:
1685
+ HTML footer section
1686
+ """
1687
+ return f"""<footer>
1688
+ <p>Generated by mcp-vector-search v{export.metadata.tool_version}</p>
1689
+ <p>Schema version: {export.metadata.version}</p>
1690
+ </footer>
1691
+ </div>"""
1692
+
1693
+ def _generate_scripts(self, export: AnalysisExport) -> str:
1694
+ """Generate JavaScript for charts and interactivity.
1695
+
1696
+ Args:
1697
+ export: Analysis export for chart data
1698
+
1699
+ Returns:
1700
+ <script> block with JavaScript
1701
+ """
1702
+ # Calculate grade distribution for chart
1703
+ grades = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
1704
+ for f in export.files:
1705
+ grade = self._get_grade(f.cognitive_complexity)
1706
+ grades[grade] += 1
1707
+
1708
+ return f"""<script>
1709
+ // ===== ACCESSIBILITY CONTROLS =====
1710
+ (function() {{
1711
+ const contrastButton = document.getElementById('toggle-contrast');
1712
+ const motionButton = document.getElementById('toggle-reduced-motion');
1713
+
1714
+ // High contrast toggle
1715
+ contrastButton?.addEventListener('click', () => {{
1716
+ const isActive = document.body.classList.toggle('high-contrast');
1717
+ contrastButton.classList.toggle('active', isActive);
1718
+ contrastButton.setAttribute('aria-pressed', isActive);
1719
+ announceToScreenReader(isActive ? 'High contrast mode enabled' : 'High contrast mode disabled');
1720
+ }});
1721
+
1722
+ // Reduced motion toggle
1723
+ motionButton?.addEventListener('click', () => {{
1724
+ const isActive = document.body.classList.toggle('reduced-motion');
1725
+ motionButton.classList.toggle('active', isActive);
1726
+ motionButton.setAttribute('aria-pressed', isActive);
1727
+ announceToScreenReader(isActive ? 'Reduced motion enabled' : 'Reduced motion disabled');
1728
+ }});
1729
+
1730
+ // Respect user's prefers-reduced-motion
1731
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {{
1732
+ document.body.classList.add('reduced-motion');
1733
+ motionButton?.classList.add('active');
1734
+ motionButton?.setAttribute('aria-pressed', 'true');
1735
+ }}
1736
+ }})();
1737
+
1738
+ // Screen reader announcement helper
1739
+ function announceToScreenReader(message) {{
1740
+ const announcer = document.getElementById('sr-announcements');
1741
+ if (announcer) {{
1742
+ announcer.textContent = message;
1743
+ setTimeout(() => announcer.textContent = '', 100);
1744
+ }}
1745
+ }}
1746
+
1747
+ // ===== EXPORT FUNCTIONS =====
1748
+ function exportAsPNG() {{
1749
+ try {{
1750
+ const svg = document.getElementById('d3-graph');
1751
+ if (!svg) throw new Error('Graph not found');
1752
+
1753
+ // Create canvas
1754
+ const canvas = document.createElement('canvas');
1755
+ const bbox = svg.getBBox();
1756
+ canvas.width = bbox.width;
1757
+ canvas.height = bbox.height;
1758
+ const ctx = canvas.getContext('2d');
1759
+
1760
+ // Serialize SVG to data URL
1761
+ const svgData = new XMLSerializer().serializeToString(svg);
1762
+ const svgBlob = new Blob([svgData], {{ type: 'image/svg+xml;charset=utf-8' }});
1763
+ const url = URL.createObjectURL(svgBlob);
1764
+
1765
+ // Load as image and draw to canvas
1766
+ const img = new Image();
1767
+ img.onload = () => {{
1768
+ ctx.fillStyle = 'white';
1769
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1770
+ ctx.drawImage(img, 0, 0);
1771
+
1772
+ // Download
1773
+ canvas.toBlob(blob => {{
1774
+ const a = document.createElement('a');
1775
+ a.href = URL.createObjectURL(blob);
1776
+ a.download = 'dependency-graph.png';
1777
+ a.click();
1778
+ URL.revokeObjectURL(url);
1779
+ announceToScreenReader('Graph exported as PNG');
1780
+ }});
1781
+ }};
1782
+ img.src = url;
1783
+ }} catch (error) {{
1784
+ console.error('PNG export failed:', error);
1785
+ alert('Failed to export PNG: ' + error.message);
1786
+ }}
1787
+ }}
1788
+
1789
+ function exportAsSVG() {{
1790
+ try {{
1791
+ const svg = document.getElementById('d3-graph');
1792
+ if (!svg) throw new Error('Graph not found');
1793
+
1794
+ const svgData = new XMLSerializer().serializeToString(svg);
1795
+ const blob = new Blob([svgData], {{ type: 'image/svg+xml;charset=utf-8' }});
1796
+ const url = URL.createObjectURL(blob);
1797
+
1798
+ const a = document.createElement('a');
1799
+ a.href = url;
1800
+ a.download = 'dependency-graph.svg';
1801
+ a.click();
1802
+ URL.revokeObjectURL(url);
1803
+ announceToScreenReader('Graph exported as SVG');
1804
+ }} catch (error) {{
1805
+ console.error('SVG export failed:', error);
1806
+ alert('Failed to export SVG: ' + error.message);
1807
+ }}
1808
+ }}
1809
+
1810
+ function copyShareLink() {{
1811
+ try {{
1812
+ // Encode current filter state in URL hash
1813
+ const filterState = window.graphState?.filterState || {{}};
1814
+ const hash = '#filters=' + btoa(JSON.stringify(filterState));
1815
+ const url = window.location.href.split('#')[0] + hash;
1816
+
1817
+ navigator.clipboard.writeText(url).then(() => {{
1818
+ announceToScreenReader('Link copied to clipboard');
1819
+ alert('Shareable link copied to clipboard!');
1820
+ }});
1821
+ }} catch (error) {{
1822
+ console.error('Copy link failed:', error);
1823
+ alert('Failed to copy link: ' + error.message);
1824
+ }}
1825
+ }}
1826
+
1827
+ // Load filters from URL hash
1828
+ function loadFiltersFromURL() {{
1829
+ const hash = window.location.hash;
1830
+ if (hash.startsWith('#filters=')) {{
1831
+ try {{
1832
+ const encoded = hash.substring(9);
1833
+ const filterState = JSON.parse(atob(encoded));
1834
+ if (window.graphState) {{
1835
+ // Update filter UI elements
1836
+ if (filterState.grades) {{
1837
+ document.querySelectorAll('[id^="filter-grade-"]').forEach(cb => {{
1838
+ cb.checked = filterState.grades.includes(cb.value);
1839
+ }});
1840
+ }}
1841
+ if (filterState.smells) {{
1842
+ document.querySelectorAll('[id^="filter-smell-"]').forEach(cb => {{
1843
+ cb.checked = filterState.smells.includes(cb.value);
1844
+ }});
1845
+ }}
1846
+ if (filterState.modules) {{
1847
+ const moduleSelect = document.getElementById('filter-module');
1848
+ if (moduleSelect) {{
1849
+ Array.from(moduleSelect.options).forEach(opt => {{
1850
+ opt.selected = filterState.modules.includes(opt.value);
1851
+ }});
1852
+ }}
1853
+ }}
1854
+ if (filterState.search) {{
1855
+ const searchInput = document.getElementById('filter-search');
1856
+ if (searchInput) {{
1857
+ searchInput.value = filterState.search;
1858
+ }}
1859
+ }}
1860
+
1861
+ // Trigger filter application
1862
+ const event = new Event('change');
1863
+ document.querySelector('[id^="filter-grade-"]')?.dispatchEvent(event);
1864
+
1865
+ announceToScreenReader('Filters loaded from shared link');
1866
+ }}
1867
+ }} catch (error) {{
1868
+ console.error('Failed to load filters from URL:', error);
1869
+ }}
1870
+ }}
1871
+ }}
1872
+
1873
+ // Initialize D3 Graph with error handling and performance optimizations
1874
+ (function() {{
1875
+ const loadingEl = document.getElementById('graph-loading');
1876
+ const errorEl = document.getElementById('graph-error');
1877
+ const errorMsgEl = document.getElementById('graph-error-message');
1878
+
1879
+ function showError(message) {{
1880
+ if (loadingEl) loadingEl.style.display = 'none';
1881
+ if (errorEl) errorEl.style.display = 'block';
1882
+ if (errorMsgEl) errorMsgEl.textContent = message;
1883
+ announceToScreenReader('Graph error: ' + message);
1884
+ }}
1885
+
1886
+ function showLoading() {{
1887
+ if (loadingEl) loadingEl.style.display = 'block';
1888
+ if (errorEl) errorEl.style.display = 'none';
1889
+ }}
1890
+
1891
+ function hideLoading() {{
1892
+ if (loadingEl) loadingEl.style.display = 'none';
1893
+ }}
1894
+
1895
+ // Check if D3 loaded
1896
+ if (typeof d3 === 'undefined') {{
1897
+ showError('D3.js library failed to load. Please check your internet connection.');
1898
+ return;
1899
+ }}
1900
+
1901
+ showLoading();
1902
+
1903
+ const dataScript = document.getElementById('d3-graph-data');
1904
+ if (!dataScript) {{
1905
+ showError('Graph data not found.');
1906
+ return;
1907
+ }}
1908
+
1909
+ let graphData;
1910
+ try {{
1911
+ graphData = JSON.parse(dataScript.textContent);
1912
+ }} catch (error) {{
1913
+ showError('Failed to parse graph data: ' + error.message);
1914
+ return;
1915
+ }}
1916
+
1917
+ if (!graphData.nodes || graphData.nodes.length === 0) {{
1918
+ showError('No files in analysis. Add some code files to see the dependency graph.');
1919
+ return;
1920
+ }}
1921
+
1922
+ hideLoading();
1923
+
1924
+ const svg = d3.select("#d3-graph");
1925
+ const container = document.getElementById("d3-graph-container");
1926
+ const width = container.clientWidth;
1927
+ const height = container.clientHeight;
1928
+
1929
+ svg.attr("viewBox", [0, 0, width, height]);
1930
+
1931
+ // ===== PERFORMANCE OPTIMIZATIONS =====
1932
+ const nodeCount = graphData.nodes.length;
1933
+ const LARGE_GRAPH_THRESHOLD = 100;
1934
+ const isLargeGraph = nodeCount > LARGE_GRAPH_THRESHOLD;
1935
+
1936
+ // Disable animations for large graphs
1937
+ const animationDuration = isLargeGraph ? 0 :
1938
+ (document.body.classList.contains('reduced-motion') ? 0 : 600);
1939
+
1940
+ // Throttle simulation updates for smooth 60fps during drag
1941
+ let lastTickTime = 0;
1942
+ const TICK_THROTTLE_MS = 16; // ~60fps
1943
+
1944
+ // Debounce filter changes
1945
+ let filterDebounceTimer;
1946
+ const FILTER_DEBOUNCE_MS = 150;
1947
+
1948
+ // LOD (Level of Detail) state
1949
+ let currentZoom = 1.0;
1950
+ const LOD_ZOOM_THRESHOLD_LOW = 0.5;
1951
+ const LOD_ZOOM_THRESHOLD_HIGH = 1.5;
1952
+
1953
+ // Helper functions for visual encoding
1954
+ const complexityColor = (complexity) => {{
1955
+ if (complexity <= 5) return '#f3f4f6';
1956
+ if (complexity <= 10) return '#9ca3af';
1957
+ if (complexity <= 20) return '#4b5563';
1958
+ if (complexity <= 30) return '#1f2937';
1959
+ return '#111827';
1960
+ }};
1961
+
1962
+ const smellBorder = (severity) => {{
1963
+ const borders = {{
1964
+ 'none': {{ color: '#e5e7eb', width: 1 }},
1965
+ 'info': {{ color: '#fca5a5', width: 2 }},
1966
+ 'warning': {{ color: '#f87171', width: 3 }},
1967
+ 'error': {{ color: '#ef4444', width: 4 }},
1968
+ 'critical': {{ color: '#dc2626', width: 5 }}
1969
+ }};
1970
+ return borders[severity] || borders['none'];
1971
+ }};
1972
+
1973
+ // Edge color scale based on coupling strength
1974
+ const maxCoupling = d3.max(graphData.links, d => d.coupling) || 1;
1975
+ const edgeColorScale = d3.scaleLinear()
1976
+ .domain([1, maxCoupling])
1977
+ .range(['#d1d5db', '#4b5563']);
1978
+
1979
+ // Size scale for LOC (min 8px, max 40px)
1980
+ const maxLoc = d3.max(graphData.nodes, d => d.loc) || 100;
1981
+ const sizeScale = d3.scaleSqrt()
1982
+ .domain([0, maxLoc])
1983
+ .range([8, 40]);
1984
+
1985
+ // Edge thickness scale (min 1px, max 4px)
1986
+ const edgeScale = d3.scaleLinear()
1987
+ .domain([1, maxCoupling])
1988
+ .range([1, 4]);
1989
+
1990
+ // Force simulation
1991
+ const simulation = d3.forceSimulation(graphData.nodes)
1992
+ .force("link", d3.forceLink(graphData.links).id(d => d.id).distance(100))
1993
+ .force("charge", d3.forceManyBody().strength(-200))
1994
+ .force("center", d3.forceCenter(width / 2, height / 2))
1995
+ .force("collision", d3.forceCollide().radius(d => sizeScale(d.loc) + 5));
1996
+
1997
+ // Zoom behavior with LOD (Level of Detail)
1998
+ const zoom = d3.zoom()
1999
+ .scaleExtent([0.1, 4])
2000
+ .on("zoom", (event) => {{
2001
+ g.attr("transform", event.transform);
2002
+
2003
+ // Update current zoom level
2004
+ const newZoom = event.transform.k;
2005
+ if (Math.abs(newZoom - currentZoom) > 0.1) {{
2006
+ currentZoom = newZoom;
2007
+ updateLOD(currentZoom);
2008
+ }}
2009
+ }});
2010
+
2011
+ svg.call(zoom);
2012
+
2013
+ // LOD update function
2014
+ function updateLOD(zoomLevel) {{
2015
+ // Use requestAnimationFrame for smooth rendering
2016
+ requestAnimationFrame(() => {{
2017
+ if (zoomLevel < LOD_ZOOM_THRESHOLD_LOW) {{
2018
+ // Zoom out: Hide labels, simplify nodes
2019
+ node.selectAll("text").style("display", "none");
2020
+ node.selectAll("circle")
2021
+ .style("stroke-width", d => Math.min(smellBorder(d.smell_severity).width, 2));
2022
+ // Hide complexity details
2023
+ node.selectAll(".complexity-label").style("display", "none");
2024
+ }} else if (zoomLevel > LOD_ZOOM_THRESHOLD_HIGH) {{
2025
+ // Zoom in: Show additional details
2026
+ node.selectAll("text").style("display", "block");
2027
+ node.selectAll("circle")
2028
+ .style("stroke-width", d => smellBorder(d.smell_severity).width);
2029
+ // Show complexity number in node
2030
+ node.each(function(d) {{
2031
+ const nodeGroup = d3.select(this);
2032
+ if (!nodeGroup.select(".complexity-label").node()) {{
2033
+ nodeGroup.append("text")
2034
+ .attr("class", "complexity-label")
2035
+ .attr("dy", 4)
2036
+ .attr("text-anchor", "middle")
2037
+ .style("font-size", "10px")
2038
+ .style("font-weight", "bold")
2039
+ .style("fill", d.complexity > 20 ? "white" : "#374151")
2040
+ .style("pointer-events", "none")
2041
+ .text(d.complexity);
2042
+ }}
2043
+ }});
2044
+ }} else {{
2045
+ // Normal zoom: Show labels, full styling
2046
+ node.selectAll("text").style("display", "block");
2047
+ node.selectAll("circle")
2048
+ .style("stroke-width", d => smellBorder(d.smell_severity).width);
2049
+ node.selectAll(".complexity-label").style("display", "none");
2050
+ }}
2051
+ }});
2052
+ }}
2053
+
2054
+ const g = svg.append("g");
2055
+
2056
+ // Define arrowhead markers
2057
+ svg.append("defs").selectAll("marker")
2058
+ .data(["arrowhead", "arrowhead-circular"])
2059
+ .join("marker")
2060
+ .attr("id", d => d)
2061
+ .attr("viewBox", "0 -5 10 10")
2062
+ .attr("refX", 20)
2063
+ .attr("refY", 0)
2064
+ .attr("markerWidth", 6)
2065
+ .attr("markerHeight", 6)
2066
+ .attr("orient", "auto")
2067
+ .append("path")
2068
+ .attr("d", "M0,-5L10,0L0,5")
2069
+ .attr("class", d => d);
2070
+
2071
+ // Draw module cluster hulls (background layer)
2072
+ const hullGroup = g.append("g").attr("class", "hulls");
2073
+
2074
+ // Draw edges with curves and arrowheads
2075
+ const linkGroup = g.append("g");
2076
+ const link = linkGroup.selectAll("path")
2077
+ .data(graphData.links)
2078
+ .join("path")
2079
+ .attr("class", d => d.circular ? "link link-circular" : "link")
2080
+ .attr("stroke", d => d.circular ? "#dc2626" : edgeColorScale(d.coupling))
2081
+ .attr("stroke-width", d => edgeScale(d.coupling))
2082
+ .attr("marker-end", d => d.circular ? "url(#arrowhead-circular)" : "url(#arrowhead)");
2083
+
2084
+ // Draw nodes with entrance animation
2085
+ const nodeGroup = g.append("g");
2086
+ const node = nodeGroup.selectAll("g")
2087
+ .data(graphData.nodes)
2088
+ .join("g")
2089
+ .attr("opacity", 0)
2090
+ .call(d3.drag()
2091
+ .on("start", dragstarted)
2092
+ .on("drag", dragged)
2093
+ .on("end", dragended));
2094
+
2095
+ // Node entrance animation (conditional based on graph size and motion preference)
2096
+ if (animationDuration > 0) {{
2097
+ node.transition()
2098
+ .duration(animationDuration)
2099
+ .delay((d, i) => i * 30)
2100
+ .attr("opacity", 1);
2101
+ }} else {{
2102
+ node.attr("opacity", 1);
2103
+ }}
2104
+
2105
+ // Node circles with complexity fill and smell border
2106
+ node.append("circle")
2107
+ .attr("r", animationDuration > 0 ? 0 : d => sizeScale(d.loc))
2108
+ .attr("fill", d => complexityColor(d.complexity))
2109
+ .attr("stroke", d => smellBorder(d.smell_severity).color)
2110
+ .attr("stroke-width", d => smellBorder(d.smell_severity).width)
2111
+ .style("filter", d => d.smell_severity === 'critical' ? 'drop-shadow(0 0 4px #dc2626)' : null)
2112
+ .attr("tabindex", 0)
2113
+ .attr("role", "button")
2114
+ .attr("aria-label", d => `${{d.label}}, complexity ${{d.complexity}}, ${{d.smell_count}} code smells`);
2115
+
2116
+ if (animationDuration > 0) {{
2117
+ node.selectAll("circle")
2118
+ .transition()
2119
+ .duration(animationDuration)
2120
+ .delay((d, i) => i * 30)
2121
+ .attr("r", d => sizeScale(d.loc));
2122
+ }}
2123
+
2124
+ // Node labels
2125
+ node.append("text")
2126
+ .text(d => d.label)
2127
+ .attr("class", "node-label")
2128
+ .attr("dy", d => sizeScale(d.loc) + 12);
2129
+
2130
+ // Tooltip
2131
+ const tooltip = d3.select("body").append("div")
2132
+ .attr("class", "d3-tooltip");
2133
+
2134
+ // Enhanced tooltip rendering
2135
+ const renderTooltip = (d, event) => {{
2136
+ const getGrade = (complexity) => {{
2137
+ if (complexity <= 5) return {{ grade: 'A', class: 'grade-a', color: '#22c55e' }};
2138
+ if (complexity <= 10) return {{ grade: 'B', class: 'grade-b', color: '#3b82f6' }};
2139
+ if (complexity <= 20) return {{ grade: 'C', class: 'grade-c', color: '#f59e0b' }};
2140
+ if (complexity <= 30) return {{ grade: 'D', class: 'grade-d', color: '#f97316' }};
2141
+ return {{ grade: 'F', class: 'grade-f', color: '#ef4444' }};
2142
+ }};
2143
+
2144
+ const grade = getGrade(d.complexity);
2145
+ const smellColor = smellBorder(d.smell_severity).color;
2146
+
2147
+ // Calculate complexity bar percentage (max 50 for visualization)
2148
+ const complexityPct = Math.min((d.complexity / 50) * 100, 100);
2149
+ const complexityBarColor = grade.color;
2150
+
2151
+ // Count imports/imported-by
2152
+ const incomingCount = graphData.links.filter(l => l.target.id === d.id).length;
2153
+ const outgoingCount = d.imports ? d.imports.length : 0;
2154
+
2155
+ tooltip.html(`
2156
+ <div class="tooltip-header">${{d.label}}</div>
2157
+ <div class="tooltip-subtitle">Module: ${{d.module}}</div>
2158
+
2159
+ <div class="tooltip-metric">
2160
+ <span class="tooltip-metric-label">Complexity</span>
2161
+ <span class="tooltip-metric-value">
2162
+ ${{d.complexity}}
2163
+ <span class="tooltip-badge ${{grade.class}}">${{grade.grade}}</span>
2164
+ </span>
2165
+ </div>
2166
+ <div class="tooltip-bar">
2167
+ <div class="tooltip-bar-fill" style="width: ${{complexityPct}}%; background: ${{complexityBarColor}};"></div>
2168
+ </div>
2169
+
2170
+ <div class="tooltip-section">
2171
+ <div class="tooltip-metric">
2172
+ <span class="tooltip-metric-label">Lines of Code</span>
2173
+ <span class="tooltip-metric-value">${{d.loc}}</span>
2174
+ </div>
2175
+ <div class="tooltip-metric">
2176
+ <span class="tooltip-metric-label">Code Smells</span>
2177
+ <span class="tooltip-metric-value" style="color: ${{smellColor}}">
2178
+ ${{d.smell_count}}
2179
+ <span class="tooltip-badge" style="background: ${{smellColor}}; color: white;">${{d.smell_severity}}</span>
2180
+ </span>
2181
+ </div>
2182
+ </div>
2183
+
2184
+ <div class="tooltip-section">
2185
+ <div class="tooltip-metric">
2186
+ <span class="tooltip-metric-label">Imports</span>
2187
+ <span class="tooltip-metric-value">${{outgoingCount}}</span>
2188
+ </div>
2189
+ <div class="tooltip-metric">
2190
+ <span class="tooltip-metric-label">Imported by</span>
2191
+ <span class="tooltip-metric-value">${{incomingCount}}</span>
2192
+ </div>
2193
+ </div>
2194
+ `);
2195
+ }};
2196
+
2197
+ // Hover highlighting with connected nodes
2198
+ node.on("mouseenter", (event, d) => {{
2199
+ // Find connected nodes
2200
+ const connectedNodes = new Set([d.id]);
2201
+ graphData.links.forEach(link => {{
2202
+ if (link.source.id === d.id) connectedNodes.add(link.target.id);
2203
+ if (link.target.id === d.id) connectedNodes.add(link.source.id);
2204
+ }});
2205
+
2206
+ // Dim unconnected nodes
2207
+ node.classed("node-dimmed", n => !connectedNodes.has(n.id))
2208
+ .classed("node-highlighted", n => connectedNodes.has(n.id));
2209
+
2210
+ // Dim unconnected edges
2211
+ link.classed("link-dimmed", l => l.source.id !== d.id && l.target.id !== d.id)
2212
+ .classed("link-highlighted", l => l.source.id === d.id || l.target.id === d.id);
2213
+
2214
+ // Show enhanced tooltip
2215
+ renderTooltip(d, event);
2216
+ tooltip.transition().duration(200).style("opacity", 1);
2217
+ }})
2218
+ .on("mousemove", (event) => {{
2219
+ // Tooltip follows cursor
2220
+ tooltip
2221
+ .style("left", (event.pageX + 10) + "px")
2222
+ .style("top", (event.pageY - 10) + "px");
2223
+ }})
2224
+ .on("mouseleave", () => {{
2225
+ // Remove dimming
2226
+ node.classed("node-dimmed", false).classed("node-highlighted", false);
2227
+ link.classed("link-dimmed", false).classed("link-highlighted", false);
2228
+
2229
+ tooltip.transition().duration(500).style("opacity", 0);
2230
+ }})
2231
+ .on("click", (event, d) => {{
2232
+ event.stopPropagation();
2233
+ showNodeDetails(d, graphData);
2234
+ }});
2235
+
2236
+ // Function to update module cluster hulls
2237
+ function updateHulls() {{
2238
+ if (!graphData.modules || graphData.modules.length === 0) return;
2239
+
2240
+ const hullData = graphData.modules.map(module => {{
2241
+ const moduleNodes = graphData.nodes.filter(n =>
2242
+ module.node_ids.includes(n.id)
2243
+ );
2244
+ if (moduleNodes.length < 2) return null;
2245
+
2246
+ // Calculate convex hull points
2247
+ const points = moduleNodes.map(n => [n.x, n.y]);
2248
+ const hull = d3.polygonHull(points);
2249
+
2250
+ return hull ? {{ points: hull, color: module.color, name: module.name }} : null;
2251
+ }}).filter(h => h !== null);
2252
+
2253
+ hullGroup.selectAll("path")
2254
+ .data(hullData)
2255
+ .join("path")
2256
+ .attr("class", "module-hull")
2257
+ .attr("d", d => {{
2258
+ // Add padding around hull
2259
+ const centroid = d3.polygonCentroid(d.points);
2260
+ const paddedPoints = d.points.map(p => {{
2261
+ const dx = p[0] - centroid[0];
2262
+ const dy = p[1] - centroid[1];
2263
+ const dist = Math.sqrt(dx * dx + dy * dy);
2264
+ const padding = 30;
2265
+ return [
2266
+ p[0] + (dx / dist) * padding,
2267
+ p[1] + (dy / dist) * padding
2268
+ ];
2269
+ }});
2270
+ return "M" + paddedPoints.join("L") + "Z";
2271
+ }})
2272
+ .attr("fill", d => d.color)
2273
+ .attr("stroke", d => d.color);
2274
+ }}
2275
+
2276
+ // Simulation tick with throttling for performance
2277
+ simulation.on("tick", () => {{
2278
+ const now = Date.now();
2279
+ if (now - lastTickTime < TICK_THROTTLE_MS) {{
2280
+ return; // Skip this tick to maintain 60fps
2281
+ }}
2282
+ lastTickTime = now;
2283
+
2284
+ // Use requestAnimationFrame for smooth rendering
2285
+ requestAnimationFrame(() => {{
2286
+ // Update curved edges
2287
+ link.attr("d", d => {{
2288
+ const dx = d.target.x - d.source.x;
2289
+ const dy = d.target.y - d.source.y;
2290
+ const dr = Math.sqrt(dx * dx + dy * dy) * 2;
2291
+ return `M${{d.source.x}},${{d.source.y}}A${{dr}},${{dr}} 0 0,1 ${{d.target.x}},${{d.target.y}}`;
2292
+ }});
2293
+
2294
+ node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
2295
+
2296
+ // Update hulls periodically (not every tick for performance)
2297
+ if (simulation.alpha() < 0.1) {{
2298
+ updateHulls();
2299
+ }}
2300
+ }});
2301
+ }});
2302
+
2303
+ // Drag functions with throttling
2304
+ let dragThrottle;
2305
+ function dragstarted(event) {{
2306
+ if (!event.active) simulation.alphaTarget(0.3).restart();
2307
+ event.subject.fx = event.subject.x;
2308
+ event.subject.fy = event.subject.y;
2309
+ announceToScreenReader(`Dragging ${{event.subject.label}}`);
2310
+ }}
2311
+
2312
+ function dragged(event) {{
2313
+ // Throttle drag updates for performance
2314
+ if (dragThrottle) clearTimeout(dragThrottle);
2315
+ dragThrottle = setTimeout(() => {{
2316
+ event.subject.fx = event.x;
2317
+ event.subject.fy = event.y;
2318
+ }}, TICK_THROTTLE_MS);
2319
+ }}
2320
+
2321
+ function dragended(event) {{
2322
+ if (!event.active) simulation.alphaTarget(0);
2323
+ event.subject.fx = null;
2324
+ event.subject.fy = null;
2325
+ announceToScreenReader(`Released ${{event.subject.label}}`);
2326
+ }}
2327
+ // ===== FILTER FUNCTIONALITY =====
2328
+
2329
+ // Get complexity grade from complexity value
2330
+ const getComplexityGrade = (complexity) => {{
2331
+ if (complexity <= 5) return 'A';
2332
+ if (complexity <= 10) return 'B';
2333
+ if (complexity <= 20) return 'C';
2334
+ if (complexity <= 30) return 'D';
2335
+ return 'F';
2336
+ }};
2337
+
2338
+ // Filter state
2339
+ const filterState = {{
2340
+ grades: new Set(['A', 'B', 'C', 'D', 'F']),
2341
+ smells: new Set(['none', 'info', 'warning', 'error']),
2342
+ modules: new Set(['']), // Empty string means "All Modules"
2343
+ search: ''
2344
+ }};
2345
+
2346
+ // Apply filters to nodes and edges (with debounce)
2347
+ const applyFilters = () => {{
2348
+ // Debounce for performance
2349
+ if (filterDebounceTimer) clearTimeout(filterDebounceTimer);
2350
+ filterDebounceTimer = setTimeout(() => {{
2351
+ const visibleNodeIds = new Set();
2352
+
2353
+ node.each((d, i, nodes) => {{
2354
+ const nodeGrade = getComplexityGrade(d.complexity);
2355
+ const nodeSmell = d.smell_severity || 'none';
2356
+ const nodeModule = d.module || '';
2357
+
2358
+ // Check if node passes all filters
2359
+ const passesGrade = filterState.grades.has(nodeGrade);
2360
+ const passesSmell = filterState.smells.has(nodeSmell);
2361
+ const passesModule = filterState.modules.has('') || filterState.modules.has(nodeModule);
2362
+ const passesSearch = !filterState.search ||
2363
+ d.id.toLowerCase().includes(filterState.search.toLowerCase()) ||
2364
+ d.label.toLowerCase().includes(filterState.search.toLowerCase());
2365
+
2366
+ const isVisible = passesGrade && passesSmell && passesModule && passesSearch;
2367
+
2368
+ // Apply filtering class
2369
+ d3.select(nodes[i])
2370
+ .classed("node-filtered", !isVisible)
2371
+ .classed("node-search-highlight", passesSearch && filterState.search.length > 0);
2372
+
2373
+ if (isVisible) {{
2374
+ visibleNodeIds.add(d.id);
2375
+ }}
2376
+ }});
2377
+
2378
+ // Filter edges (only show if both source and target are visible)
2379
+ link.classed("link-filtered", l =>
2380
+ !visibleNodeIds.has(l.source.id) || !visibleNodeIds.has(l.target.id)
2381
+ );
2382
+
2383
+ // Update hulls to only include visible nodes
2384
+ updateHulls();
2385
+
2386
+ // Screen reader announcement
2387
+ const visibleCount = visibleNodeIds.size;
2388
+ const totalCount = graphData.nodes.length;
2389
+ announceToScreenReader(`Showing ${{visibleCount}} of ${{totalCount}} files`);
2390
+ }}, FILTER_DEBOUNCE_MS);
2391
+ }};
2392
+
2393
+ // Update hulls to exclude filtered nodes
2394
+ const originalUpdateHulls = updateHulls;
2395
+ updateHulls = function() {{
2396
+ if (!graphData.modules || graphData.modules.length === 0) return;
2397
+
2398
+ const hullData = graphData.modules.map(module => {{
2399
+ const moduleNodes = graphData.nodes.filter(n =>
2400
+ module.node_ids.includes(n.id) &&
2401
+ !d3.select(`[data-node-id="${{n.id}}"]`).classed("node-filtered")
2402
+ );
2403
+ if (moduleNodes.length < 2) return null;
2404
+
2405
+ const points = moduleNodes.map(n => [n.x, n.y]);
2406
+ const hull = d3.polygonHull(points);
2407
+
2408
+ return hull ? {{ points: hull, color: module.color, name: module.name }} : null;
2409
+ }}).filter(h => h !== null);
2410
+
2411
+ hullGroup.selectAll("path")
2412
+ .data(hullData)
2413
+ .join("path")
2414
+ .attr("class", "module-hull")
2415
+ .attr("d", d => {{
2416
+ const centroid = d3.polygonCentroid(d.points);
2417
+ const paddedPoints = d.points.map(p => {{
2418
+ const dx = p[0] - centroid[0];
2419
+ const dy = p[1] - centroid[1];
2420
+ const dist = Math.sqrt(dx * dx + dy * dy);
2421
+ const padding = 30;
2422
+ return [
2423
+ p[0] + (dx / dist) * padding,
2424
+ p[1] + (dy / dist) * padding
2425
+ ];
2426
+ }});
2427
+ return "M" + paddedPoints.join("L") + "Z";
2428
+ }})
2429
+ .attr("fill", d => d.color)
2430
+ .attr("stroke", d => d.color);
2431
+ }};
2432
+
2433
+ // Add data-node-id attribute to nodes for filtering
2434
+ node.attr("data-node-id", d => d.id);
2435
+
2436
+ // Setup filter event listeners
2437
+ document.querySelectorAll('[id^="filter-grade-"]').forEach(checkbox => {{
2438
+ checkbox.addEventListener('change', (e) => {{
2439
+ const grade = e.target.value;
2440
+ if (e.target.checked) {{
2441
+ filterState.grades.add(grade);
2442
+ }} else {{
2443
+ filterState.grades.delete(grade);
2444
+ }}
2445
+ applyFilters();
2446
+ }});
2447
+ }});
2448
+
2449
+ document.querySelectorAll('[id^="filter-smell-"]').forEach(checkbox => {{
2450
+ checkbox.addEventListener('change', (e) => {{
2451
+ const smell = e.target.value;
2452
+ if (e.target.checked) {{
2453
+ filterState.smells.add(smell);
2454
+ }} else {{
2455
+ filterState.smells.delete(smell);
2456
+ }}
2457
+ applyFilters();
2458
+ }});
2459
+ }});
2460
+
2461
+ const moduleSelect = document.getElementById('filter-module');
2462
+ if (moduleSelect) {{
2463
+ moduleSelect.addEventListener('change', (e) => {{
2464
+ filterState.modules = new Set(
2465
+ Array.from(e.target.selectedOptions).map(opt => opt.value)
2466
+ );
2467
+ applyFilters();
2468
+ }});
2469
+ }}
2470
+
2471
+ const searchInput = document.getElementById('filter-search');
2472
+ if (searchInput) {{
2473
+ searchInput.addEventListener('input', (e) => {{
2474
+ filterState.search = e.target.value;
2475
+ applyFilters();
2476
+ }});
2477
+ }}
2478
+
2479
+ // Store references for keyboard navigation and filter state
2480
+ window.graphState = {{
2481
+ node,
2482
+ graphData,
2483
+ currentFocusIndex: -1,
2484
+ visibleNodes: [],
2485
+ filterState: filterState
2486
+ }};
2487
+
2488
+ // Load filters from URL if present
2489
+ loadFiltersFromURL();
2490
+
2491
+ // Announce graph loaded
2492
+ announceToScreenReader(`Dependency graph loaded with ${{nodeCount}} files`);
2493
+
2494
+ // ===== KEYBOARD NAVIGATION =====
2495
+
2496
+ // Get visible (non-filtered) nodes
2497
+ const getVisibleNodes = () => {{
2498
+ const visible = [];
2499
+ node.each((d, i, nodes) => {{
2500
+ if (!d3.select(nodes[i]).classed("node-filtered")) {{
2501
+ visible.push({{ data: d, element: nodes[i], index: i }});
2502
+ }}
2503
+ }});
2504
+ return visible;
2505
+ }};
2506
+
2507
+ // Focus on a specific node
2508
+ const focusNode = (nodeIndex) => {{
2509
+ const visibleNodes = getVisibleNodes();
2510
+ if (visibleNodes.length === 0) return;
2511
+
2512
+ // Clamp index
2513
+ nodeIndex = Math.max(0, Math.min(nodeIndex, visibleNodes.length - 1));
2514
+ window.graphState.currentFocusIndex = nodeIndex;
2515
+
2516
+ // Remove previous focus
2517
+ node.classed("node-focused", false);
2518
+
2519
+ // Add focus to current node
2520
+ const focusedNode = visibleNodes[nodeIndex];
2521
+ d3.select(focusedNode.element).classed("node-focused", true);
2522
+
2523
+ // Scroll node into view (center of SVG)
2524
+ const transform = d3.zoomTransform(svg.node());
2525
+ const newTransform = d3.zoomIdentity
2526
+ .translate(width / 2, height / 2)
2527
+ .scale(transform.k)
2528
+ .translate(-focusedNode.data.x, -focusedNode.data.y);
2529
+
2530
+ svg.transition()
2531
+ .duration(500)
2532
+ .call(zoom.transform, newTransform);
2533
+
2534
+ return focusedNode;
2535
+ }};
2536
+
2537
+ // Navigate to connected nodes
2538
+ const navigateToConnected = (direction) => {{
2539
+ const visibleNodes = getVisibleNodes();
2540
+ if (window.graphState.currentFocusIndex < 0 || visibleNodes.length === 0) return;
2541
+
2542
+ const currentNode = visibleNodes[window.graphState.currentFocusIndex];
2543
+ const connectedIds = new Set();
2544
+
2545
+ graphData.links.forEach(link => {{
2546
+ if (link.source.id === currentNode.data.id) {{
2547
+ connectedIds.add(link.target.id);
2548
+ }}
2549
+ if (link.target.id === currentNode.data.id) {{
2550
+ connectedIds.add(link.source.id);
2551
+ }}
2552
+ }});
2553
+
2554
+ // Find next connected visible node
2555
+ const connectedVisible = visibleNodes.filter(n => connectedIds.has(n.data.id));
2556
+ if (connectedVisible.length > 0) {{
2557
+ const nextIndex = visibleNodes.indexOf(connectedVisible[0]);
2558
+ focusNode(nextIndex);
2559
+ }}
2560
+ }};
2561
+
2562
+ // Global keyboard handler
2563
+ document.addEventListener('keydown', (e) => {{
2564
+ // Only handle if not in an input field
2565
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') {{
2566
+ return;
2567
+ }}
2568
+
2569
+ const visibleNodes = getVisibleNodes();
2570
+
2571
+ switch(e.key) {{
2572
+ case 'Tab':
2573
+ e.preventDefault();
2574
+ // Tab through visible nodes
2575
+ const nextIndex = (window.graphState.currentFocusIndex + 1) % visibleNodes.length;
2576
+ focusNode(nextIndex);
2577
+ break;
2578
+
2579
+ case 'Enter':
2580
+ case ' ':
2581
+ e.preventDefault();
2582
+ // Select focused node (show detail panel)
2583
+ if (window.graphState.currentFocusIndex >= 0 && visibleNodes.length > 0) {{
2584
+ const focusedNode = visibleNodes[window.graphState.currentFocusIndex];
2585
+ showNodeDetails(focusedNode.data, graphData);
2586
+ }}
2587
+ break;
2588
+
2589
+ case 'Escape':
2590
+ e.preventDefault();
2591
+ // Clear selection and focus
2592
+ node.classed("node-focused", false);
2593
+ window.graphState.currentFocusIndex = -1;
2594
+ const detailPanel = document.getElementById('node-detail-panel');
2595
+ if (detailPanel) {{
2596
+ detailPanel.classList.add('hidden');
2597
+ }}
2598
+ break;
2599
+
2600
+ case 'ArrowRight':
2601
+ case 'ArrowDown':
2602
+ e.preventDefault();
2603
+ // Next node or connected node
2604
+ if (e.shiftKey) {{
2605
+ navigateToConnected('next');
2606
+ }} else {{
2607
+ focusNode(window.graphState.currentFocusIndex + 1);
2608
+ }}
2609
+ break;
2610
+
2611
+ case 'ArrowLeft':
2612
+ case 'ArrowUp':
2613
+ e.preventDefault();
2614
+ // Previous node
2615
+ focusNode(window.graphState.currentFocusIndex - 1);
2616
+ break;
2617
+ }}
2618
+ }});
2619
+ }})();
2620
+
2621
+ // Helper function to reset all filters
2622
+ function resetFilters() {{
2623
+ // Reset checkboxes
2624
+ document.querySelectorAll('[id^="filter-grade-"]').forEach(cb => cb.checked = true);
2625
+ document.querySelectorAll('[id^="filter-smell-"]').forEach(cb => cb.checked = true);
2626
+
2627
+ // Reset module select
2628
+ const moduleSelect = document.getElementById('filter-module');
2629
+ if (moduleSelect) {{
2630
+ Array.from(moduleSelect.options).forEach(opt => {{
2631
+ opt.selected = opt.value === '';
2632
+ }});
2633
+ }}
2634
+
2635
+ // Reset search
2636
+ const searchInput = document.getElementById('filter-search');
2637
+ if (searchInput) {{
2638
+ searchInput.value = '';
2639
+ }}
2640
+
2641
+ // Trigger change events to apply filters
2642
+ if (window.graphState && window.graphState.node) {{
2643
+ const event = new Event('change');
2644
+ document.querySelector('[id^="filter-grade-"]')?.dispatchEvent(event);
2645
+ }}
2646
+ }}
2647
+
2648
+ // Helper function to clear node selection
2649
+ function clearSelection() {{
2650
+ const detailPanel = document.getElementById('node-detail-panel');
2651
+ if (detailPanel) {{
2652
+ detailPanel.classList.add('hidden');
2653
+ }}
2654
+
2655
+ if (window.graphState && window.graphState.node) {{
2656
+ window.graphState.node.classed("node-focused", false);
2657
+ window.graphState.currentFocusIndex = -1;
2658
+ }}
2659
+ }}
2660
+
2661
+ // Helper function to show node details in detail panel with focus trap
2662
+ function showNodeDetails(nodeData, graphData) {{
2663
+ const detailPanel = document.getElementById('node-detail-panel');
2664
+ const detailContent = document.getElementById('node-detail-content');
2665
+
2666
+ // Store last focused element for restoration
2667
+ if (!window.lastFocusedElement) {{
2668
+ window.lastFocusedElement = document.activeElement;
2669
+ }}
2670
+
2671
+ // Get complexity grade
2672
+ const getGrade = (complexity) => {{
2673
+ if (complexity <= 5) return {{ grade: 'A', class: 'grade-a' }};
2674
+ if (complexity <= 10) return {{ grade: 'B', class: 'grade-b' }};
2675
+ if (complexity <= 20) return {{ grade: 'C', class: 'grade-c' }};
2676
+ if (complexity <= 30) return {{ grade: 'D', class: 'grade-d' }};
2677
+ return {{ grade: 'F', class: 'grade-f' }};
2678
+ }};
2679
+
2680
+ const complexityGrade = getGrade(nodeData.complexity);
2681
+ const cyclomaticGrade = getGrade(nodeData.cyclomatic_complexity);
2682
+
2683
+ // Find incoming edges (imported-by)
2684
+ const incomingEdges = graphData.links.filter(link => link.target.id === nodeData.id);
2685
+ const importedBy = incomingEdges.map(link => link.source.id || link.source);
2686
+
2687
+ // Outgoing edges are in nodeData.imports
2688
+ const imports = nodeData.imports || [];
2689
+
2690
+ // Render smells
2691
+ let smellsHtml = '<p style="color: var(--gray-600); font-size: 0.875rem;">No smells detected</p>';
2692
+ if (nodeData.smells && nodeData.smells.length > 0) {{
2693
+ const smellItems = nodeData.smells.map(smell => {{
2694
+ const badgeClass = smell.severity === 'error' ? 'smell-badge-error' :
2695
+ smell.severity === 'warning' ? 'smell-badge-warning' :
2696
+ 'smell-badge-info';
2697
+ return `
2698
+ <div class="panel-list-item">
2699
+ <span class="smell-badge-small ${{badgeClass}}">${{smell.severity}}</span>
2700
+ <strong>${{smell.type}}</strong> (line ${{smell.line}})<br/>
2701
+ <span style="font-size: 0.75rem; color: var(--gray-600);">${{smell.message}}</span>
2702
+ </div>
2703
+ `;
2704
+ }}).join('');
2705
+ smellsHtml = `<ul class="panel-list">${{smellItems}}</ul>`;
2706
+ }}
2707
+
2708
+ // Render imports
2709
+ let importsHtml = '<p style="color: var(--gray-600); font-size: 0.875rem;">No imports</p>';
2710
+ if (imports.length > 0) {{
2711
+ const importItems = imports.map(imp =>
2712
+ `<li class="panel-list-item" style="font-family: 'Monaco', 'Courier New', monospace; font-size: 0.75rem;">${{imp}}</li>`
2713
+ ).join('');
2714
+ importsHtml = `<ul class="panel-list">${{importItems}}</ul>`;
2715
+ }}
2716
+
2717
+ // Render imported-by
2718
+ let importedByHtml = '<p style="color: var(--gray-600); font-size: 0.875rem;">Not imported by any files</p>';
2719
+ if (importedBy.length > 0) {{
2720
+ const importedByItems = importedBy.map(file =>
2721
+ `<li class="panel-list-item" style="font-family: 'Monaco', 'Courier New', monospace; font-size: 0.75rem;">${{file}}</li>`
2722
+ ).join('');
2723
+ importedByHtml = `<ul class="panel-list">${{importedByItems}}</ul>`;
2724
+ }}
2725
+
2726
+ // Build detail HTML
2727
+ detailContent.innerHTML = `
2728
+ <div class="detail-header">
2729
+ <div class="detail-header-title">${{nodeData.label}}</div>
2730
+ <div class="detail-header-path">${{nodeData.id}}</div>
2731
+ </div>
2732
+
2733
+ <div class="detail-section">
2734
+ <div class="detail-section-title">Metrics</div>
2735
+ <div class="panel-stat">
2736
+ <span class="panel-stat-label">Cognitive Complexity</span>
2737
+ <span class="panel-stat-value">
2738
+ ${{nodeData.complexity}}
2739
+ <span class="grade-badge ${{complexityGrade.class}}">${{complexityGrade.grade}}</span>
2740
+ </span>
2741
+ </div>
2742
+ <div class="panel-stat">
2743
+ <span class="panel-stat-label">Cyclomatic Complexity</span>
2744
+ <span class="panel-stat-value">
2745
+ ${{nodeData.cyclomatic_complexity}}
2746
+ <span class="grade-badge ${{cyclomaticGrade.class}}">${{cyclomaticGrade.grade}}</span>
2747
+ </span>
2748
+ </div>
2749
+ <div class="panel-stat">
2750
+ <span class="panel-stat-label">Lines of Code</span>
2751
+ <span class="panel-stat-value">${{nodeData.loc}}</span>
2752
+ </div>
2753
+ <div class="panel-stat">
2754
+ <span class="panel-stat-label">Functions</span>
2755
+ <span class="panel-stat-value">${{nodeData.function_count}}</span>
2756
+ </div>
2757
+ <div class="panel-stat">
2758
+ <span class="panel-stat-label">Classes</span>
2759
+ <span class="panel-stat-value">${{nodeData.class_count}}</span>
2760
+ </div>
2761
+ </div>
2762
+
2763
+ <div class="detail-section">
2764
+ <div class="detail-section-title">Code Smells (${{nodeData.smell_count}})</div>
2765
+ ${{smellsHtml}}
2766
+ </div>
2767
+
2768
+ <div class="detail-section">
2769
+ <div class="detail-section-title">Imports (${{imports.length}})</div>
2770
+ ${{importsHtml}}
2771
+ </div>
2772
+
2773
+ <div class="detail-section">
2774
+ <div class="detail-section-title">Imported By (${{importedBy.length}})</div>
2775
+ ${{importedByHtml}}
2776
+ </div>
2777
+ `;
2778
+
2779
+ // Show the panel
2780
+ detailPanel.classList.remove('hidden');
2781
+ detailPanel.classList.add('focus-trapped');
2782
+
2783
+ // Focus first focusable element in panel
2784
+ setTimeout(() => {{
2785
+ const firstFocusable = detailPanel.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
2786
+ if (firstFocusable) {{
2787
+ firstFocusable.focus();
2788
+ }}
2789
+ }}, 100);
2790
+
2791
+ // Screen reader announcement
2792
+ announceToScreenReader(`Showing details for ${{nodeData.label}}`);
2793
+
2794
+ // Add close button handler for focus restoration
2795
+ const closeButton = detailPanel.querySelector('.detail-close-button');
2796
+ if (closeButton) {{
2797
+ closeButton.addEventListener('click', () => {{
2798
+ detailPanel.classList.add('hidden');
2799
+ detailPanel.classList.remove('focus-trapped');
2800
+ if (window.lastFocusedElement) {{
2801
+ window.lastFocusedElement.focus();
2802
+ window.lastFocusedElement = null;
2803
+ }}
2804
+ }}, {{ once: true }});
2805
+ }}
2806
+ }}
2807
+
2808
+ // Helper function to toggle legend
2809
+ function toggleLegend() {{
2810
+ const legendContent = document.getElementById('legend-content');
2811
+ const toggleIcon = document.getElementById('legend-toggle-icon');
2812
+
2813
+ if (legendContent.classList.contains('collapsed')) {{
2814
+ legendContent.classList.remove('collapsed');
2815
+ toggleIcon.textContent = '▼';
2816
+ announceToScreenReader('Legend expanded');
2817
+ }} else {{
2818
+ legendContent.classList.add('collapsed');
2819
+ toggleIcon.textContent = '▶';
2820
+ announceToScreenReader('Legend collapsed');
2821
+ }}
2822
+ }}
2823
+
2824
+ // Helper function to toggle stats panel
2825
+ function toggleStatsPanel() {{
2826
+ const statsPanel = document.getElementById('stats-panel-container');
2827
+ const toggleButton = document.getElementById('stats-toggle');
2828
+
2829
+ if (!statsPanel || !toggleButton) return;
2830
+
2831
+ const isVisible = statsPanel.classList.toggle('visible');
2832
+ toggleButton.classList.toggle('active', isVisible);
2833
+ toggleButton.setAttribute('aria-expanded', isVisible);
2834
+ statsPanel.setAttribute('aria-hidden', !isVisible);
2835
+
2836
+ // Update button text
2837
+ toggleButton.textContent = isVisible ? '✕ Hide Stats' : '📊 Show Stats';
2838
+
2839
+ // Announce to screen readers
2840
+ announceToScreenReader(isVisible ? 'Statistics panel opened' : 'Statistics panel closed');
2841
+ }}
2842
+
2843
+ // Initialize complexity chart
2844
+ const ctx = document.getElementById('complexityChart');
2845
+ if (ctx) {{
2846
+ new Chart(ctx, {{
2847
+ type: 'doughnut',
2848
+ data: {{
2849
+ labels: ['A (Excellent)', 'B (Good)', 'C (Acceptable)', 'D (Needs Work)', 'F (Refactor)'],
2850
+ datasets: [{{
2851
+ data: [{grades["A"]}, {grades["B"]}, {grades["C"]}, {grades["D"]}, {grades["F"]}],
2852
+ backgroundColor: ['#22c55e', '#3b82f6', '#f59e0b', '#f97316', '#ef4444'],
2853
+ }}]
2854
+ }},
2855
+ options: {{
2856
+ responsive: true,
2857
+ maintainAspectRatio: false,
2858
+ plugins: {{
2859
+ legend: {{ position: 'right' }}
2860
+ }}
2861
+ }}
2862
+ }});
2863
+ }}
2864
+
2865
+ // Initialize syntax highlighting
2866
+ hljs.highlightAll();
2867
+ </script>"""
2868
+
2869
+ @staticmethod
2870
+ def _get_grade(complexity: int) -> str:
2871
+ """Get letter grade from complexity score.
2872
+
2873
+ Uses standard complexity thresholds:
2874
+ - A: 0-5 (Excellent)
2875
+ - B: 6-10 (Good)
2876
+ - C: 11-20 (Acceptable)
2877
+ - D: 21-30 (Needs work)
2878
+ - F: 31+ (Refactor required)
2879
+
2880
+ Args:
2881
+ complexity: Cognitive complexity score
2882
+
2883
+ Returns:
2884
+ Letter grade (A, B, C, D, or F)
2885
+ """
2886
+ if complexity <= 5:
2887
+ return "A"
2888
+ elif complexity <= 10:
2889
+ return "B"
2890
+ elif complexity <= 20:
2891
+ return "C"
2892
+ elif complexity <= 30:
2893
+ return "D"
2894
+ else:
2895
+ return "F"