Files
bun.sh/src/bundler/graph_visualizer.html
Claude c275a93bdb wip
2025-09-10 09:14:04 +02:00

1087 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bun Bundler Graph Visualizer</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre-d3@0.6.4/dist/dagre-d3.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1e1e1e;
color: #d4d4d4;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
background: #2d2d30;
padding: 10px 20px;
border-bottom: 1px solid #3e3e42;
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
#header h1 {
font-size: 20px;
font-weight: 600;
color: #fff;
}
#controls {
display: flex;
gap: 10px;
align-items: center;
}
#controls button, #controls select, #controls input {
background: #3e3e42;
color: #cccccc;
border: 1px solid #555;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
}
#controls button:hover, #controls select:hover {
background: #4e4e52;
}
#container {
display: flex;
flex: 1;
overflow: hidden;
}
#sidebar {
width: 350px;
background: #252526;
border-right: 1px solid #3e3e42;
display: flex;
flex-direction: column;
overflow: hidden;
}
#tabs {
display: flex;
background: #2d2d30;
border-bottom: 1px solid #3e3e42;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-right: 1px solid #3e3e42;
font-size: 13px;
user-select: none;
}
.tab.active {
background: #1e1e1e;
color: #fff;
}
#tab-content {
flex: 1;
overflow-y: auto;
padding: 15px;
}
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#graph-container {
flex: 1;
background: #1e1e1e;
overflow: hidden;
position: relative;
}
#graph-svg {
width: 100%;
height: 100%;
}
#details {
height: 200px;
background: #252526;
border-top: 1px solid #3e3e42;
overflow-y: auto;
padding: 15px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.file-item, .symbol-item, .chunk-item {
padding: 8px;
margin: 2px 0;
background: #2d2d30;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
word-break: break-all;
}
.file-item:hover, .symbol-item:hover, .chunk-item:hover {
background: #3e3e42;
}
.file-item.selected, .symbol-item.selected, .chunk-item.selected {
background: #094771;
border: 1px solid #007ACC;
}
.search-box {
width: 100%;
padding: 8px;
background: #3e3e42;
border: 1px solid #555;
color: #cccccc;
margin-bottom: 10px;
border-radius: 3px;
font-size: 12px;
}
.node {
cursor: pointer;
}
.node rect {
stroke: #333;
stroke-width: 1.5px;
}
.node.entry-point rect {
stroke: #4EC9B0;
stroke-width: 2px;
fill: #2d5a4e !important;
}
.node.has-exports rect {
stroke: #569CD6;
stroke-width: 2px;
}
.node.css-file rect {
fill: #4e3a5a !important;
}
.edge {
stroke: #555;
stroke-width: 1.5px;
fill: none;
marker-end: url(#arrowhead);
}
.edge.dynamic-import {
stroke-dasharray: 5, 5;
stroke: #CE9178;
}
.edge.css-import {
stroke: #b267e6;
}
#legend {
position: absolute;
top: 10px;
right: 10px;
background: rgba(30, 30, 30, 0.95);
border: 1px solid #3e3e42;
padding: 10px;
border-radius: 3px;
font-size: 12px;
}
.legend-item {
margin: 5px 0;
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 14px;
border-radius: 2px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 15px;
}
.stat-item {
background: #2d2d30;
padding: 10px;
border-radius: 3px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #4EC9B0;
}
.stat-label {
font-size: 11px;
color: #808080;
margin-top: 3px;
}
pre {
background: #2d2d30;
padding: 10px;
border-radius: 3px;
overflow-x: auto;
}
.highlight {
background: #3e5c1e;
animation: pulse 1s;
}
@keyframes pulse {
0% { background: #5e8c2e; }
100% { background: #3e5c1e; }
}
</style>
</head>
<body>
<div id="header">
<h1>🚀 Bun Bundler Graph Visualizer</h1>
<div id="controls">
<input type="file" id="file-input" accept=".json" style="display: none;">
<button onclick="document.getElementById('file-input').click()">📁 Load JSON</button>
<select id="view-mode">
<option value="files">Files View</option>
<option value="chunks">Chunks View</option>
<option value="dependencies">Dependencies View</option>
<option value="symbols">Symbols View</option>
</select>
<select id="layout-mode">
<option value="dagre">Hierarchical</option>
<option value="force">Force-Directed</option>
<option value="circular">Circular</option>
</select>
<button id="zoom-fit">🔍 Fit</button>
<button id="zoom-in"></button>
<button id="zoom-out"></button>
<button id="export-svg">💾 Export SVG</button>
</div>
</div>
<div id="container">
<div id="sidebar">
<div id="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="files">Files</div>
<div class="tab" data-tab="symbols">Symbols</div>
<div class="tab" data-tab="chunks">Chunks</div>
</div>
<div id="tab-content">
<div id="overview-content" class="tab-pane">
<h3 style="margin-bottom: 15px;">Graph Statistics</h3>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="stat-files">-</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-reachable">-</div>
<div class="stat-label">Reachable Files</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-chunks">-</div>
<div class="stat-label">Chunks</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-symbols">-</div>
<div class="stat-label">Symbols</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-exports">-</div>
<div class="stat-label">Exports</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-imports">-</div>
<div class="stat-label">Imports</div>
</div>
</div>
<h3 style="margin: 15px 0;">Metadata</h3>
<div id="metadata-content"></div>
</div>
<div id="files-content" class="tab-pane" style="display: none;">
<input type="text" class="search-box" placeholder="Search files..." id="files-search">
<div id="files-list"></div>
</div>
<div id="symbols-content" class="tab-pane" style="display: none;">
<input type="text" class="search-box" placeholder="Search symbols..." id="symbols-search">
<div id="symbols-list"></div>
</div>
<div id="chunks-content" class="tab-pane" style="display: none;">
<div id="chunks-list"></div>
</div>
</div>
</div>
<div id="main">
<div id="graph-container">
<svg id="graph-svg">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
</marker>
</defs>
</svg>
<div id="legend">
<div class="legend-item">
<div class="legend-color" style="background: #2d5a4e; border: 2px solid #4EC9B0;"></div>
<span>Entry Point</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #3e3e42; border: 2px solid #569CD6;"></div>
<span>Has Exports</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #4e3a5a;"></div>
<span>CSS File</span>
</div>
<div class="legend-item">
<div style="width: 20px; height: 2px; background: #CE9178; border-bottom: 2px dashed #CE9178;"></div>
<span>Dynamic Import</span>
</div>
</div>
</div>
<div id="details">
<div style="color: #808080;">Select a node to view details...</div>
</div>
</div>
</div>
<script>
let graphData = null;
let currentView = 'files';
let currentLayout = 'dagre';
let svg = d3.select('#graph-svg');
let g = svg.append('g');
let zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
tab.classList.add('active');
document.getElementById(tab.dataset.tab + '-content').style.display = 'block';
});
});
// File input handler
document.getElementById('file-input').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (file) {
const text = await file.text();
graphData = JSON.parse(text);
updateUI();
renderGraph();
}
});
// View mode change
document.getElementById('view-mode').addEventListener('change', (event) => {
currentView = event.target.value;
renderGraph();
});
// Layout mode change
document.getElementById('layout-mode').addEventListener('change', (event) => {
currentLayout = event.target.value;
renderGraph();
});
// Zoom controls
document.getElementById('zoom-fit').addEventListener('click', () => {
const bounds = g.node().getBBox();
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const scale = 0.9 * Math.min(width / bounds.width, height / bounds.height);
const transform = d3.zoomIdentity
.translate(width / 2 - scale * (bounds.x + bounds.width / 2),
height / 2 - scale * (bounds.y + bounds.height / 2))
.scale(scale);
svg.transition().duration(750).call(zoom.transform, transform);
});
document.getElementById('zoom-in').addEventListener('click', () => {
svg.transition().duration(300).call(zoom.scaleBy, 1.3);
});
document.getElementById('zoom-out').addEventListener('click', () => {
svg.transition().duration(300).call(zoom.scaleBy, 0.7);
});
// Export SVG
document.getElementById('export-svg').addEventListener('click', () => {
const svgData = svg.node().outerHTML;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bundler-graph-${Date.now()}.svg`;
a.click();
URL.revokeObjectURL(url);
});
function updateUI() {
if (!graphData) return;
// Update stats
document.getElementById('stat-files').textContent =
graphData.metadata?.total_files || '-';
document.getElementById('stat-reachable').textContent =
graphData.metadata?.reachable_files || '-';
document.getElementById('stat-chunks').textContent =
graphData.chunks?.length || '-';
document.getElementById('stat-symbols').textContent =
graphData.symbols?.total_symbols || '-';
document.getElementById('stat-exports').textContent =
graphData.imports_and_exports?.total_exports || '-';
document.getElementById('stat-imports').textContent =
graphData.imports_and_exports?.total_imports || '-';
// Update metadata
const metadataHtml = Object.entries(graphData.metadata || {})
.filter(([key]) => !key.startsWith('total'))
.map(([key, value]) => `
<div style="margin: 5px 0;">
<strong>${key}:</strong> ${value}
</div>
`).join('');
document.getElementById('metadata-content').innerHTML = metadataHtml;
// Update files list
const filesList = graphData.files || [];
const filesHtml = filesList.map((file, idx) => `
<div class="file-item" data-index="${idx}">
<strong>${file.path || `File ${idx}`}</strong>
<div style="font-size: 11px; color: #808080;">
${file.loader || 'unknown'} | ${file.part_count || 0} parts
</div>
</div>
`).join('');
document.getElementById('files-list').innerHTML = filesHtml;
// Update symbols list
const symbolsHtml = (graphData.symbols?.by_source || [])
.flatMap(source =>
(source.symbols || []).map(symbol => `
<div class="symbol-item">
<strong>${symbol.original_name}</strong>
<div style="font-size: 11px; color: #808080;">
${symbol.kind} | Source ${source.source_index}
</div>
</div>
`)
).join('');
document.getElementById('symbols-list').innerHTML = symbolsHtml;
// Update chunks list
const chunksHtml = (graphData.chunks || []).map((chunk, idx) => `
<div class="chunk-item" data-index="${idx}">
<strong>Chunk ${idx}</strong>
<div style="font-size: 11px; color: #808080;">
${chunk.is_entry_point ? 'Entry Point | ' : ''}
${chunk.files_in_chunk?.length || 0} files
</div>
</div>
`).join('');
document.getElementById('chunks-list').innerHTML = chunksHtml;
// Remove old search listeners and add new ones
const filesSearch = document.getElementById('files-search');
const newFilesSearch = filesSearch.cloneNode(true);
filesSearch.parentNode.replaceChild(newFilesSearch, filesSearch);
newFilesSearch.addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.file-item').forEach(item => {
const visible = item.textContent.toLowerCase().includes(search);
item.style.display = visible ? 'block' : 'none';
});
});
const symbolsSearch = document.getElementById('symbols-search');
const newSymbolsSearch = symbolsSearch.cloneNode(true);
symbolsSearch.parentNode.replaceChild(newSymbolsSearch, symbolsSearch);
newSymbolsSearch.addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.symbol-item').forEach(item => {
const visible = item.textContent.toLowerCase().includes(search);
item.style.display = visible ? 'block' : 'none';
});
});
// Add click handlers for selection
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
const idx = parseInt(item.dataset.index);
showFileDetails(graphData.files[idx], idx);
});
});
}
function showFileDetails(data, index) {
let detailsHtml = '';
// Check if it's a chunk or a file
if (data.final_path !== undefined || data.content_type !== undefined) {
// It's a chunk
detailsHtml = `
<h3>Chunk ${index}</h3>
<div style="margin-bottom: 10px;">
<strong>Path:</strong> ${data.final_path || 'N/A'}<br>
<strong>Type:</strong> ${data.content_type || 'N/A'}<br>
<strong>Entry Point:</strong> ${data.is_entry_point ? 'Yes' : 'No'}<br>
<strong>Files in Chunk:</strong> ${data.files_in_chunk?.length || 0}<br>
<strong>Cross-Chunk Imports:</strong> ${data.cross_chunk_import_count || 0}
</div>
`;
// Add sourcemap viewer if available
if (data.sourcemap_data) {
detailsHtml += `
<div style="margin-top: 15px;">
<h4>Source Map Data</h4>
<div style="background: #2d2d30; padding: 10px; border-radius: 3px; margin-top: 5px;">
<div style="color: #4EC9B0; margin-bottom: 5px;">VLQ-encoded mappings available</div>
<textarea readonly style="width: 100%; height: 100px; background: #1e1e1e; color: #d4d4d4; border: 1px solid #3e3e42; padding: 5px; font-family: monospace; font-size: 11px;">${data.sourcemap_data.substring(0, 500)}${data.sourcemap_data.length > 500 ? '...' : ''}</textarea>
<div style="color: #808080; font-size: 11px; margin-top: 5px;">
Total length: ${data.sourcemap_data.length} characters
</div>
</div>
</div>
`;
}
// Add output snippet if available
if (data.output_snippet) {
detailsHtml += `
<div style="margin-top: 15px;">
<h4>Output Preview</h4>
<pre style="background: #2d2d30; padding: 10px; border-radius: 3px; overflow-x: auto; max-height: 200px;">${escapeHtml(data.output_snippet)}</pre>
</div>
`;
}
// Add full data in collapsible section
detailsHtml += `
<details style="margin-top: 15px;">
<summary style="cursor: pointer; color: #4EC9B0;">Full Chunk Data</summary>
<pre style="margin-top: 10px; background: #2d2d30; padding: 10px; border-radius: 3px; overflow-x: auto;">${JSON.stringify(data, null, 2)}</pre>
</details>
`;
} else {
// It's a file
detailsHtml = `
<h3>File: ${data.path || `File ${index}`}</h3>
<div style="margin-bottom: 10px;">
<strong>Loader:</strong> ${data.loader || 'N/A'}<br>
<strong>Source Length:</strong> ${data.source_length || 0} bytes<br>
<strong>Entry Point:</strong> ${data.entry_point_kind || 'none'}<br>
<strong>Parts:</strong> ${data.part_count || 0}<br>
<strong>Named Exports:</strong> ${data.named_exports_count || 0}<br>
<strong>Named Imports:</strong> ${data.named_imports_count || 0}
</div>
`;
// Add source snippet if available
if (data.source_snippet) {
const preview = data.source_snippet.substring(0, 500);
detailsHtml += `
<div style="margin-top: 15px;">
<h4>Source Preview</h4>
<pre style="background: #2d2d30; padding: 10px; border-radius: 3px; overflow-x: auto; max-height: 200px;">${escapeHtml(preview)}${data.source_snippet.length > 500 ? '...' : ''}</pre>
</div>
`;
}
// Add full data in collapsible section
detailsHtml += `
<details style="margin-top: 15px;">
<summary style="cursor: pointer; color: #4EC9B0;">Full File Data</summary>
<pre style="margin-top: 10px; background: #2d2d30; padding: 10px; border-radius: 3px; overflow-x: auto;">${JSON.stringify(data, null, 2)}</pre>
</details>
`;
}
document.getElementById('details').innerHTML = detailsHtml;
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderGraph() {
if (!graphData) return;
// Clear existing graph
g.selectAll('*').remove();
switch (currentView) {
case 'files':
renderFilesGraph();
break;
case 'chunks':
renderChunksGraph();
break;
case 'dependencies':
renderDependencyGraph();
break;
case 'symbols':
renderSymbolsGraph();
break;
}
}
function renderFilesGraph() {
const files = graphData.files || [];
const edges = graphData.dependency_graph?.edges || [];
// Create nodes
const nodes = files.map((file, idx) => ({
id: `file-${idx}`,
label: file.path?.split('/').pop() || `File ${idx}`,
data: file,
index: idx
}));
// Create edges from dependency graph
const edgeMap = new Map();
edges.forEach(edge => {
const key = `${edge.from.source}-${edge.to.source}`;
if (!edgeMap.has(key) && edge.from.source !== edge.to.source) {
edgeMap.set(key, {
source: `file-${edge.from.source}`,
target: `file-${edge.to.source}`
});
}
});
const uniqueEdges = Array.from(edgeMap.values());
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, uniqueEdges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, uniqueEdges);
} else {
renderCircularLayout(nodes, uniqueEdges);
}
}
function renderChunksGraph() {
const chunks = graphData.chunks || [];
const nodes = chunks.map((chunk, idx) => ({
id: `chunk-${idx}`,
label: `Chunk ${idx}`,
data: chunk,
index: idx
}));
// Create edges based on cross-chunk imports
const edges = [];
chunks.forEach((chunk, idx) => {
if (chunk.cross_chunk_imports && Array.isArray(chunk.cross_chunk_imports)) {
chunk.cross_chunk_imports.forEach(importInfo => {
edges.push({
source: `chunk-${idx}`,
target: `chunk-${importInfo.chunk_index}`
});
});
}
});
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, edges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, edges);
} else {
renderCircularLayout(nodes, edges);
}
}
function renderDependencyGraph() {
const edges = graphData.dependency_graph?.edges || [];
const files = graphData.files || [];
// Create unique nodes from edges
const nodeMap = new Map();
edges.forEach(edge => {
const fromId = `${edge.from.source}-${edge.from.part}`;
const toId = `${edge.to.source}-${edge.to.part}`;
if (!nodeMap.has(fromId)) {
const file = files[edge.from.source];
nodeMap.set(fromId, {
id: fromId,
label: `${file?.path?.split('/').pop() || edge.from.source}:${edge.from.part}`,
data: { source: edge.from.source, part: edge.from.part }
});
}
if (!nodeMap.has(toId)) {
const file = files[edge.to.source];
nodeMap.set(toId, {
id: toId,
label: `${file?.path?.split('/').pop() || edge.to.source}:${edge.to.part}`,
data: { source: edge.to.source, part: edge.to.part }
});
}
});
const nodes = Array.from(nodeMap.values());
const graphEdges = edges.slice(0, 500).map(edge => ({
source: `${edge.from.source}-${edge.from.part}`,
target: `${edge.to.source}-${edge.to.part}`
}));
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, graphEdges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, graphEdges);
} else {
renderCircularLayout(nodes, graphEdges);
}
}
function renderSymbolsGraph() {
const symbols = graphData.imports_and_exports || {};
const exports = symbols.exports || [];
const imports = symbols.imports || [];
// Create nodes for files with exports/imports
const nodeMap = new Map();
const edges = [];
exports.forEach(exp => {
const nodeId = `file-${exp.source}`;
if (!nodeMap.has(nodeId)) {
nodeMap.set(nodeId, {
id: nodeId,
label: `Source ${exp.source}`,
data: { exports: [], imports: [] }
});
}
nodeMap.get(nodeId).data.exports.push(exp);
});
imports.forEach(imp => {
const nodeId = `file-${imp.source}`;
if (!nodeMap.has(nodeId)) {
nodeMap.set(nodeId, {
id: nodeId,
label: `Source ${imp.source}`,
data: { exports: [], imports: [] }
});
}
nodeMap.get(nodeId).data.imports.push(imp);
if (imp.target_source !== undefined && imp.target_source !== null) {
edges.push({
source: nodeId,
target: `file-${imp.target_source}`
});
}
});
const nodes = Array.from(nodeMap.values());
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, edges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, edges);
} else {
renderCircularLayout(nodes, edges);
}
}
function renderDagreLayout(nodes, edges) {
// Create a new directed graph
const dagreGraph = new dagreD3.graphlib.Graph()
.setGraph({ rankdir: 'TB', nodesep: 50, ranksep: 50 })
.setDefaultEdgeLabel(() => ({}));
// Add nodes
nodes.forEach(node => {
dagreGraph.setNode(node.id, {
label: node.label,
width: Math.max(node.label.length * 7, 100),
height: 40,
rx: 5,
ry: 5
});
});
// Add edges
edges.forEach(edge => {
if (dagreGraph.hasNode(edge.source) && dagreGraph.hasNode(edge.target)) {
dagreGraph.setEdge(edge.source, edge.target);
}
});
// Layout
dagre.layout(dagreGraph);
// Render nodes
const nodeSelection = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entry_point_kind === 'user_specified') classes += ' entry-point';
if (d.data?.named_exports_count > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.attr('transform', d => {
const node = dagreGraph.node(d.id);
return `translate(${node.x},${node.y})`;
});
nodeSelection.append('rect')
.attr('x', d => -dagreGraph.node(d.id).width / 2)
.attr('y', -20)
.attr('width', d => dagreGraph.node(d.id).width)
.attr('height', 40)
.attr('rx', 5)
.attr('fill', '#3e3e42');
nodeSelection.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '12px')
.text(d => d.label);
// Render edges
g.selectAll('.edge')
.data(edges)
.enter()
.append('path')
.attr('class', 'edge')
.attr('d', d => {
const edge = dagreGraph.edge(d.source, d.target);
if (!edge || !edge.points) return '';
const line = d3.line()
.x(p => p.x)
.y(p => p.y)
.curve(d3.curveBasis);
return line(edge.points);
});
// Add interactivity
nodeSelection.on('click', (event, d) => {
showFileDetails(d.data, d.index);
// Highlight connected edges
g.selectAll('.edge')
.style('stroke', e =>
(e.source === d.id || e.target === d.id) ? '#4EC9B0' : '#555')
.style('stroke-width', e =>
(e.source === d.id || e.target === d.id) ? 2.5 : 1.5);
});
}
function renderForceLayout(nodes, edges) {
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(50));
// Render edges
const link = g.selectAll('.edge')
.data(edges)
.enter()
.append('line')
.attr('class', 'edge');
// Render nodes
const node = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entry_point_kind === 'user_specified') classes += ' entry-point';
if (d.data?.named_exports_count > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('rect')
.attr('x', -40)
.attr('y', -20)
.attr('width', 80)
.attr('height', 40)
.attr('rx', 5)
.attr('fill', '#3e3e42');
node.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '11px')
.text(d => d.label.length > 12 ? d.label.substring(0, 12) + '...' : d.label);
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
node.on('click', (event, d) => {
showFileDetails(d.data, d.index);
});
}
function renderCircularLayout(nodes, edges) {
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const radius = Math.min(width, height) / 2 - 100;
const centerX = width / 2;
const centerY = height / 2;
// Position nodes in a circle
nodes.forEach((node, i) => {
const angle = (i / nodes.length) * 2 * Math.PI;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
// Render edges
const link = g.selectAll('.edge')
.data(edges)
.enter()
.append('path')
.attr('class', 'edge')
.attr('d', d => {
const source = nodes.find(n => n.id === d.source);
const target = nodes.find(n => n.id === d.target);
if (!source || !target) return '';
// Create curved path for better visibility
const dx = target.x - source.x;
const dy = target.y - source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${source.x},${source.y}A${dr},${dr} 0 0,1 ${target.x},${target.y}`;
})
.style('fill', 'none');
// Render nodes
const node = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entry_point_kind === 'user_specified') classes += ' entry-point';
if (d.data?.named_exports_count > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.attr('transform', d => `translate(${d.x},${d.y})`);
node.append('circle')
.attr('r', 25)
.attr('fill', '#3e3e42');
node.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '10px')
.text(d => {
const label = d.label.split('/').pop();
return label.length > 10 ? label.substring(0, 10) + '...' : label;
});
node.on('click', (event, d) => {
showFileDetails(d.data, d.index);
});
}
// Auto-load if URL has a graph parameter
const urlParams = new URLSearchParams(window.location.search);
const graphFile = urlParams.get('graph');
if (graphFile) {
fetch(graphFile)
.then(res => res.json())
.then(data => {
graphData = data;
updateUI();
renderGraph();
})
.catch(err => console.error('Failed to load graph:', err));
}
</script>
</body>
</html>