Skip to content

Instantly share code, notes, and snippets.

@jim-my
Last active March 23, 2025 10:26
Show Gist options
  • Save jim-my/2e6fe181d61e17124de2c98bda0c6d71 to your computer and use it in GitHub Desktop.
Save jim-my/2e6fe181d61e17124de2c98bda0c6d71 to your computer and use it in GitHub Desktop.
Cytoscape.js - Dependency Graph. Cytoscape.js can handle thousands of nodes easily(choice of layout is important). E.g. if viz.js or d3-graphviz doesn't work.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dependency Graph Visualizer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.26.0/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape-dagre/2.5.0/cytoscape-dagre.min.js"></script>
<style>
:root {
--primary-color: #4CAF50;
--primary-hover: #45a049;
--download-color: #2196F3;
--download-hover: #0b7dda;
--background-color: #fff;
--border-color: #ccc;
--node-color: #90caf9;
--node-border: #0d47a1;
--highlight-node: #ffeb3b;
--highlight-border: #f57f17;
--highlight-edge: #f44336;
--edge-color: #555;
--text-color: #333;
--status-bg: rgba(0, 0, 0, 0.7);
--status-color: white;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Helvetica, Arial, sans-serif;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
height: 100vh;
color: var(--text-color);
}
h1 {
margin-top: 0;
margin-bottom: 20px;
}
.container {
display: flex;
flex: 1;
gap: 20px;
height: calc(100vh - 100px);
}
.input-panel {
width: 300px;
display: flex;
flex-direction: column;
}
.visualization-panel {
flex: 1;
border: 1px solid var(--border-color);
position: relative;
border-radius: 4px;
overflow: hidden;
}
textarea {
flex: 1;
width: 100%;
padding: 10px;
font-size: 16px;
font-family: monospace;
border: 1px solid var(--border-color);
border-radius: 4px;
resize: none;
}
.btn {
margin-top: 10px;
padding: 10px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.btn:hover {
background-color: var(--primary-hover);
}
.btn--download {
background-color: var(--download-color);
}
.btn--download:hover {
background-color: var(--download-hover);
}
#cy {
width: 100%;
height: 100%;
position: absolute;
}
#cy-container {
width: 100%;
height: 100%;
position: relative;
transform: rotate(0deg);
transform-origin: center center;
}
.controls {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 10;
display: flex;
gap: 10px;
}
.control-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
background-color: var(--primary-color);
color: white;
border: none;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.control-btn:hover {
background-color: var(--primary-hover);
transform: scale(1.05);
}
.control-btn--download {
background-color: var(--download-color);
}
.control-btn--download:hover {
background-color: var(--download-hover);
}
.sample-data {
margin-top: 10px;
cursor: pointer;
text-decoration: underline;
color: blue;
}
.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background: var(--background-color);
padding: 20px;
}
.status-message {
position: absolute;
top: 10px;
left: 10px;
padding: 5px 10px;
background-color: var(--status-bg);
color: var(--status-color);
border-radius: 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
}
.status-message.visible {
opacity: 1;
}
</style>
</head>
<body>
<h1>Dependency Graph Visualizer</h1>
<div class="container">
<div class="input-panel">
<p>Enter dependencies (one per line, format: "a -> b")</p>
<textarea id="input-data" placeholder='a -> b -> c
"Complex Node" -> "Other Node with spaces"
standalone_node
a -> b'></textarea>
<button id="visualize-btn" class="btn">Visualize</button>
<button id="download-svg-btn" class="btn btn--download">Download as SVG</button>
<div class="sample-data" id="load-sample">Load sample data</div>
</div>
<div class="visualization-panel">
<div id="cy-container">
<div id="cy"></div>
</div>
<div class="status-message" id="status-message"></div>
<div class="controls">
<button id="zoom-in" class="control-btn" title="Zoom In">+</button>
<button id="zoom-out" class="control-btn" title="Zoom Out">-</button>
<button id="rotate-cw" class="control-btn" title="Rotate Clockwise">↻</button>
<button id="rotate-ccw" class="control-btn" title="Rotate Counter-Clockwise">↺</button>
<button id="fit-all" class="control-btn" title="Fit All">⟳</button>
<button id="fullscreen-btn" class="control-btn" title="Fullscreen">⛶</button>
<button id="download-svg" class="control-btn control-btn--download" title="Download SVG">↓</button>
</div>
</div>
</div>
<script type="module">
/**
* Dependency Graph Visualizer
* A tool to visualize dependencies defined in a simple text format.
*/
(function() {
// Modules for better code organization
const App = {
/**
* Configuration settings
*/
Config: {
node: {
shape: 'rectangle',
backgroundColor: '#90caf9',
textValign: 'center',
textHalign: 'center',
padding: 10,
fontSize: 14,
fontFamily: 'Helvetica',
borderWidth: 1,
borderColor: '#0d47a1'
},
edge: {
width: 2,
lineColor: '#555',
arrowColor: '#555',
arrowShape: 'triangle'
},
highlight: {
nodeColor: '#ffeb3b',
nodeBorderColor: '#f57f17',
edgeColor: '#f44336',
edgeWidth: 4
},
layout: {
name: 'breadthfirst',
directed: true,
padding: 30,
spacingFactor: 1.5,
animationDuration: 500
},
rotation: {
step: 15 // degrees to rotate each click
},
delayTime: 1000, // ms delay for auto-render
download: {
filename: 'dependency-graph.svg'
}
},
/**
* State management
*/
State: {
currentRotation: 0,
renderTimeout: null,
cy: null,
/**
* Reset the state for a new graph
*/
resetGraph: function() {
if (this.cy) {
this.cy.elements().remove();
}
}
},
/**
* UI related functions
*/
UI: {
/**
* Get DOM elements
*/
elements: {
get inputData() { return document.getElementById('input-data'); },
get visualizeBtn() { return document.getElementById('visualize-btn'); },
get downloadSvgBtn() { return document.getElementById('download-svg-btn'); },
get downloadSvg() { return document.getElementById('download-svg'); },
get loadSample() { return document.getElementById('load-sample'); },
get zoomIn() { return document.getElementById('zoom-in'); },
get zoomOut() { return document.getElementById('zoom-out'); },
get rotateClockwise() { return document.getElementById('rotate-cw'); },
get rotateCounterClockwise() { return document.getElementById('rotate-ccw'); },
get fitAll() { return document.getElementById('fit-all'); },
get fullscreenBtn() { return document.getElementById('fullscreen-btn'); },
get statusMessage() { return document.getElementById('status-message'); },
get cyContainer() { return document.getElementById('cy-container'); },
get cy() { return document.getElementById('cy'); },
get visualizationPanel() { return document.querySelector('.visualization-panel'); }
},
/**
* Show a temporary status message
* @param {string} message - The message to display
* @param {number} duration - Duration in ms to show the message (default: 2000)
*/
showStatusMessage: function(message, duration = 2000) {
const statusEl = this.elements.statusMessage;
statusEl.textContent = message;
statusEl.classList.add('visible');
setTimeout(() => {
statusEl.classList.remove('visible');
}, duration);
},
/**
* Toggle fullscreen mode
*/
toggleFullscreen: function() {
const container = this.elements.visualizationPanel;
container.classList.toggle('fullscreen');
// Resize the graph after toggling fullscreen
setTimeout(() => {
App.State.cy.resize();
App.State.cy.fit();
}, 100);
if (container.classList.contains('fullscreen')) {
this.showStatusMessage('Fullscreen mode (press ESC to exit)');
}
}
},
/**
* Graph operations
*/
Graph: {
/**
* Create the Cytoscape instance and set up styling
*/
createCytoscapeInstance: function() {
const config = App.Config;
App.State.cy = cytoscape({
container: App.UI.elements.cy,
style: [
{
selector: 'node',
style: {
'shape': config.node.shape,
'background-color': config.node.backgroundColor,
'label': 'data(id)',
'text-valign': config.node.textValign,
'text-halign': config.node.textHalign,
'text-wrap': 'wrap',
'text-max-width': '100px',
'width': 'label',
'height': 'label',
'padding': `${config.node.padding}px`,
'font-size': `${config.node.fontSize}px`,
'font-family': config.node.fontFamily,
'border-width': `${config.node.borderWidth}px`,
'border-color': config.node.borderColor
}
},
{
selector: 'edge',
style: {
'width': config.edge.width,
'line-color': config.edge.lineColor,
'target-arrow-color': config.edge.arrowColor,
'target-arrow-shape': config.edge.arrowShape,
'curve-style': 'bezier'
}
},
{
selector: 'node.highlighted',
style: {
'background-color': config.highlight.nodeColor,
'border-color': config.highlight.nodeBorderColor,
'border-width': '2px'
}
},
{
selector: 'node.faded',
style: {
'opacity': 0.3
}
},
{
selector: 'edge.highlighted',
style: {
'line-color': config.highlight.edgeColor,
'target-arrow-color': config.highlight.edgeColor,
'width': config.highlight.edgeWidth
}
},
{
selector: 'edge.faded',
style: {
'opacity': 0.3
}
}
]
});
return App.State.cy;
},
/**
* Apply layout to the graph
*/
applyLayout: function() {
const cy = App.State.cy;
const config = App.Config;
const layout = cy.layout(config.layout);
layout.run();
// Fit the graph to the container after animation
setTimeout(() => {
cy.fit();
cy.zoom(cy.zoom() * 0.9); // Slight zoom out for margin
// Apply rotation if needed
if (App.State.currentRotation !== 0) {
App.UI.elements.cyContainer.style.transform =
`rotate(${App.State.currentRotation}deg)`;
}
}, config.layout.animationDuration + 100);
},
/**
* Rotate the graph by a given angle
* @param {number} degrees - Degrees to rotate
*/
rotate: function(degrees) {
const cyContainer = App.UI.elements.cyContainer;
// Update the rotation angle
App.State.currentRotation = (App.State.currentRotation + degrees) % 360;
// Ensure positive value
if (App.State.currentRotation < 0) App.State.currentRotation += 360;
// Apply rotation
cyContainer.style.transform = `rotate(${App.State.currentRotation}deg)`;
// Show status message
App.UI.showStatusMessage(`Rotated to ${App.State.currentRotation}°`);
// Refit graph
App.State.cy.fit();
App.State.cy.zoom(App.State.cy.zoom() * 0.9);
},
/**
* Highlight a node and its connections
* @param {Object} event - The node click event
*/
highlightNode: function(event) {
const cy = App.State.cy;
const node = event.target;
// Clear previous highlights
cy.elements().removeClass('highlighted faded');
// Highlight the selected node and its connections
node.addClass('highlighted');
node.connectedEdges().addClass('highlighted');
// Get connected nodes
const connectedNodes = node.neighborhood('node');
connectedNodes.addClass('highlighted');
// Fade all other elements
cy.elements().not('.highlighted').addClass('faded');
App.UI.showStatusMessage(`Selected: ${node.id()}`);
}
},
/**
* Input parsing and data handling
*/
Data: {
/**
* Parse the input text into nodes and edges
* @param {string} input - The input text
* @returns {Object} - Object containing nodes and edges
*/
parseInput: function(input) {
const lines = input.trim().split('\n');
// Set to track all nodes
const nodes = new Set();
// Array to store edges
const edges = [];
// Parse lines
lines.forEach(line => {
const trimmedLine = line.trim();
if (!trimmedLine) return; // Skip empty lines
// Check for quoted node names and -> separators
const parts = this.parseLineIntoParts(trimmedLine);
// Process the parts
if (parts.length === 1) {
// Single node with no connections
let nodeName = parts[0];
// If quoted, remove the quotes
if (nodeName.startsWith('"') && nodeName.endsWith('"')) {
nodeName = nodeName.substring(1, nodeName.length - 1);
}
nodes.add(nodeName);
} else if (parts.length > 1) {
// Multiple connected nodes
for (let i = 0; i < parts.length - 1; i++) {
let source = parts[i];
let target = parts[i + 1];
// If quoted, remove the quotes
if (source.startsWith('"') && source.endsWith('"')) {
source = source.substring(1, source.length - 1);
}
if (target.startsWith('"') && target.endsWith('"')) {
target = target.substring(1, target.length - 1);
}
nodes.add(source);
nodes.add(target);
edges.push({ source, target });
}
}
});
return { nodes, edges };
},
/**
* Parse a line into parts considering quoted strings
* @param {string} line - The line to parse
* @returns {Array} - Array of parts
*/
parseLineIntoParts: function(line) {
const parts = [];
let currentPart = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"' && (i === 0 || line[i-1] !== '\\')) {
inQuotes = !inQuotes;
currentPart += char;
} else if (!inQuotes && line.substr(i, 2) === '->') {
parts.push(currentPart.trim());
currentPart = '';
i++; // Skip the next character (the '>' of '->')
} else {
currentPart += char;
}
}
if (currentPart.trim()) {
parts.push(currentPart.trim());
}
return parts;
},
/**
* Load sample data
*/
loadSampleData: function() {
App.UI.elements.inputData.value = `A -> B -> C
"Complex Node with spaces" -> "Another complex, node."
B -> D
E -> F -> G -> H
standalone_node_1
"Quoted standalone node"
A -> "Node with spaces" -> D
E -> B`;
App.Controller.parseAndVisualize();
App.UI.showStatusMessage('Sample data loaded');
},
/**
* Load initial data
*/
loadInitialData: function() {
App.UI.elements.inputData.value = `A -> B
B -> C
A -> D
standalone_node`;
}
},
/**
* Export functionality
*/
Export: {
/**
* Download the graph as an SVG file
*/
downloadAsSVG: function() {
try {
// Get the current graph as SVG
const svgContent = this.generateSVG();
// Create a Blob object
const blob = new Blob([svgContent], {type: 'image/svg+xml'});
// Create a URL for the blob
const url = URL.createObjectURL(blob);
// Create a download link
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = App.Config.download.filename;
// Append to body, click, and remove
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// Release the URL object
setTimeout(() => URL.revokeObjectURL(url), 100);
App.UI.showStatusMessage('SVG downloaded successfully');
} catch (error) {
console.error('Error downloading SVG:', error);
App.UI.showStatusMessage('Error downloading SVG');
}
},
/**
* Generate SVG content from the current graph
* @returns {string} - SVG content as string
*/
generateSVG: function() {
const cy = App.State.cy;
const config = App.Config;
// Get the current graph dimensions
const bbox = cy.elements().boundingBox();
// Get the Cytoscape container dimensions
const width = cy.width();
const height = cy.height();
// Adjusted for padding
const padding = 50;
// Create an SVG with viewBox
let svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="${width}"
height="${height}"
viewBox="${bbox.x1 - padding} ${bbox.y1 - padding} ${bbox.w + padding*2} ${bbox.h + padding*2}">
<title>Dependency Graph</title>
<desc>Generated by Dependency Graph Visualizer</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="${config.edge.arrowColor}"/>
</marker>
</defs>`;
// Background
svg += `<rect x="${bbox.x1 - padding}" y="${bbox.y1 - padding}"
width="${bbox.w + padding*2}" height="${bbox.h + padding*2}" fill="white"/>`;
// Add edges (lines)
cy.edges().forEach(edge => {
const edgeData = edge.data();
const sourceNode = cy.getElementById(edgeData.source);
const targetNode = cy.getElementById(edgeData.target);
if (!sourceNode || !targetNode) return;
const sourcePos = sourceNode.position();
const targetPos = targetNode.position();
// Add path for edge
const isHighlighted = edge.hasClass('highlighted');
const strokeColor = isHighlighted ? config.highlight.edgeColor : config.edge.lineColor;
const strokeWidth = isHighlighted ? config.highlight.edgeWidth : config.edge.width;
const opacity = edge.hasClass('faded') ? 0.3 : 1;
svg += `<line x1="${sourcePos.x}" y1="${sourcePos.y}"
x2="${targetPos.x}" y2="${targetPos.y}"
stroke="${strokeColor}"
stroke-width="${strokeWidth}"
opacity="${opacity}"
marker-end="url(#arrow)"/>`;
});
// Add nodes (rectangles with text)
cy.nodes().forEach(node => {
const nodeData = node.data();
const position = node.position();
const dimensions = node.boundingBox();
const width = dimensions.w;
const height = dimensions.h;
const isHighlighted = node.hasClass('highlighted');
const fillColor = isHighlighted ? config.highlight.nodeColor : config.node.backgroundColor;
const borderColor = isHighlighted ? config.highlight.nodeBorderColor : config.node.borderColor;
const opacity = node.hasClass('faded') ? 0.3 : 1;
// Escape HTML special characters in node text
const nodeText = this.escapeHtml(nodeData.id);
// Rectangle for node
svg += `<rect x="${position.x - width/2}" y="${position.y - height/2}"
width="${width}" height="${height}"
rx="3" ry="3"
fill="${fillColor}"
stroke="${borderColor}"
stroke-width="${config.node.borderWidth}"
opacity="${opacity}"/>`;
// Text label for node
svg += `<text x="${position.x}" y="${position.y}"
font-family="${config.node.fontFamily}"
font-size="${config.node.fontSize}"
text-anchor="middle"
dominant-baseline="middle"
opacity="${opacity}">${nodeText}</text>`;
});
// Close SVG
svg += '</svg>';
return svg;
},
/**
* Escape HTML special characters to prevent SVG injection issues
* @param {string} text - Text to escape
* @returns {string} - Escaped text
*/
escapeHtml: function(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
},
/**
* Controller - coordinates actions between modules
*/
Controller: {
/**
* Initialize the application
*/
init: function() {
App.Graph.createCytoscapeInstance();
this.setupEventListeners();
// Load initial data
App.Data.loadInitialData();
// Initial visualization with a short delay
setTimeout(() => this.parseAndVisualize(), 100);
},
/**
* Set up all event listeners
*/
setupEventListeners: function() {
const ui = App.UI.elements;
const cy = App.State.cy;
// Visualization button
ui.visualizeBtn.addEventListener('click', () => {
clearTimeout(App.State.renderTimeout);
this.parseAndVisualize();
});
// Auto-update with delay on text change
ui.inputData.addEventListener('input', () => {
clearTimeout(App.State.renderTimeout);
App.State.renderTimeout = setTimeout(
() => this.parseAndVisualize(),
App.Config.delayTime
);
});
// Zoom in button
ui.zoomIn.addEventListener('click', () => {
cy.zoom(cy.zoom() * 1.2);
});
// Zoom out button
ui.zoomOut.addEventListener('click', () => {
cy.zoom(cy.zoom() * 0.8);
});
// Fit all button
ui.fitAll.addEventListener('click', () => {
cy.fit();
cy.zoom(cy.zoom() * 0.9); // Slight zoom out for margin
});
// Rotate clockwise button
ui.rotateClockwise.addEventListener('click', () => {
App.Graph.rotate(App.Config.rotation.step);
});
// Rotate counter-clockwise button
ui.rotateCounterClockwise.addEventListener('click', () => {
App.Graph.rotate(-App.Config.rotation.step);
});
// Fullscreen toggle button
ui.fullscreenBtn.addEventListener('click', () => {
App.UI.toggleFullscreen();
});
// Exit fullscreen with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const container = ui.visualizationPanel;
if (container.classList.contains('fullscreen')) {
container.classList.remove('fullscreen');
// Resize the graph after exiting fullscreen
setTimeout(() => {
cy.resize();
cy.fit();
}, 100);
}
}
});
// Sample data loader
ui.loadSample.addEventListener('click', () => {
App.Data.loadSampleData();
});
// Download SVG buttons
ui.downloadSvg.addEventListener('click', () => {
App.Export.downloadAsSVG();
});
ui.downloadSvgBtn.addEventListener('click', () => {
App.Export.downloadAsSVG();
});
// Node highlighting
cy.on('tap', 'node', (e) => {
App.Graph.highlightNode(e);
});
// Clear highlights when clicking on the background
cy.on('tap', (e) => {
if (e.target === cy) {
cy.elements().removeClass('highlighted faded');
}
});
},
/**
* Parse the input and visualize the graph
*/
parseAndVisualize: function() {
// Clear previous graph
App.State.resetGraph();
// Get input text
const input = App.UI.elements.inputData.value;
try {
const { nodes, edges } = App.Data.parseInput(input);
// Add nodes to graph
nodes.forEach(node => {
App.State.cy.add({
group: 'nodes',
data: { id: node }
});
});
// Add edges to graph (deduplicating as needed)
const uniqueEdges = {};
edges.forEach(edge => {
const edgeId = `${edge.source}-${edge.target}`;
uniqueEdges[edgeId] = edge;
});
Object.entries(uniqueEdges).forEach(([edgeId, edge]) => {
App.State.cy.add({
group: 'edges',
data: {
id: edgeId,
source: edge.source,
target: edge.target
}
});
});
// Apply layout
App.Graph.applyLayout();
const uniqueEdgeCount = Object.keys(uniqueEdges).length;
App.UI.showStatusMessage(`Graph created with ${nodes.size} nodes and ${uniqueEdgeCount} edges${uniqueEdgeCount !== edges.length ? ` (${edges.length - uniqueEdgeCount} duplicates removed)` : ''}`);
} catch (error) {
console.error('Error parsing input:', error);
App.UI.showStatusMessage('Error parsing input');
}
}
}
};
// Initialize the application
App.Controller.init();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment