<!DOCTYPE html>
<html>
<head>
<title>Component Dependency Graph</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
.node {
stroke: #fff;
stroke-width: 1.5px;
}
.link {
stroke: #999;
stroke-opacity: 0.6;
stroke-width: 1px;
}
.node text {
font-size: 12px;
font-family: Arial, sans-serif;
}
.tooltip {
position: absolute;
padding: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
}
#graph {
width: 100%;
height: 100vh;
overflow: hidden;
}
svg {
width: 100%;
height: 100%;
}
.search-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.search-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 200px;
font-size: 14px;
}
.export-container {
position: fixed;
top: 20px;
left: 20px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
gap: 8px;
}
.export-button {
padding: 8px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.export-button:hover {
background: #f0f0f0;
}
.legend {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 12px;
max-width: 300px;
}
.legend-title {
font-weight: bold;
margin-bottom: 8px;
font-size: 14px;
}
.legend-section {
margin-bottom: 12px;
}
.legend-section-title {
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
.legend-item {
display: flex;
align-items: center;
margin: 4px 0;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
border: 1px solid rgba(0,0,0,0.1);
}
.legend-line {
width: 20px;
height: 2px;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="search-container">
<input type="text"
class="search-input"
placeholder="Search components..."
oninput="filterNodes(this.value)">
</div>
<div class="export-container">
<button class="export-button" onclick="exportAsSVG()">Export SVG</button>
<button class="export-button" onclick="exportAsPNG()">Export PNG</button>
</div>
<div class="legend">
<div class="legend-title">Legend</div>
<div class="legend-section">
<div class="legend-section-title">Projects</div>
<div id="project-legend"></div>
</div>
<div class="legend-section">
<div class="legend-section-title">Dependencies</div>
<div class="legend-item">
<div class="legend-line" style="background: #999;"></div>
<span>Internal dependency</span>
</div>
<div class="legend-item">
<div class="legend-line" style="background: #ff7f0e;"></div>
<span>Cross-project dependency</span>
</div>
</div>
</div>
<div id="graph"></div>
<script>
(function initComponentGraph() {
const projectsData = [{"graph":{"components":[{"id":"11611080489164640768","name":"Button","path":"source-lib/src/components/Button.tsx","props":{"disabled":1,"label":1,"onClick":1,"variant":1}},{"id":"11611080489164640769","name":"Input","path":"source-lib/src/components/Input.tsx","props":{"onChange":1,"placeholder":1,"type":1,"value":1}},{"id":"11611080489164640770","name":"Card","path":"source-lib/src/components/Card.tsx","props":{"children":1,"padding":1,"title":1}},{"id":"11611080489164640771","name":"Modal","path":"source-lib/src/components/Modal.tsx","props":{"children":1,"isOpen":1,"onClose":1,"title":1}}],"edges":[{"from":"11611080489164640771","project_context":"source-lib","to":"11611080489164640770"},{"from":"11611080489164640771","project_context":"source-lib","to":"11611080489164640768"},{"from":"11611080489164640770","project_context":"source-lib","to":"11611080489164640768"},{"from":"11611080489164640770","project_context":"source-lib","to":"11611080489164640769"}]},"name":"source-lib"},{"graph":{"components":[{"id":"14300231078674835378","name":"App","path":"consumer-app/src/App.tsx","props":{}},{"id":"14300231078674835379","name":"LoginForm","path":"consumer-app/src/components/LoginForm.tsx","props":{"error":1,"onSubmit":1}},{"id":"14300231078674835380","name":"UserProfile","path":"consumer-app/src/components/UserProfile.tsx","props":{"onEdit":1,"user":1}},{"id":"14300231078674835381","name":"SettingsModal","path":"consumer-app/src/components/SettingsModal.tsx","props":{"isOpen":1,"onClose":1,"settings":1}}],"edges":[{"from":"14300231078674835378","project_context":"source-lib","to":"11611080489164640768"},{"from":"14300231078674835378","project_context":"consumer-app","to":"14300231078674835379"},{"from":"14300231078674835378","project_context":"consumer-app","to":"14300231078674835380"},{"from":"14300231078674835379","project_context":"source-lib","to":"11611080489164640768"},{"from":"14300231078674835379","project_context":"source-lib","to":"11611080489164640769"},{"from":"14300231078674835380","project_context":"source-lib","to":"11611080489164640770"},{"from":"14300231078674835380","project_context":"consumer-app","to":"14300231078674835381"},{"from":"14300231078674835381","project_context":"source-lib","to":"11611080489164640771"}]},"name":"consumer-app"}];
console.log('Projects data:', projectsData);
let simulation = null;
let globalNodeMap = new Map();
// Create color scale for projects
const projectColors = new Map();
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
projectsData.forEach((project, i) => {
projectColors.set(project.name, colorScale(i));
});
// Initialize project legend
const projectLegend = document.getElementById('project-legend');
projectsData.forEach(project => {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `
<div class="legend-color" style="background: ${projectColors.get(project.name)};"></div>
<span>${project.name}</span>
`;
projectLegend.appendChild(item);
});
const width = window.innerWidth;
const height = window.innerHeight;
let svg = createSvg(width, height);
let g = svg.append('g');
// Store zoom behavior so we can programmatically transform
let zoom = setupZoom(svg, g);
// Initialize graph with all components
initGraph(projectsData);
// New search: mark nodes, update results list, and attach click to zoom.
window.filterNodes = function(searchTerm) {
const term = searchTerm.toLowerCase();
d3.selectAll('.node-group')
.transition()
.duration(200)
.attr('opacity', d => d.name.toLowerCase().includes(term) ? 1 : 0.2);
d3.selectAll('.link')
.transition()
.duration(200)
.attr('opacity', d =>
(d.source.name.toLowerCase().includes(term) ||
d.target.name.toLowerCase().includes(term)) ? 1 : 0.2);
};
window.exportAsSVG = function() {
exportSvg(svg);
};
window.exportAsPNG = function() {
exportPng(svg);
};
document.addEventListener('keydown', handleKeydown);
function initGraph(projectsData) {
console.log('Initializing graph with data:', projectsData);
// Combine all components and edges
const allComponents = projectsData.flatMap(project =>
project.graph.components.map(component => ({
...component,
project_context: project.name
}))
);
const allEdges = projectsData.flatMap(project =>
project.graph.edges.map(edge => ({
...edge,
project_context: project.name
}))
);
// Initialize global node map with string IDs
globalNodeMap.clear();
allComponents.forEach(node => {
node.id = String(node.id);
globalNodeMap.set(node.id, node);
});
const incomingEdgeCounts = calculateIncomingEdgeCounts(allEdges);
const tooltip = createTooltip();
addArrowheadMarker(svg);
simulation = createSimulation(width, height, allComponents, allEdges);
renderGraph(allComponents, allEdges, { svg, g, simulation, tooltip, incomingEdgeCounts, projectColors, globalNodeMap });
}
function createSimulation(width, height, nodes, edges) {
console.log('Creating simulation with nodes:', nodes);
console.log('Creating simulation with edges:', edges);
// Convert edges to use node objects instead of IDs
const links = edges.map(edge => {
const source = globalNodeMap.get(edge.from);
const target = globalNodeMap.get(edge.to);
console.log('Creating link:', {
from: edge.from,
to: edge.to,
source: source ? { id: source.id, name: source.name, project: source.project_context } : null,
target: target ? { id: target.id, name: target.name, project: target.project_context } : null
});
if (!source || !target) {
console.error('Missing node:', { from: edge.from, to: edge.to, source, target });
return null;
}
return {
source,
target,
project_context: edge.project_context
};
}).filter(link => link !== null);
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links)
.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));
return sim;
}
function renderGraph(components, edges, context) {
const { simulation, g, tooltip, incomingEdgeCounts, projectColors, globalNodeMap } = context;
// Convert edges to use node objects
const linksData = edges.map(edge => {
const source = globalNodeMap.get(String(edge.from));
const target = globalNodeMap.get(String(edge.to));
console.log('Rendering link:', {
from: edge.from,
to: edge.to,
source: source ? { id: source.id, name: source.name, project: source.project_context } : null,
target: target ? { id: target.id, name: target.name, project: target.project_context } : null,
project_context: edge.project_context
});
if (!source || !target) {
console.error('Missing node:', { from: edge.from, to: edge.to, source, target });
return null;
}
return {
source,
target,
project_context: edge.project_context
};
}).filter(link => link !== null);
// Add links
const link = g.append('g')
.selectAll('line')
.data(linksData)
.enter()
.append('line')
.attr('class', 'link')
.style('stroke', d => d.source.project_context === d.target.project_context ? '#999' : '#ff7f0e')
.style('stroke-opacity', d => d.source.project_context === d.target.project_context ? 0.6 : 0.8)
.style('stroke-width', d => d.source.project_context === d.target.project_context ? 1 : 2)
.attr('marker-end', d => d.source.project_context === d.target.project_context ? 'url(#arrowhead-internal)' : 'url(#arrowhead-external)');
// Add nodes
const node = g.append('g')
.selectAll('g')
.data(components)
.enter()
.append('g')
.attr('class', 'node-group')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('circle')
.attr('class', 'node')
.attr('r', 8)
.style('fill', d => projectColors.get(d.project_context));
node.append('text')
.attr('dx', 12)
.attr('dy', '.35em')
.text(d => d.name);
// Add tooltips
node.on('mouseover', function(event, d) {
tooltip.transition()
.duration(200)
.style('opacity', .9);
tooltip.html(`
<strong>${d.name}</strong><br/>
Project: ${d.project_context}<br/>
Path: ${d.path}<br/>
Props: ${Object.keys(d.props).join(', ')}
`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px');
})
.on('mouseout', function() {
tooltip.transition()
.duration(500)
.style('opacity', 0);
});
function ticked() {
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})`);
}
simulation.nodes(components).on('tick', ticked);
simulation.force('link').links(linksData);
simulation.alpha(1).restart();
}
function calculateIncomingEdgeCounts(edges) {
const counts = {};
edges.forEach(edge => {
counts[edge.to] = (counts[edge.to] || 0) + 1;
});
return counts;
}
function createSvg (width, height) {
return d3.select('#graph')
.append('svg')
.attr('width', width)
.attr('height', height)
}
function createTooltip () {
return d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0)
}
// Return the zoom behavior so we can later call zoom.transform
function setupZoom (svg, container) {
const zoomBehavior = d3.zoom()
.scaleExtent([0.5, 4])
.on('zoom', event => {
container.attr('transform', event.transform)
})
svg.call(zoomBehavior)
return zoomBehavior
}
function addArrowheadMarker (svg) {
// Internal dependency marker
svg.append('defs')
.append('marker')
.attr('id', 'arrowhead-internal')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 18)
.attr('refY', 0)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#999');
// External dependency marker
svg.append('defs')
.append('marker')
.attr('id', 'arrowhead-external')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 13.8)
.attr('refY', 0)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#ff7f0e');
}
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)
}
function formatProps (props) {
return Object.entries(props)
.map(([prop, count]) => `${prop}(${count})`)
.join(', ')
}
function handleKeydown (e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
document.querySelector('.search-input').focus()
}
if (e.key === 'Escape') {
const input = document.querySelector('.search-input')
input.value = ''
window.filterNodes('')
}
}
function cloneAndPrepareSvg (svgElement, bbox) {
const clonedSvg = svgElement.cloneNode(true)
const styleElement = document.createElement('style')
styleElement.textContent = `
.node {
stroke: #fff;
stroke-width: 1.5px;
}
.link {
stroke: #999;
stroke-opacity: 0.6;
stroke-width: 1px;
}
.node text {
font-size: 12px;
font-family: Arial, sans-serif;
}
marker#arrowhead path {
fill: #999;
}
`
clonedSvg.insertBefore(styleElement, clonedSvg.firstChild)
clonedSvg.setAttribute('width', bbox.width + bbox.x * 2)
clonedSvg.setAttribute('height', bbox.height + bbox.y * 2)
clonedSvg.setAttribute('viewBox', `${bbox.x} ${bbox.y} ${bbox.width + bbox.x} ${bbox.height + bbox.y}`)
return clonedSvg
}
function exportSvg(svgElement) {
const svgNode = svgElement.node ? svgElement.node() : svgElement
const bbox = svgNode.getBBox()
const clonedSvg = cloneAndPrepareSvg(svgNode, bbox)
const serializer = new XMLSerializer()
const source = serializer.serializeToString(clonedSvg)
const svgData = '<?xml version="1.0" standalone="no"?>\r\n' + source
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'component-graph.svg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function exportPng(svgElement) {
const svgNode = svgElement.node ? svgElement.node() : svgElement
const bbox = svgNode.getBBox()
const width = bbox.width + bbox.x * 2
const height = bbox.height + bbox.y * 2
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
const clonedSvg = cloneAndPrepareSvg(svgNode, bbox)
const serializer = new XMLSerializer()
const svgData = serializer.serializeToString(clonedSvg)
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const image = new Image()
image.onload = function () {
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(image, 0, 0)
const pngUrl = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.href = pngUrl
link.download = 'component-graph.png'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
image.src = url
}
})()
</script>
</body>
</html>