/**
* 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 = '
';
}
const html = '' +
'' +
'
' +
'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 = '' +
'' +
'
' +
'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
}