@optave/codegraph 2.6.0 → 3.0.1

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.
package/src/viewer.js ADDED
@@ -0,0 +1,948 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Graph from 'graphology';
4
+ import louvain from 'graphology-communities-louvain';
5
+ import { isTestFile } from './queries.js';
6
+
7
+ const DEFAULT_MIN_CONFIDENCE = 0.5;
8
+
9
+ const DEFAULT_NODE_COLORS = {
10
+ function: '#4CAF50',
11
+ method: '#66BB6A',
12
+ class: '#2196F3',
13
+ interface: '#42A5F5',
14
+ type: '#7E57C2',
15
+ struct: '#FF7043',
16
+ enum: '#FFA726',
17
+ trait: '#26A69A',
18
+ record: '#EC407A',
19
+ module: '#78909C',
20
+ file: '#90A4AE',
21
+ };
22
+
23
+ const DEFAULT_ROLE_COLORS = {
24
+ entry: '#e8f5e9',
25
+ core: '#e3f2fd',
26
+ utility: '#f5f5f5',
27
+ dead: '#ffebee',
28
+ leaf: '#fffde7',
29
+ };
30
+
31
+ const COMMUNITY_COLORS = [
32
+ '#4CAF50',
33
+ '#2196F3',
34
+ '#FF9800',
35
+ '#9C27B0',
36
+ '#F44336',
37
+ '#00BCD4',
38
+ '#CDDC39',
39
+ '#E91E63',
40
+ '#3F51B5',
41
+ '#FF5722',
42
+ '#009688',
43
+ '#795548',
44
+ ];
45
+
46
+ const DEFAULT_CONFIG = {
47
+ layout: { algorithm: 'hierarchical', direction: 'LR' },
48
+ physics: { enabled: true, nodeDistance: 150 },
49
+ nodeColors: DEFAULT_NODE_COLORS,
50
+ roleColors: DEFAULT_ROLE_COLORS,
51
+ colorBy: 'kind',
52
+ edgeStyle: { color: '#666', smooth: true },
53
+ filter: { kinds: null, roles: null, files: null },
54
+ title: 'Codegraph',
55
+ seedStrategy: 'all',
56
+ seedCount: 30,
57
+ clusterBy: 'none',
58
+ sizeBy: 'uniform',
59
+ overlays: { complexity: false, risk: false },
60
+ riskThresholds: { highBlastRadius: 10, lowMI: 40 },
61
+ };
62
+
63
+ /**
64
+ * Load .plotDotCfg or .plotDotCfg.json from given directory.
65
+ * Returns merged config with defaults.
66
+ */
67
+ export function loadPlotConfig(dir) {
68
+ for (const name of ['.plotDotCfg', '.plotDotCfg.json']) {
69
+ const p = path.join(dir, name);
70
+ if (fs.existsSync(p)) {
71
+ try {
72
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
73
+ return {
74
+ ...DEFAULT_CONFIG,
75
+ ...raw,
76
+ layout: { ...DEFAULT_CONFIG.layout, ...(raw.layout || {}) },
77
+ physics: { ...DEFAULT_CONFIG.physics, ...(raw.physics || {}) },
78
+ nodeColors: {
79
+ ...DEFAULT_CONFIG.nodeColors,
80
+ ...(raw.nodeColors || {}),
81
+ },
82
+ roleColors: {
83
+ ...DEFAULT_CONFIG.roleColors,
84
+ ...(raw.roleColors || {}),
85
+ },
86
+ edgeStyle: {
87
+ ...DEFAULT_CONFIG.edgeStyle,
88
+ ...(raw.edgeStyle || {}),
89
+ },
90
+ filter: { ...DEFAULT_CONFIG.filter, ...(raw.filter || {}) },
91
+ overlays: {
92
+ ...DEFAULT_CONFIG.overlays,
93
+ ...(raw.overlays || {}),
94
+ },
95
+ riskThresholds: {
96
+ ...DEFAULT_CONFIG.riskThresholds,
97
+ ...(raw.riskThresholds || {}),
98
+ },
99
+ };
100
+ } catch {
101
+ // Invalid JSON — use defaults
102
+ }
103
+ }
104
+ }
105
+ return { ...DEFAULT_CONFIG };
106
+ }
107
+
108
+ // ─── Data Preparation ─────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Prepare enriched graph data for the HTML viewer.
112
+ */
113
+ export function prepareGraphData(db, opts = {}) {
114
+ const fileLevel = opts.fileLevel !== false;
115
+ const noTests = opts.noTests || false;
116
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
117
+ const cfg = opts.config || DEFAULT_CONFIG;
118
+
119
+ return fileLevel
120
+ ? prepareFileLevelData(db, noTests, minConf, cfg)
121
+ : prepareFunctionLevelData(db, noTests, minConf, cfg);
122
+ }
123
+
124
+ function prepareFunctionLevelData(db, noTests, minConf, cfg) {
125
+ let edges = db
126
+ .prepare(
127
+ `
128
+ SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
129
+ n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
130
+ n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
131
+ n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
132
+ e.kind AS edge_kind
133
+ FROM edges e
134
+ JOIN nodes n1 ON e.source_id = n1.id
135
+ JOIN nodes n2 ON e.target_id = n2.id
136
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
137
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
138
+ AND e.kind = 'calls'
139
+ AND e.confidence >= ?
140
+ `,
141
+ )
142
+ .all(minConf);
143
+ if (noTests)
144
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
145
+
146
+ if (cfg.filter.kinds) {
147
+ const kinds = new Set(cfg.filter.kinds);
148
+ edges = edges.filter((e) => kinds.has(e.source_kind) && kinds.has(e.target_kind));
149
+ }
150
+ if (cfg.filter.files) {
151
+ const patterns = cfg.filter.files;
152
+ edges = edges.filter(
153
+ (e) =>
154
+ patterns.some((p) => e.source_file.includes(p)) &&
155
+ patterns.some((p) => e.target_file.includes(p)),
156
+ );
157
+ }
158
+
159
+ const nodeMap = new Map();
160
+ for (const e of edges) {
161
+ if (!nodeMap.has(e.source_id)) {
162
+ nodeMap.set(e.source_id, {
163
+ id: e.source_id,
164
+ name: e.source_name,
165
+ kind: e.source_kind,
166
+ file: e.source_file,
167
+ line: e.source_line,
168
+ role: e.source_role,
169
+ });
170
+ }
171
+ if (!nodeMap.has(e.target_id)) {
172
+ nodeMap.set(e.target_id, {
173
+ id: e.target_id,
174
+ name: e.target_name,
175
+ kind: e.target_kind,
176
+ file: e.target_file,
177
+ line: e.target_line,
178
+ role: e.target_role,
179
+ });
180
+ }
181
+ }
182
+
183
+ if (cfg.filter.roles) {
184
+ const roles = new Set(cfg.filter.roles);
185
+ for (const [id, n] of nodeMap) {
186
+ if (!roles.has(n.role)) nodeMap.delete(id);
187
+ }
188
+ const nodeIds = new Set(nodeMap.keys());
189
+ edges = edges.filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id));
190
+ }
191
+
192
+ // Complexity data
193
+ const complexityMap = new Map();
194
+ try {
195
+ const rows = db
196
+ .prepare(
197
+ 'SELECT node_id, cognitive, cyclomatic, max_nesting, maintainability_index FROM function_complexity',
198
+ )
199
+ .all();
200
+ for (const r of rows) {
201
+ complexityMap.set(r.node_id, {
202
+ cognitive: r.cognitive,
203
+ cyclomatic: r.cyclomatic,
204
+ maintainabilityIndex: r.maintainability_index,
205
+ });
206
+ }
207
+ } catch {
208
+ // table may not exist in old DBs
209
+ }
210
+
211
+ // Fan-in / fan-out
212
+ const fanInMap = new Map();
213
+ const fanOutMap = new Map();
214
+ const fanInRows = db
215
+ .prepare(
216
+ "SELECT target_id AS node_id, COUNT(*) AS fan_in FROM edges WHERE kind = 'calls' GROUP BY target_id",
217
+ )
218
+ .all();
219
+ for (const r of fanInRows) fanInMap.set(r.node_id, r.fan_in);
220
+
221
+ const fanOutRows = db
222
+ .prepare(
223
+ "SELECT source_id AS node_id, COUNT(*) AS fan_out FROM edges WHERE kind = 'calls' GROUP BY source_id",
224
+ )
225
+ .all();
226
+ for (const r of fanOutRows) fanOutMap.set(r.node_id, r.fan_out);
227
+
228
+ // Communities (Louvain)
229
+ const communityMap = new Map();
230
+ if (nodeMap.size > 0) {
231
+ try {
232
+ const graph = new Graph({ type: 'undirected' });
233
+ for (const [id] of nodeMap) graph.addNode(String(id));
234
+ for (const e of edges) {
235
+ const src = String(e.source_id);
236
+ const tgt = String(e.target_id);
237
+ if (src !== tgt && !graph.hasEdge(src, tgt)) graph.addEdge(src, tgt);
238
+ }
239
+ const communities = louvain(graph);
240
+ for (const [nid, cid] of Object.entries(communities)) communityMap.set(Number(nid), cid);
241
+ } catch {
242
+ // louvain can fail on disconnected graphs
243
+ }
244
+ }
245
+
246
+ // Build enriched nodes
247
+ const visNodes = [...nodeMap.values()].map((n) => {
248
+ const cx = complexityMap.get(n.id) || null;
249
+ const fanIn = fanInMap.get(n.id) || 0;
250
+ const fanOut = fanOutMap.get(n.id) || 0;
251
+ const community = communityMap.get(n.id) ?? null;
252
+ const directory = path.dirname(n.file);
253
+ const risk = [];
254
+ if (n.role === 'dead') risk.push('dead-code');
255
+ if (fanIn >= (cfg.riskThresholds?.highBlastRadius ?? 10)) risk.push('high-blast-radius');
256
+ if (cx && cx.maintainabilityIndex < (cfg.riskThresholds?.lowMI ?? 40)) risk.push('low-mi');
257
+
258
+ const color =
259
+ cfg.colorBy === 'role' && n.role
260
+ ? cfg.roleColors[n.role] || DEFAULT_ROLE_COLORS[n.role] || '#ccc'
261
+ : cfg.colorBy === 'community' && community !== null
262
+ ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length]
263
+ : cfg.nodeColors[n.kind] || DEFAULT_NODE_COLORS[n.kind] || '#ccc';
264
+
265
+ return {
266
+ id: n.id,
267
+ label: n.name,
268
+ title: `${n.file}:${n.line} (${n.kind}${n.role ? `, ${n.role}` : ''})`,
269
+ color,
270
+ kind: n.kind,
271
+ role: n.role || '',
272
+ file: n.file,
273
+ line: n.line,
274
+ community,
275
+ cognitive: cx?.cognitive ?? null,
276
+ cyclomatic: cx?.cyclomatic ?? null,
277
+ maintainabilityIndex: cx?.maintainabilityIndex ?? null,
278
+ fanIn,
279
+ fanOut,
280
+ directory,
281
+ risk,
282
+ };
283
+ });
284
+
285
+ const visEdges = edges.map((e, i) => ({
286
+ id: `e${i}`,
287
+ from: e.source_id,
288
+ to: e.target_id,
289
+ }));
290
+
291
+ // Seed strategy
292
+ let seedNodeIds;
293
+ if (cfg.seedStrategy === 'top-fanin') {
294
+ const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn);
295
+ seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id);
296
+ } else if (cfg.seedStrategy === 'entry') {
297
+ seedNodeIds = visNodes.filter((n) => n.role === 'entry').map((n) => n.id);
298
+ } else {
299
+ seedNodeIds = visNodes.map((n) => n.id);
300
+ }
301
+
302
+ return { nodes: visNodes, edges: visEdges, seedNodeIds };
303
+ }
304
+
305
+ function prepareFileLevelData(db, noTests, minConf, cfg) {
306
+ let edges = db
307
+ .prepare(
308
+ `
309
+ SELECT DISTINCT n1.file AS source, n2.file AS target
310
+ FROM edges e
311
+ JOIN nodes n1 ON e.source_id = n1.id
312
+ JOIN nodes n2 ON e.target_id = n2.id
313
+ WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
314
+ AND e.confidence >= ?
315
+ `,
316
+ )
317
+ .all(minConf);
318
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
319
+
320
+ const files = new Set();
321
+ for (const { source, target } of edges) {
322
+ files.add(source);
323
+ files.add(target);
324
+ }
325
+
326
+ const fileIds = new Map();
327
+ let idx = 0;
328
+ for (const f of files) fileIds.set(f, idx++);
329
+
330
+ // Fan-in/fan-out
331
+ const fanInCount = new Map();
332
+ const fanOutCount = new Map();
333
+ for (const { source, target } of edges) {
334
+ fanOutCount.set(source, (fanOutCount.get(source) || 0) + 1);
335
+ fanInCount.set(target, (fanInCount.get(target) || 0) + 1);
336
+ }
337
+
338
+ // Communities
339
+ const communityMap = new Map();
340
+ if (files.size > 0) {
341
+ try {
342
+ const graph = new Graph({ type: 'undirected' });
343
+ for (const f of files) graph.addNode(f);
344
+ for (const { source, target } of edges) {
345
+ if (source !== target && !graph.hasEdge(source, target)) graph.addEdge(source, target);
346
+ }
347
+ const communities = louvain(graph);
348
+ for (const [file, cid] of Object.entries(communities)) communityMap.set(file, cid);
349
+ } catch {
350
+ // ignore
351
+ }
352
+ }
353
+
354
+ const visNodes = [...files].map((f) => {
355
+ const id = fileIds.get(f);
356
+ const community = communityMap.get(f) ?? null;
357
+ const fanIn = fanInCount.get(f) || 0;
358
+ const fanOut = fanOutCount.get(f) || 0;
359
+ const directory = path.dirname(f);
360
+ const color =
361
+ cfg.colorBy === 'community' && community !== null
362
+ ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length]
363
+ : cfg.nodeColors.file || DEFAULT_NODE_COLORS.file;
364
+
365
+ return {
366
+ id,
367
+ label: path.basename(f),
368
+ title: f,
369
+ color,
370
+ kind: 'file',
371
+ role: '',
372
+ file: f,
373
+ line: 0,
374
+ community,
375
+ cognitive: null,
376
+ cyclomatic: null,
377
+ maintainabilityIndex: null,
378
+ fanIn,
379
+ fanOut,
380
+ directory,
381
+ risk: [],
382
+ };
383
+ });
384
+
385
+ const visEdges = edges.map(({ source, target }, i) => ({
386
+ id: `e${i}`,
387
+ from: fileIds.get(source),
388
+ to: fileIds.get(target),
389
+ }));
390
+
391
+ let seedNodeIds;
392
+ if (cfg.seedStrategy === 'top-fanin') {
393
+ const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn);
394
+ seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id);
395
+ } else if (cfg.seedStrategy === 'entry') {
396
+ seedNodeIds = visNodes.map((n) => n.id);
397
+ } else {
398
+ seedNodeIds = visNodes.map((n) => n.id);
399
+ }
400
+
401
+ return { nodes: visNodes, edges: visEdges, seedNodeIds };
402
+ }
403
+
404
+ // ─── HTML Generation ──────────────────────────────────────────────────
405
+
406
+ /**
407
+ * Generate a self-contained interactive HTML file with vis-network.
408
+ */
409
+ export function generatePlotHTML(db, opts = {}) {
410
+ const cfg = opts.config || DEFAULT_CONFIG;
411
+ const data = prepareGraphData(db, opts);
412
+ const layoutOpts = buildLayoutOptions(cfg);
413
+ const title = cfg.title || 'Codegraph';
414
+
415
+ // Resolve effective colorBy (overlays.complexity overrides)
416
+ const effectiveColorBy =
417
+ cfg.overlays?.complexity && cfg.colorBy === 'kind' ? 'complexity' : cfg.colorBy || 'kind';
418
+ const effectiveRisk = cfg.overlays?.risk || false;
419
+
420
+ return `<!DOCTYPE html>
421
+ <html lang="en">
422
+ <head>
423
+ <meta charset="UTF-8">
424
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
425
+ <title>${escapeHtml(title)}</title>
426
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
427
+ <style>
428
+ * { margin: 0; padding: 0; box-sizing: border-box; }
429
+ body { font-family: monospace; background: #fafafa; }
430
+ #controls { padding: 8px 12px; background: #fff; border-bottom: 1px solid #ddd; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
431
+ #controls label { font-size: 13px; }
432
+ #controls select, #controls input[type="text"] { font-size: 13px; padding: 2px 6px; }
433
+ #main { display: flex; height: calc(100vh - 44px); }
434
+ #graph { flex: 1; }
435
+ #detail { width: 320px; border-left: 1px solid #ddd; background: #fff; overflow-y: auto; display: none; padding: 12px; font-size: 13px; }
436
+ #detail h3 { margin-bottom: 6px; word-break: break-all; }
437
+ #detailClose { float: right; cursor: pointer; font-size: 18px; color: #999; line-height: 1; }
438
+ #detailClose:hover { color: #333; }
439
+ .detail-meta { margin-bottom: 4px; }
440
+ .detail-file { color: #666; margin-bottom: 10px; font-size: 12px; }
441
+ .detail-section { margin-bottom: 10px; }
442
+ .detail-section table { width: 100%; border-collapse: collapse; }
443
+ .detail-section td { padding: 2px 8px 2px 0; }
444
+ .detail-section ul { list-style: none; padding: 0; }
445
+ .detail-section li { padding: 2px 0; }
446
+ .detail-section a { color: #1976D2; text-decoration: none; cursor: pointer; }
447
+ .detail-section a:hover { text-decoration: underline; }
448
+ .badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; margin-right: 4px; }
449
+ .kind-badge { background: #E3F2FD; color: #1565C0; }
450
+ .role-badge { background: #E8F5E9; color: #2E7D32; }
451
+ .risk-badge { background: #FFEBEE; color: #C62828; }
452
+ #legend { position: absolute; bottom: 12px; right: 12px; background: rgba(255,255,255,0.95); border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; font-size: 12px; max-height: 300px; overflow-y: auto; }
453
+ #legend div { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
454
+ #legend span.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; flex-shrink: 0; }
455
+ </style>
456
+ </head>
457
+ <body>
458
+ <div id="controls">
459
+ <label>Layout:
460
+ <select id="layoutSelect">
461
+ <option value="hierarchical"${cfg.layout.algorithm === 'hierarchical' ? ' selected' : ''}>Hierarchical</option>
462
+ <option value="force"${cfg.layout.algorithm === 'force' ? ' selected' : ''}>Force</option>
463
+ <option value="radial"${cfg.layout.algorithm === 'radial' ? ' selected' : ''}>Radial</option>
464
+ </select>
465
+ </label>
466
+ <label>Physics: <input type="checkbox" id="physicsToggle"${cfg.physics.enabled ? ' checked' : ''}></label>
467
+ <label>Search: <input type="text" id="searchInput" placeholder="Filter nodes..."></label>
468
+ <label>Color by:
469
+ <select id="colorBySelect">
470
+ <option value="kind"${effectiveColorBy === 'kind' ? ' selected' : ''}>Kind</option>
471
+ <option value="role"${effectiveColorBy === 'role' ? ' selected' : ''}>Role</option>
472
+ <option value="community"${effectiveColorBy === 'community' ? ' selected' : ''}>Community</option>
473
+ <option value="complexity"${effectiveColorBy === 'complexity' ? ' selected' : ''}>Complexity</option>
474
+ </select>
475
+ </label>
476
+ <label>Size by:
477
+ <select id="sizeBySelect">
478
+ <option value="uniform"${(cfg.sizeBy || 'uniform') === 'uniform' ? ' selected' : ''}>Uniform</option>
479
+ <option value="fan-in"${cfg.sizeBy === 'fan-in' ? ' selected' : ''}>Fan-in</option>
480
+ <option value="fan-out"${cfg.sizeBy === 'fan-out' ? ' selected' : ''}>Fan-out</option>
481
+ <option value="complexity"${cfg.sizeBy === 'complexity' ? ' selected' : ''}>Complexity</option>
482
+ </select>
483
+ </label>
484
+ <label>Cluster by:
485
+ <select id="clusterBySelect">
486
+ <option value="none"${(cfg.clusterBy || 'none') === 'none' ? ' selected' : ''}>None</option>
487
+ <option value="community"${cfg.clusterBy === 'community' ? ' selected' : ''}>Community</option>
488
+ <option value="directory"${cfg.clusterBy === 'directory' ? ' selected' : ''}>Directory</option>
489
+ </select>
490
+ </label>
491
+ <label>Risk: <input type="checkbox" id="riskToggle"${effectiveRisk ? ' checked' : ''}></label>
492
+ </div>
493
+ <div id="main">
494
+ <div id="graph"></div>
495
+ <div id="detail">
496
+ <span id="detailClose">&times;</span>
497
+ <div id="detailContent"></div>
498
+ </div>
499
+ </div>
500
+ <div id="legend"></div>
501
+ <script>
502
+ /* ── Data ──────────────────────────────────────────────────────────── */
503
+ var allNodes = ${JSON.stringify(data.nodes)};
504
+ var allEdges = ${JSON.stringify(data.edges)};
505
+ var seedNodeIds = ${JSON.stringify(data.seedNodeIds)};
506
+ var nodeColorMap = ${JSON.stringify(cfg.nodeColors || DEFAULT_NODE_COLORS)};
507
+ var roleColorMap = ${JSON.stringify(cfg.roleColors || DEFAULT_ROLE_COLORS)};
508
+ var communityColors = ${JSON.stringify(COMMUNITY_COLORS)};
509
+
510
+ /* ── Lookups ───────────────────────────────────────────────────────── */
511
+ var nodeById = {};
512
+ allNodes.forEach(function(n) { nodeById[n.id] = n; });
513
+ var adjIndex = {};
514
+ allNodes.forEach(function(n) { adjIndex[n.id] = { callers: [], callees: [] }; });
515
+ allEdges.forEach(function(e) {
516
+ if (adjIndex[e.from]) adjIndex[e.from].callees.push(e.to);
517
+ if (adjIndex[e.to]) adjIndex[e.to].callers.push(e.from);
518
+ });
519
+
520
+ /* ── State ─────────────────────────────────────────────────────────── */
521
+ var seedSet = new Set(seedNodeIds);
522
+ var visibleNodeIds = new Set(seedNodeIds);
523
+ var expandedNodes = new Set();
524
+ var drillDownActive = ${JSON.stringify((cfg.seedStrategy || 'all') !== 'all')};
525
+
526
+ /* ── Helpers ───────────────────────────────────────────────────────── */
527
+ function escHtml(s) {
528
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
529
+ }
530
+
531
+ /* ── vis-network init ──────────────────────────────────────────────── */
532
+ function getVisibleNodes() {
533
+ return allNodes.filter(function(n) { return visibleNodeIds.has(n.id); });
534
+ }
535
+ function getVisibleEdges() {
536
+ return allEdges.filter(function(e) { return visibleNodeIds.has(e.from) && visibleNodeIds.has(e.to); });
537
+ }
538
+
539
+ var nodes = new vis.DataSet(getVisibleNodes());
540
+ var edges = new vis.DataSet(getVisibleEdges());
541
+ var container = document.getElementById('graph');
542
+ var options = ${JSON.stringify(layoutOpts, null, 2)};
543
+ var network = new vis.Network(container, { nodes: nodes, edges: edges }, options);
544
+
545
+ /* ── Appearance ────────────────────────────────────────────────────── */
546
+ function refreshNodeAppearance() {
547
+ var colorBy = document.getElementById('colorBySelect').value;
548
+ var sizeBy = document.getElementById('sizeBySelect').value;
549
+ var riskEnabled = document.getElementById('riskToggle').checked;
550
+ var updates = [];
551
+
552
+ allNodes.forEach(function(n) {
553
+ if (!visibleNodeIds.has(n.id)) return;
554
+ var update = { id: n.id };
555
+
556
+ // Background color
557
+ var bg;
558
+ if (colorBy === 'role') {
559
+ bg = n.role ? (roleColorMap[n.role] || nodeColorMap[n.kind] || '#ccc') : (nodeColorMap[n.kind] || '#ccc');
560
+ } else if (colorBy === 'community') {
561
+ bg = n.community !== null ? communityColors[n.community % communityColors.length] : '#ccc';
562
+ } else {
563
+ bg = nodeColorMap[n.kind] || '#ccc';
564
+ }
565
+
566
+ var borderColor = '#888';
567
+ var borderWidth = 1;
568
+ var borderDashes = false;
569
+ var shadow = false;
570
+
571
+ // Complexity border (when colorBy is 'complexity')
572
+ if (colorBy === 'complexity' && n.maintainabilityIndex !== null) {
573
+ var mi = n.maintainabilityIndex;
574
+ if (mi >= 80) { borderColor = '#4CAF50'; borderWidth = 2; }
575
+ else if (mi >= 65) { borderColor = '#FFC107'; borderWidth = 3; }
576
+ else if (mi >= 40) { borderColor = '#FF9800'; borderWidth = 3; }
577
+ else { borderColor = '#F44336'; borderWidth = 4; }
578
+ }
579
+
580
+ // Risk overlay (overrides border when active)
581
+ if (riskEnabled && n.risk && n.risk.length > 0) {
582
+ if (n.risk.indexOf('dead-code') >= 0) {
583
+ borderColor = '#F44336'; borderDashes = [5, 5]; borderWidth = 3;
584
+ }
585
+ if (n.risk.indexOf('high-blast-radius') >= 0) {
586
+ borderColor = '#FF9800'; shadow = true; borderWidth = 3;
587
+ }
588
+ if (n.risk.indexOf('low-mi') >= 0) {
589
+ borderColor = '#FF9800'; borderWidth = 3;
590
+ }
591
+ }
592
+
593
+ update.color = { background: bg, border: borderColor };
594
+ update.borderWidth = borderWidth;
595
+ update.borderDashes = borderDashes;
596
+ update.shadow = shadow;
597
+
598
+ // Size
599
+ if (sizeBy === 'fan-in') {
600
+ update.size = 15 + Math.min(n.fanIn || 0, 30) * 2;
601
+ update.shape = 'dot';
602
+ } else if (sizeBy === 'fan-out') {
603
+ update.size = 15 + Math.min(n.fanOut || 0, 30) * 2;
604
+ update.shape = 'dot';
605
+ } else if (sizeBy === 'complexity') {
606
+ update.size = 15 + Math.min(n.cyclomatic || 0, 20) * 3;
607
+ update.shape = 'dot';
608
+ } else {
609
+ update.shape = 'box';
610
+ }
611
+
612
+ updates.push(update);
613
+ });
614
+
615
+ nodes.update(updates);
616
+ }
617
+
618
+ /* ── Clustering ────────────────────────────────────────────────────── */
619
+ function applyClusterBy(mode) {
620
+ // Open all existing clusters first
621
+ var ids = nodes.getIds();
622
+ for (var i = 0; i < ids.length; i++) {
623
+ if (network.isCluster(ids[i])) {
624
+ try { network.openCluster(ids[i]); } catch(e) { /* ignore */ }
625
+ }
626
+ }
627
+
628
+ if (mode === 'none') return;
629
+
630
+ if (mode === 'community') {
631
+ var communities = {};
632
+ allNodes.forEach(function(n) {
633
+ if (n.community !== null && visibleNodeIds.has(n.id)) {
634
+ if (!communities[n.community]) communities[n.community] = [];
635
+ communities[n.community].push(n.id);
636
+ }
637
+ });
638
+ Object.keys(communities).forEach(function(cid) {
639
+ if (communities[cid].length < 2) return;
640
+ var cidNum = parseInt(cid, 10);
641
+ network.cluster({
642
+ joinCondition: function(opts) { return opts.community === cidNum; },
643
+ clusterNodeProperties: {
644
+ label: 'Community ' + cid,
645
+ shape: 'diamond',
646
+ color: communityColors[cidNum % communityColors.length]
647
+ }
648
+ });
649
+ });
650
+ } else if (mode === 'directory') {
651
+ var dirs = {};
652
+ allNodes.forEach(function(n) {
653
+ if (visibleNodeIds.has(n.id)) {
654
+ var d = n.directory || '(root)';
655
+ if (!dirs[d]) dirs[d] = [];
656
+ dirs[d].push(n.id);
657
+ }
658
+ });
659
+ Object.keys(dirs).forEach(function(dir) {
660
+ if (dirs[dir].length < 2) return;
661
+ network.cluster({
662
+ joinCondition: function(opts) { return (opts.directory || '(root)') === dir; },
663
+ clusterNodeProperties: {
664
+ label: dir,
665
+ shape: 'diamond',
666
+ color: '#B0BEC5'
667
+ }
668
+ });
669
+ });
670
+ }
671
+ }
672
+
673
+ /* ── Detail Panel ──────────────────────────────────────────────────── */
674
+ function showDetail(nodeId) {
675
+ var n = nodeById[nodeId];
676
+ if (!n) { hideDetail(); return; }
677
+ var adj = adjIndex[nodeId] || { callers: [], callees: [] };
678
+
679
+ var h = '<h3>' + escHtml(n.label) + '</h3>';
680
+ h += '<div class="detail-meta">';
681
+ h += '<span class="badge kind-badge">' + escHtml(n.kind) + '</span>';
682
+ if (n.role) h += '<span class="badge role-badge">' + escHtml(n.role) + '</span>';
683
+ h += '</div>';
684
+ h += '<div class="detail-file">' + escHtml(n.file) + ':' + n.line + '</div>';
685
+
686
+ h += '<div class="detail-section"><strong>Metrics</strong><table>';
687
+ h += '<tr><td>Fan-in</td><td>' + n.fanIn + '</td></tr>';
688
+ h += '<tr><td>Fan-out</td><td>' + n.fanOut + '</td></tr>';
689
+ if (n.cognitive !== null) h += '<tr><td>Cognitive</td><td>' + n.cognitive + '</td></tr>';
690
+ if (n.cyclomatic !== null) h += '<tr><td>Cyclomatic</td><td>' + n.cyclomatic + '</td></tr>';
691
+ if (n.maintainabilityIndex !== null) h += '<tr><td>MI</td><td>' + n.maintainabilityIndex.toFixed(1) + '</td></tr>';
692
+ h += '</table></div>';
693
+
694
+ if (n.risk && n.risk.length > 0) {
695
+ h += '<div class="detail-section"><strong>Risk</strong><br>';
696
+ n.risk.forEach(function(r) { h += '<span class="badge risk-badge">' + escHtml(r) + '</span>'; });
697
+ h += '</div>';
698
+ }
699
+
700
+ if (adj.callers.length > 0) {
701
+ h += '<div class="detail-section"><strong>Callers (' + adj.callers.length + ')</strong><ul>';
702
+ adj.callers.forEach(function(cid) {
703
+ var c = nodeById[cid];
704
+ if (c) h += '<li><a onclick="focusNode(' + cid + ')">' + escHtml(c.label) + '</a></li>';
705
+ });
706
+ h += '</ul></div>';
707
+ }
708
+
709
+ if (adj.callees.length > 0) {
710
+ h += '<div class="detail-section"><strong>Callees (' + adj.callees.length + ')</strong><ul>';
711
+ adj.callees.forEach(function(cid) {
712
+ var c = nodeById[cid];
713
+ if (c) h += '<li><a onclick="focusNode(' + cid + ')">' + escHtml(c.label) + '</a></li>';
714
+ });
715
+ h += '</ul></div>';
716
+ }
717
+
718
+ document.getElementById('detailContent').innerHTML = h;
719
+ document.getElementById('detail').style.display = 'block';
720
+ }
721
+
722
+ function hideDetail() {
723
+ document.getElementById('detail').style.display = 'none';
724
+ }
725
+
726
+ function focusNode(nodeId) {
727
+ if (drillDownActive && !visibleNodeIds.has(nodeId)) expandNode(nodeId);
728
+ network.focus(nodeId, { scale: 1.2, animation: true });
729
+ network.selectNodes([nodeId]);
730
+ showDetail(nodeId);
731
+ }
732
+
733
+ /* ── Drill-down ────────────────────────────────────────────────────── */
734
+ function expandNode(nodeId) {
735
+ if (!drillDownActive) return;
736
+ expandedNodes.add(nodeId);
737
+ var adj = adjIndex[nodeId] || { callers: [], callees: [] };
738
+ var newNodeData = [];
739
+ adj.callers.concat(adj.callees).forEach(function(nid) {
740
+ if (!visibleNodeIds.has(nid)) {
741
+ visibleNodeIds.add(nid);
742
+ var n = nodeById[nid];
743
+ if (n) newNodeData.push(n);
744
+ }
745
+ });
746
+ if (newNodeData.length > 0) {
747
+ nodes.add(newNodeData);
748
+ var newEdges = allEdges.filter(function(e) {
749
+ return visibleNodeIds.has(e.from) && visibleNodeIds.has(e.to) && !edges.get(e.id);
750
+ });
751
+ if (newEdges.length > 0) edges.add(newEdges);
752
+ refreshNodeAppearance();
753
+ }
754
+ }
755
+
756
+ function collapseNode(nodeId) {
757
+ if (!drillDownActive) return;
758
+ expandedNodes.delete(nodeId);
759
+ recalculateVisibility();
760
+ }
761
+
762
+ function recalculateVisibility() {
763
+ var newVisible = new Set(seedSet);
764
+ expandedNodes.forEach(function(nid) {
765
+ newVisible.add(nid);
766
+ var adj = adjIndex[nid] || { callers: [], callees: [] };
767
+ adj.callers.concat(adj.callees).forEach(function(id) { newVisible.add(id); });
768
+ });
769
+
770
+ var toRemove = [];
771
+ visibleNodeIds.forEach(function(id) { if (!newVisible.has(id)) toRemove.push(id); });
772
+ if (toRemove.length > 0) nodes.remove(toRemove);
773
+
774
+ var toAdd = [];
775
+ newVisible.forEach(function(id) {
776
+ if (!visibleNodeIds.has(id) && nodeById[id]) toAdd.push(nodeById[id]);
777
+ });
778
+ if (toAdd.length > 0) nodes.add(toAdd);
779
+
780
+ visibleNodeIds = newVisible;
781
+ edges.clear();
782
+ edges.add(allEdges.filter(function(e) {
783
+ return visibleNodeIds.has(e.from) && visibleNodeIds.has(e.to);
784
+ }));
785
+ refreshNodeAppearance();
786
+ }
787
+
788
+ /* ── Legend ─────────────────────────────────────────────────────────── */
789
+ function updateLegend(colorBy) {
790
+ var legend = document.getElementById('legend');
791
+ legend.innerHTML = '';
792
+ var items = {};
793
+
794
+ if (colorBy === 'kind') {
795
+ allNodes.forEach(function(n) { if (n.kind && visibleNodeIds.has(n.id)) items[n.kind] = nodeColorMap[n.kind] || '#ccc'; });
796
+ } else if (colorBy === 'role') {
797
+ allNodes.forEach(function(n) {
798
+ if (visibleNodeIds.has(n.id)) {
799
+ var key = n.role || n.kind;
800
+ items[key] = n.role ? (roleColorMap[n.role] || '#ccc') : (nodeColorMap[n.kind] || '#ccc');
801
+ }
802
+ });
803
+ } else if (colorBy === 'community') {
804
+ allNodes.forEach(function(n) {
805
+ if (n.community !== null && visibleNodeIds.has(n.id)) {
806
+ items['Community ' + n.community] = communityColors[n.community % communityColors.length];
807
+ }
808
+ });
809
+ } else if (colorBy === 'complexity') {
810
+ items['MI >= 80'] = '#4CAF50';
811
+ items['MI 65-80'] = '#FFC107';
812
+ items['MI 40-65'] = '#FF9800';
813
+ items['MI < 40'] = '#F44336';
814
+ }
815
+
816
+ Object.keys(items).sort().forEach(function(k) {
817
+ var d = document.createElement('div');
818
+ d.innerHTML = '<span class="swatch" style="background:' + items[k] + '"></span>' + escHtml(k);
819
+ legend.appendChild(d);
820
+ });
821
+ }
822
+
823
+ /* ── Network Events ────────────────────────────────────────────────── */
824
+ network.on('click', function(params) {
825
+ if (params.nodes.length === 1) {
826
+ var nodeId = params.nodes[0];
827
+ if (network.isCluster(nodeId)) {
828
+ network.openCluster(nodeId);
829
+ return;
830
+ }
831
+ if (drillDownActive && !expandedNodes.has(nodeId)) expandNode(nodeId);
832
+ showDetail(nodeId);
833
+ } else {
834
+ hideDetail();
835
+ }
836
+ });
837
+
838
+ network.on('doubleClick', function(params) {
839
+ if (params.nodes.length === 1) {
840
+ var nodeId = params.nodes[0];
841
+ if (network.isCluster(nodeId)) return;
842
+ if (drillDownActive && expandedNodes.has(nodeId)) collapseNode(nodeId);
843
+ }
844
+ });
845
+
846
+ /* ── Control Events ────────────────────────────────────────────────── */
847
+ document.getElementById('layoutSelect').addEventListener('change', function(e) {
848
+ var val = e.target.value;
849
+ if (val === 'hierarchical') {
850
+ network.setOptions({ layout: { hierarchical: { enabled: true, direction: ${JSON.stringify(cfg.layout.direction || 'LR')} } }, physics: { enabled: document.getElementById('physicsToggle').checked } });
851
+ } else if (val === 'radial') {
852
+ network.setOptions({ layout: { hierarchical: false, improvedLayout: true }, physics: { enabled: true, solver: 'repulsion', repulsion: { nodeDistance: 200 } } });
853
+ } else {
854
+ network.setOptions({ layout: { hierarchical: false }, physics: { enabled: true } });
855
+ }
856
+ });
857
+
858
+ document.getElementById('physicsToggle').addEventListener('change', function(e) {
859
+ network.setOptions({ physics: { enabled: e.target.checked } });
860
+ });
861
+
862
+ document.getElementById('searchInput').addEventListener('input', function(e) {
863
+ var q = e.target.value.toLowerCase();
864
+ if (!q) {
865
+ nodes.update(getVisibleNodes().map(function(n) { return { id: n.id, hidden: false }; }));
866
+ return;
867
+ }
868
+ getVisibleNodes().forEach(function(n) {
869
+ var match = n.label.toLowerCase().includes(q) || (n.file && n.file.toLowerCase().includes(q));
870
+ nodes.update({ id: n.id, hidden: !match });
871
+ });
872
+ });
873
+
874
+ document.getElementById('colorBySelect').addEventListener('change', function() {
875
+ refreshNodeAppearance();
876
+ updateLegend(document.getElementById('colorBySelect').value);
877
+ });
878
+
879
+ document.getElementById('sizeBySelect').addEventListener('change', function() {
880
+ refreshNodeAppearance();
881
+ });
882
+
883
+ document.getElementById('clusterBySelect').addEventListener('change', function(e) {
884
+ applyClusterBy(e.target.value);
885
+ });
886
+
887
+ document.getElementById('riskToggle').addEventListener('change', function() {
888
+ refreshNodeAppearance();
889
+ });
890
+
891
+ document.getElementById('detailClose').addEventListener('click', hideDetail);
892
+
893
+ /* ── Init ──────────────────────────────────────────────────────────── */
894
+ refreshNodeAppearance();
895
+ updateLegend(${JSON.stringify(effectiveColorBy)});
896
+ ${(cfg.clusterBy || 'none') !== 'none' ? `applyClusterBy(${JSON.stringify(cfg.clusterBy)});` : ''}
897
+ </script>
898
+ </body>
899
+ </html>`;
900
+ }
901
+
902
+ // ─── Internal Helpers ─────────────────────────────────────────────────
903
+
904
+ function escapeHtml(s) {
905
+ return String(s)
906
+ .replace(/&/g, '&amp;')
907
+ .replace(/</g, '&lt;')
908
+ .replace(/>/g, '&gt;')
909
+ .replace(/"/g, '&quot;');
910
+ }
911
+
912
+ function buildLayoutOptions(cfg) {
913
+ const opts = {
914
+ nodes: {
915
+ shape: 'box',
916
+ font: { face: 'monospace', size: 12 },
917
+ },
918
+ edges: {
919
+ arrows: 'to',
920
+ color: cfg.edgeStyle.color || '#666',
921
+ smooth: cfg.edgeStyle.smooth !== false,
922
+ },
923
+ physics: {
924
+ enabled: cfg.physics.enabled !== false,
925
+ barnesHut: {
926
+ gravitationalConstant: -3000,
927
+ springLength: cfg.physics.nodeDistance || 150,
928
+ },
929
+ },
930
+ interaction: {
931
+ tooltipDelay: 200,
932
+ hover: true,
933
+ },
934
+ };
935
+
936
+ if (cfg.layout.algorithm === 'hierarchical') {
937
+ opts.layout = {
938
+ hierarchical: {
939
+ enabled: true,
940
+ direction: cfg.layout.direction || 'LR',
941
+ sortMethod: 'directed',
942
+ nodeSpacing: cfg.physics.nodeDistance || 150,
943
+ },
944
+ };
945
+ }
946
+
947
+ return opts;
948
+ }