diff --git a/CHANGELOG.md b/CHANGELOG.md
index df5b99f..e6bcd74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.2.0] - 2026-01-18
+
+### Added
+
+**Enhanced Tooltips**
+- Node tooltips now display "Links out" and "Links in" counts
+- Color-coded: orange for outgoing links, green for incoming links
+- Helps quickly understand node connectivity
+
+**Links Count Panel**
+- New "Links count" tab in the Unlinked Modules panel
+- Configurable threshold filter (default: 1) to find nodes by connection count
+- Checkboxes to filter by "links in" or "links out" criteria
+- Click on any item to navigate and zoom to it on the graph
+- Useful for finding highly connected or isolated nodes
+
+**Display Filters**
+- Show/hide nodes by type: Modules, Classes, Functions, External
+- Show/hide links by type: Module→Module, Module→Entity, Dependencies
+- All filters available in the expanded Display panel
+
+**CSV Export**
+- New `--csv PATH` option to export graph data to CSV file
+- Columns: name, type (module/function/class/external), parent_module, full_path, links_out, links_in, lines
+- Example: `codegraph /path/to/code --csv output.csv`
+
+### Changed
+
+- Legend panel moved to the right of Controls panel (both at top-left area)
+- Renamed "Unlinked Modules" panel header, now uses tabs interface
+- "Unlinked" is now a tab showing modules with zero connections
+- "Links count" tab provides flexible filtering by connection count
+
+### Refactored
+
+**Template Extraction**
+- HTML, CSS, and JavaScript moved to separate files in `codegraph/templates/`
+- `templates/index.html` - HTML structure with placeholders
+- `templates/styles.css` - all CSS styles
+- `templates/main.js` - all JavaScript code
+- `vizualyzer.py` reduced from ~2000 to ~340 lines
+- Easier to maintain and edit frontend code separately
+
## [1.1.0] - 2025-01-18
### Added
diff --git a/README.md b/README.md
index 675054f..c73c811 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,13 @@ It is based only on lexical and syntax parsing, so it doesn't need to install al

-**Tooltips** - Hover over any node to see details: type, parent module, full path, and connection count.
+**Tooltips** - Hover over any node to see details: type, parent module, full path, lines of code, and connection counts (links in/out).
+
+### Links Count
+
+
+
+**Links Count Panel** - Find nodes by their connection count. Filter by "links in" or "links out" with configurable threshold.
### Unlinked Modules
@@ -36,6 +42,18 @@ It is based only on lexical and syntax parsing, so it doesn't need to install al
**Unlinked Panel** - Shows modules with no connections. Click to navigate to them on the graph.
+### Massive Objects Detection
+
+
+
+**Massive Objects Panel** - Find large code entities by lines of code. Filter by type (modules, classes, functions) with configurable threshold.
+
+### Display Settings
+
+
+
+**Display Filters** - Show/hide nodes by type (Modules, Classes, Functions, External) and links by type (Module→Module, Module→Entity, Dependencies).
+
### UI Tips

