@bfra.me/workspace-analyzer 0.1.0 → 0.2.0

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.
@@ -0,0 +1,959 @@
1
+ /**
2
+ * D3.js force-directed graph template for dependency visualization.
3
+ *
4
+ * This module generates the JavaScript code that initializes and renders
5
+ * the interactive dependency graph using D3.js force simulation.
6
+ * The generated code is embedded directly in the HTML output.
7
+ *
8
+ * Features:
9
+ * - Force-directed layout for automatic node positioning
10
+ * - Node rendering with severity-based colors and cycle highlighting
11
+ * - Edge rendering with directional arrows and type styling
12
+ * - Interactive tooltips with node/edge details
13
+ * - Zoom and pan controls
14
+ * - Node dragging
15
+ * - Client-side filtering (layer, severity, package)
16
+ */
17
+
18
+ import type {VisualizationData} from '../types'
19
+
20
+ /**
21
+ * Configuration for the graph visualization.
22
+ */
23
+ export interface GraphConfig {
24
+ /** Width of the SVG container */
25
+ readonly width: number
26
+ /** Height of the SVG container */
27
+ readonly height: number
28
+ /** Node radius (base size) */
29
+ readonly nodeRadius: number
30
+ /** Link distance for force simulation */
31
+ readonly linkDistance: number
32
+ /** Charge strength for force simulation (negative = repulsion) */
33
+ readonly chargeStrength: number
34
+ /** Center force strength */
35
+ readonly centerStrength: number
36
+ /** Collision force radius multiplier */
37
+ readonly collisionRadiusMultiplier: number
38
+ /** Zoom scale extent [min, max] */
39
+ readonly zoomExtent: readonly [number, number]
40
+ /** Alpha decay rate for simulation */
41
+ readonly alphaDecay: number
42
+ /** Velocity decay rate for simulation */
43
+ readonly velocityDecay: number
44
+ }
45
+
46
+ /**
47
+ * Default graph configuration.
48
+ */
49
+ export const DEFAULT_GRAPH_CONFIG: GraphConfig = {
50
+ width: 1200,
51
+ height: 800,
52
+ nodeRadius: 8,
53
+ linkDistance: 80,
54
+ chargeStrength: -300,
55
+ centerStrength: 0.05,
56
+ collisionRadiusMultiplier: 1.5,
57
+ zoomExtent: [0.1, 4],
58
+ alphaDecay: 0.0228,
59
+ velocityDecay: 0.4,
60
+ }
61
+
62
+ /**
63
+ * Generates the main graph initialization JavaScript code.
64
+ *
65
+ * This code is designed to be embedded in the HTML output and executed
66
+ * after the D3.js library is loaded. It reads visualization data from
67
+ * a global variable and renders the force-directed graph.
68
+ *
69
+ * @returns JavaScript code as a string
70
+ */
71
+ export function generateGraphInitScript(): string {
72
+ return `
73
+ /**
74
+ * Dependency Graph Visualization
75
+ * Generated by @bfra.me/workspace-analyzer
76
+ */
77
+ (function() {
78
+ 'use strict';
79
+
80
+ // Get visualization data from global scope
81
+ const visualizationData = window.VISUALIZATION_DATA;
82
+ if (!visualizationData) {
83
+ console.error('Visualization data not found');
84
+ return;
85
+ }
86
+
87
+ // Extract data
88
+ const { nodes, edges, cycles, statistics, layers, metadata } = visualizationData;
89
+
90
+ // Configuration
91
+ const config = {
92
+ nodeRadius: 8,
93
+ linkDistance: 80,
94
+ chargeStrength: -300,
95
+ centerStrength: 0.05,
96
+ collisionRadiusMultiplier: 1.5,
97
+ zoomExtent: [0.1, 4],
98
+ alphaDecay: 0.0228,
99
+ velocityDecay: 0.4
100
+ };
101
+
102
+ // State management
103
+ const state = {
104
+ filters: {
105
+ layers: new Set(layers.map(l => l.name)),
106
+ severities: new Set(['critical', 'error', 'warning', 'info']),
107
+ packages: new Set(),
108
+ showCyclesOnly: false,
109
+ showViolationsOnly: false,
110
+ searchQuery: ''
111
+ },
112
+ selectedNode: null,
113
+ highlightedNodes: new Set(),
114
+ transform: d3.zoomIdentity
115
+ };
116
+
117
+ // Color scales
118
+ const severityColors = {
119
+ critical: '#dc2626',
120
+ error: '#ea580c',
121
+ warning: '#ca8a04',
122
+ info: '#2563eb'
123
+ };
124
+
125
+ const layerColors = {
126
+ domain: '#8b5cf6',
127
+ application: '#06b6d4',
128
+ infrastructure: '#84cc16',
129
+ presentation: '#f97316',
130
+ shared: '#6b7280',
131
+ unknown: '#9ca3af'
132
+ };
133
+
134
+ // Get container dimensions
135
+ const container = document.querySelector('.graph-container');
136
+ const width = container.clientWidth;
137
+ const height = container.clientHeight;
138
+
139
+ // Create SVG
140
+ const svg = d3.select('.graph-canvas')
141
+ .attr('viewBox', [0, 0, width, height])
142
+ .attr('preserveAspectRatio', 'xMidYMid meet');
143
+
144
+ // Create groups for layering
145
+ const g = svg.append('g').attr('class', 'graph-layer');
146
+ const edgeGroup = g.append('g').attr('class', 'edges');
147
+ const nodeGroup = g.append('g').attr('class', 'nodes');
148
+
149
+ // Arrow marker definition
150
+ svg.append('defs').append('marker')
151
+ .attr('id', 'arrow')
152
+ .attr('viewBox', '0 -5 10 10')
153
+ .attr('refX', 20)
154
+ .attr('refY', 0)
155
+ .attr('markerWidth', 6)
156
+ .attr('markerHeight', 6)
157
+ .attr('orient', 'auto')
158
+ .append('path')
159
+ .attr('d', 'M0,-5L10,0L0,5')
160
+ .attr('class', 'edge-arrow');
161
+
162
+ // Arrow marker for cycle edges
163
+ svg.select('defs').append('marker')
164
+ .attr('id', 'arrow-cycle')
165
+ .attr('viewBox', '0 -5 10 10')
166
+ .attr('refX', 20)
167
+ .attr('refY', 0)
168
+ .attr('markerWidth', 6)
169
+ .attr('markerHeight', 6)
170
+ .attr('orient', 'auto')
171
+ .append('path')
172
+ .attr('d', 'M0,-5L10,0L0,5')
173
+ .attr('class', 'edge-arrow cycle');
174
+
175
+ // Create simulation data with mutable copies
176
+ const simNodes = nodes.map(n => ({...n}));
177
+ const simEdges = edges.map(e => ({
178
+ ...e,
179
+ source: e.source,
180
+ target: e.target
181
+ }));
182
+
183
+ // Create node ID lookup
184
+ const nodeById = new Map(simNodes.map(n => [n.id, n]));
185
+
186
+ // Force simulation
187
+ const simulation = d3.forceSimulation(simNodes)
188
+ .force('link', d3.forceLink(simEdges)
189
+ .id(d => d.id)
190
+ .distance(config.linkDistance))
191
+ .force('charge', d3.forceManyBody()
192
+ .strength(config.chargeStrength))
193
+ .force('center', d3.forceCenter(width / 2, height / 2)
194
+ .strength(config.centerStrength))
195
+ .force('collide', d3.forceCollide()
196
+ .radius(config.nodeRadius * config.collisionRadiusMultiplier))
197
+ .alphaDecay(config.alphaDecay)
198
+ .velocityDecay(config.velocityDecay);
199
+
200
+ // Zoom behavior
201
+ const zoom = d3.zoom()
202
+ .scaleExtent(config.zoomExtent)
203
+ .on('zoom', (event) => {
204
+ state.transform = event.transform;
205
+ g.attr('transform', 'translate(' + event.transform.x + ',' + event.transform.y + ') scale(' + event.transform.k + ')');
206
+ updateZoomDisplay();
207
+ });
208
+
209
+ svg.call(zoom);
210
+
211
+ // Draw edges
212
+ function drawEdges() {
213
+ const filteredEdges = getFilteredEdges();
214
+
215
+ const edge = edgeGroup.selectAll('.graph-edge')
216
+ .data(filteredEdges, d => d.source.id + '-' + d.target.id);
217
+
218
+ edge.exit().remove();
219
+
220
+ const edgeEnter = edge.enter()
221
+ .append('g')
222
+ .attr('class', 'graph-edge');
223
+
224
+ // Edge line
225
+ edgeEnter.append('line')
226
+ .attr('class', d => getEdgeClass(d))
227
+ .attr('marker-end', d => d.isInCycle ? 'url(#arrow-cycle)' : 'url(#arrow)')
228
+ .on('mouseenter', handleEdgeMouseEnter)
229
+ .on('mouseleave', handleEdgeMouseLeave)
230
+ .on('click', handleEdgeClick);
231
+
232
+ // Merge and update
233
+ const edgeMerged = edgeEnter.merge(edge);
234
+
235
+ edgeMerged.select('line')
236
+ .attr('class', d => getEdgeClass(d));
237
+
238
+ return edgeMerged;
239
+ }
240
+
241
+ // Draw nodes
242
+ function drawNodes() {
243
+ const filteredNodes = getFilteredNodes();
244
+
245
+ const node = nodeGroup.selectAll('.graph-node')
246
+ .data(filteredNodes, d => d.id);
247
+
248
+ node.exit().remove();
249
+
250
+ const nodeEnter = node.enter()
251
+ .append('g')
252
+ .attr('class', 'graph-node')
253
+ .call(drag(simulation));
254
+
255
+ // Node circle
256
+ nodeEnter.append('circle')
257
+ .attr('r', config.nodeRadius)
258
+ .attr('class', d => getNodeClass(d));
259
+
260
+ // Node label
261
+ nodeEnter.append('text')
262
+ .attr('class', 'node-label')
263
+ .attr('dy', config.nodeRadius + 12)
264
+ .text(d => getNodeLabel(d));
265
+
266
+ // Merge and update
267
+ const nodeMerged = nodeEnter.merge(node);
268
+
269
+ nodeMerged.select('circle')
270
+ .attr('class', d => getNodeClass(d));
271
+
272
+ // Event handlers
273
+ nodeMerged
274
+ .on('mouseenter', handleNodeMouseEnter)
275
+ .on('mouseleave', handleNodeMouseLeave)
276
+ .on('click', handleNodeClick);
277
+
278
+ return nodeMerged;
279
+ }
280
+
281
+ // Get CSS class for edge
282
+ function getEdgeClass(edge) {
283
+ const classes = ['edge-line'];
284
+ if (edge.isInCycle) classes.push('cycle');
285
+ classes.push('type-' + edge.type);
286
+ return classes.join(' ');
287
+ }
288
+
289
+ // Get CSS class for node
290
+ function getNodeClass(node) {
291
+ const classes = ['node-circle'];
292
+
293
+ // Cycle highlighting
294
+ if (node.isInCycle) classes.push('cycle');
295
+
296
+ // Severity-based color (takes precedence)
297
+ if (node.highestViolationSeverity) {
298
+ classes.push('severity-' + node.highestViolationSeverity);
299
+ } else if (node.layer) {
300
+ // Layer-based color
301
+ classes.push('layer-' + node.layer.toLowerCase());
302
+ } else {
303
+ classes.push('default');
304
+ }
305
+
306
+ return classes.join(' ');
307
+ }
308
+
309
+ // Get display label for node
310
+ function getNodeLabel(node) {
311
+ // Show just the filename
312
+ const parts = node.name.split('/');
313
+ return parts[parts.length - 1] || node.name;
314
+ }
315
+
316
+ // Filtering
317
+ function getFilteredNodes() {
318
+ return simNodes.filter(node => {
319
+ // Layer filter
320
+ if (node.layer && !state.filters.layers.has(node.layer)) {
321
+ return false;
322
+ }
323
+
324
+ // Severity filter - only filter out nodes with violations if their highest severity is not selected
325
+ if (node.highestViolationSeverity && !state.filters.severities.has(node.highestViolationSeverity)) {
326
+ return false;
327
+ }
328
+
329
+ // Cycles only
330
+ if (state.filters.showCyclesOnly && !node.isInCycle) {
331
+ return false;
332
+ }
333
+
334
+ // Violations only
335
+ if (state.filters.showViolationsOnly && node.violations.length === 0) {
336
+ return false;
337
+ }
338
+
339
+ // Search query
340
+ if (state.filters.searchQuery) {
341
+ const query = state.filters.searchQuery.toLowerCase();
342
+ if (!node.name.toLowerCase().includes(query) &&
343
+ !node.filePath.toLowerCase().includes(query) &&
344
+ !(node.packageName && node.packageName.toLowerCase().includes(query))) {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ return true;
350
+ });
351
+ }
352
+
353
+ function getFilteredEdges() {
354
+ const filteredNodeIds = new Set(getFilteredNodes().map(n => n.id));
355
+ return simEdges.filter(edge => {
356
+ const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
357
+ const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
358
+ return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId);
359
+ });
360
+ }
361
+
362
+ // Drag behavior
363
+ function drag(simulation) {
364
+ function dragstarted(event) {
365
+ if (!event.active) simulation.alphaTarget(0.3).restart();
366
+ event.subject.fx = event.subject.x;
367
+ event.subject.fy = event.subject.y;
368
+ }
369
+
370
+ function dragged(event) {
371
+ event.subject.fx = event.x;
372
+ event.subject.fy = event.y;
373
+ }
374
+
375
+ function dragended(event) {
376
+ if (!event.active) simulation.alphaTarget(0);
377
+ event.subject.fx = null;
378
+ event.subject.fy = null;
379
+ }
380
+
381
+ return d3.drag()
382
+ .on('start', dragstarted)
383
+ .on('drag', dragged)
384
+ .on('end', dragended);
385
+ }
386
+
387
+ // Tooltip
388
+ const tooltip = d3.select('.tooltip');
389
+
390
+ function handleNodeMouseEnter(event, d) {
391
+ // Show tooltip
392
+ showTooltip(event, d);
393
+
394
+ // Highlight connected nodes
395
+ highlightConnectedNodes(d);
396
+ }
397
+
398
+ function handleNodeMouseLeave(event, d) {
399
+ hideTooltip();
400
+ clearHighlights();
401
+ }
402
+
403
+ function handleNodeClick(event, d) {
404
+ state.selectedNode = state.selectedNode === d ? null : d;
405
+ if (state.selectedNode) {
406
+ centerOnNode(d);
407
+ }
408
+ }
409
+
410
+ function handleEdgeMouseEnter(event, d) {
411
+ showEdgeTooltip(event, d);
412
+ }
413
+
414
+ function handleEdgeMouseLeave(event, d) {
415
+ hideTooltip();
416
+ }
417
+
418
+ function handleEdgeClick(event, d) {
419
+ event.stopPropagation();
420
+ showEdgeTooltip(event, d);
421
+ }
422
+
423
+ function showTooltip(event, node) {
424
+ const iconClass = node.highestViolationSeverity
425
+ ? 'severity-' + node.highestViolationSeverity
426
+ : node.isInCycle ? 'cycle' : 'default';
427
+
428
+ let violationsHtml = '';
429
+ if (node.violations.length > 0) {
430
+ violationsHtml = '<div class="tooltip-violations">' +
431
+ '<div class="tooltip-violations-title">Violations</div>' +
432
+ node.violations.slice(0, 5).map(v =>
433
+ '<div class="tooltip-violation">' +
434
+ '<div class="tooltip-violation-dot severity-dot ' + v.severity + '"></div>' +
435
+ '<div class="tooltip-violation-text">' + escapeHtml(v.message) + '</div>' +
436
+ '</div>'
437
+ ).join('') +
438
+ (node.violations.length > 5 ? '<div class="tooltip-violation"><div class="tooltip-violation-text">... and ' + (node.violations.length - 5) + ' more</div></div>' : '') +
439
+ '</div>';
440
+ }
441
+
442
+ const html = '<div class="tooltip-header">' +
443
+ '<div class="tooltip-icon ' + iconClass + '">' +
444
+ (node.isInCycle ? '↻' : node.violations.length > 0 ? '!' : '○') +
445
+ '</div>' +
446
+ '<div>' +
447
+ '<div class="tooltip-title">' + escapeHtml(node.name) + '</div>' +
448
+ '<div class="tooltip-subtitle">' + escapeHtml(node.filePath) + '</div>' +
449
+ '</div>' +
450
+ '</div>' +
451
+ '<div class="tooltip-content">' +
452
+ '<div class="tooltip-row">' +
453
+ '<span class="tooltip-label">Package</span>' +
454
+ '<span class="tooltip-value">' + escapeHtml(node.packageName || 'N/A') + '</span>' +
455
+ '</div>' +
456
+ '<div class="tooltip-row">' +
457
+ '<span class="tooltip-label">Layer</span>' +
458
+ '<span class="tooltip-value">' + escapeHtml(node.layer || 'Unknown') + '</span>' +
459
+ '</div>' +
460
+ '<div class="tooltip-divider"></div>' +
461
+ '<div class="tooltip-row">' +
462
+ '<span class="tooltip-label">Imports</span>' +
463
+ '<span class="tooltip-value">' + node.importsCount + '</span>' +
464
+ '</div>' +
465
+ '<div class="tooltip-row">' +
466
+ '<span class="tooltip-label">Imported by</span>' +
467
+ '<span class="tooltip-value">' + node.importedByCount + '</span>' +
468
+ '</div>' +
469
+ (node.isInCycle ? '<div class="tooltip-row"><span class="tooltip-label">In Cycle</span><span class="tooltip-value" style="color: #ef4444;">Yes</span></div>' : '') +
470
+ '</div>' +
471
+ violationsHtml;
472
+
473
+ tooltip.html(html);
474
+
475
+ // Position tooltip
476
+ const tooltipNode = tooltip.node();
477
+ const tooltipRect = tooltipNode.getBoundingClientRect();
478
+ const viewportWidth = window.innerWidth;
479
+ const viewportHeight = window.innerHeight;
480
+
481
+ let left = event.clientX + 10;
482
+ let top = event.clientY + 10;
483
+
484
+ // Adjust if tooltip would overflow
485
+ if (left + tooltipRect.width > viewportWidth - 20) {
486
+ left = event.clientX - tooltipRect.width - 10;
487
+ }
488
+ if (top + tooltipRect.height > viewportHeight - 20) {
489
+ top = event.clientY - tooltipRect.height - 10;
490
+ }
491
+
492
+ tooltip
493
+ .style('left', left + 'px')
494
+ .style('top', top + 'px')
495
+ .classed('visible', true);
496
+ }
497
+
498
+ function showEdgeTooltip(event, edge) {
499
+ const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
500
+ const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
501
+ const sourceNode = nodeById.get(sourceId);
502
+ const targetNode = nodeById.get(targetId);
503
+
504
+ if (!sourceNode || !targetNode) return;
505
+
506
+ const iconClass = edge.isInCycle ? 'cycle' : 'default';
507
+ const importType = edge.type === 'static' ? 'Static Import' :
508
+ edge.type === 'dynamic' ? 'Dynamic Import' :
509
+ edge.type === 'type-only' ? 'Type-Only Import' :
510
+ 'Require Import';
511
+
512
+ const html = '<div class=\"tooltip-header\">' +
513
+ '<div class=\"tooltip-icon ' + iconClass + '\">' +
514
+ (edge.isInCycle ? '↻' : '→') +
515
+ '</div>' +
516
+ '<div>' +
517
+ '<div class=\"tooltip-title\">Import Relationship</div>' +
518
+ '<div class=\"tooltip-subtitle\">' + escapeHtml(importType) + '</div>' +
519
+ '</div>' +
520
+ '</div>' +
521
+ '<div class=\"tooltip-content\">' +
522
+ '<div class=\"tooltip-row\">' +
523
+ '<span class=\"tooltip-label\">From</span>' +
524
+ '<span class=\"tooltip-value\">' + escapeHtml(sourceNode.name) + '</span>' +
525
+ '</div>' +
526
+ '<div class=\"tooltip-row\">' +
527
+ '<span class=\"tooltip-label\">To</span>' +
528
+ '<span class=\"tooltip-value\">' + escapeHtml(targetNode.name) + '</span>' +
529
+ '</div>' +
530
+ '<div class=\"tooltip-divider\"></div>' +
531
+ '<div class=\"tooltip-row\">' +
532
+ '<span class=\"tooltip-label\">Type</span>' +
533
+ '<span class=\"tooltip-value\">' + escapeHtml(edge.type) + '</span>' +
534
+ '</div>' +
535
+ (edge.isInCycle ? '<div class=\"tooltip-row\"><span class=\"tooltip-label\">Part of Cycle</span><span class=\"tooltip-value\" style=\"color: #ef4444;\">Yes</span></div>' : '') +
536
+ (edge.cycleId ? '<div class=\"tooltip-row\"><span class=\"tooltip-label\">Cycle ID</span><span class=\"tooltip-value\">' + escapeHtml(edge.cycleId) + '</span></div>' : '') +
537
+ '</div>';
538
+
539
+ tooltip.html(html);
540
+
541
+ // Position tooltip
542
+ const tooltipNode = tooltip.node();
543
+ const tooltipRect = tooltipNode.getBoundingClientRect();
544
+ const viewportWidth = window.innerWidth;
545
+ const viewportHeight = window.innerHeight;
546
+
547
+ let left = event.clientX + 10;
548
+ let top = event.clientY + 10;
549
+
550
+ // Adjust if tooltip would overflow
551
+ if (left + tooltipRect.width > viewportWidth - 20) {
552
+ left = event.clientX - tooltipRect.width - 10;
553
+ }
554
+ if (top + tooltipRect.height > viewportHeight - 20) {
555
+ top = event.clientY - tooltipRect.height - 10;
556
+ }
557
+
558
+ tooltip
559
+ .style('left', left + 'px')
560
+ .style('top', top + 'px')
561
+ .classed('visible', true);
562
+ }
563
+
564
+ function hideTooltip() {
565
+ tooltip.classed('visible', false);
566
+ }
567
+
568
+ // Highlighting
569
+ function highlightConnectedNodes(node) {
570
+ const connectedIds = new Set([node.id]);
571
+
572
+ // Find connected nodes
573
+ simEdges.forEach(edge => {
574
+ const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
575
+ const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
576
+ if (sourceId === node.id) connectedIds.add(targetId);
577
+ if (targetId === node.id) connectedIds.add(sourceId);
578
+ });
579
+
580
+ state.highlightedNodes = connectedIds;
581
+
582
+ // Update visual state
583
+ nodeGroup.selectAll('.graph-node')
584
+ .classed('dimmed', d => !connectedIds.has(d.id))
585
+ .classed('highlighted', d => connectedIds.has(d.id));
586
+
587
+ edgeGroup.selectAll('.graph-edge')
588
+ .classed('dimmed', d => {
589
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
590
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target;
591
+ return sourceId !== node.id && targetId !== node.id;
592
+ })
593
+ .classed('highlighted', d => {
594
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
595
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target;
596
+ return sourceId === node.id || targetId === node.id;
597
+ });
598
+ }
599
+
600
+ function clearHighlights() {
601
+ state.highlightedNodes.clear();
602
+ nodeGroup.selectAll('.graph-node')
603
+ .classed('dimmed', false)
604
+ .classed('highlighted', false);
605
+ edgeGroup.selectAll('.graph-edge')
606
+ .classed('dimmed', false)
607
+ .classed('highlighted', false);
608
+ }
609
+
610
+ // Center on node
611
+ function centerOnNode(node) {
612
+ const scale = state.transform.k;
613
+ const x = width / 2 - node.x * scale;
614
+ const y = height / 2 - node.y * scale;
615
+ svg.transition()
616
+ .duration(500)
617
+ .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale));
618
+ }
619
+
620
+ // Zoom controls
621
+ function zoomIn() {
622
+ svg.transition().call(zoom.scaleBy, 1.3);
623
+ }
624
+
625
+ function zoomOut() {
626
+ svg.transition().call(zoom.scaleBy, 0.7);
627
+ }
628
+
629
+ function zoomReset() {
630
+ svg.transition().call(zoom.transform, d3.zoomIdentity);
631
+ }
632
+
633
+ function updateZoomDisplay() {
634
+ const percentage = Math.round(state.transform.k * 100);
635
+ const zoomLevel = document.querySelector('.zoom-level');
636
+ if (zoomLevel) {
637
+ zoomLevel.textContent = percentage + '%';
638
+ }
639
+ }
640
+
641
+ // Filter controls
642
+ function setLayerFilter(layer, enabled) {
643
+ if (enabled) {
644
+ state.filters.layers.add(layer);
645
+ } else {
646
+ state.filters.layers.delete(layer);
647
+ }
648
+ updateGraph();
649
+ }
650
+
651
+ function setSeverityFilter(severity, enabled) {
652
+ if (enabled) {
653
+ state.filters.severities.add(severity);
654
+ } else {
655
+ state.filters.severities.delete(severity);
656
+ }
657
+ updateGraph();
658
+ }
659
+
660
+ function setViewMode(mode) {
661
+ state.filters.showCyclesOnly = mode === 'cycles';
662
+ state.filters.showViolationsOnly = mode === 'violations';
663
+ updateGraph();
664
+ }
665
+
666
+ function setSearchQuery(query) {
667
+ state.filters.searchQuery = query;
668
+ updateGraph();
669
+ }
670
+
671
+ // Update graph when filters change
672
+ function updateGraph() {
673
+ drawEdges();
674
+ drawNodes();
675
+ simulation.alpha(0.3).restart();
676
+ }
677
+
678
+ // Simulation tick handler
679
+ simulation.on('tick', () => {
680
+ edgeGroup.selectAll('.graph-edge line')
681
+ .attr('x1', d => d.source.x)
682
+ .attr('y1', d => d.source.y)
683
+ .attr('x2', d => d.target.x)
684
+ .attr('y2', d => d.target.y);
685
+
686
+ nodeGroup.selectAll('.graph-node')
687
+ .attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
688
+ });
689
+
690
+ // Helper functions
691
+ function escapeHtml(str) {
692
+ if (!str) return '';
693
+ return str
694
+ .replace(/&/g, '&amp;')
695
+ .replace(/</g, '&lt;')
696
+ .replace(/>/g, '&gt;')
697
+ .replace(/"/g, '&quot;')
698
+ .replace(/'/g, '&#039;');
699
+ }
700
+
701
+ // Initialize
702
+ drawEdges();
703
+ drawNodes();
704
+
705
+ // Update statistics display
706
+ function updateStatistics() {
707
+ const filtered = getFilteredNodes();
708
+ const filteredEdges = getFilteredEdges();
709
+
710
+ document.getElementById('stat-nodes').textContent = filtered.length;
711
+ document.getElementById('stat-edges').textContent = filteredEdges.length;
712
+ document.getElementById('stat-cycles').textContent = cycles.length;
713
+
714
+ // Update severity counts
715
+ const severityCounts = { critical: 0, error: 0, warning: 0, info: 0 };
716
+ filtered.forEach(n => {
717
+ n.violations.forEach(v => {
718
+ severityCounts[v.severity]++;
719
+ });
720
+ });
721
+
722
+ Object.keys(severityCounts).forEach(severity => {
723
+ const el = document.getElementById('stat-' + severity);
724
+ if (el) el.textContent = severityCounts[severity];
725
+ });
726
+ }
727
+
728
+ updateStatistics();
729
+
730
+ // Expose API for control panel
731
+ window.graphAPI = {
732
+ zoomIn,
733
+ zoomOut,
734
+ zoomReset,
735
+ setLayerFilter,
736
+ setSeverityFilter,
737
+ setViewMode,
738
+ setSearchQuery,
739
+ updateGraph,
740
+ centerOnNode: (nodeId) => {
741
+ const node = nodeById.get(nodeId);
742
+ if (node) centerOnNode(node);
743
+ }
744
+ };
745
+
746
+ // Hide loading overlay
747
+ const loadingOverlay = document.querySelector('.loading-overlay');
748
+ if (loadingOverlay) {
749
+ loadingOverlay.style.display = 'none';
750
+ }
751
+
752
+ console.log('Dependency graph visualization initialized with', nodes.length, 'nodes and', edges.length, 'edges');
753
+ })();
754
+ `
755
+ }
756
+
757
+ /**
758
+ * Generates the control panel JavaScript code for filter interactions.
759
+ *
760
+ * @returns JavaScript code for control panel functionality
761
+ */
762
+ export function generateControlPanelScript(): string {
763
+ return `
764
+ /**
765
+ * Control Panel Script
766
+ * Handles user interactions with filter controls
767
+ */
768
+ (function() {
769
+ 'use strict';
770
+
771
+ // Wait for graph API to be available
772
+ function waitForGraphAPI(callback) {
773
+ if (window.graphAPI) {
774
+ callback();
775
+ } else {
776
+ setTimeout(() => waitForGraphAPI(callback), 100);
777
+ }
778
+ }
779
+
780
+ waitForGraphAPI(() => {
781
+ const api = window.graphAPI;
782
+
783
+ // Zoom controls
784
+ document.getElementById('zoom-in')?.addEventListener('click', api.zoomIn);
785
+ document.getElementById('zoom-out')?.addEventListener('click', api.zoomOut);
786
+ document.getElementById('zoom-reset')?.addEventListener('click', api.zoomReset);
787
+
788
+ // View mode buttons
789
+ document.querySelectorAll('.view-mode-btn').forEach(btn => {
790
+ btn.addEventListener('click', () => {
791
+ document.querySelectorAll('.view-mode-btn').forEach(b => b.classList.remove('active'));
792
+ btn.classList.add('active');
793
+ api.setViewMode(btn.dataset.mode);
794
+ });
795
+ });
796
+
797
+ // Search input
798
+ const searchInput = document.getElementById('search-input');
799
+ if (searchInput) {
800
+ let debounceTimer;
801
+ searchInput.addEventListener('input', (e) => {
802
+ clearTimeout(debounceTimer);
803
+ debounceTimer = setTimeout(() => {
804
+ api.setSearchQuery(e.target.value);
805
+ }, 300);
806
+ });
807
+ }
808
+
809
+ // Layer checkboxes
810
+ document.querySelectorAll('.layer-checkbox').forEach(checkbox => {
811
+ checkbox.addEventListener('change', (e) => {
812
+ api.setLayerFilter(e.target.dataset.layer, e.target.checked);
813
+ });
814
+ });
815
+
816
+ // Severity checkboxes
817
+ document.querySelectorAll('.severity-checkbox').forEach(checkbox => {
818
+ checkbox.addEventListener('change', (e) => {
819
+ api.setSeverityFilter(e.target.dataset.severity, e.target.checked);
820
+ });
821
+ });
822
+
823
+ // Keyboard shortcuts
824
+ document.addEventListener('keydown', (e) => {
825
+ // Only if not focused on input
826
+ if (document.activeElement.tagName === 'INPUT') return;
827
+
828
+ switch (e.key) {
829
+ case '+':
830
+ case '=':
831
+ api.zoomIn();
832
+ break;
833
+ case '-':
834
+ case '_':
835
+ api.zoomOut();
836
+ break;
837
+ case '0':
838
+ api.zoomReset();
839
+ break;
840
+ case 'Escape':
841
+ api.setSearchQuery('');
842
+ if (searchInput) searchInput.value = '';
843
+ break;
844
+ }
845
+ });
846
+
847
+ console.log('Control panel initialized');
848
+ });
849
+ })();
850
+ `
851
+ }
852
+
853
+ /**
854
+ * Generates the complete HTML template with all visualization scripts and styles.
855
+ *
856
+ * @param _data - The visualization data to embed (used by html-renderer.ts)
857
+ * @param title - The title for the HTML document
858
+ * @returns Complete HTML document as a string
859
+ */
860
+ export function generateHtmlTemplate(_data: VisualizationData, title: string): string {
861
+ // This is a template function - actual implementation is in html-renderer.ts
862
+ // This provides the structure for the HTML output
863
+ return `<!DOCTYPE html>
864
+ <html lang="en">
865
+ <head>
866
+ <meta charset="UTF-8">
867
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
868
+ <title>${title}</title>
869
+ <!-- Styles will be injected here -->
870
+ </head>
871
+ <body>
872
+ <div class="app-container">
873
+ <!-- Sidebar -->
874
+ <aside class="sidebar">
875
+ <!-- Statistics and filters -->
876
+ </aside>
877
+ <!-- Main content -->
878
+ <main class="main-content">
879
+ <!-- Control panel -->
880
+ <div class="control-panel">
881
+ <!-- Filter controls -->
882
+ </div>
883
+ <!-- Graph canvas -->
884
+ <div class="graph-container">
885
+ <svg class="graph-canvas"></svg>
886
+ <div class="loading-overlay">
887
+ <div class="loading-spinner"></div>
888
+ </div>
889
+ </div>
890
+ </main>
891
+ </div>
892
+ <!-- Tooltip -->
893
+ <div class="tooltip"></div>
894
+ <!-- D3.js and visualization scripts will be injected here -->
895
+ </body>
896
+ </html>`
897
+ }
898
+
899
+ /**
900
+ * Gets the node radius based on its characteristics.
901
+ *
902
+ * @param node - Node data
903
+ * @param node.importsCount - Number of imports from this node
904
+ * @param node.importedByCount - Number of nodes that import this one
905
+ * @param node.violations - Array of violations for this node
906
+ * @param baseRadius - Base radius size
907
+ * @returns Calculated radius for the node
908
+ */
909
+ export function calculateNodeRadius(
910
+ node: {importsCount: number; importedByCount: number; violations: readonly unknown[]},
911
+ baseRadius: number,
912
+ ): number {
913
+ // Scale by connectivity (clamped)
914
+ const connectivity = Math.min(node.importsCount + node.importedByCount, 20)
915
+ const connectivityScale = 1 + connectivity * 0.05
916
+
917
+ // Boost for violations
918
+ const violationBoost = node.violations.length > 0 ? 1.2 : 1
919
+
920
+ return Math.round(baseRadius * connectivityScale * violationBoost)
921
+ }
922
+
923
+ /**
924
+ * Calculates the optimal link distance based on graph density.
925
+ *
926
+ * @param nodeCount - Number of nodes
927
+ * @param edgeCount - Number of edges
928
+ * @param baseDistance - Base link distance
929
+ * @returns Optimal link distance
930
+ */
931
+ export function calculateLinkDistance(
932
+ nodeCount: number,
933
+ edgeCount: number,
934
+ baseDistance: number,
935
+ ): number {
936
+ // Denser graphs need more spacing
937
+ const density = edgeCount / Math.max(nodeCount, 1)
938
+ const densityFactor = Math.min(1 + density * 0.1, 2)
939
+
940
+ return Math.round(baseDistance * densityFactor)
941
+ }
942
+
943
+ /**
944
+ * Calculates charge strength based on graph size.
945
+ *
946
+ * @param nodeCount - Number of nodes
947
+ * @param baseStrength - Base charge strength (negative for repulsion)
948
+ * @returns Optimal charge strength
949
+ */
950
+ export function calculateChargeStrength(nodeCount: number, baseStrength: number): number {
951
+ // Larger graphs need less repulsion to avoid spreading too far
952
+ if (nodeCount > 200) {
953
+ return baseStrength * 0.5
954
+ }
955
+ if (nodeCount > 100) {
956
+ return baseStrength * 0.7
957
+ }
958
+ return baseStrength
959
+ }