Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created April 21, 2025 10:45
Show Gist options
  • Select an option

  • Save shricodev/6c3577d486b96129616393971134ca5e to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/6c3577d486b96129616393971134ca5e to your computer and use it in GitHub Desktop.
Gemini 2.5 - Blog Dependency Visualizer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS Dependency Graph Visualizer</title>
<link rel="stylesheet" href="style.css">
<!-- Include Cytoscape.js -->
<script src="https://unpkg.com/cytoscape@3.23.0/dist/cytoscape.min.js"></script>
<!-- Include Tippy.js for tooltips (Optional but nice) -->
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="https://unpkg.com/tippy.js@6"></script>
<!-- Cytoscape extension for Tippy -->
<script src="https://unpkg.com/cytoscape-popper@2.0.0/cytoscape-popper.js"></script>
</head>
<body>
<h1>JavaScript Dependency Graph Visualizer</h1>
<div class="container">
<div class="input-area">
<h2>Enter your package.json content:</h2>
<textarea id="packageJsonInput" rows="15" placeholder="Paste package.json content here..."></textarea>
<div class="or-divider">OR</div>
<label for="packageJsonFile" class="file-upload-label">Upload package.json:</label>
<input type="file" id="packageJsonFile" accept=".json">
<button id="visualizeBtn">Visualize Dependencies</button>
<div id="status"></div>
<div class="options">
<label>
<input type="checkbox" id="includeDevDeps" checked> Include Dev Dependencies
</label>
</div>
</div>
<div class="graph-area">
<h2>Dependency Graph:</h2>
<div id="cy"></div>
<div class="legend">
<span>Root: <span class="node-shape root"></span></span>
<span>Prod Dep: <span class="node-shape prod"></span></span>
<span>Dev Dep: <span class="node-shape dev"></span></span>
<span>Transitive: <span class="node-shape transitive"></span></span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
const packageJsonInput = document.getElementById('packageJsonInput');
const packageJsonFile = document.getElementById('packageJsonFile');
const visualizeBtn = document.getElementById('visualizeBtn');
const statusDiv = document.getElementById('status');
const cyDiv = document.getElementById('cy');
const includeDevDepsCheckbox = document.getElementById('includeDevDeps');
let cy = null; // Cytoscape instance
const fetchedPackages = new Map(); // Cache for fetched package data (key: name@version)
const nodes = new Map(); // Use Map for easier node management (key: id)
const edges = new Set(); // Use Set to avoid duplicate edges (key: source->target)
// --- UI Event Listeners ---
packageJsonFile.addEventListener('change', handleFileUpload);
visualizeBtn.addEventListener('click', handleVisualizationRequest);
// --- Functions ---
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
packageJsonInput.value = e.target.result;
};
reader.onerror = () => {
setStatus('Error reading file.', 'error');
};
reader.readAsText(file);
}
async function handleVisualizationRequest() {
const jsonText = packageJsonInput.value.trim();
if (!jsonText) {
setStatus('Please paste or upload a package.json file.', 'error');
return;
}
let packageData;
try {
packageData = JSON.parse(jsonText);
} catch (error) {
setStatus(`Invalid JSON: ${error.message}`, 'error');
return;
}
if (!packageData.name) {
setStatus('package.json must have a "name" property.', 'error');
return;
}
setStatus('Processing... Fetching dependencies...', 'loading');
visualizeBtn.disabled = true;
fetchedPackages.clear();
nodes.clear();
edges.clear();
// Initialize Cytoscape if not already done
if (!cy) {
initCytoscape();
} else {
cy.elements().remove(); // Clear previous graph
}
const includeDevDeps = includeDevDepsCheckbox.checked;
const rootId = `${packageData.name}@${packageData.version || 'local'}`;
addNode(rootId, packageData.name, packageData.version || 'local', 'root');
try {
const dependencies = {
...(packageData.dependencies || {}),
...(includeDevDeps ? (packageData.devDependencies || {}) : {}),
};
const depPromises = Object.entries(dependencies).map(([name, version]) => {
const isDev = includeDevDeps && packageData.devDependencies && packageData.devDependencies[name];
return resolveDependencies(name, version, rootId, isDev ? 'dev' : 'prod');
});
await Promise.all(depPromises);
// Convert Maps/Sets to arrays for Cytoscape
const graphElements = {
nodes: Array.from(nodes.values()),
edges: Array.from(edges).map(edgeId => {
const [source, target] = edgeId.split('->');
return { data: { id: edgeId, source, target } };
})
};
cy.add(graphElements);
// Apply layout
const layout = cy.layout({
name: 'cose', // Concentric layout, good for trees/networks
// name: 'breadthfirst', // Alternative tree layout
// name: 'dagre', // Directed acyclic graph layout (requires cytoscape-dagre extension)
animate: true,
padding: 30,
nodeDimensionsIncludeLabels: true,
idealEdgeLength: 100,
nodeRepulsion: 400000,
fit: true
});
layout.run();
setStatus('Graph rendered successfully!', 'success');
setupTooltips(); // Setup tooltips after elements are added and layout is done
} catch (error) {
console.error("Error resolving dependencies:", error);
setStatus(`Error: ${error.message}`, 'error');
} finally {
visualizeBtn.disabled = false;
}
}
function addNode(id, name, version, type) {
if (!nodes.has(id)) {
nodes.set(id, {
data: {
id: id,
label: `${name}\n@${version}`,
name: name,
version: version,
type: type // 'root', 'prod', 'dev', 'transitive'
},
classes: type // Add class for styling
});
} else {
// If node exists, maybe update its type if it's also a direct dep
const existingNode = nodes.get(id);
if ((type === 'prod' || type === 'dev') && existingNode.data.type === 'transitive') {
existingNode.data.type = type;
existingNode.classes = type; // Update class
}
}
}
function addEdge(sourceId, targetId) {
edges.add(`${sourceId}->${targetId}`);
}
async function resolveDependencies(packageName, versionRange, parentId, type) {
// Simple cache key: Use package name only for now to avoid fetching same package multiple times
// A more robust cache would include version/range. Using 'latest' simplifies this.
const cacheKey = `${packageName}@latest`; // Simplification: always check against latest
// Avoid infinite loops for circular dependencies within a single resolution path
if (parentId.startsWith(packageName + '@')) {
console.warn(`Circular dependency detected: ${packageName} required by ${parentId}. Stopping recursion for this path.`);
return;
}
// Use cached data if available
if (fetchedPackages.has(cacheKey)) {
const cachedData = fetchedPackages.get(cacheKey);
if (cachedData instanceof Promise) { // Handle concurrent requests
await cachedData; // Wait if it's still being fetched
return resolveDependencies(packageName, versionRange, parentId, type); // Retry after promise resolves
}
const targetId = cachedData.id;
addNode(targetId, packageName, cachedData.version, type === 'root' || type === 'prod' || type === 'dev' ? type : 'transitive'); // Ensure direct deps types are preserved
addEdge(parentId, targetId);
// Don't re-resolve sub-dependencies if already processed fully
return;
}
// Mark as fetching
const fetchPromise = fetchPackageInfo(packageName);
fetchedPackages.set(cacheKey, fetchPromise); // Store the promise
try {
const packageInfo = await fetchPromise;
const version = packageInfo.version; // Actual resolved version (latest)
const targetId = `${packageName}@${version}`;
fetchedPackages.set(cacheKey, { id: targetId, version: version }); // Replace promise with actual data
addNode(targetId, packageName, version, type === 'root' || type === 'prod' || type === 'dev' ? type : 'transitive');
addEdge(parentId, targetId);
const subDependencies = packageInfo.dependencies || {};
const subDepPromises = Object.entries(subDependencies).map(([subName, subVersion]) =>
resolveDependencies(subName, subVersion, targetId, 'transitive') // Sub-dependencies are transitive
);
await Promise.all(subDepPromises);
} catch (error) {
console.error(`Failed to fetch info for ${packageName}: ${error.message}`);
// Add a node indicating the failure? Or just skip? Let's skip for now.
fetchedPackages.delete(cacheKey); // Remove failed entry from cache
// Optionally re-throw or handle differently
// throw error; // Re-throwing might stop the whole process
setStatus(`Warning: Could not fetch ${packageName}. ${error.message}`, 'error'); // Show non-blocking warning
}
}
async function fetchPackageInfo(packageName) {
setStatus(`Fetching info for ${packageName}...`, 'loading');
// Use a proxy if direct fetching is blocked by CORS or for other reasons
// const proxyUrl = 'https://cors-anywhere.herokuapp.com/'; // Example proxy
// const url = `${proxyUrl}https://registry.npmjs.org/${packageName}/latest`;
const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Package "${packageName}" not found (404).`);
}
throw new Error(`Failed to fetch ${packageName}: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Return only needed info (version and dependencies)
return {
name: data.name,
version: data.version,
dependencies: data.dependencies || {}
};
} catch (error) {
// Handle network errors or JSON parsing errors
console.error(`Network or parsing error for ${packageName}:`, error);
throw new Error(`Network error fetching ${packageName}: ${error.message}`);
}
}
function setStatus(message, type = 'info') { // type: 'info', 'loading', 'success', 'error'
statusDiv.textContent = message;
statusDiv.className = type; // Set class for styling
}
function initCytoscape() {
cy = cytoscape({
container: cyDiv,
style: [
{
selector: 'node',
style: {
'background-color': '#95a5a6', // Default: Transitive
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'color': '#ffffff',
'text-outline-width': 2,
'text-outline-color': '#888',
'text-wrap': 'wrap',
'width': 'label', // Adjust width based on label
'height': 'label',
'padding': '10px',
'shape': 'round-rectangle'
}
},
{
selector: 'node.root', // Style for root node
style: {
'background-color': '#e67e22', // Orange
'text-outline-color': '#c0392b',
'font-size': '12px',
'font-weight': 'bold',
'z-index': 10 // Bring root to front slightly
}
},
{
selector: 'node.prod', // Style for direct production dependencies
style: {
'background-color': '#3498db', // Blue
'text-outline-color': '#2980b9'
}
},
{
selector: 'node.dev', // Style for direct dev dependencies
style: {
'background-color': '#f1c40f', // Yellow
'text-outline-color': '#f39c12',
'color': '#333' // Darker text for yellow bg
}
},
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#ccc',
'target-arrow-color': '#ccc',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier' // Or 'haystack' for performance on large graphs
}
}
],
layout: {
name: 'grid' // Initial layout before data loading
},
// Interaction options
zoomingEnabled: true,
userZoomingEnabled: true,
panningEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
autoungrabify: false, // Allow grabbing nodes
wheelSensitivity: 0.2 // Adjust zoom speed
});
// Handle window resize
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (cy) {
cy.resize();
cy.fit(); // Refit the graph to the new container size
}
}, 250); // Debounce resize event
});
}
function setupTooltips() {
if (!cy || !window.tippy) return; // Ensure cy and tippy are available
// Remove previous tooltips if any
cy.elements().forEach(el => {
if (el.scratch('_tippy')) {
el.scratch('_tippy').destroy();
el.scratch('_tippy', null);
}
});
cy.nodes().forEach(node => {
const nodeData = node.data();
let content = `<strong>${nodeData.name}</strong><br>@ ${nodeData.version}<br>Type: ${nodeData.type}`;
// Get dependencies (outgoing edges)
const dependencies = node.outgoers('edge').targets();
if(dependencies.length > 0) {
content += `<hr style='margin: 3px 0; border-color: #555;'>Deps: ${dependencies.length}`;
}
// Get dependents (incoming edges)
const dependents = node.incomers('edge').sources();
if(dependents.length > 0) {
content += `<br>Depended on by: ${dependents.length}`;
}
const ref = node.popperRef(); // Get a reference for positioning
const tippyInstance = tippy(document.createElement('div'), { // Dummy element
getReferenceClientRect: ref.getBoundingClientRect,
trigger: 'manual', // We'll trigger manually on hover
content: content,
allowHTML: true,
arrow: true,
placement: 'top',
hideOnClick: false,
theme: 'custom', // Use custom theme defined in CSS
interactive: false, // Keep it simple
appendTo: document.body // Append to body to avoid overflow issues
});
node.scratch('_tippy', tippyInstance); // Store instance on the node
node.on('mouseover', () => tippyInstance.show());
node.on('mouseout', () => tippyInstance.hide());
node.on('position', () => tippyInstance.popperInstance?.update()); // Update position when node moves
});
cy.on('pan zoom resize', () => {
cy.nodes().forEach(node => {
if (node.scratch('_tippy') && node.scratch('_tippy').state.isVisible) {
node.scratch('_tippy').popperInstance?.update();
}
});
});
}
});
body {
font-family: sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
color: #333;
}
h1, h2 {
text-align: center;
color: #2c3e50;
}
.container {
display: flex;
gap: 20px;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
}
.input-area {
flex: 1; /* Takes up 1 part of the space */
min-width: 300px; /* Minimum width before wrapping */
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.graph-area {
flex: 2; /* Takes up 2 parts of the space */
min-width: 400px; /* Minimum width */
display: flex;
flex-direction: column;
}
textarea#packageJsonInput {
width: 95%;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
font-family: monospace;
}
.or-divider {
text-align: center;
margin: 10px 0;
font-weight: bold;
color: #777;
}
.file-upload-label {
display: inline-block; /* Or block */
padding: 8px 15px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px; /* Spacing */
font-size: 0.9em;
transition: background-color 0.2s ease;
}
.file-upload-label:hover {
background-color: #2980b9;
}
input#packageJsonFile {
/* Hide the default file input */
display: none;
}
button#visualizeBtn {
display: block;
width: 100%;
padding: 12px;
background-color: #2ecc71;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.1em;
margin-top: 15px;
transition: background-color 0.2s ease;
}
button#visualizeBtn:hover {
background-color: #27ae60;
}
button#visualizeBtn:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
#status {
margin-top: 15px;
font-weight: bold;
min-height: 20px; /* Prevent layout shifts */
}
#status.error {
color: #e74c3c;
}
#status.loading {
color: #3498db;
}
#status.success {
color: #2ecc71;
}
#cy {
height: 600px; /* Adjust as needed */
width: 100%;
border: 1px solid #ccc;
background-color: #fff;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
border-radius: 8px;
position: relative; /* Needed for Cytoscape pan/zoom */
}
.options {
margin-top: 15px;
}
.legend {
margin-top: 10px;
font-size: 0.9em;
text-align: center;
}
.legend span {
margin: 0 10px;
display: inline-flex; /* Align items vertically */
align-items: center; /* Align items vertically */
}
.node-shape {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%; /* Circle */
margin-left: 5px;
border: 1px solid #555;
}
.node-shape.root { background-color: #e67e22; }
.node-shape.prod { background-color: #3498db; }
.node-shape.dev { background-color: #f1c40f; }
.node-shape.transitive { background-color: #95a5a6; }
/* Tippy.js Tooltip Styling */
.tippy-box[data-theme~='custom'] {
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.9em;
}
.tippy-box[data-theme~='custom'][data-placement^='top'] > .tippy-arrow::before {
border-top-color: #333;
}
.tippy-box[data-theme~='custom'][data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: #333;
}
/* etc. for left/right */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment