/** * D3.js force-directed graph template for dependency visualization. * * This module generates the JavaScript code that initializes and renders * the interactive dependency graph using D3.js force simulation. * The generated code is embedded directly in the HTML output. * * Features: * - Force-directed layout for automatic node positioning * - Node rendering with severity-based colors and cycle highlighting * - Edge rendering with directional arrows and type styling * - Interactive tooltips with node/edge details * - Zoom and pan controls * - Node dragging * - Client-side filtering (layer, severity, package) */ import type {VisualizationData} from '../types' /** * Configuration for the graph visualization. */ export interface GraphConfig { /** Width of the SVG container */ readonly width: number /** Height of the SVG container */ readonly height: number /** Node radius (base size) */ readonly nodeRadius: number /** Link distance for force simulation */ readonly linkDistance: number /** Charge strength for force simulation (negative = repulsion) */ readonly chargeStrength: number /** Center force strength */ readonly centerStrength: number /** Collision force radius multiplier */ readonly collisionRadiusMultiplier: number /** Zoom scale extent [min, max] */ readonly zoomExtent: readonly [number, number] /** Alpha decay rate for simulation */ readonly alphaDecay: number /** Velocity decay rate for simulation */ readonly velocityDecay: number } /** * Default graph configuration. */ export const DEFAULT_GRAPH_CONFIG: GraphConfig = { width: 1200, height: 800, nodeRadius: 8, linkDistance: 80, chargeStrength: -300, centerStrength: 0.05, collisionRadiusMultiplier: 1.5, zoomExtent: [0.1, 4], alphaDecay: 0.0228, velocityDecay: 0.4, } /** * Generates the main graph initialization JavaScript code. * * This code is designed to be embedded in the HTML output and executed * after the D3.js library is loaded. It reads visualization data from * a global variable and renders the force-directed graph. * * @returns JavaScript code as a string */ export function generateGraphInitScript(): string { return ` /** * Dependency Graph Visualization * Generated by @bfra.me/workspace-analyzer */ (function() { 'use strict'; // Get visualization data from global scope const visualizationData = window.VISUALIZATION_DATA; if (!visualizationData) { console.error('Visualization data not found'); return; } // Extract data const { nodes, edges, cycles, statistics, layers, metadata } = visualizationData; // Configuration const config = { nodeRadius: 8, linkDistance: 80, chargeStrength: -300, centerStrength: 0.05, collisionRadiusMultiplier: 1.5, zoomExtent: [0.1, 4], alphaDecay: 0.0228, velocityDecay: 0.4 }; // State management const state = { filters: { layers: new Set(layers.map(l => l.name)), severities: new Set(['critical', 'error', 'warning', 'info']), packages: new Set(), showCyclesOnly: false, showViolationsOnly: false, searchQuery: '' }, selectedNode: null, highlightedNodes: new Set(), transform: d3.zoomIdentity }; // Color scales const severityColors = { critical: '#dc2626', error: '#ea580c', warning: '#ca8a04', info: '#2563eb' }; const layerColors = { domain: '#8b5cf6', application: '#06b6d4', infrastructure: '#84cc16', presentation: '#f97316', shared: '#6b7280', unknown: '#9ca3af' }; // Get container dimensions const container = document.querySelector('.graph-container'); const width = container.clientWidth; const height = container.clientHeight; // Create SVG const svg = d3.select('.graph-canvas') .attr('viewBox', [0, 0, width, height]) .attr('preserveAspectRatio', 'xMidYMid meet'); // Create groups for layering const g = svg.append('g').attr('class', 'graph-layer'); const edgeGroup = g.append('g').attr('class', 'edges'); const nodeGroup = g.append('g').attr('class', 'nodes'); // Arrow marker definition svg.append('defs').append('marker') .attr('id', 'arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'edge-arrow'); // Arrow marker for cycle edges svg.select('defs').append('marker') .attr('id', 'arrow-cycle') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'edge-arrow cycle'); // Create simulation data with mutable copies const simNodes = nodes.map(n => ({...n})); const simEdges = edges.map(e => ({ ...e, source: e.source, target: e.target })); // Create node ID lookup const nodeById = new Map(simNodes.map(n => [n.id, n])); // Force simulation const simulation = d3.forceSimulation(simNodes) .force('link', d3.forceLink(simEdges) .id(d => d.id) .distance(config.linkDistance)) .force('charge', d3.forceManyBody() .strength(config.chargeStrength)) .force('center', d3.forceCenter(width / 2, height / 2) .strength(config.centerStrength)) .force('collide', d3.forceCollide() .radius(config.nodeRadius * config.collisionRadiusMultiplier)) .alphaDecay(config.alphaDecay) .velocityDecay(config.velocityDecay); // Zoom behavior const zoom = d3.zoom() .scaleExtent(config.zoomExtent) .on('zoom', (event) => { state.transform = event.transform; g.attr('transform', 'translate(' + event.transform.x + ',' + event.transform.y + ') scale(' + event.transform.k + ')'); updateZoomDisplay(); }); svg.call(zoom); // Draw edges function drawEdges() { const filteredEdges = getFilteredEdges(); const edge = edgeGroup.selectAll('.graph-edge') .data(filteredEdges, d => d.source.id + '-' + d.target.id); edge.exit().remove(); const edgeEnter = edge.enter() .append('g') .attr('class', 'graph-edge'); // Edge line edgeEnter.append('line') .attr('class', d => getEdgeClass(d)) .attr('marker-end', d => d.isInCycle ? 'url(#arrow-cycle)' : 'url(#arrow)') .on('mouseenter', handleEdgeMouseEnter) .on('mouseleave', handleEdgeMouseLeave) .on('click', handleEdgeClick); // Merge and update const edgeMerged = edgeEnter.merge(edge); edgeMerged.select('line') .attr('class', d => getEdgeClass(d)); return edgeMerged; } // Draw nodes function drawNodes() { const filteredNodes = getFilteredNodes(); const node = nodeGroup.selectAll('.graph-node') .data(filteredNodes, d => d.id); node.exit().remove(); const nodeEnter = node.enter() .append('g') .attr('class', 'graph-node') .call(drag(simulation)); // Node circle nodeEnter.append('circle') .attr('r', config.nodeRadius) .attr('class', d => getNodeClass(d)); // Node label nodeEnter.append('text') .attr('class', 'node-label') .attr('dy', config.nodeRadius + 12) .text(d => getNodeLabel(d)); // Merge and update const nodeMerged = nodeEnter.merge(node); nodeMerged.select('circle') .attr('class', d => getNodeClass(d)); // Event handlers nodeMerged .on('mouseenter', handleNodeMouseEnter) .on('mouseleave', handleNodeMouseLeave) .on('click', handleNodeClick); return nodeMerged; } // Get CSS class for edge function getEdgeClass(edge) { const classes = ['edge-line']; if (edge.isInCycle) classes.push('cycle'); classes.push('type-' + edge.type); return classes.join(' '); } // Get CSS class for node function getNodeClass(node) { const classes = ['node-circle']; // Cycle highlighting if (node.isInCycle) classes.push('cycle'); // Severity-based color (takes precedence) if (node.highestViolationSeverity) { classes.push('severity-' + node.highestViolationSeverity); } else if (node.layer) { // Layer-based color classes.push('layer-' + node.layer.toLowerCase()); } else { classes.push('default'); } return classes.join(' '); } // Get display label for node function getNodeLabel(node) { // Show just the filename const parts = node.name.split('/'); return parts[parts.length - 1] || node.name; } // Filtering function getFilteredNodes() { return simNodes.filter(node => { // Layer filter if (node.layer && !state.filters.layers.has(node.layer)) { return false; } // Severity filter - only filter out nodes with violations if their highest severity is not selected if (node.highestViolationSeverity && !state.filters.severities.has(node.highestViolationSeverity)) { return false; } // Cycles only if (state.filters.showCyclesOnly && !node.isInCycle) { return false; } // Violations only if (state.filters.showViolationsOnly && node.violations.length === 0) { return false; } // Search query if (state.filters.searchQuery) { const query = state.filters.searchQuery.toLowerCase(); if (!node.name.toLowerCase().includes(query) && !node.filePath.toLowerCase().includes(query) && !(node.packageName && node.packageName.toLowerCase().includes(query))) { return false; } } return true; }); } function getFilteredEdges() { const filteredNodeIds = new Set(getFilteredNodes().map(n => n.id)); return simEdges.filter(edge => { const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source; const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target; return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId); }); } // Drag behavior function drag(simulation) { function dragstarted(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } function dragended(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; } return d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended); } // Tooltip const tooltip = d3.select('.tooltip'); function handleNodeMouseEnter(event, d) { // Show tooltip showTooltip(event, d); // Highlight connected nodes highlightConnectedNodes(d); } function handleNodeMouseLeave(event, d) { hideTooltip(); clearHighlights(); } function handleNodeClick(event, d) { state.selectedNode = state.selectedNode === d ? null : d; if (state.selectedNode) { centerOnNode(d); } } function handleEdgeMouseEnter(event, d) { showEdgeTooltip(event, d); } function handleEdgeMouseLeave(event, d) { hideTooltip(); } function handleEdgeClick(event, d) { event.stopPropagation(); showEdgeTooltip(event, d); } function showTooltip(event, node) { const iconClass = node.highestViolationSeverity ? 'severity-' + node.highestViolationSeverity : node.isInCycle ? 'cycle' : 'default'; let violationsHtml = ''; if (node.violations.length > 0) { violationsHtml = '
' + '
Violations
' + node.violations.slice(0, 5).map(v => '
' + '
' + '
' + escapeHtml(v.message) + '
' + '
' ).join('') + (node.violations.length > 5 ? '
... and ' + (node.violations.length - 5) + ' more
' : '') + '
'; } const html = '
' + '
' + (node.isInCycle ? '↻' : node.violations.length > 0 ? '!' : '○') + '
' + '
' + '
' + escapeHtml(node.name) + '
' + '
' + escapeHtml(node.filePath) + '
' + '
' + '
' + '
' + '
' + 'Package' + '' + escapeHtml(node.packageName || 'N/A') + '' + '
' + '
' + 'Layer' + '' + escapeHtml(node.layer || 'Unknown') + '' + '
' + '
' + '
' + 'Imports' + '' + node.importsCount + '' + '
' + '
' + 'Imported by' + '' + node.importedByCount + '' + '
' + (node.isInCycle ? '
In CycleYes
' : '') + '
' + violationsHtml; tooltip.html(html); // Position tooltip const tooltipNode = tooltip.node(); const tooltipRect = tooltipNode.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left = event.clientX + 10; let top = event.clientY + 10; // Adjust if tooltip would overflow if (left + tooltipRect.width > viewportWidth - 20) { left = event.clientX - tooltipRect.width - 10; } if (top + tooltipRect.height > viewportHeight - 20) { top = event.clientY - tooltipRect.height - 10; } tooltip .style('left', left + 'px') .style('top', top + 'px') .classed('visible', true); } function showEdgeTooltip(event, edge) { const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source; const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target; const sourceNode = nodeById.get(sourceId); const targetNode = nodeById.get(targetId); if (!sourceNode || !targetNode) return; const iconClass = edge.isInCycle ? 'cycle' : 'default'; const importType = edge.type === 'static' ? 'Static Import' : edge.type === 'dynamic' ? 'Dynamic Import' : edge.type === 'type-only' ? 'Type-Only Import' : 'Require Import'; const html = '
' + '
' + (edge.isInCycle ? '↻' : '→') + '
' + '
' + '
Import Relationship
' + '
' + escapeHtml(importType) + '
' + '
' + '
' + '
' + '
' + 'From' + '' + escapeHtml(sourceNode.name) + '' + '
' + '
' + 'To' + '' + escapeHtml(targetNode.name) + '' + '
' + '
' + '
' + 'Type' + '' + escapeHtml(edge.type) + '' + '
' + (edge.isInCycle ? '
Part of CycleYes
' : '') + (edge.cycleId ? '
Cycle ID' + escapeHtml(edge.cycleId) + '
' : '') + '
'; tooltip.html(html); // Position tooltip const tooltipNode = tooltip.node(); const tooltipRect = tooltipNode.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left = event.clientX + 10; let top = event.clientY + 10; // Adjust if tooltip would overflow if (left + tooltipRect.width > viewportWidth - 20) { left = event.clientX - tooltipRect.width - 10; } if (top + tooltipRect.height > viewportHeight - 20) { top = event.clientY - tooltipRect.height - 10; } tooltip .style('left', left + 'px') .style('top', top + 'px') .classed('visible', true); } function hideTooltip() { tooltip.classed('visible', false); } // Highlighting function highlightConnectedNodes(node) { const connectedIds = new Set([node.id]); // Find connected nodes simEdges.forEach(edge => { const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source; const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target; if (sourceId === node.id) connectedIds.add(targetId); if (targetId === node.id) connectedIds.add(sourceId); }); state.highlightedNodes = connectedIds; // Update visual state nodeGroup.selectAll('.graph-node') .classed('dimmed', d => !connectedIds.has(d.id)) .classed('highlighted', d => connectedIds.has(d.id)); edgeGroup.selectAll('.graph-edge') .classed('dimmed', d => { const sourceId = typeof d.source === 'object' ? d.source.id : d.source; const targetId = typeof d.target === 'object' ? d.target.id : d.target; return sourceId !== node.id && targetId !== node.id; }) .classed('highlighted', d => { const sourceId = typeof d.source === 'object' ? d.source.id : d.source; const targetId = typeof d.target === 'object' ? d.target.id : d.target; return sourceId === node.id || targetId === node.id; }); } function clearHighlights() { state.highlightedNodes.clear(); nodeGroup.selectAll('.graph-node') .classed('dimmed', false) .classed('highlighted', false); edgeGroup.selectAll('.graph-edge') .classed('dimmed', false) .classed('highlighted', false); } // Center on node function centerOnNode(node) { const scale = state.transform.k; const x = width / 2 - node.x * scale; const y = height / 2 - node.y * scale; svg.transition() .duration(500) .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); } // Zoom controls function zoomIn() { svg.transition().call(zoom.scaleBy, 1.3); } function zoomOut() { svg.transition().call(zoom.scaleBy, 0.7); } function zoomReset() { svg.transition().call(zoom.transform, d3.zoomIdentity); } function updateZoomDisplay() { const percentage = Math.round(state.transform.k * 100); const zoomLevel = document.querySelector('.zoom-level'); if (zoomLevel) { zoomLevel.textContent = percentage + '%'; } } // Filter controls function setLayerFilter(layer, enabled) { if (enabled) { state.filters.layers.add(layer); } else { state.filters.layers.delete(layer); } updateGraph(); } function setSeverityFilter(severity, enabled) { if (enabled) { state.filters.severities.add(severity); } else { state.filters.severities.delete(severity); } updateGraph(); } function setViewMode(mode) { state.filters.showCyclesOnly = mode === 'cycles'; state.filters.showViolationsOnly = mode === 'violations'; updateGraph(); } function setSearchQuery(query) { state.filters.searchQuery = query; updateGraph(); } // Update graph when filters change function updateGraph() { drawEdges(); drawNodes(); simulation.alpha(0.3).restart(); } // Simulation tick handler simulation.on('tick', () => { edgeGroup.selectAll('.graph-edge line') .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); nodeGroup.selectAll('.graph-node') .attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); }); // Helper functions function escapeHtml(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Initialize drawEdges(); drawNodes(); // Update statistics display function updateStatistics() { const filtered = getFilteredNodes(); const filteredEdges = getFilteredEdges(); document.getElementById('stat-nodes').textContent = filtered.length; document.getElementById('stat-edges').textContent = filteredEdges.length; document.getElementById('stat-cycles').textContent = cycles.length; // Update severity counts const severityCounts = { critical: 0, error: 0, warning: 0, info: 0 }; filtered.forEach(n => { n.violations.forEach(v => { severityCounts[v.severity]++; }); }); Object.keys(severityCounts).forEach(severity => { const el = document.getElementById('stat-' + severity); if (el) el.textContent = severityCounts[severity]; }); } updateStatistics(); // Expose API for control panel window.graphAPI = { zoomIn, zoomOut, zoomReset, setLayerFilter, setSeverityFilter, setViewMode, setSearchQuery, updateGraph, centerOnNode: (nodeId) => { const node = nodeById.get(nodeId); if (node) centerOnNode(node); } }; // Hide loading overlay const loadingOverlay = document.querySelector('.loading-overlay'); if (loadingOverlay) { loadingOverlay.style.display = 'none'; } console.log('Dependency graph visualization initialized with', nodes.length, 'nodes and', edges.length, 'edges'); })(); ` } /** * Generates the control panel JavaScript code for filter interactions. * * @returns JavaScript code for control panel functionality */ export function generateControlPanelScript(): string { return ` /** * Control Panel Script * Handles user interactions with filter controls */ (function() { 'use strict'; // Wait for graph API to be available function waitForGraphAPI(callback) { if (window.graphAPI) { callback(); } else { setTimeout(() => waitForGraphAPI(callback), 100); } } waitForGraphAPI(() => { const api = window.graphAPI; // Zoom controls document.getElementById('zoom-in')?.addEventListener('click', api.zoomIn); document.getElementById('zoom-out')?.addEventListener('click', api.zoomOut); document.getElementById('zoom-reset')?.addEventListener('click', api.zoomReset); // View mode buttons document.querySelectorAll('.view-mode-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.view-mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); api.setViewMode(btn.dataset.mode); }); }); // Search input const searchInput = document.getElementById('search-input'); if (searchInput) { let debounceTimer; searchInput.addEventListener('input', (e) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { api.setSearchQuery(e.target.value); }, 300); }); } // Layer checkboxes document.querySelectorAll('.layer-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { api.setLayerFilter(e.target.dataset.layer, e.target.checked); }); }); // Severity checkboxes document.querySelectorAll('.severity-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { api.setSeverityFilter(e.target.dataset.severity, e.target.checked); }); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Only if not focused on input if (document.activeElement.tagName === 'INPUT') return; switch (e.key) { case '+': case '=': api.zoomIn(); break; case '-': case '_': api.zoomOut(); break; case '0': api.zoomReset(); break; case 'Escape': api.setSearchQuery(''); if (searchInput) searchInput.value = ''; break; } }); console.log('Control panel initialized'); }); })(); ` } /** * Generates the complete HTML template with all visualization scripts and styles. * * @param _data - The visualization data to embed (used by html-renderer.ts) * @param title - The title for the HTML document * @returns Complete HTML document as a string */ export function generateHtmlTemplate(_data: VisualizationData, title: string): string { // This is a template function - actual implementation is in html-renderer.ts // This provides the structure for the HTML output return ` ${title}
` } /** * Gets the node radius based on its characteristics. * * @param node - Node data * @param node.importsCount - Number of imports from this node * @param node.importedByCount - Number of nodes that import this one * @param node.violations - Array of violations for this node * @param baseRadius - Base radius size * @returns Calculated radius for the node */ export function calculateNodeRadius( node: {importsCount: number; importedByCount: number; violations: readonly unknown[]}, baseRadius: number, ): number { // Scale by connectivity (clamped) const connectivity = Math.min(node.importsCount + node.importedByCount, 20) const connectivityScale = 1 + connectivity * 0.05 // Boost for violations const violationBoost = node.violations.length > 0 ? 1.2 : 1 return Math.round(baseRadius * connectivityScale * violationBoost) } /** * Calculates the optimal link distance based on graph density. * * @param nodeCount - Number of nodes * @param edgeCount - Number of edges * @param baseDistance - Base link distance * @returns Optimal link distance */ export function calculateLinkDistance( nodeCount: number, edgeCount: number, baseDistance: number, ): number { // Denser graphs need more spacing const density = edgeCount / Math.max(nodeCount, 1) const densityFactor = Math.min(1 + density * 0.1, 2) return Math.round(baseDistance * densityFactor) } /** * Calculates charge strength based on graph size. * * @param nodeCount - Number of nodes * @param baseStrength - Base charge strength (negative for repulsion) * @returns Optimal charge strength */ export function calculateChargeStrength(nodeCount: number, baseStrength: number): number { // Larger graphs need less repulsion to avoid spreading too far if (nodeCount > 200) { return baseStrength * 0.5 } if (nodeCount > 100) { return baseStrength * 0.7 } return baseStrength }