mcp-vector-search 1.0.3__py3-none-any.whl → 1.1.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_vector_search/__init__.py +3 -3
- mcp_vector_search/analysis/__init__.py +48 -1
- mcp_vector_search/analysis/baseline/__init__.py +68 -0
- mcp_vector_search/analysis/baseline/comparator.py +462 -0
- mcp_vector_search/analysis/baseline/manager.py +621 -0
- mcp_vector_search/analysis/collectors/__init__.py +35 -0
- mcp_vector_search/analysis/collectors/cohesion.py +463 -0
- mcp_vector_search/analysis/collectors/coupling.py +1162 -0
- mcp_vector_search/analysis/collectors/halstead.py +514 -0
- mcp_vector_search/analysis/collectors/smells.py +325 -0
- mcp_vector_search/analysis/debt.py +516 -0
- mcp_vector_search/analysis/interpretation.py +685 -0
- mcp_vector_search/analysis/metrics.py +74 -1
- mcp_vector_search/analysis/reporters/__init__.py +3 -1
- mcp_vector_search/analysis/reporters/console.py +424 -0
- mcp_vector_search/analysis/reporters/markdown.py +480 -0
- mcp_vector_search/analysis/reporters/sarif.py +377 -0
- mcp_vector_search/analysis/storage/__init__.py +93 -0
- mcp_vector_search/analysis/storage/metrics_store.py +762 -0
- mcp_vector_search/analysis/storage/schema.py +245 -0
- mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
- mcp_vector_search/analysis/trends.py +308 -0
- mcp_vector_search/analysis/visualizer/__init__.py +90 -0
- mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
- mcp_vector_search/analysis/visualizer/exporter.py +484 -0
- mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
- mcp_vector_search/analysis/visualizer/schemas.py +525 -0
- mcp_vector_search/cli/commands/analyze.py +665 -11
- mcp_vector_search/cli/commands/chat.py +193 -0
- mcp_vector_search/cli/commands/index.py +600 -2
- mcp_vector_search/cli/commands/index_background.py +467 -0
- mcp_vector_search/cli/commands/search.py +194 -1
- mcp_vector_search/cli/commands/setup.py +64 -13
- mcp_vector_search/cli/commands/status.py +302 -3
- mcp_vector_search/cli/commands/visualize/cli.py +26 -10
- mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +8 -4
- mcp_vector_search/cli/commands/visualize/graph_builder.py +167 -234
- mcp_vector_search/cli/commands/visualize/server.py +304 -15
- mcp_vector_search/cli/commands/visualize/templates/base.py +60 -6
- mcp_vector_search/cli/commands/visualize/templates/scripts.py +2100 -65
- mcp_vector_search/cli/commands/visualize/templates/styles.py +1297 -88
- mcp_vector_search/cli/didyoumean.py +5 -0
- mcp_vector_search/cli/main.py +16 -5
- mcp_vector_search/cli/output.py +134 -5
- mcp_vector_search/config/thresholds.py +89 -1
- mcp_vector_search/core/__init__.py +16 -0
- mcp_vector_search/core/database.py +39 -2
- mcp_vector_search/core/embeddings.py +24 -0
- mcp_vector_search/core/git.py +380 -0
- mcp_vector_search/core/indexer.py +445 -84
- mcp_vector_search/core/llm_client.py +9 -4
- mcp_vector_search/core/models.py +88 -1
- mcp_vector_search/core/relationships.py +473 -0
- mcp_vector_search/core/search.py +1 -1
- mcp_vector_search/mcp/server.py +795 -4
- mcp_vector_search/parsers/python.py +285 -5
- mcp_vector_search/utils/gitignore.py +0 -3
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +3 -2
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/RECORD +62 -39
- mcp_vector_search/cli/commands/visualize.py.original +0 -2536
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +0 -0
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +0 -0
- {mcp_vector_search-1.0.3.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"
|