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 ![Node Information](docs/img/node_information.png) -**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](docs/img/links_count.png) + +**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](docs/img/massive_objects_detection.png) + +**Massive Objects Panel** - Find large code entities by lines of code. Filter by type (modules, classes, functions) with configurable threshold. + +### Display Settings + +![Display Settings](docs/img/graph_display_settings.png) + +**Display Filters** - Show/hide nodes by type (Modules, Classes, Functions, External) and links by type (Module→Module, Module→Entity, Dependencies). + ### UI Tips ![UI Tips](docs/img/tips_in_ui.png) @@ -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: + +
+ +
+
+

Controls

+ +
+
+

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

+
+
+
+
+

Legend

+ +
+
+
+

Nodes

+
+
+ Module (.py file) +
+
+
+ Entity (function/class) +
+
+
+ External dependency +
+
+
+

Links

+
+ + Module → Module +
+
+ + Module → Entity +
+
+ + Entity → Dependency +
+
+
+
+
+
+

Statistics

+ +
+
+
+
+
+

Connections

+ +
+
+
+ + +
+
+ +
+
+
+
+

Massive Objects

+ +
+
+
+
+ + + +
+
+ Min lines: + +
+
+ +
+
+
+
+

Display

+ +
+
+ +
+
Show nodes
+ + + + +
+
+
Show links
+ + + +
+
+
+ + + + 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 = ` + + `; +} 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 = ` + + `; + + // 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 + '
    ' : ''} + + ${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: - -
    - -
    -
    -

    Controls

    - -
    -
    -

    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

    -
    -
    -
    -
    -

    Legend

    - -
    -
    -
    -

    Nodes

    -
    -
    - Module (.py file) -
    -
    -
    - Entity (function/class) -
    -
    -
    - External dependency -
    -
    -
    -

    Links

    -
    - - Module → Module -
    -
    - - Module → Entity -
    -
    - - Entity → Dependency -
    -
    -
    -
    -
    -
    -

    Statistics

    - -
    -
    -
    -
    -
    -

    Unlinked

    - -
    -
    -
    -
    -
    -

    Massive Objects

    - -
    -
    -
    -
    - - - -
    -
    - Min lines: - -
    -
    - -
    -
    -
    -
    -

    Display

    - -
    -
    - -
    -
    - - - -''' + # 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()