-
-
Save shricodev/6c3577d486b96129616393971134ca5e to your computer and use it in GitHub Desktop.
Gemini 2.5 - Blog Dependency Visualizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } | |
| }); | |
| }); | |
| } | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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