@@ -63,9 +81,27 @@ This will generate an interactive HTML visualization and open it in your browser
| Option | Description |
|--------|-------------|
| `--output PATH` | Custom output path for HTML file (default: `./codegraph.html`) |
+| `--csv PATH` | Export graph data to CSV file |
| `--matplotlib` | Use legacy matplotlib visualization instead of D3.js |
| `-o, --object-only` | Print dependencies to console only, no visualization |
+### CSV Export
+
+Export graph data to CSV for analysis in spreadsheets or other tools:
+
+```console
+codegraph /path/to/code --csv output.csv
+```
+
+CSV columns:
+- `name` - Entity name
+- `type` - module / function / class / external
+- `parent_module` - Parent module (for functions/classes)
+- `full_path` - File path
+- `links_out` - Outgoing dependencies count
+- `links_in` - Incoming dependencies count
+- `lines` - Lines of code
+
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for full version history.
diff --git a/codegraph/__init__.py b/codegraph/__init__.py
index 6849410..c68196d 100644
--- a/codegraph/__init__.py
+++ b/codegraph/__init__.py
@@ -1 +1 @@
-__version__ = "1.1.0"
+__version__ = "1.2.0"
diff --git a/codegraph/main.py b/codegraph/main.py
index 53eb8b9..93f8a91 100644
--- a/codegraph/main.py
+++ b/codegraph/main.py
@@ -35,7 +35,12 @@
type=click.Path(),
help="Output path for D3.js HTML file (default: ./codegraph.html)",
)
-def cli(paths, object_only, file_path, distance, matplotlib, output):
+@click.option(
+ "--csv",
+ type=click.Path(),
+ help="Export graph data to CSV file (specify output path)",
+)
+def cli(paths, object_only, file_path, distance, matplotlib, output, csv):
"""
Tool that creates a graph of code to show dependencies between code entities (methods, classes, etc.).
CodeGraph does not execute code, it is based only on lex and syntax parsing.
@@ -56,6 +61,7 @@ def cli(paths, object_only, file_path, distance, matplotlib, output):
distance=distance,
matplotlib=matplotlib,
output=output,
+ csv=csv,
)
main(args)
@@ -72,6 +78,10 @@ def main(args):
click.echo(f" Distance {distance}: {', '.join(files)}")
elif args.object_only:
pprint.pprint(usage_graph)
+ elif args.csv:
+ import codegraph.vizualyzer as vz
+
+ vz.export_to_csv(usage_graph, entity_metadata=entity_metadata, output_path=args.csv)
else:
import codegraph.vizualyzer as vz
diff --git a/codegraph/templates/index.html b/codegraph/templates/index.html
new file mode 100644
index 0000000..c078e52
--- /dev/null
+++ b/codegraph/templates/index.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+ CodeGraph - Interactive Visualization
+
+
+
+
+
+
+
+
+
+
+
+
+ Highlighting:
+ Clear (Esc)
+
+
+
+
+
+
Ctrl+F Search nodes
+
Scroll Zoom in/out
+
Drag on background - Pan
+
Drag on node - Pin node position
+
Click module/entity - Collapse/Expand
+
Double-click - Unpin / Focus on node
+
Esc Clear search highlight
+
+
+
+
+
+
+
Nodes
+
+
+
+
Entity (function/class)
+
+
+
+
External dependency
+
+
+
+
Links
+
+
+
+
+
Entity → Dependency
+
+
+
+
+
+
+
+
+
+ Unlinked
+ Links count
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codegraph/templates/main.js b/codegraph/templates/main.js
new file mode 100644
index 0000000..caf93b5
--- /dev/null
+++ b/codegraph/templates/main.js
@@ -0,0 +1,970 @@
+// Panel drag and collapse functionality
+document.querySelectorAll('.panel').forEach(panel => {
+ const header = panel.querySelector('.panel-header');
+ const toggleBtn = panel.querySelector('.panel-toggle');
+ let isDragging = false;
+ let startX, startY, startLeft, startTop;
+
+ // Collapse/expand
+ toggleBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ panel.classList.toggle('collapsed');
+ toggleBtn.textContent = panel.classList.contains('collapsed') ? '+' : '−';
+ toggleBtn.title = panel.classList.contains('collapsed') ? 'Expand' : 'Collapse';
+ });
+
+ // Drag functionality
+ header.addEventListener('mousedown', (e) => {
+ if (e.target === toggleBtn) return;
+ isDragging = true;
+ const rect = panel.getBoundingClientRect();
+ startX = e.clientX;
+ startY = e.clientY;
+ startLeft = rect.left;
+ startTop = rect.top;
+ panel.style.right = 'auto';
+ panel.style.bottom = 'auto';
+ panel.style.left = startLeft + 'px';
+ panel.style.top = startTop + 'px';
+ document.body.style.cursor = 'move';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ panel.style.left = (startLeft + dx) + 'px';
+ panel.style.top = (startTop + dy) + 'px';
+ });
+
+ document.addEventListener('mouseup', () => {
+ isDragging = false;
+ document.body.style.cursor = '';
+ });
+});
+
+// Calculate stats
+const moduleCount = graphData.nodes.filter(n => n.type === 'module').length;
+const entityCount = graphData.nodes.filter(n => n.type === 'entity').length;
+const moduleLinks = graphData.links.filter(l => l.type === 'module-module').length;
+document.getElementById('stats-content').innerHTML = `
+ Modules: ${moduleCount}
+ Entities: ${entityCount}
+ Module connections: ${moduleLinks}
+`;
+
+// Calculate links count for each node
+const nodeLinksMap = {};
+graphData.nodes.forEach(n => {
+ nodeLinksMap[n.id] = { linksIn: 0, linksOut: 0 };
+});
+graphData.links.forEach(l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+ if (nodeLinksMap[sourceId]) nodeLinksMap[sourceId].linksOut++;
+ if (nodeLinksMap[targetId]) nodeLinksMap[targetId].linksIn++;
+});
+
+// Populate unlinked modules panel
+const unlinkedModules = graphData.unlinkedModules || [];
+document.getElementById('unlinked-count').textContent = `(${unlinkedModules.length})`;
+if (unlinkedModules.length > 0) {
+ document.getElementById('unlinked-list').innerHTML = `
+
+ ${unlinkedModules.map(m => `
+
+ ${m.id}
+ ${m.fullPath}
+
+ `).join('')}
+
+ `;
+} else {
+ document.getElementById('unlinked-list').innerHTML = 'No unlinked modules
';
+}
+
+// Tab switching for unlinked panel
+document.querySelectorAll('.unlinked-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.unlinked-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.unlinked-tab-content').forEach(c => c.classList.remove('active'));
+ tab.classList.add('active');
+ document.getElementById(tab.dataset.tab).classList.add('active');
+ });
+});
+
+// Links count filter function
+function updateLinksCount() {
+ const threshold = parseInt(document.getElementById('links-threshold').value) || 0;
+ const countIn = document.getElementById('links-in-check').checked;
+ const countOut = document.getElementById('links-out-check').checked;
+
+ const nodesWithLinks = graphData.nodes
+ .filter(n => n.type === 'module' || n.type === 'entity')
+ .map(n => {
+ const links = nodeLinksMap[n.id] || { linksIn: 0, linksOut: 0 };
+ let total = 0;
+ if (countIn && countOut) total = links.linksIn + links.linksOut;
+ else if (countIn) total = links.linksIn;
+ else if (countOut) total = links.linksOut;
+ return { ...n, linksIn: links.linksIn, linksOut: links.linksOut, totalLinks: total };
+ })
+ .filter(n => n.totalLinks > threshold)
+ .sort((a, b) => b.totalLinks - a.totalLinks);
+
+ document.getElementById('links-count').textContent = `(${nodesWithLinks.length})`;
+
+ if (nodesWithLinks.length > 0) {
+ document.getElementById('links-count-content').innerHTML = `
+
+ ${nodesWithLinks.map(n => `
+
+ ${n.totalLinks} ${n.label || n.id}
+ ${n.type === 'module' ? 'module' : n.entityType}
+
+ `).join('')}
+
+ `;
+
+ // Add click handlers for links count items
+ document.querySelectorAll('#links-count-content li').forEach(li => {
+ li.addEventListener('click', () => {
+ const nodeId = li.dataset.nodeId;
+ highlightNode(nodeId);
+ });
+ });
+ } else {
+ document.getElementById('links-count-content').innerHTML = 'No matching nodes
';
+ }
+}
+
+// Initialize links count and add event listeners
+document.getElementById('links-in-check').addEventListener('change', updateLinksCount);
+document.getElementById('links-out-check').addEventListener('change', updateLinksCount);
+document.getElementById('links-threshold').addEventListener('input', updateLinksCount);
+updateLinksCount();
+
+// Size scaling state
+let sizeByCode = true;
+
+// Calculate max lines for scaling
+const maxLines = Math.max(...graphData.nodes.map(n => n.lines || 0), 1);
+
+// Function to get node size based on lines of code
+function getNodeSize(d, baseSize) {
+ if (!sizeByCode || !d.lines) return baseSize;
+ // Scale between baseSize and baseSize * 3 based on lines
+ const scale = 1 + (d.lines / maxLines) * 2;
+ return baseSize * scale;
+}
+
+// Function to update massive objects list
+function updateMassiveObjects() {
+ const threshold = parseInt(document.getElementById('massive-threshold').value) || 50;
+ const showModules = document.getElementById('filter-modules').checked;
+ const showClasses = document.getElementById('filter-classes').checked;
+ const showFunctions = document.getElementById('filter-functions').checked;
+
+ const massiveNodes = graphData.nodes
+ .filter(n => (n.type === 'entity' || n.type === 'module') && n.lines >= threshold)
+ .filter(n => {
+ if (n.type === 'module') return showModules;
+ if (n.entityType === 'class') return showClasses;
+ if (n.entityType === 'function') return showFunctions;
+ return true;
+ })
+ .sort((a, b) => b.lines - a.lines);
+
+ document.getElementById('massive-count').textContent = `(${massiveNodes.length})`;
+ document.getElementById('massive-list').innerHTML = massiveNodes.map(n => `
+
+ ${n.lines} ${n.label || n.id}
+ ${n.type === 'module' ? 'module' : n.entityType}
+
+ `).join('');
+
+ // Add click handlers
+ document.querySelectorAll('#massive-list li').forEach(li => {
+ li.addEventListener('click', () => {
+ const nodeId = li.dataset.nodeId;
+ highlightNode(nodeId);
+ });
+ });
+}
+
+// Add event listeners for massive objects filters
+document.getElementById('filter-modules').addEventListener('change', updateMassiveObjects);
+document.getElementById('filter-classes').addEventListener('change', updateMassiveObjects);
+document.getElementById('filter-functions').addEventListener('change', updateMassiveObjects);
+document.getElementById('massive-threshold').addEventListener('input', updateMassiveObjects);
+
+// Initial population
+updateMassiveObjects();
+
+const width = window.innerWidth;
+const height = window.innerHeight;
+
+// Create SVG
+const svg = d3.select("#graph")
+ .append("svg")
+ .attr("width", width)
+ .attr("height", height);
+
+// Add zoom behavior
+const g = svg.append("g");
+
+const zoom = d3.zoom()
+ .scaleExtent([0.05, 4])
+ .on("zoom", (event) => {
+ g.attr("transform", event.transform);
+ });
+
+svg.call(zoom);
+
+// Tooltip
+const tooltip = d3.select("#tooltip");
+
+// Track collapsed nodes (modules and entities)
+const collapsedNodes = new Set();
+
+// Create arrow markers for different link types
+const defs = svg.append("defs");
+
+// Module-module arrow (orange)
+defs.append("marker")
+ .attr("id", "arrow-module-module")
+ .attr("viewBox", "0 -5 10 10")
+ .attr("refX", 25)
+ .attr("refY", 0)
+ .attr("markerWidth", 8)
+ .attr("markerHeight", 8)
+ .attr("orient", "auto")
+ .append("path")
+ .attr("fill", "#ff9800")
+ .attr("d", "M0,-5L10,0L0,5");
+
+// Module-entity arrow (green)
+defs.append("marker")
+ .attr("id", "arrow-module-entity")
+ .attr("viewBox", "0 -5 10 10")
+ .attr("refX", 18)
+ .attr("refY", 0)
+ .attr("markerWidth", 6)
+ .attr("markerHeight", 6)
+ .attr("orient", "auto")
+ .append("path")
+ .attr("fill", "#009c2c")
+ .attr("d", "M0,-5L10,0L0,5");
+
+// Dependency arrow (red)
+defs.append("marker")
+ .attr("id", "arrow-dependency")
+ .attr("viewBox", "0 -5 10 10")
+ .attr("refX", 18)
+ .attr("refY", 0)
+ .attr("markerWidth", 6)
+ .attr("markerHeight", 6)
+ .attr("orient", "auto")
+ .append("path")
+ .attr("fill", "#d94a4a")
+ .attr("d", "M0,-5L10,0L0,5");
+
+// Scale spacing based on number of nodes
+const nodeCount = graphData.nodes.length;
+const scaleFactor = nodeCount > 40 ? 1 + (nodeCount - 40) / 50 : 1;
+
+// Create force simulation with adjusted parameters for better spacing
+const simulation = d3.forceSimulation(graphData.nodes)
+ .force("link", d3.forceLink(graphData.links).id(d => d.id).distance(d => {
+ const base = d.type === 'module-module' ? 300 : d.type === 'module-entity' ? 100 : 120;
+ return base * scaleFactor;
+ }).strength(0.3 / scaleFactor))
+ .force("charge", d3.forceManyBody().strength(d => {
+ const base = d.type === 'module' ? -800 : -300;
+ return base * scaleFactor;
+ }))
+ .force("center", d3.forceCenter(width / 2, height / 2).strength(0.05 / scaleFactor))
+ .force("collision", d3.forceCollide().radius(d => {
+ const base = d.type === 'module' ? 80 : 40;
+ return base * scaleFactor;
+ }).strength(1));
+
+// Create links (module-module first so they appear behind)
+const link = g.append("g")
+ .selectAll("line")
+ .data(graphData.links.sort((a, b) => {
+ const order = {'module-module': 0, 'module-entity': 1, 'dependency': 2};
+ return (order[a.type] || 2) - (order[b.type] || 2);
+ }))
+ .join("line")
+ .attr("class", d => `link link-${d.type}`)
+ .attr("marker-end", d => `url(#arrow-${d.type})`);
+
+// Create nodes
+const node = g.append("g")
+ .selectAll("g")
+ .data(graphData.nodes)
+ .join("g")
+ .attr("class", "node")
+ .call(d3.drag()
+ .on("start", dragstarted)
+ .on("drag", dragged)
+ .on("end", dragended));
+
+// Add shapes based on node type with size based on lines of code
+node.each(function(d) {
+ const el = d3.select(this);
+ if (d.type === "module") {
+ const size = getNodeSize(d, 30);
+ el.append("rect")
+ .attr("class", "node-module")
+ .attr("width", size)
+ .attr("height", size)
+ .attr("x", -size / 2)
+ .attr("y", -size / 2)
+ .attr("rx", 4);
+ } else if (d.type === "entity") {
+ const r = getNodeSize(d, 10);
+ el.append("circle")
+ .attr("class", "node-entity")
+ .attr("r", r);
+ } else {
+ el.append("circle")
+ .attr("class", "node-external")
+ .attr("r", 7);
+ }
+});
+
+// Function to update node sizes
+function updateNodeSizes() {
+ node.each(function(d) {
+ const el = d3.select(this);
+ if (d.type === "module") {
+ const size = getNodeSize(d, 30);
+ el.select("rect")
+ .attr("width", size)
+ .attr("height", size)
+ .attr("x", -size / 2)
+ .attr("y", -size / 2);
+ } else if (d.type === "entity") {
+ const r = getNodeSize(d, 10);
+ el.select("circle").attr("r", r);
+ }
+ });
+ // Update labels position
+ labels.attr("dy", d => {
+ if (d.type === "module") {
+ return getNodeSize(d, 30) / 2 + 15;
+ }
+ return getNodeSize(d, 10) + 10;
+ });
+}
+
+// Size toggle event listener
+document.getElementById('size-by-code').addEventListener('change', function() {
+ sizeByCode = this.checked;
+ updateNodeSizes();
+});
+
+// Display filter state
+const displayFilters = {
+ showModules: true,
+ showClasses: true,
+ showFunctions: true,
+ showExternal: true,
+ showLinkModule: true,
+ showLinkEntity: true,
+ showLinkDependency: true
+};
+
+// Display filter event listeners
+document.getElementById('show-modules').addEventListener('change', function() {
+ displayFilters.showModules = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-classes').addEventListener('change', function() {
+ displayFilters.showClasses = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-functions').addEventListener('change', function() {
+ displayFilters.showFunctions = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-external').addEventListener('change', function() {
+ displayFilters.showExternal = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-link-module').addEventListener('change', function() {
+ displayFilters.showLinkModule = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-link-entity').addEventListener('change', function() {
+ displayFilters.showLinkEntity = this.checked;
+ updateDisplayFilters();
+});
+document.getElementById('show-link-dependency').addEventListener('change', function() {
+ displayFilters.showLinkDependency = this.checked;
+ updateDisplayFilters();
+});
+
+// Check if node should be hidden by display filter
+function isNodeFilteredOut(nodeData) {
+ if (nodeData.type === 'module') return !displayFilters.showModules;
+ if (nodeData.type === 'external') return !displayFilters.showExternal;
+ if (nodeData.type === 'entity') {
+ if (nodeData.entityType === 'class') return !displayFilters.showClasses;
+ if (nodeData.entityType === 'function') return !displayFilters.showFunctions;
+ }
+ return false;
+}
+
+// Check if link should be hidden by display filter
+function isLinkFilteredOut(linkData) {
+ if (linkData.type === 'module-module') return !displayFilters.showLinkModule;
+ if (linkData.type === 'module-entity') return !displayFilters.showLinkEntity;
+ if (linkData.type === 'dependency') return !displayFilters.showLinkDependency;
+ return false;
+}
+
+// Update display based on filters
+function updateDisplayFilters() {
+ // Update node visibility
+ node.classed("node-hidden", d => isNodeFilteredOut(d) || isNodeHidden(d));
+
+ // Update label visibility
+ labels.classed("label-hidden", d => isNodeFilteredOut(d) || isNodeHidden(d));
+
+ // Update link visibility
+ link.classed("link-hidden", d => {
+ // First check display filter
+ if (isLinkFilteredOut(d)) return true;
+
+ // Check if connected nodes are filtered out
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target;
+ const sourceNode = graphData.nodes.find(n => n.id === sourceId);
+ const targetNode = graphData.nodes.find(n => n.id === targetId);
+
+ if (sourceNode && isNodeFilteredOut(sourceNode)) return true;
+ if (targetNode && isNodeFilteredOut(targetNode)) return true;
+
+ // Then check collapse state
+ if (d.type === 'module-module') return false;
+ if (sourceNode && isNodeHidden(sourceNode)) return true;
+ if (targetNode && isNodeHidden(targetNode)) return true;
+ if (d.type === 'module-entity' && collapsedNodes.has(sourceId)) return true;
+ if (d.type === 'dependency' && collapsedNodes.has(sourceId)) return true;
+
+ return false;
+ });
+}
+
+// Add labels with dynamic positioning based on node size
+const labels = g.append("g")
+ .selectAll("text")
+ .data(graphData.nodes)
+ .join("text")
+ .attr("class", d => `label ${d.type === 'module' ? 'label-module' : ''}`)
+ .attr("dy", d => {
+ if (d.type === "module") {
+ return getNodeSize(d, 30) / 2 + 15;
+ }
+ return getNodeSize(d, 10) + 10;
+ })
+ .attr("text-anchor", "middle")
+ .text(d => d.label || d.id);
+
+// Node interactions
+node.on("mouseover", function(event, d) {
+ // Highlight connected links
+ link.style("stroke-opacity", l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+ return (sourceId === d.id || targetId === d.id) ? 1 : 0.2;
+ });
+
+ // Count connections
+ const outgoing = graphData.links.filter(l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ return sourceId === d.id;
+ }).length;
+ const incoming = graphData.links.filter(l => {
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+ return targetId === d.id;
+ }).length;
+
+ tooltip
+ .style("opacity", 1)
+ .style("left", (event.pageX + 15) + "px")
+ .style("top", (event.pageY - 15) + "px")
+ .html(`
+ ${d.label || d.id}
+ Type: ${d.entityType || d.type}
+ ${d.lines ? 'Lines of code: ' + d.lines + ' ' : ''}
+ ${d.fullPath ? 'Full Path: ' + d.fullPath + ' ' : ''}
+ ${d.parent ? 'Module: ' + d.parent + ' ' : ''}
+
+ Links out: ${outgoing}
+ Links in: ${incoming}
+
+ ${collapsedNodes.has(d.id) ? '(collapsed) ' : ''}
+ `);
+})
+.on("mouseout", function() {
+ link.style("stroke-opacity", 0.6);
+ tooltip.style("opacity", 0);
+})
+.on("click", function(event, d) {
+ if (d.type === "module" || d.type === "entity") {
+ toggleCollapse(d);
+ }
+})
+.on("dblclick", function(event, d) {
+ event.stopPropagation();
+ // If node is pinned (was dragged), release it
+ if (d.fx !== null || d.fy !== null) {
+ d.fx = null;
+ d.fy = null;
+ simulation.alpha(0.3).restart();
+ } else {
+ // Focus on this node (zoom to it)
+ const scale = 1.5;
+ svg.transition()
+ .duration(500)
+ .call(zoom.transform, d3.zoomIdentity
+ .translate(width / 2, height / 2)
+ .scale(scale)
+ .translate(-d.x, -d.y));
+ }
+});
+
+function toggleCollapse(targetNode) {
+ const nodeId = targetNode.id;
+
+ if (collapsedNodes.has(nodeId)) {
+ collapsedNodes.delete(nodeId);
+ } else {
+ collapsedNodes.add(nodeId);
+ }
+
+ // Update node visual to show collapsed state
+ node.select("rect, circle")
+ .classed("collapsed", d => collapsedNodes.has(d.id));
+
+ updateVisibility();
+}
+
+function getChildNodes(nodeId, nodeType) {
+ // Get all nodes that are direct children of this node
+ const children = new Set();
+
+ if (nodeType === 'module') {
+ // Module's children are entities with this module as parent
+ graphData.nodes.forEach(n => {
+ if (n.parent === nodeId) {
+ children.add(n.id);
+ }
+ });
+ } else if (nodeType === 'entity') {
+ // Entity's children are nodes it links to via dependency
+ graphData.links.forEach(l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+ if (sourceId === nodeId && l.type === 'dependency') {
+ children.add(targetId);
+ }
+ });
+ }
+
+ return children;
+}
+
+function isNodeHidden(nodeData) {
+ // Module nodes are never hidden
+ if (nodeData.type === 'module') return false;
+
+ // Check if parent module is collapsed
+ if (nodeData.parent && collapsedNodes.has(nodeData.parent)) {
+ return true;
+ }
+
+ // Check if this is a dependency of a collapsed entity
+ for (const link of graphData.links) {
+ const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
+ const targetId = typeof link.target === 'object' ? link.target.id : link.target;
+
+ if (targetId === nodeData.id && link.type === 'dependency') {
+ // Check if source entity is collapsed or hidden
+ const sourceNode = graphData.nodes.find(n => n.id === sourceId);
+ if (sourceNode) {
+ if (collapsedNodes.has(sourceId)) return true;
+ if (sourceNode.parent && collapsedNodes.has(sourceNode.parent)) return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+function updateVisibility() {
+ // Use updateDisplayFilters which handles both collapse state and display filters
+ updateDisplayFilters();
+}
+
+// Simulation tick
+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})`);
+
+ labels
+ .attr("x", d => d.x)
+ .attr("y", d => d.y);
+});
+
+// Drag functions - nodes stay where you drag them
+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);
+ // Keep node at dragged position (don't reset fx/fy to null)
+ // Double-click to release node back to simulation
+}
+
+// Initial zoom to fit content
+simulation.on("end", () => {
+ // Calculate bounds for ALL nodes
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ graphData.nodes.forEach(n => {
+ minX = Math.min(minX, n.x);
+ maxX = Math.max(maxX, n.x);
+ minY = Math.min(minY, n.y);
+ maxY = Math.max(maxY, n.y);
+ });
+
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+
+ const padding = 100;
+ const graphWidth = maxX - minX + padding * 2;
+ const graphHeight = maxY - minY + padding * 2;
+
+ // Calculate scale to fit all nodes
+ const fitScale = Math.min(width / graphWidth, height / graphHeight);
+
+ // For larger graphs (>20 nodes), zoom out more aggressively
+ const nodeCount = graphData.nodes.length;
+ let maxZoom = 0.7;
+ if (nodeCount > 20) {
+ // Reduce max zoom based on node count: 0.7 -> down to 0.4 for 120+ nodes
+ maxZoom = Math.max(0.4, 0.7 - (nodeCount - 20) * 0.003);
+ }
+
+ const scale = Math.min(fitScale * 0.85, maxZoom);
+
+ svg.transition()
+ .duration(500)
+ .call(zoom.transform, d3.zoomIdentity
+ .translate(width / 2, height / 2)
+ .scale(scale)
+ .translate(-centerX, -centerY));
+});
+
+// ==================== SEARCH FUNCTIONALITY ====================
+
+const searchInput = document.getElementById('searchInput');
+const searchClear = document.getElementById('searchClear');
+const autocompleteList = document.getElementById('autocompleteList');
+const highlightInfo = document.getElementById('highlightInfo');
+const highlightText = document.getElementById('highlightText');
+const clearHighlightBtn = document.getElementById('clearHighlight');
+
+let selectedAutocompleteIndex = -1;
+let currentHighlightedNode = null;
+let filteredNodes = [];
+
+// Build searchable index
+const searchIndex = graphData.nodes.map(n => ({
+ id: n.id,
+ label: n.label || n.id,
+ type: n.type,
+ parent: n.parent || null,
+ searchText: ((n.label || n.id) + ' ' + (n.parent || '')).toLowerCase()
+}));
+
+// Get connected nodes for a given node
+function getConnectedNodes(nodeId) {
+ const connected = new Set();
+ connected.add(nodeId);
+
+ graphData.links.forEach(l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+
+ if (sourceId === nodeId) {
+ connected.add(targetId);
+ }
+ if (targetId === nodeId) {
+ connected.add(sourceId);
+ }
+ });
+
+ return connected;
+}
+
+// Get connected links for a given node
+function getConnectedLinks(nodeId) {
+ return graphData.links.filter(l => {
+ const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
+ const targetId = typeof l.target === 'object' ? l.target.id : l.target;
+ return sourceId === nodeId || targetId === nodeId;
+ });
+}
+
+// Highlight a node and its connections
+function highlightNode(nodeId) {
+ const connectedNodes = getConnectedNodes(nodeId);
+ currentHighlightedNode = nodeId;
+
+ // Update nodes
+ node.classed('dimmed', d => !connectedNodes.has(d.id))
+ .classed('highlighted', d => connectedNodes.has(d.id) && d.id !== nodeId)
+ .classed('highlighted-main', d => d.id === nodeId);
+
+ // Update links
+ link.classed('dimmed', d => {
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target;
+ return sourceId !== nodeId && targetId !== nodeId;
+ })
+ .classed('highlighted', d => {
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target;
+ return sourceId === nodeId || targetId === nodeId;
+ });
+
+ // Update labels
+ labels.classed('dimmed', d => !connectedNodes.has(d.id));
+
+ // Show highlight info
+ const nodeData = graphData.nodes.find(n => n.id === nodeId);
+ highlightText.textContent = `Highlighting: ${nodeData.label || nodeData.id} (${connectedNodes.size} connected)`;
+ highlightInfo.classList.add('visible');
+
+ // Zoom to the node
+ const targetNode = graphData.nodes.find(n => n.id === nodeId);
+ if (targetNode) {
+ const scale = 1.2;
+ svg.transition()
+ .duration(500)
+ .call(zoom.transform, d3.zoomIdentity
+ .translate(width / 2, height / 2)
+ .scale(scale)
+ .translate(-targetNode.x, -targetNode.y));
+ }
+}
+
+// Clear all highlighting
+function clearHighlight() {
+ currentHighlightedNode = null;
+
+ node.classed('dimmed', false)
+ .classed('highlighted', false)
+ .classed('highlighted-main', false);
+
+ link.classed('dimmed', false)
+ .classed('highlighted', false);
+
+ labels.classed('dimmed', false);
+
+ highlightInfo.classList.remove('visible');
+ searchInput.value = '';
+ searchClear.classList.remove('visible');
+ hideAutocomplete();
+}
+
+// Filter nodes based on search query
+function filterNodes(query) {
+ if (!query) return [];
+ const lowerQuery = query.toLowerCase();
+ return searchIndex
+ .filter(n => n.searchText.includes(lowerQuery))
+ .slice(0, 10); // Limit to 10 results
+}
+
+// Render autocomplete list
+function renderAutocomplete(results) {
+ if (results.length === 0) {
+ hideAutocomplete();
+ return;
+ }
+
+ filteredNodes = results;
+ selectedAutocompleteIndex = -1;
+
+ autocompleteList.innerHTML = results.map((n, i) => `
+
+ ${n.type}
+ ${n.label}
+ ${n.parent ? `${n.parent} ` : ''}
+
+ `).join('');
+
+ autocompleteList.classList.add('visible');
+
+ // Add click handlers
+ autocompleteList.querySelectorAll('.autocomplete-item').forEach(item => {
+ item.addEventListener('click', () => {
+ selectNode(item.dataset.id);
+ });
+ });
+}
+
+// Hide autocomplete
+function hideAutocomplete() {
+ autocompleteList.classList.remove('visible');
+ filteredNodes = [];
+ selectedAutocompleteIndex = -1;
+}
+
+// Select a node from autocomplete
+function selectNode(nodeId) {
+ const nodeData = searchIndex.find(n => n.id === nodeId);
+ if (nodeData) {
+ searchInput.value = nodeData.label;
+ hideAutocomplete();
+ highlightNode(nodeId);
+ }
+}
+
+// Update selected item in autocomplete
+function updateSelectedItem() {
+ const items = autocompleteList.querySelectorAll('.autocomplete-item');
+ items.forEach((item, i) => {
+ item.classList.toggle('selected', i === selectedAutocompleteIndex);
+ });
+
+ // Scroll into view
+ if (selectedAutocompleteIndex >= 0 && items[selectedAutocompleteIndex]) {
+ items[selectedAutocompleteIndex].scrollIntoView({ block: 'nearest' });
+ }
+}
+
+// Search input event handlers
+searchInput.addEventListener('input', (e) => {
+ const query = e.target.value.trim();
+ searchClear.classList.toggle('visible', query.length > 0);
+
+ if (query.length > 0) {
+ const results = filterNodes(query);
+ renderAutocomplete(results);
+ } else {
+ hideAutocomplete();
+ }
+});
+
+searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (filteredNodes.length > 0) {
+ selectedAutocompleteIndex = Math.min(selectedAutocompleteIndex + 1, filteredNodes.length - 1);
+ updateSelectedItem();
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (filteredNodes.length > 0) {
+ selectedAutocompleteIndex = Math.max(selectedAutocompleteIndex - 1, 0);
+ updateSelectedItem();
+ }
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (selectedAutocompleteIndex >= 0 && filteredNodes[selectedAutocompleteIndex]) {
+ selectNode(filteredNodes[selectedAutocompleteIndex].id);
+ } else if (filteredNodes.length > 0) {
+ selectNode(filteredNodes[0].id);
+ }
+ } else if (e.key === 'Escape') {
+ if (autocompleteList.classList.contains('visible')) {
+ hideAutocomplete();
+ } else {
+ clearHighlight();
+ }
+ searchInput.blur();
+ }
+});
+
+searchInput.addEventListener('focus', () => {
+ const query = searchInput.value.trim();
+ if (query.length > 0) {
+ const results = filterNodes(query);
+ renderAutocomplete(results);
+ }
+});
+
+// Clear button
+searchClear.addEventListener('click', () => {
+ clearHighlight();
+});
+
+// Clear highlight button
+clearHighlightBtn.addEventListener('click', () => {
+ clearHighlight();
+});
+
+// Close autocomplete when clicking outside
+document.addEventListener('click', (e) => {
+ if (!e.target.closest('.search-container')) {
+ hideAutocomplete();
+ }
+});
+
+// Keyboard shortcut to focus search (Ctrl+F or Cmd+F)
+document.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
+ e.preventDefault();
+ searchInput.focus();
+ searchInput.select();
+ }
+ if (e.key === 'Escape' && currentHighlightedNode) {
+ clearHighlight();
+ }
+});
+
+// Orphan modules click handler - navigate to module node
+document.querySelectorAll('#unlinked-list li').forEach(li => {
+ li.addEventListener('click', () => {
+ const moduleId = li.dataset.moduleId;
+ const targetNode = graphData.nodes.find(n => n.id === moduleId);
+ if (targetNode) {
+ // Zoom and pan to the node
+ const scale = 1.5;
+ svg.transition()
+ .duration(750)
+ .call(zoom.transform, d3.zoomIdentity
+ .translate(width / 2 - targetNode.x * scale, height / 2 - targetNode.y * scale)
+ .scale(scale));
+
+ // Highlight the node temporarily
+ node.selectAll("rect, circle")
+ .style("filter", n => n.id === moduleId ? "brightness(2) drop-shadow(0 0 10px #ff9800)" : "none");
+
+ // Reset highlight after 2 seconds
+ setTimeout(() => {
+ node.selectAll("rect, circle").style("filter", "none");
+ }, 2000);
+ }
+ });
+});
diff --git a/codegraph/templates/styles.css b/codegraph/templates/styles.css
new file mode 100644
index 0000000..1927524
--- /dev/null
+++ b/codegraph/templates/styles.css
@@ -0,0 +1,601 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #1a1a2e;
+ overflow: hidden;
+}
+#graph {
+ width: 100vw;
+ height: 100vh;
+}
+.node {
+ cursor: pointer;
+}
+.node-module {
+ fill: #009c2c;
+ stroke: #00ff44;
+ stroke-width: 2px;
+}
+.node-module.collapsed {
+ fill: #006618;
+ stroke: #00ff44;
+ stroke-width: 3px;
+ stroke-dasharray: 4, 2;
+}
+.node-entity {
+ fill: #4a90d9;
+ stroke: #70b8ff;
+ stroke-width: 1.5px;
+}
+.node-entity.collapsed {
+ fill: #2a5080;
+ stroke: #70b8ff;
+ stroke-width: 2px;
+ stroke-dasharray: 3, 2;
+}
+.node-external {
+ fill: #808080;
+ stroke: #aaaaaa;
+ stroke-width: 1px;
+}
+.node:hover {
+ filter: brightness(1.3);
+}
+.node-hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+.link {
+ fill: none;
+ stroke-opacity: 0.6;
+}
+.link-module-module {
+ stroke: #ff9800;
+ stroke-width: 3px;
+ stroke-opacity: 0.8;
+}
+.link-module-entity {
+ stroke: #009c2c;
+ stroke-width: 1.5px;
+ stroke-dasharray: 5, 3;
+}
+.link-dependency {
+ stroke: #d94a4a;
+ stroke-width: 1.5px;
+}
+.link-hidden {
+ opacity: 0;
+}
+.label {
+ font-size: 11px;
+ fill: #ffffff;
+ pointer-events: none;
+ text-shadow: 0 0 3px #000, 0 0 6px #000;
+}
+.label-module {
+ font-size: 13px;
+ font-weight: bold;
+}
+.label-hidden {
+ opacity: 0;
+}
+.tooltip {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.9);
+ color: #fff;
+ padding: 10px 14px;
+ border-radius: 6px;
+ font-size: 12px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.2s;
+ border: 1px solid #555;
+ max-width: 300px;
+}
+.tooltip .links-info {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid #444;
+}
+.tooltip .links-info span {
+ display: inline-block;
+ margin-right: 12px;
+}
+.tooltip .links-in {
+ color: #4CAF50;
+}
+.tooltip .links-out {
+ color: #ff9800;
+}
+.controls {
+ top: 10px;
+ left: 10px;
+}
+.controls .panel-header h4 {
+ color: #70b8ff;
+}
+.controls p {
+ margin: 5px 0;
+ color: #ccc;
+}
+.controls kbd {
+ background: #333;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: monospace;
+}
+.legend {
+ inset: 10px auto auto 290px;
+}
+.legend .panel-header h4 {
+ color: #70b8ff;
+}
+.legend .panel-content h4 {
+ margin-bottom: 8px;
+ color: #70b8ff;
+ margin-top: 0;
+}
+.legend-section {
+ margin-bottom: 10px;
+}
+.legend-item {
+ display: flex;
+ align-items: center;
+ margin: 4px 0;
+}
+.legend-color {
+ width: 20px;
+ height: 20px;
+ margin-right: 10px;
+ border-radius: 3px;
+}
+.legend-line {
+ width: 30px;
+ height: 3px;
+ margin-right: 10px;
+}
+.legend-module { background: #009c2c; }
+.legend-entity { background: #4a90d9; border-radius: 50%; }
+.legend-external { background: #808080; border-radius: 50%; }
+.legend-link-module { background: #ff9800; }
+.legend-link-entity { background: #009c2c; }
+.legend-link-dep { background: #d94a4a; }
+.stats {
+ top: 10px;
+ right: 10px;
+}
+.stats .panel-header h4 {
+ color: #70b8ff;
+}
+.unlinked-modules {
+ top: 10px;
+ right: 220px;
+ max-height: 400px;
+ max-width: 300px;
+}
+.unlinked-modules .panel-header h4 {
+ color: #ff9800;
+}
+.unlinked-modules .count {
+ color: #888;
+ font-size: 11px;
+ margin-left: 5px;
+}
+/* Tabs inside unlinked panel */
+.unlinked-tabs {
+ display: flex;
+ border-bottom: 1px solid #333;
+ margin-bottom: 10px;
+}
+.unlinked-tab {
+ flex: 1;
+ padding: 8px 4px;
+ background: none;
+ border: none;
+ color: #888;
+ cursor: pointer;
+ font-size: 11px;
+ transition: color 0.2s, background 0.2s;
+ text-align: center;
+}
+.unlinked-tab:hover {
+ color: #ccc;
+}
+.unlinked-tab.active {
+ color: #ff9800;
+ background: rgba(255, 152, 0, 0.1);
+ border-bottom: 2px solid #ff9800;
+}
+.unlinked-tab-content {
+ display: none;
+}
+.unlinked-tab-content.active {
+ display: block;
+}
+/* Links filter in Links count tab */
+.links-filter {
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #333;
+}
+.links-filter .filter-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.links-filter label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ font-size: 11px;
+}
+.links-filter input[type="checkbox"] {
+ cursor: pointer;
+}
+.links-filter input[type="number"] {
+ width: 50px;
+ padding: 4px 6px;
+ border: 1px solid #555;
+ border-radius: 4px;
+ background: #333;
+ color: #fff;
+ font-size: 11px;
+}
+.unlinked-modules ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.unlinked-modules li {
+ padding: 4px 8px;
+ margin: 2px 0;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.2s;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.unlinked-modules li:hover {
+ background: rgba(255, 152, 0, 0.3);
+}
+.unlinked-modules li .path {
+ color: #888;
+ font-size: 10px;
+ display: block;
+ margin-top: 2px;
+}
+.unlinked-modules li .links-count {
+ color: #ff9800;
+ font-weight: bold;
+}
+.unlinked-modules li .entity-type {
+ color: #888;
+ font-size: 10px;
+}
+.unlinked-modules::-webkit-scrollbar {
+ width: 6px;
+}
+.unlinked-modules::-webkit-scrollbar-thumb {
+ background: #555;
+ border-radius: 3px;
+}
+/* Draggable panel styles */
+.panel {
+ position: fixed;
+ background: rgba(0, 0, 0, 0.85);
+ border-radius: 8px;
+ color: #fff;
+ font-size: 12px;
+ z-index: 100;
+ min-width: 150px;
+}
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 15px;
+ cursor: move;
+ border-bottom: 1px solid #333;
+ user-select: none;
+}
+.panel-header h4 {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.panel-toggle {
+ background: none;
+ border: none;
+ color: #888;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0 4px;
+ transition: color 0.2s;
+}
+.panel-toggle:hover {
+ color: #fff;
+}
+.panel-content {
+ padding: 15px;
+ overflow-y: auto;
+}
+.panel.collapsed .panel-content {
+ display: none;
+}
+.panel.collapsed {
+ min-width: auto;
+}
+/* Massive objects panel */
+.massive-objects {
+ bottom: 10px;
+ right: 10px;
+ max-height: 400px;
+ max-width: 300px;
+}
+.massive-objects .panel-header h4 {
+ color: #e91e63;
+}
+.massive-objects .filters {
+ margin-bottom: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.massive-objects .filter-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.massive-objects label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+.massive-objects input[type="checkbox"] {
+ cursor: pointer;
+}
+.massive-objects input[type="number"] {
+ width: 60px;
+ padding: 4px 8px;
+ border: 1px solid #555;
+ border-radius: 4px;
+ background: #333;
+ color: #fff;
+ font-size: 12px;
+}
+.massive-objects .count {
+ color: #888;
+ font-size: 11px;
+ margin-left: 5px;
+}
+.massive-objects ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 250px;
+ overflow-y: auto;
+}
+.massive-objects li {
+ padding: 4px 8px;
+ margin: 2px 0;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+.massive-objects li:hover {
+ background: rgba(233, 30, 99, 0.3);
+}
+.massive-objects li .lines {
+ color: #e91e63;
+ font-weight: bold;
+}
+.massive-objects li .entity-type {
+ color: #888;
+ font-size: 10px;
+}
+.massive-objects::-webkit-scrollbar {
+ width: 6px;
+}
+.massive-objects::-webkit-scrollbar-thumb {
+ background: #555;
+ border-radius: 3px;
+}
+/* Size toggle / Display panel */
+.size-toggle {
+ bottom: 10px;
+ left: 10px;
+ min-width: 180px;
+}
+.size-toggle .panel-header h4 {
+ color: #9c27b0;
+}
+.size-toggle label {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 3px 0;
+}
+.size-toggle input[type="checkbox"] {
+ cursor: pointer;
+ width: 14px;
+ height: 14px;
+}
+.size-toggle .display-section {
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid #333;
+}
+.size-toggle .display-section h5 {
+ margin: 0 0 8px 0;
+ color: #888;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+/* Search box styles */
+.search-container {
+ position: fixed;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1001;
+ width: 350px;
+}
+.search-box {
+ position: relative;
+ width: 100%;
+}
+.search-input {
+ width: 100%;
+ padding: 12px 40px 12px 16px;
+ font-size: 14px;
+ border: 2px solid #444;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.9);
+ color: #fff;
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+.search-input:focus {
+ border-color: #70b8ff;
+ box-shadow: 0 0 10px rgba(112, 184, 255, 0.3);
+}
+.search-input::placeholder {
+ color: #888;
+}
+.search-clear {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: #888;
+ font-size: 18px;
+ cursor: pointer;
+ padding: 4px;
+ display: none;
+}
+.search-clear:hover {
+ color: #fff;
+}
+.search-clear.visible {
+ display: block;
+}
+.autocomplete-list {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 300px;
+ overflow-y: auto;
+ background: rgba(0, 0, 0, 0.95);
+ border: 1px solid #444;
+ border-top: none;
+ border-radius: 0 0 8px 8px;
+ display: none;
+}
+.autocomplete-list.visible {
+ display: block;
+}
+.autocomplete-item {
+ padding: 10px 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid #333;
+}
+.autocomplete-item:last-child {
+ border-bottom: none;
+}
+.autocomplete-item:hover,
+.autocomplete-item.selected {
+ background: rgba(112, 184, 255, 0.2);
+}
+.autocomplete-item .node-type {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ font-weight: bold;
+}
+.autocomplete-item .node-type.module {
+ background: #009c2c;
+ color: #fff;
+}
+.autocomplete-item .node-type.entity {
+ background: #4a90d9;
+ color: #fff;
+}
+.autocomplete-item .node-type.external {
+ background: #808080;
+ color: #fff;
+}
+.autocomplete-item .node-name {
+ color: #fff;
+ flex: 1;
+}
+.autocomplete-item .node-parent {
+ color: #888;
+ font-size: 12px;
+}
+.highlight-info {
+ position: fixed;
+ bottom: 10px;
+ right: 10px;
+ background: rgba(112, 184, 255, 0.9);
+ color: #000;
+ padding: 10px 15px;
+ border-radius: 8px;
+ font-size: 12px;
+ display: none;
+ align-items: center;
+ gap: 10px;
+}
+.highlight-info.visible {
+ display: flex;
+}
+.highlight-info button {
+ background: #333;
+ color: #fff;
+ border: none;
+ padding: 5px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.highlight-info button:hover {
+ background: #555;
+}
+/* Dimmed state for non-highlighted nodes/links */
+.node.dimmed {
+ opacity: 0.15;
+}
+.link.dimmed {
+ opacity: 0.05;
+}
+.label.dimmed {
+ opacity: 0.1;
+}
+/* Highlighted state */
+.node.highlighted {
+ filter: brightness(1.3) drop-shadow(0 0 8px rgba(255, 255, 255, 0.5));
+}
+.node.highlighted-main {
+ filter: brightness(1.5) drop-shadow(0 0 15px rgba(112, 184, 255, 0.8));
+}
+.link.highlighted {
+ stroke-opacity: 1;
+ filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.5));
+}
diff --git a/codegraph/vizualyzer.py b/codegraph/vizualyzer.py
index 86f5018..6bf08b7 100644
--- a/codegraph/vizualyzer.py
+++ b/codegraph/vizualyzer.py
@@ -1,3 +1,4 @@
+import csv
import json
import logging
import os
@@ -283,1481 +284,33 @@ def convert_to_d3_format(modules_entities: Dict, entity_metadata: Dict = None) -
return {"nodes": nodes, "links": links, "unlinkedModules": unlinked_modules}
+def _get_template_dir() -> str:
+ """Get the path to the templates directory."""
+ return os.path.join(os.path.dirname(__file__), 'templates')
+
+
+def _read_template_file(filename: str) -> str:
+ """Read a template file from the templates directory."""
+ template_path = os.path.join(_get_template_dir(), filename)
+ with open(template_path, 'r', encoding='utf-8') as f:
+ return f.read()
+
+
def get_d3_html_template(graph_data: Dict) -> str:
"""Generate HTML with embedded D3.js visualization."""
graph_json = json.dumps(graph_data, indent=2)
- return f'''
-
-
-
-
- CodeGraph - Interactive Visualization
-
-
-
-
-
-
-
-
-
-
-
-
- Highlighting:
- Clear (Esc)
-
-
-
-
-
-
Ctrl+F Search nodes
-
Scroll Zoom in/out
-
Drag on background - Pan
-
Drag on node - Pin node position
-
Click module/entity - Collapse/Expand
-
Double-click - Unpin / Focus on node
-
Esc Clear search highlight
-
-
-
-
-
-
-
Nodes
-
-
-
-
Entity (function/class)
-
-
-
-
External dependency
-
-
-
-
Links
-
-
-
-
-
Entity → Dependency
-
-
-
-
-
-
-
-
-
-
-
-
- Size by lines of code
-
-
-
-
-
-
-'''
+ # Read template files
+ html_template = _read_template_file('index.html')
+ css_content = _read_template_file('styles.css')
+ js_content = _read_template_file('main.js')
+
+ # Replace placeholders
+ html_content = html_template.replace('/* STYLES_PLACEHOLDER */', css_content)
+ html_content = html_content.replace('/* GRAPH_DATA_PLACEHOLDER */', graph_json)
+ html_content = html_content.replace('/* SCRIPT_PLACEHOLDER */', js_content)
+
+ return html_content
def draw_graph(modules_entities: Dict, entity_metadata: Dict = None, output_path: str = None) -> None:
@@ -1788,3 +341,88 @@ def draw_graph(modules_entities: Dict, entity_metadata: Dict = None, output_path
# Import click here to avoid circular imports and only when needed
import click
click.echo(f"Interactive graph saved and opened in browser: {output_path}")
+
+
+def export_to_csv(modules_entities: Dict, entity_metadata: Dict = None, output_path: str = None) -> None:
+ """Export graph data to CSV file.
+
+ Args:
+ modules_entities: Graph data with modules and their entities.
+ entity_metadata: Metadata for entities (lines of code, type).
+ output_path: Path to save CSV file. Default: ./codegraph.csv
+ """
+ import click
+
+ # Get D3 format data to reuse link calculation logic
+ graph_data = convert_to_d3_format(modules_entities, entity_metadata)
+ nodes = graph_data["nodes"]
+ links = graph_data["links"]
+
+ # Build links_in and links_out counts
+ links_out: Dict[str, int] = {}
+ links_in: Dict[str, int] = {}
+
+ for link in links:
+ source = link["source"]
+ target = link["target"]
+ link_type = link.get("type", "")
+
+ # Skip module-entity links (structural, not dependency)
+ if link_type == "module-entity":
+ continue
+
+ links_out[source] = links_out.get(source, 0) + 1
+ links_in[target] = links_in.get(target, 0) + 1
+
+ # Determine output path
+ if output_path is None:
+ output_path = os.path.join(os.getcwd(), "codegraph.csv")
+
+ output_path = os.path.abspath(output_path)
+
+ # Write CSV
+ with open(output_path, 'w', newline='', encoding='utf-8') as csvfile:
+ fieldnames = ['name', 'type', 'parent_module', 'full_path', 'links_out', 'links_in', 'lines']
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+ writer.writeheader()
+
+ for node in nodes:
+ node_id = node["id"]
+ node_type = node.get("type", "")
+
+ # Determine display type
+ if node_type == "module":
+ display_type = "module"
+ parent_module = ""
+ full_path = node.get("fullPath", "")
+ lines = node.get("lines", 0)
+ name = node_id
+ elif node_type == "entity":
+ display_type = node.get("entityType", "function")
+ parent_module = node.get("parent", "")
+ # Find full path from parent module
+ full_path = ""
+ for n in nodes:
+ if n["id"] == parent_module and n["type"] == "module":
+ full_path = n.get("fullPath", "")
+ break
+ lines = node.get("lines", 0)
+ name = node.get("label", node_id)
+ else: # external
+ display_type = "external"
+ parent_module = ""
+ full_path = ""
+ lines = 0
+ name = node.get("label", node_id)
+
+ writer.writerow({
+ 'name': name,
+ 'type': display_type,
+ 'parent_module': parent_module,
+ 'full_path': full_path,
+ 'links_out': links_out.get(node_id, 0),
+ 'links_in': links_in.get(node_id, 0),
+ 'lines': lines
+ })
+
+ click.echo(f"Graph data exported to CSV: {output_path}")
diff --git a/docs/img/graph_display_settings.png b/docs/img/graph_display_settings.png
index 1f099ce..d802a7a 100644
Binary files a/docs/img/graph_display_settings.png and b/docs/img/graph_display_settings.png differ
diff --git a/docs/img/interactive_code_visualization.png b/docs/img/interactive_code_visualization.png
index 07db8e1..9ec023e 100644
Binary files a/docs/img/interactive_code_visualization.png and b/docs/img/interactive_code_visualization.png differ
diff --git a/docs/img/links_count.png b/docs/img/links_count.png
new file mode 100644
index 0000000..35dcacf
Binary files /dev/null and b/docs/img/links_count.png differ
diff --git a/docs/img/listing_unlinked_nodes.png b/docs/img/listing_unlinked_nodes.png
index 561a36b..dda3245 100644
Binary files a/docs/img/listing_unlinked_nodes.png and b/docs/img/listing_unlinked_nodes.png differ
diff --git a/docs/img/node_information.png b/docs/img/node_information.png
index 7c2970b..22e5757 100644
Binary files a/docs/img/node_information.png and b/docs/img/node_information.png differ
diff --git a/pyproject.toml b/pyproject.toml
index b039879..e6bf148 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "codegraph"
-version = "1.1.0"
+version = "1.2.0"
license = "MIT"
readme = "docs/README.rst"
homepage = "https://github.com/xnuinside/codegraph"
diff --git a/tests/test_graph_generation.py b/tests/test_graph_generation.py
index 5322ac5..7c917c5 100644
--- a/tests/test_graph_generation.py
+++ b/tests/test_graph_generation.py
@@ -1,10 +1,12 @@
"""Tests for graph generation functionality."""
+import csv
import pathlib
+import tempfile
from argparse import Namespace
from codegraph.core import CodeGraph
from codegraph.parser import create_objects_array, Import
-from codegraph.vizualyzer import convert_to_d3_format
+from codegraph.vizualyzer import convert_to_d3_format, export_to_csv
TEST_DATA_DIR = pathlib.Path(__file__).parent / "test_data"
@@ -330,3 +332,182 @@ def test_core_utils_connection(self):
# CodeGraph class should use utils.get_python_paths_list
codegraph_deps = usage_graph[core_path]["CodeGraph"]
assert any("utils" in str(d) for d in codegraph_deps)
+
+
+class TestCSVExport:
+ """Tests for CSV export functionality."""
+
+ def test_export_creates_file(self):
+ """Test that export_to_csv creates a CSV file."""
+ usage_graph = {
+ "/path/to/module.py": {
+ "func_a": ["func_b"],
+ "func_b": [],
+ }
+ }
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, output_path=output_path)
+
+ assert pathlib.Path(output_path).exists()
+ pathlib.Path(output_path).unlink()
+
+ def test_export_has_correct_columns(self):
+ """Test that CSV has all required columns."""
+ usage_graph = {
+ "/path/to/module.py": {
+ "func_a": [],
+ }
+ }
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, output_path=output_path)
+
+ with open(output_path, 'r') as csvfile:
+ reader = csv.DictReader(csvfile)
+ fieldnames = reader.fieldnames
+
+ expected_columns = ['name', 'type', 'parent_module', 'full_path', 'links_out', 'links_in', 'lines']
+ assert fieldnames == expected_columns
+ pathlib.Path(output_path).unlink()
+
+ def test_export_module_data(self):
+ """Test that module nodes are exported correctly."""
+ usage_graph = {
+ "/path/to/module.py": {
+ "func_a": [],
+ }
+ }
+ entity_metadata = {
+ "/path/to/module.py": {
+ "func_a": {"lines": 10, "entity_type": "function"}
+ }
+ }
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, entity_metadata=entity_metadata, output_path=output_path)
+
+ with open(output_path, 'r') as csvfile:
+ reader = csv.DictReader(csvfile)
+ rows = list(reader)
+
+ # Find module row
+ module_row = next((r for r in rows if r['type'] == 'module'), None)
+ assert module_row is not None
+ assert module_row['name'] == 'module.py'
+ assert module_row['parent_module'] == ''
+
+ pathlib.Path(output_path).unlink()
+
+ def test_export_entity_data(self):
+ """Test that entity nodes are exported correctly."""
+ usage_graph = {
+ "/path/to/module.py": {
+ "my_function": [],
+ "MyClass": [],
+ }
+ }
+ entity_metadata = {
+ "/path/to/module.py": {
+ "my_function": {"lines": 15, "entity_type": "function"},
+ "MyClass": {"lines": 50, "entity_type": "class"},
+ }
+ }
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, entity_metadata=entity_metadata, output_path=output_path)
+
+ with open(output_path, 'r') as csvfile:
+ reader = csv.DictReader(csvfile)
+ rows = list(reader)
+
+ # Find function row
+ func_row = next((r for r in rows if r['name'] == 'my_function'), None)
+ assert func_row is not None
+ assert func_row['type'] == 'function'
+ assert func_row['parent_module'] == 'module.py'
+ assert func_row['lines'] == '15'
+
+ # Find class row
+ class_row = next((r for r in rows if r['name'] == 'MyClass'), None)
+ assert class_row is not None
+ assert class_row['type'] == 'class'
+ assert class_row['lines'] == '50'
+
+ pathlib.Path(output_path).unlink()
+
+ def test_export_links_count(self):
+ """Test that links_in and links_out are calculated correctly."""
+ usage_graph = {
+ "/path/to/a.py": {
+ "func_a": ["b.func_b", "b.func_c"],
+ },
+ "/path/to/b.py": {
+ "func_b": [],
+ "func_c": [],
+ },
+ }
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, output_path=output_path)
+
+ with open(output_path, 'r') as csvfile:
+ reader = csv.DictReader(csvfile)
+ rows = list(reader)
+
+ # func_a should have links_out (dependencies)
+ func_a_row = next((r for r in rows if r['name'] == 'func_a'), None)
+ assert func_a_row is not None
+ assert int(func_a_row['links_out']) >= 2
+
+ # func_b should have links_in (being depended on)
+ func_b_row = next((r for r in rows if r['name'] == 'func_b'), None)
+ assert func_b_row is not None
+ assert int(func_b_row['links_in']) >= 1
+
+ pathlib.Path(output_path).unlink()
+
+ def test_export_codegraph_on_itself(self):
+ """Test CSV export on codegraph package itself."""
+ codegraph_path = pathlib.Path(__file__).parents[1] / "codegraph"
+ args = Namespace(paths=[codegraph_path.as_posix()])
+
+ code_graph = CodeGraph(args)
+ usage_graph = code_graph.usage_graph()
+ entity_metadata = code_graph.get_entity_metadata()
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
+ output_path = f.name
+
+ export_to_csv(usage_graph, entity_metadata=entity_metadata, output_path=output_path)
+
+ with open(output_path, 'r') as csvfile:
+ reader = csv.DictReader(csvfile)
+ rows = list(reader)
+
+ # Should have modules
+ module_names = [r['name'] for r in rows if r['type'] == 'module']
+ assert 'core.py' in module_names
+ assert 'parser.py' in module_names
+ assert 'main.py' in module_names
+ assert 'vizualyzer.py' in module_names
+
+ # Should have functions and classes
+ types = set(r['type'] for r in rows)
+ assert 'module' in types
+ assert 'function' in types
+ assert 'class' in types
+
+ # CodeGraph class should exist
+ codegraph_row = next((r for r in rows if r['name'] == 'CodeGraph'), None)
+ assert codegraph_row is not None
+ assert codegraph_row['type'] == 'class'
+ assert codegraph_row['parent_module'] == 'core.py'
+ assert int(codegraph_row['lines']) > 0
+
+ pathlib.Path(output_path).unlink()