Spaces:
Runtime error
Runtime error
| import os | |
| import json | |
| import networkx as nx | |
| from collections import Counter, defaultdict | |
| from typing import Dict, List, Tuple, Any, Optional | |
| from datetime import datetime | |
| import numpy as np | |
| from pyvis.network import Network | |
| import re | |
| import google.generativeai as genai | |
| class RepositoryVisualizer: | |
| """Handles visualization of GitHub repository data using Enhanced PyVis""" | |
| def __init__(self, config: Any = None, max_nodes: int = 150): | |
| """ | |
| Initialize the repository visualizer | |
| Args: | |
| config: Configuration object (optional) | |
| max_nodes: Maximum number of nodes to include in visualizations (if config not provided) | |
| """ | |
| # Handle both config object and direct parameters | |
| if config is not None: | |
| self.max_nodes = getattr(config, 'visualization_node_limit', 150) | |
| else: | |
| self.max_nodes = max_nodes | |
| self.node_colors = { | |
| 'file': { | |
| 'py': '#3572A5', # Python (blue) | |
| 'js': '#F7DF1E', # JavaScript (yellow) | |
| 'ts': '#3178C6', # TypeScript (blue) | |
| 'jsx': '#61DAFB', # React JSX (cyan) | |
| 'tsx': '#61DAFB', # React TSX (cyan) | |
| 'html': '#E34F26', # HTML (orange) | |
| 'css': '#563D7C', # CSS (purple) | |
| 'java': '#B07219', # Java (brown) | |
| 'cpp': '#F34B7D', # C++ (pink) | |
| 'c': '#A8B9CC', # C (light blue) | |
| 'go': '#00ADD8', # Go (blue) | |
| 'md': '#083fa1', # Markdown (blue) | |
| 'json': '#292929', # JSON (dark gray) | |
| 'default': '#7F7F7F' # Default (gray) | |
| }, | |
| 'contributor': '#e74c3c', # Contributor (red) | |
| 'issue': '#3498db', # Issue (blue) | |
| 'directory': '#2ecc71' # Directory (green) | |
| } | |
| # Add group definitions for visualization | |
| self.groups = { | |
| 'files': {"color": {"background": "#3498db"}, "shape": "dot"}, | |
| 'contributors': {"color": {"background": "#e74c3c"}, "shape": "diamond"}, | |
| 'directories': {"color": {"background": "#2ecc71"}, "shape": "triangle"}, | |
| 'issues': {"color": {"background": "#9b59b6"}, "shape": "star"} | |
| } | |
| def _get_important_subgraph(self, graph: nx.Graph, max_nodes: int) -> nx.Graph: | |
| """ | |
| Get a subgraph containing the most important nodes | |
| Args: | |
| graph: Input graph | |
| max_nodes: Maximum number of nodes to include | |
| Returns: | |
| Subgraph with most important nodes | |
| """ | |
| # Return original graph if it's already small enough | |
| if len(graph.nodes) <= max_nodes: | |
| return graph | |
| # Try different centrality measures | |
| try: | |
| # First try degree centrality | |
| centrality = nx.degree_centrality(graph) | |
| except: | |
| # Fall back to simpler degree if that fails | |
| centrality = {node: graph.degree(node) for node in graph.nodes()} | |
| # Sort nodes by importance | |
| sorted_nodes = sorted(centrality.items(), key=lambda x: x[1], reverse=True) | |
| # Take top nodes | |
| top_nodes = [node for node, _ in sorted_nodes[:max_nodes]] | |
| # Create subgraph | |
| return graph.subgraph(top_nodes) | |
| def _extract_dependencies(self, file_contents: Dict) -> Dict[str, List[str]]: | |
| """ | |
| Extract file dependencies based on imports and includes | |
| Args: | |
| file_contents: Dictionary of file contents | |
| Returns: | |
| Dictionary mapping files to their dependencies | |
| """ | |
| dependencies = defaultdict(list) | |
| # Map of common import patterns by language | |
| import_patterns = { | |
| 'py': [ | |
| r'^\s*import\s+(\w+)', # import module | |
| r'^\s*from\s+(\w+)', # from module import | |
| r'^\s*import\s+([\w.]+)' # import module.submodule | |
| ], | |
| 'js': [ | |
| r'^\s*import.*from\s+[\'"](.+)[\'"]', # ES6 import | |
| r'^\s*require\([\'"](.+)[\'"]\)', # CommonJS require | |
| r'^\s*import\s+[\'"](.+)[\'"]' # Side-effect import | |
| ], | |
| 'java': [ | |
| r'^\s*import\s+([\w.]+)' # Java import | |
| ], | |
| 'cpp': [ | |
| r'^\s*#include\s+[<"](.+)[>"]' # C/C++ include | |
| ], | |
| 'go': [ | |
| r'^\s*import\s+[\'"](.+)[\'"]', # Go single import | |
| r'^\s*import\s+\(\s*[\'"](.+)[\'"]' # Go multiple imports | |
| ] | |
| } | |
| # Process each file | |
| for filename, file_data in file_contents.items(): | |
| # Get file extension | |
| _, ext = os.path.splitext(filename) | |
| ext = ext.lstrip('.').lower() if ext else '' | |
| # Skip if we don't have patterns for this language | |
| if ext not in import_patterns: | |
| continue | |
| # Get content | |
| content = file_data.get('content', '') | |
| if not content: | |
| continue | |
| # Search for imports | |
| lines = content.split('\n') | |
| patterns = import_patterns[ext] | |
| for line in lines: | |
| for pattern in patterns: | |
| # Find imports | |
| import_match = re.search(pattern, line) | |
| if import_match: | |
| imported = import_match.group(1) | |
| # Look for matching files | |
| for target_file in file_contents.keys(): | |
| target_name = os.path.basename(target_file) | |
| target_module = os.path.splitext(target_name)[0] | |
| # Check if this might be the imported file | |
| if imported == target_module or imported.endswith('.' + target_module): | |
| dependencies[filename].append(target_file) | |
| break | |
| return dependencies | |
| def _format_size(self, size_bytes: int) -> str: | |
| """ | |
| Format file size in human-readable format | |
| Args: | |
| size_bytes: Size in bytes | |
| Returns: | |
| Formatted size string | |
| """ | |
| if size_bytes < 1024: | |
| return f"{size_bytes} bytes" | |
| elif size_bytes < 1024 * 1024: | |
| return f"{size_bytes / 1024:.1f} KB" | |
| else: | |
| return f"{size_bytes / (1024 * 1024):.1f} MB" | |
| def _add_directory_nodes(self, graph: nx.Graph) -> None: | |
| """ | |
| Add directory nodes to graph for hierarchical structure | |
| Args: | |
| graph: NetworkX graph to modify | |
| """ | |
| file_nodes = [node for node, data in graph.nodes(data=True) | |
| if data.get('type') == 'file'] | |
| # Extract unique directories | |
| directories = set() | |
| for filepath in file_nodes: | |
| path_parts = os.path.dirname(filepath).split('/') | |
| current_path = "" | |
| for part in path_parts: | |
| if not part: # Skip empty parts | |
| continue | |
| if current_path: | |
| current_path = f"{current_path}/{part}" | |
| else: | |
| current_path = part | |
| directories.add(current_path) | |
| # Add directory nodes | |
| for directory in directories: | |
| if directory not in graph: | |
| graph.add_node(directory, type='directory') | |
| # Connect files to their parent directories | |
| for filepath in file_nodes: | |
| parent_dir = os.path.dirname(filepath) | |
| if parent_dir and parent_dir in graph: | |
| graph.add_edge(filepath, parent_dir, type='parent') | |
| # Connect directories to their parents | |
| for directory in directories: | |
| parent_dir = os.path.dirname(directory) | |
| if parent_dir and parent_dir in graph: | |
| graph.add_edge(directory, parent_dir, type='parent') | |
| def create_repository_graph(self, knowledge_graph: nx.Graph, output_path: str = "repo_graph.html") -> str: | |
| """ | |
| Create an interactive visualization of the repository structure | |
| Enhanced with better physics, filtering, and groups | |
| Args: | |
| knowledge_graph: NetworkX graph of repository data | |
| output_path: Path to save the HTML visualization | |
| Returns: | |
| Path to the saved HTML file | |
| """ | |
| # Create a copy of the graph to avoid modifying the original | |
| graph = knowledge_graph.copy() | |
| # Limit the number of nodes if necessary | |
| if len(graph.nodes()) > self.max_nodes: | |
| print(f"Graph has {len(graph.nodes())} nodes, limiting to {self.max_nodes} most important nodes") | |
| graph = self._get_important_subgraph(graph, self.max_nodes) | |
| # Extract directories from file paths for hierarchical structure | |
| self._add_directory_nodes(graph) | |
| # Create PyVis network with improved settings | |
| net = Network(height="750px", width="100%", notebook=False, directed=False, | |
| bgcolor="#222222", font_color="white", select_menu=True, filter_menu=True) | |
| # Add custom groups for better filtering | |
| for group_name, group_props in self.groups.items(): | |
| net.add_node(f"group_{group_name}", hidden=True, **group_props) | |
| # Customize physics for better visualization | |
| net.barnes_hut(gravity=-80000, central_gravity=0.3, spring_length=250, spring_strength=0.001, | |
| damping=0.09, overlap=0) | |
| # Add nodes with appropriate styling and interactive features | |
| for node_id in graph.nodes(): | |
| node_data = graph.nodes[node_id] | |
| node_type = node_data.get('type', 'unknown') | |
| # Default node properties | |
| title = node_id | |
| color = self.node_colors.get(node_type, {}).get('default', "#7F7F7F") | |
| shape = "dot" | |
| size = 15 | |
| group = None | |
| if node_type == 'file': | |
| # Get file extension | |
| _, ext = os.path.splitext(node_id) | |
| ext = ext.lstrip('.').lower() if ext else 'default' | |
| # Set color based on file extension | |
| color = self.node_colors['file'].get(ext, self.node_colors['file']['default']) | |
| # Use filename as label | |
| label = os.path.basename(node_id) | |
| # Set title with additional info | |
| file_type = node_data.get('file_type', 'unknown') | |
| file_size = node_data.get('size', 0) | |
| title = f"<div style='max-width: 300px;'><h3>{label}</h3><hr/><strong>Path:</strong> {node_id}<br/><strong>Type:</strong> {file_type}<br/><strong>Size:</strong> {self._format_size(file_size)}</div>" | |
| # Set group for filtering | |
| group = 'files' | |
| elif node_type == 'contributor': | |
| # Contributor styling | |
| color = self.node_colors['contributor'] | |
| shape = "diamond" | |
| # Scale size based on contributions | |
| contributions = node_data.get('contributions', 0) | |
| size = min(30, 15 + contributions / 20) | |
| label = node_id | |
| title = f"<div style='max-width: 300px;'><h3>Contributor: {node_id}</h3><hr/><strong>Contributions:</strong> {contributions}</div>" | |
| # Set group for filtering | |
| group = 'contributors' | |
| elif node_type == 'directory': | |
| # Directory styling | |
| color = self.node_colors['directory'] | |
| shape = "triangle" | |
| label = os.path.basename(node_id) if node_id else "/" | |
| title = f"<div style='max-width: 300px;'><h3>Directory: {label}</h3><hr/><strong>Path:</strong> {node_id}</div>" | |
| # Set group for filtering | |
| group = 'directories' | |
| else: | |
| # Default styling | |
| label = node_id | |
| # Add node to network with searchable property and group | |
| net.add_node(node_id, label=label, title=title, color=color, shape=shape, size=size, | |
| group=group, searchable=True) | |
| # Add edges with appropriate styling and information | |
| for source, target, data in graph.edges(data=True): | |
| # Default edge properties | |
| width = 1 | |
| color = "#ffffff80" # Semi-transparent white | |
| title = f"{source} → {target}" | |
| smooth = True # Enable smooth edges | |
| # Adjust based on edge data | |
| edge_type = data.get('type', 'default') | |
| weight = data.get('weight', 1) | |
| # Scale width based on weight | |
| width = min(10, 1 + weight / 5) | |
| if edge_type == 'co-occurrence': | |
| title = f"<div style='max-width: 200px;'><strong>Co-occurred in {weight} commits</strong><br/>Files modified together frequently</div>" | |
| color = "#9b59b680" # Semi-transparent purple | |
| elif edge_type == 'contribution': | |
| title = f"<div style='max-width: 200px;'><strong>Modified {weight} times</strong><br/>By this contributor</div>" | |
| color = "#e74c3c80" # Semi-transparent red | |
| elif edge_type == 'imports': | |
| title = f"<div style='max-width: 200px;'><strong>Imports</strong><br/>This file imports the target</div>" | |
| color = "#3498db80" # Semi-transparent blue | |
| elif edge_type == 'parent': | |
| title = f"<div style='max-width: 200px;'><strong>Parent directory</strong></div>" | |
| color = "#2ecc7180" # Semi-transparent green | |
| width = 1 # Fixed width for parent relationships | |
| # Add edge to network with additional properties | |
| net.add_edge(source, target, title=title, width=width, color=color, smooth=smooth, selectionWidth=width*1.5) | |
| # Configure network options with improved UI and interactivity | |
| options = """ | |
| var options = { | |
| "nodes": { | |
| "borderWidth": 2, | |
| "borderWidthSelected": 4, | |
| "opacity": 0.9, | |
| "font": { | |
| "size": 12, | |
| "face": "Tahoma" | |
| }, | |
| "shadow": true | |
| }, | |
| "edges": { | |
| "color": { | |
| "inherit": false | |
| }, | |
| "smooth": { | |
| "type": "continuous", | |
| "forceDirection": "none" | |
| }, | |
| "shadow": true, | |
| "selectionWidth": 3 | |
| }, | |
| "physics": { | |
| "barnesHut": { | |
| "gravitationalConstant": -80000, | |
| "centralGravity": 0.3, | |
| "springLength": 250, | |
| "springConstant": 0.001, | |
| "damping": 0.09, | |
| "avoidOverlap": 0.1 | |
| }, | |
| "maxVelocity": 50, | |
| "minVelocity": 0.1, | |
| "stabilization": { | |
| "enabled": true, | |
| "iterations": 1000, | |
| "updateInterval": 100, | |
| "onlyDynamicEdges": false, | |
| "fit": true | |
| } | |
| }, | |
| "interaction": { | |
| "tooltipDelay": 200, | |
| "hideEdgesOnDrag": true, | |
| "multiselect": true, | |
| "hover": true, | |
| "navigationButtons": true, | |
| "keyboard": { | |
| "enabled": true, | |
| "speed": { | |
| "x": 10, | |
| "y": 10, | |
| "zoom": 0.1 | |
| }, | |
| "bindToWindow": true | |
| } | |
| }, | |
| "configure": { | |
| "enabled": true, | |
| "filter": ["physics", "nodes", "edges"], | |
| "showButton": true | |
| }, | |
| "groups": { | |
| "files": {"color": {"background": "#3498db"}, "shape": "dot"}, | |
| "contributors": {"color": {"background": "#e74c3c"}, "shape": "diamond"}, | |
| "directories": {"color": {"background": "#2ecc71"}, "shape": "triangle"}, | |
| "issues": {"color": {"background": "#9b59b6"}, "shape": "star"} | |
| } | |
| } | |
| """ | |
| net.set_options(options) | |
| # Add search functionality and control buttons to the HTML | |
| html_before = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Repository Visualization</title> | |
| <style> | |
| body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, sans-serif; } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| } | |
| #search { | |
| padding: 5px; | |
| width: 200px; | |
| border-radius: 3px; | |
| border: none; | |
| } | |
| .btn { | |
| padding: 5px 10px; | |
| margin-left: 5px; | |
| background: #3498db; | |
| color: white; | |
| border: none; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .btn:hover { background: #2980b9; } | |
| #legend { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| max-width: 250px; | |
| } | |
| .legend-item { | |
| margin: 5px 0; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .legend-color { | |
| width: 15px; | |
| height: 15px; | |
| display: inline-block; | |
| margin-right: 5px; | |
| border-radius: 2px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <input type="text" id="search" placeholder="Search nodes..."> | |
| <button class="btn" id="searchBtn">Search</button> | |
| <button class="btn" id="resetBtn">Reset</button> | |
| <button class="btn" id="togglePhysicsBtn">Toggle Physics</button> | |
| <select id="layoutSelect" class="btn"> | |
| <option value="default">Default Layout</option> | |
| <option value="hierarchical">Hierarchical Layout</option> | |
| <option value="radial">Radial Layout</option> | |
| </select> | |
| </div> | |
| <div id="legend"> | |
| <h3 style="margin-top: 0;">Legend</h3> | |
| <div class="legend-item"><span class="legend-color" style="background:#3498db"></span> Files</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#e74c3c"></span> Contributors</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#2ecc71"></span> Directories</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#9b59b6"></span> Issues</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#9b59b680"></span> Co-occurrence</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#e74c3c80"></span> Contribution</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#3498db80"></span> Imports</div> | |
| <div class="legend-item"><span class="legend-color" style="background:#2ecc7180"></span> Parent</div> | |
| </div> | |
| """ | |
| html_after = """ | |
| <script> | |
| // Get network instance | |
| const network = document.getElementById('mynetwork').vis; | |
| // Search functionality | |
| document.getElementById('searchBtn').addEventListener('click', function() { | |
| const searchTerm = document.getElementById('search').value.toLowerCase(); | |
| if (!searchTerm) return; | |
| // Find matching nodes | |
| const allNodes = network.body.data.nodes.get(); | |
| const matchingNodes = allNodes.filter(node => | |
| node.label && node.label.toLowerCase().includes(searchTerm) || | |
| node.id && node.id.toLowerCase().includes(searchTerm) | |
| ); | |
| if (matchingNodes.length > 0) { | |
| // Focus on first matching node | |
| network.focus(matchingNodes[0].id, { | |
| scale: 1.2, | |
| animation: true | |
| }); | |
| network.selectNodes([matchingNodes[0].id]); | |
| } else { | |
| alert('No matching nodes found'); | |
| } | |
| }); | |
| // Reset view | |
| document.getElementById('resetBtn').addEventListener('click', function() { | |
| network.fit({ | |
| animation: true | |
| }); | |
| }); | |
| // Toggle physics | |
| document.getElementById('togglePhysicsBtn').addEventListener('click', function() { | |
| const physics = network.physics.options.enabled; | |
| network.setOptions({ physics: { enabled: !physics } }); | |
| }); | |
| // Layout selector | |
| document.getElementById('layoutSelect').addEventListener('change', function() { | |
| const layout = this.value; | |
| if (layout === 'hierarchical') { | |
| network.setOptions({ | |
| layout: { | |
| hierarchical: { | |
| enabled: true, | |
| direction: 'UD', | |
| sortMethod: 'directed', | |
| nodeSpacing: 150, | |
| levelSeparation: 150 | |
| } | |
| }, | |
| physics: { enabled: false } | |
| }); | |
| } else if (layout === 'radial') { | |
| // For radial, we use physics to create a radial effect | |
| network.setOptions({ | |
| layout: { hierarchical: { enabled: false } }, | |
| physics: { | |
| enabled: true, | |
| barnesHut: { | |
| gravitationalConstant: -2000, | |
| centralGravity: 0.3, | |
| springLength: 95, | |
| springConstant: 0.04, | |
| } | |
| } | |
| }); | |
| // Radial positioning | |
| const nodes = network.body.data.nodes.get(); | |
| const centerX = 0; | |
| const centerY = 0; | |
| const radius = 500; | |
| nodes.forEach((node, i) => { | |
| const angle = (2 * Math.PI * i) / nodes.length; | |
| const x = centerX + radius * Math.cos(angle); | |
| const y = centerY + radius * Math.sin(angle); | |
| network.moveNode(node.id, x, y); | |
| }); | |
| } else { | |
| // Default layout | |
| network.setOptions({ | |
| layout: { hierarchical: { enabled: false } }, | |
| physics: { | |
| enabled: true, | |
| barnesHut: { | |
| gravitationalConstant: -80000, | |
| centralGravity: 0.3, | |
| springLength: 250, | |
| springConstant: 0.001, | |
| damping: 0.09, | |
| avoidOverlap: 0.1 | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| // Add keyboard shortcuts | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'f' && (e.ctrlKey || e.metaKey)) { | |
| e.preventDefault(); | |
| document.getElementById('search').focus(); | |
| } else if (e.key === 'Escape') { | |
| network.unselectAll(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Convert file_stats to JSON for the template | |
| file_stats_json = json.dumps(file_stats) | |
| # Replace placeholder with actual data | |
| html = html.replace('FILE_STATS', file_stats_json) | |
| # Save to file | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(html) | |
| return output_path | |
| # Save network visualization to HTML file with custom HTML | |
| net.save_graph(output_path) | |
| # Read the generated file | |
| with open(output_path, 'r', encoding='utf-8') as f: | |
| net_html = f.read() | |
| # Insert our custom HTML | |
| net_html = net_html.replace('<html>', html_before).replace('</html>', html_after) | |
| # Write the modified file | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(net_html) | |
| return output_path | |
| def create_contributor_network(self, contributors: Dict, commits: List[Dict], | |
| output_path: str = "contributor_network.html") -> str: | |
| """ | |
| Create an enhanced network visualization of contributor relationships | |
| Args: | |
| contributors: Dictionary of contributor data | |
| commits: List of commit data | |
| output_path: Path to save the HTML visualization | |
| Returns: | |
| Path to the saved HTML file | |
| """ | |
| # Create graph for contributor relationships | |
| graph = nx.Graph() | |
| # Add contributor nodes | |
| for login, data in contributors.items(): | |
| graph.add_node(login, type='contributor', contributions=data['contributions']) | |
| # Find file co-authorship to establish contributor relationships | |
| file_authors = defaultdict(set) | |
| # Group files by authors | |
| for login, data in contributors.items(): | |
| for file_data in data.get('files_modified', []): | |
| filename = file_data.get('filename', '') | |
| if filename: | |
| file_authors[filename].add(login) | |
| # Create edges between contributors who worked on the same files | |
| for filename, authors in file_authors.items(): | |
| if len(authors) > 1: | |
| for author1 in authors: | |
| for author2 in authors: | |
| if author1 != author2: | |
| if graph.has_edge(author1, author2): | |
| graph[author1][author2]['weight'] += 1 | |
| graph[author1][author2]['files'].add(filename) | |
| else: | |
| graph.add_edge(author1, author2, weight=1, files={filename}, type='collaboration') | |
| # Create Pyvis network with enhanced settings | |
| net = Network(height="750px", width="100%", notebook=False, directed=False, | |
| bgcolor="#222222", font_color="white", select_menu=True, filter_menu=True) | |
| # Configure physics | |
| net.barnes_hut(gravity=-5000, central_gravity=0.3, spring_length=150, spring_strength=0.05) | |
| # Add nodes with improved styling | |
| for login in graph.nodes(): | |
| # Get node data | |
| node_data = graph.nodes[login] | |
| contributions = node_data.get('contributions', 0) | |
| # Scale size based on contributions | |
| size = 15 + min(20, contributions / 10) | |
| # Create detailed HTML tooltip | |
| tooltip = f""" | |
| <div style='max-width: 300px; padding: 10px;'> | |
| <h3>Contributor: {login}</h3> | |
| <hr/> | |
| <strong>Contributions:</strong> {contributions}<br/> | |
| <strong>Activity Level:</strong> {"High" if contributions > 50 else "Medium" if contributions > 20 else "Low"} | |
| </div> | |
| """ | |
| # Add node with improved metadata | |
| net.add_node(login, label=login, title=tooltip, | |
| color=self.node_colors['contributor'], shape="dot", size=size, | |
| group='contributors', searchable=True) | |
| # Add edges with enhanced information | |
| for source, target, data in graph.edges(data=True): | |
| weight = data.get('weight', 1) | |
| files = data.get('files', set()) | |
| # Scale width based on collaboration strength | |
| width = min(10, 1 + weight / 2) | |
| # Create a better-formatted tooltip with file information | |
| file_list = "<br>".join(list(files)[:5]) | |
| if len(files) > 5: | |
| file_list += f"<br>...and {len(files) - 5} more" | |
| tooltip = f""" | |
| <div style='max-width: 300px; padding: 10px;'> | |
| <h3>Collaboration</h3> | |
| <hr/> | |
| <strong>Contributors:</strong> {source} & {target}<br/> | |
| <strong>Shared Files:</strong> {weight}<br/> | |
| <strong>Collaboration Strength:</strong> {"Strong" if weight > 5 else "Medium" if weight > 2 else "Light"}<br/> | |
| <hr/> | |
| <strong>Example Files:</strong><br/> | |
| {file_list} | |
| </div> | |
| """ | |
| # Add edge with enhanced styling | |
| color = "#3498db" + hex(min(255, 80 + (weight * 10)))[2:].zfill(2) # Vary opacity by weight | |
| net.add_edge(source, target, title=tooltip, width=width, color=color, smooth=True) | |
| # Configure options with enhanced UI | |
| options = """ | |
| var options = { | |
| "nodes": { | |
| "borderWidth": 2, | |
| "borderWidthSelected": 4, | |
| "opacity": 0.9, | |
| "font": { | |
| "size": 14, | |
| "face": "Tahoma" | |
| }, | |
| "shadow": true | |
| }, | |
| "edges": { | |
| "color": { | |
| "inherit": false | |
| }, | |
| "smooth": { | |
| "type": "continuous", | |
| "forceDirection": "horizontal" | |
| }, | |
| "shadow": true, | |
| "selectionWidth": 3 | |
| }, | |
| "physics": { | |
| "barnesHut": { | |
| "gravitationalConstant": -5000, | |
| "centralGravity": 0.3, | |
| "springLength": 150, | |
| "springConstant": 0.05, | |
| "damping": 0.09, | |
| "avoidOverlap": 0.2 | |
| }, | |
| "stabilization": { | |
| "enabled": true, | |
| "iterations": 1000 | |
| } | |
| }, | |
| "interaction": { | |
| "hover": true, | |
| "tooltipDelay": 200, | |
| "hideEdgesOnDrag": true, | |
| "multiselect": true, | |
| "navigationButtons": true | |
| }, | |
| "configure": { | |
| "enabled": true, | |
| "filter": ["physics", "nodes", "edges"], | |
| "showButton": true | |
| } | |
| } | |
| """ | |
| net.set_options(options) | |
| # Add search and controls similar to repository graph | |
| html_before = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Contributor Network</title> | |
| <style> | |
| body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, sans-serif; } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| } | |
| #search { | |
| padding: 5px; | |
| width: 200px; | |
| border-radius: 3px; | |
| border: none; | |
| } | |
| .btn { | |
| padding: 5px 10px; | |
| margin-left: 5px; | |
| background: #3498db; | |
| color: white; | |
| border: none; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .btn:hover { background: #2980b9; } | |
| #stats { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| max-width: 250px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <input type="text" id="search" placeholder="Search contributors..."> | |
| <button class="btn" id="searchBtn">Search</button> | |
| <button class="btn" id="resetBtn">Reset</button> | |
| <button class="btn" id="togglePhysicsBtn">Toggle Physics</button> | |
| <select id="layoutSelect" class="btn"> | |
| <option value="default">Default Layout</option> | |
| <option value="circle">Circle Layout</option> | |
| <option value="grid">Grid Layout</option> | |
| </select> | |
| </div> | |
| <div id="stats"> | |
| <h3 style="margin-top: 0;">Network Statistics</h3> | |
| <p>Contributors: <span id="nodeCount">0</span></p> | |
| <p>Collaborations: <span id="edgeCount">0</span></p> | |
| <p>Avg. Collaborations: <span id="avgEdges">0</span></p> | |
| <p>Click on a contributor to see their relationships</p> | |
| </div> | |
| """ | |
| html_after = """ | |
| <script> | |
| // Get network instance | |
| const network = document.getElementById('mynetwork').vis; | |
| // Update stats | |
| function updateStats() { | |
| const nodes = network.body.data.nodes.get(); | |
| const edges = network.body.data.edges.get(); | |
| document.getElementById('nodeCount').textContent = nodes.length; | |
| document.getElementById('edgeCount').textContent = edges.length; | |
| document.getElementById('avgEdges').textContent = (edges.length / nodes.length).toFixed(2); | |
| } | |
| updateStats(); | |
| // Search functionality | |
| document.getElementById('searchBtn').addEventListener('click', function() { | |
| const searchTerm = document.getElementById('search').value.toLowerCase(); | |
| if (!searchTerm) return; | |
| // Find matching nodes | |
| const allNodes = network.body.data.nodes.get(); | |
| const matchingNodes = allNodes.filter(node => | |
| node.label && node.label.toLowerCase().includes(searchTerm) || | |
| node.id && node.id.toLowerCase().includes(searchTerm) | |
| ); | |
| if (matchingNodes.length > 0) { | |
| // Focus on first matching node | |
| network.focus(matchingNodes[0].id, { | |
| scale: 1.2, | |
| animation: true | |
| }); | |
| network.selectNodes([matchingNodes[0].id]); | |
| } else { | |
| alert('No matching nodes found'); | |
| } | |
| }); | |
| // Reset view | |
| document.getElementById('resetBtn').addEventListener('click', function() { | |
| network.fit({ | |
| animation: true | |
| }); | |
| }); | |
| // Toggle physics | |
| document.getElementById('togglePhysicsBtn').addEventListener('click', function() { | |
| const physics = network.physics.options.enabled; | |
| network.setOptions({ physics: { enabled: !physics } }); | |
| }); | |
| // Layout selector | |
| document.getElementById('layoutSelect').addEventListener('change', function() { | |
| const layout = this.value; | |
| if (layout === 'circle') { | |
| const nodes = network.body.data.nodes.get(); | |
| const numNodes = nodes.length; | |
| const radius = 300; | |
| const center = {x: 0, y: 0}; | |
| nodes.forEach((node, i) => { | |
| const angle = (i / numNodes) * 2 * Math.PI; | |
| const x = center.x + radius * Math.cos(angle); | |
| const y = center.y + radius * Math.sin(angle); | |
| network.moveNode(node.id, x, y); | |
| }); | |
| network.setOptions({ physics: { enabled: false } }); | |
| } else if (layout === 'grid') { | |
| const nodes = network.body.data.nodes.get(); | |
| const numNodes = nodes.length; | |
| const cols = Math.ceil(Math.sqrt(numNodes)); | |
| const spacing = 150; | |
| nodes.forEach((node, i) => { | |
| const col = i % cols; | |
| const row = Math.floor(i / cols); | |
| const x = (col - cols/2) * spacing; | |
| const y = (row - Math.floor(numNodes/cols)/2) * spacing; | |
| network.moveNode(node.id, x, y); | |
| }); | |
| network.setOptions({ physics: { enabled: false } }); | |
| } else { | |
| // Default layout | |
| network.setOptions({ | |
| physics: { | |
| enabled: true, | |
| barnesHut: { | |
| gravitationalConstant: -5000, | |
| centralGravity: 0.3, | |
| springLength: 150, | |
| springConstant: 0.05, | |
| damping: 0.09, | |
| avoidOverlap: 0.2 | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| // Highlight connections on node select | |
| network.on('selectNode', function(params) { | |
| if (params.nodes.length > 0) { | |
| const selectedNode = params.nodes[0]; | |
| const connectedNodes = network.getConnectedNodes(selectedNode); | |
| const allNodes = network.body.data.nodes.get(); | |
| const allEdges = network.body.data.edges.get(); | |
| // Dim unselected nodes | |
| allNodes.forEach(node => { | |
| if (node.id === selectedNode || connectedNodes.includes(node.id)) { | |
| node.opacity = 1.0; | |
| } else { | |
| node.opacity = 0.3; | |
| } | |
| }); | |
| // Dim unrelated edges | |
| allEdges.forEach(edge => { | |
| if (edge.from === selectedNode || edge.to === selectedNode) { | |
| edge.opacity = 1.0; | |
| edge.width = edge.width * 1.5; | |
| } else { | |
| edge.opacity = 0.3; | |
| } | |
| }); | |
| network.body.data.nodes.update(allNodes); | |
| network.body.data.edges.update(allEdges); | |
| } | |
| }); | |
| network.on('deselectNode', function() { | |
| const allNodes = network.body.data.nodes.get(); | |
| const allEdges = network.body.data.edges.get(); | |
| // Reset all nodes and edges | |
| allNodes.forEach(node => { | |
| node.opacity = 1.0; | |
| }); | |
| allEdges.forEach(edge => { | |
| edge.opacity = 1.0; | |
| edge.width = edge.width / 1.5; | |
| }); | |
| network.body.data.nodes.update(allNodes); | |
| network.body.data.edges.update(allEdges); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Save to HTML file with custom HTML | |
| net.save_graph(output_path) | |
| # Read the generated file | |
| with open(output_path, 'r', encoding='utf-8') as f: | |
| net_html = f.read() | |
| # Insert our custom HTML | |
| net_html = net_html.replace('<html>', html_before).replace('</html>', html_after) | |
| # Write the modified file | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(net_html) | |
| return output_path | |
| def create_file_dependency_graph(self, file_contents: Dict, output_path: str = "dependency_graph.html") -> str: | |
| """ | |
| Create an enhanced graph of file dependencies based on imports and references | |
| Using direct PyVis implementation without relying on NetworkX | |
| Args: | |
| file_contents: Dictionary of file contents | |
| output_path: Path to save the HTML visualization | |
| Returns: | |
| Path to the saved HTML file | |
| """ | |
| # Create PyVis network directly | |
| net = Network(height="750px", width="100%", notebook=False, directed=True, | |
| bgcolor="#222222", font_color="white", select_menu=True, filter_menu=True) | |
| # Customize physics | |
| net.barnes_hut(gravity=-10000, central_gravity=0.3, spring_length=200) | |
| # Process files to find dependencies | |
| dependencies = self._extract_dependencies(file_contents) | |
| # Keep track of added nodes to avoid duplicates | |
| added_nodes = set() | |
| # Add file nodes with improved styling | |
| for filename, targets in dependencies.items(): | |
| if filename not in added_nodes: | |
| # Get file extension for color | |
| _, ext = os.path.splitext(filename) | |
| ext = ext.lstrip('.').lower() if ext else 'default' | |
| color = self.node_colors['file'].get(ext, self.node_colors['file']['default']) | |
| # Use filename as label | |
| label = os.path.basename(filename) | |
| # Enhanced tooltip with file information | |
| file_data = file_contents.get(filename, {}) | |
| file_type = file_data.get('type', 'unknown') | |
| file_size = file_data.get('size', 0) | |
| tooltip = f""" | |
| <div style='max-width: 300px; padding: 10px;'> | |
| <h3>{label}</h3> | |
| <hr/> | |
| <strong>Path:</strong> {filename}<br/> | |
| <strong>Type:</strong> {file_type}<br/> | |
| <strong>Size:</strong> {self._format_size(file_size)}<br/> | |
| <strong>Dependencies:</strong> {len(targets)} | |
| </div> | |
| """ | |
| # Add node with improved styling and metadata | |
| net.add_node(filename, label=label, title=tooltip, color=color, | |
| shape="dot", size=15, group=ext, searchable=True) | |
| added_nodes.add(filename) | |
| # Add target nodes if not already added | |
| for target in targets: | |
| if target not in added_nodes: | |
| # Get file extension for color | |
| _, ext = os.path.splitext(target) | |
| ext = ext.lstrip('.').lower() if ext else 'default' | |
| color = self.node_colors['file'].get(ext, self.node_colors['file']['default']) | |
| # Use filename as label | |
| label = os.path.basename(target) | |
| # Enhanced tooltip with file information | |
| file_data = file_contents.get(target, {}) | |
| file_type = file_data.get('type', 'unknown') | |
| file_size = file_data.get('size', 0) | |
| tooltip = f""" | |
| <div style='max-width: 300px; padding: 10px;'> | |
| <h3>{label}</h3> | |
| <hr/> | |
| <strong>Path:</strong> {target}<br/> | |
| <strong>Type:</strong> {file_type}<br/> | |
| <strong>Size:</strong> {self._format_size(file_size)} | |
| </div> | |
| """ | |
| # Add node with improved styling and metadata | |
| net.add_node(target, label=label, title=tooltip, color=color, | |
| shape="dot", size=15, group=ext, searchable=True) | |
| added_nodes.add(target) | |
| # Add edges with improved styling | |
| for source, targets in dependencies.items(): | |
| for target in targets: | |
| # Enhanced tooltip with relationship information | |
| tooltip = f""" | |
| <div style='max-width: 300px; padding: 10px;'> | |
| <h3>Dependency</h3> | |
| <hr/> | |
| <strong>{os.path.basename(source)}</strong> imports <strong>{os.path.basename(target)}</strong> | |
| </div> | |
| """ | |
| # Add edge with improved styling | |
| net.add_edge(source, target, title=tooltip, arrows="to", | |
| color="#2ecc7180", smooth=True, width=1.5) | |
| # Configure options with improved UI for dependencies | |
| options = """ | |
| var options = { | |
| "nodes": { | |
| "borderWidth": 2, | |
| "opacity": 0.9, | |
| "font": { | |
| "size": 12, | |
| "face": "Tahoma" | |
| }, | |
| "shadow": true | |
| }, | |
| "edges": { | |
| "color": { | |
| "inherit": false | |
| }, | |
| "smooth": { | |
| "type": "continuous", | |
| "roundness": 0.6 | |
| }, | |
| "arrows": { | |
| "to": { | |
| "enabled": true, | |
| "scaleFactor": 0.5 | |
| } | |
| }, | |
| "shadow": true | |
| }, | |
| "layout": { | |
| "hierarchical": { | |
| "enabled": true, | |
| "direction": "UD", | |
| "sortMethod": "directed", | |
| "nodeSpacing": 150, | |
| "levelSeparation": 150 | |
| } | |
| }, | |
| "physics": { | |
| "enabled": false | |
| }, | |
| "interaction": { | |
| "hover": true, | |
| "tooltipDelay": 200, | |
| "hideEdgesOnDrag": true, | |
| "navigationButtons": true | |
| }, | |
| "configure": { | |
| "enabled": true, | |
| "filter": ["layout", "nodes", "edges"], | |
| "showButton": true | |
| } | |
| } | |
| """ | |
| net.set_options(options) | |
| # Add search and controls similar to previous graphs | |
| html_before = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>File Dependency Graph</title> | |
| <style> | |
| body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, sans-serif; } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| } | |
| #search { | |
| padding: 5px; | |
| width: 200px; | |
| border-radius: 3px; | |
| border: none; | |
| } | |
| .btn { | |
| padding: 5px 10px; | |
| margin-left: 5px; | |
| background: #3498db; | |
| color: white; | |
| border: none; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .btn:hover { background: #2980b9; } | |
| #stats { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| z-index: 1000; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| color: white; | |
| max-width: 250px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <input type="text" id="search" placeholder="Search files..."> | |
| <button class="btn" id="searchBtn">Search</button> | |
| <button class="btn" id="resetBtn">Reset</button> | |
| <select id="layoutSelect" class="btn"> | |
| <option value="hierarchical">Hierarchical Layout</option> | |
| <option value="force">Force Layout</option> | |
| <option value="radial">Radial Layout</option> | |
| </select> | |
| </div> | |
| <div id="stats"> | |
| <h3 style="margin-top: 0;">Dependency Statistics</h3> | |
| <p>Files: <span id="nodeCount">0</span></p> | |
| <p>Dependencies: <span id="edgeCount">0</span></p> | |
| <p>Click a file to see its dependencies</p> | |
| </div> | |
| """ | |
| html_after = """ | |
| <script> | |
| // Get network instance | |
| const network = document.getElementById('mynetwork').vis; | |
| // Update stats | |
| function updateStats() { | |
| const nodes = network.body.data.nodes.get(); | |
| const edges = network.body.data.edges.get(); | |
| document.getElementById('nodeCount').textContent = nodes.length; | |
| document.getElementById('edgeCount').textContent = edges.length; | |
| } | |
| updateStats(); | |
| // Search functionality | |
| document.getElementById('searchBtn').addEventListener('click', function() { | |
| const searchTerm = document.getElementById('search').value.toLowerCase(); | |
| if (!searchTerm) return; | |
| // Find matching nodes | |
| const allNodes = network.body.data.nodes.get(); | |
| const matchingNodes = allNodes.filter(node => | |
| node.label && node.label.toLowerCase().includes(searchTerm) || | |
| node.id && node.id.toLowerCase().includes(searchTerm) | |
| ); | |
| if (matchingNodes.length > 0) { | |
| // Focus on first matching node | |
| network.focus(matchingNodes[0].id, { | |
| scale: 1.2, | |
| animation: true | |
| }); | |
| network.selectNodes([matchingNodes[0].id]); | |
| } else { | |
| alert('No matching nodes found'); | |
| } | |
| }); | |
| // Reset view | |
| document.getElementById('resetBtn').addEventListener('click', function() { | |
| network.fit({ | |
| animation: true | |
| }); | |
| }); | |
| // Layout selector | |
| document.getElementById('layoutSelect').addEventListener('change', function() { | |
| const layout = this.value; | |
| if (layout === 'hierarchical') { | |
| network.setOptions({ | |
| layout: { | |
| hierarchical: { | |
| enabled: true, | |
| direction: 'UD', | |
| sortMethod: 'directed', | |
| nodeSpacing: 150, | |
| levelSeparation: 150 | |
| } | |
| }, | |
| physics: { enabled: false } | |
| }); | |
| } else if (layout === 'radial') { | |
| network.setOptions({ | |
| layout: { hierarchical: { enabled: false } }, | |
| physics: { enabled: true } | |
| }); | |
| // Arrange nodes in a circular pattern | |
| const nodes = network.body.data.nodes.get(); | |
| const numNodes = nodes.length; | |
| const radius = 300; | |
| const center = {x: 0, y: 0}; | |
| nodes.forEach((node, i) => { | |
| const angle = (i / numNodes) * 2 * Math.PI; | |
| const x = center.x + radius * Math.cos(angle); | |
| const y = center.y + radius * Math.sin(angle); | |
| network.moveNode(node.id, x, y); | |
| }); | |
| } else { | |
| // Force layout | |
| network.setOptions({ | |
| layout: { hierarchical: { enabled: false } }, | |
| physics: { | |
| enabled: true, | |
| barnesHut: { | |
| gravitationalConstant: -10000, | |
| centralGravity: 0.3, | |
| springLength: 200, | |
| springConstant: 0.05 | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| // Highlight dependencies on node select | |
| network.on('selectNode', function(params) { | |
| if (params.nodes.length > 0) { | |
| const selectedNode = params.nodes[0]; | |
| const connectedEdges = network.getConnectedEdges(selectedNode); | |
| const connectedNodes = network.getConnectedNodes(selectedNode); | |
| // Get dependency direction | |
| const dependencies = []; | |
| const dependents = []; | |
| connectedEdges.forEach(edgeId => { | |
| const edge = network.body.data.edges.get(edgeId); | |
| if (edge.from === selectedNode) { | |
| dependencies.push(edge.to); | |
| } else { | |
| dependents.push(edge.from); | |
| } | |
| }); | |
| // Update node styles | |
| const allNodes = network.body.data.nodes.get(); | |
| allNodes.forEach(node => { | |
| if (node.id === selectedNode) { | |
| node.color = { background: '#f39c12' }; | |
| node.borderWidth = 3; | |
| node.size = node.size * 1.2; | |
| } else if (dependencies.includes(node.id)) { | |
| node.color = { background: '#2ecc71' }; | |
| node.borderWidth = 2; | |
| } else if (dependents.includes(node.id)) { | |
| node.color = { background: '#e74c3c' }; | |
| node.borderWidth = 2; | |
| } else { | |
| node.opacity = 0.3; | |
| } | |
| }); | |
| // Update edge styles | |
| const allEdges = network.body.data.edges.get(); | |
| allEdges.forEach(edge => { | |
| if (connectedEdges.includes(edge.id)) { | |
| edge.width = 2; | |
| if (edge.from === selectedNode) { | |
| edge.color = { color: '#2ecc71', opacity: 1 }; | |
| } else { | |
| edge.color = { color: '#e74c3c', opacity: 1 }; | |
| } | |
| } else { | |
| edge.opacity = 0.1; | |
| } | |
| }); | |
| network.body.data.nodes.update(allNodes); | |
| network.body.data.edges.update(allEdges); | |
| // Show node information in stats | |
| const node = network.body.data.nodes.get(selectedNode); | |
| document.getElementById('stats').innerHTML = ` | |
| <h3 style="margin-top: 0;">${node.label}</h3> | |
| <p>Dependencies: ${dependencies.length}</p> | |
| <p>Dependents: ${dependents.length}</p> | |
| <p style="color: #2ecc71;">Green: Files this imports</p> | |
| <p style="color: #e74c3c;">Red: Files that import this</p> | |
| <p style="color: #f39c12;">Yellow: Selected file</p> | |
| `; | |
| } | |
| }); | |
| network.on('deselectNode', function() { | |
| const allNodes = network.body.data.nodes.get(); | |
| const allEdges = network.body.data.edges.get(); | |
| // Reset all nodes and edges | |
| allNodes.forEach(node => { | |
| // Get original color by extension | |
| const ext = node.group || 'default'; | |
| const colors = { | |
| 'py': '#3572A5', | |
| 'js': '#F7DF1E', | |
| 'ts': '#3178C6', | |
| 'jsx': '#61DAFB', | |
| 'tsx': '#61DAFB', | |
| 'html': '#E34F26', | |
| 'css': '#563D7C', | |
| 'java': '#B07219', | |
| 'cpp': '#F34B7D', | |
| 'c': '#A8B9CC', | |
| 'go': '#00ADD8', | |
| 'md': '#083fa1', | |
| 'json': '#292929', | |
| 'default': '#7F7F7F' | |
| }; | |
| node.color = { background: colors[ext] || colors['default'] }; | |
| node.opacity = 1.0; | |
| node.borderWidth = 2; | |
| node.size = 15; | |
| }); | |
| allEdges.forEach(edge => { | |
| edge.color = { color: '#2ecc7180' }; | |
| edge.opacity = 1.0; | |
| edge.width = 1.5; | |
| }); | |
| network.body.data.nodes.update(allNodes); | |
| network.body.data.edges.update(allEdges); | |
| // Reset stats display | |
| document.getElementById('stats').innerHTML = ` | |
| <h3 style="margin-top: 0;">Dependency Statistics</h3> | |
| <p>Files: <span id="nodeCount">${allNodes.length}</span></p> | |
| <p>Dependencies: <span id="edgeCount">${allEdges.length}</span></p> | |
| <p>Click a file to see its dependencies</p> | |
| `; | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Save to HTML file with custom HTML | |
| net.save_graph(output_path) | |
| # Read the generated file | |
| with open(output_path, 'r', encoding='utf-8') as f: | |
| net_html = f.read() | |
| # Insert our custom HTML | |
| net_html = net_html.replace('<html>', html_before).replace('</html>', html_after) | |
| # Write the modified file | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(net_html) | |
| return output_path | |
| def create_commit_activity_chart(self, commits: List[Dict], output_path: str = "commit_activity.html") -> str: | |
| """ | |
| Create an enhanced interactive chart showing commit activity over time | |
| Args: | |
| commits: List of commit data | |
| output_path: Path to save the HTML visualization | |
| Returns: | |
| Path to the saved HTML file | |
| """ | |
| # Prepare commit data by month | |
| monthly_data = defaultdict(int) | |
| author_data = defaultdict(lambda: defaultdict(int)) | |
| file_type_data = defaultdict(lambda: defaultdict(int)) | |
| for commit in commits: | |
| date = commit.get('date') | |
| author = commit.get('author', 'Unknown') | |
| if date: | |
| # Format as year-month | |
| month_key = date.strftime('%Y-%m') | |
| monthly_data[month_key] += 1 | |
| author_data[author][month_key] += 1 | |
| # Count file types in this commit | |
| for file in commit.get('files', []): | |
| filename = file.get('filename', '') | |
| ext = os.path.splitext(filename)[1].lower() | |
| if ext: | |
| file_type_data[ext][month_key] += 1 | |
| # Sort by date | |
| sorted_data = sorted(monthly_data.items()) | |
| # Prepare author data for chart | |
| authors = list(author_data.keys()) | |
| author_datasets = [] | |
| # Generate colors for authors | |
| author_colors = [ | |
| '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#d35400', '#34495e', '#16a085', '#c0392b' | |
| ] | |
| for i, author in enumerate(authors[:10]): # Limit to top 10 authors | |
| color = author_colors[i % len(author_colors)] | |
| author_data_points = [] | |
| for month_key, _ in sorted_data: | |
| author_data_points.append(author_data[author].get(month_key, 0)) | |
| author_datasets.append({ | |
| 'label': author, | |
| 'data': author_data_points, | |
| 'backgroundColor': color + '80', | |
| 'borderColor': color, | |
| 'borderWidth': 1 | |
| }) | |
| # Create HTML with Chart.js and custom UI | |
| html = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Repository Activity Analysis</title> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/moment.min.js"></script> | |
| <style> | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| margin: 0; | |
| padding: 20px; | |
| background-color: #f5f5f5; | |
| color: #333; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| .chart-container { | |
| width: 100%; | |
| margin: 20px 0; | |
| background-color: white; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.1); | |
| padding: 20px; | |
| } | |
| h1, h2 { | |
| text-align: center; | |
| color: #2c3e50; | |
| } | |
| .stats { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: space-around; | |
| margin-bottom: 30px; | |
| } | |
| .stat-card { | |
| flex: 1 1 200px; | |
| background: white; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin: 10px; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.1); | |
| text-align: center; | |
| } | |
| .stat-value { | |
| font-size: 2em; | |
| font-weight: bold; | |
| color: #3498db; | |
| margin: 10px 0; | |
| } | |
| .stat-label { | |
| font-size: 0.9em; | |
| color: #7f8c8d; | |
| } | |
| .controls { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| margin: 20px 0; | |
| } | |
| .control-group { | |
| margin: 0 15px; | |
| } | |
| select, button { | |
| padding: 8px 12px; | |
| border-radius: 4px; | |
| border: 1px solid #ddd; | |
| background: white; | |
| font-family: inherit; | |
| font-size: 14px; | |
| cursor: pointer; | |
| } | |
| button { | |
| background: #3498db; | |
| color: white; | |
| border: none; | |
| } | |
| button:hover { | |
| background: #2980b9; | |
| } | |
| .tabs { | |
| display: flex; | |
| border-bottom: 1px solid #ddd; | |
| margin-bottom: 15px; | |
| } | |
| .tab { | |
| padding: 10px 20px; | |
| cursor: pointer; | |
| border-bottom: 3px solid transparent; | |
| } | |
| .tab.active { | |
| border-bottom-color: #3498db; | |
| font-weight: bold; | |
| } | |
| .tab-content { | |
| display: none; | |
| } | |
| .tab-content.active { | |
| display: block; | |
| } | |
| #authorTable, #fileTypeTable { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin-top: 20px; | |
| } | |
| #authorTable th, #authorTable td, | |
| #fileTypeTable th, #fileTypeTable td { | |
| padding: 8px 12px; | |
| text-align: left; | |
| border-bottom: 1px solid #ddd; | |
| } | |
| #authorTable th, #fileTypeTable th { | |
| background-color: #f2f2f2; | |
| } | |
| .color-dot { | |
| display: inline-block; | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| margin-right: 8px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Repository Commit Activity</h1> | |
| <div class="stats"> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="totalCommits">0</div> | |
| <div class="stat-label">Total Commits</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="activeMonths">0</div> | |
| <div class="stat-label">Active Months</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="avgCommitsPerMonth">0</div> | |
| <div class="stat-label">Avg. Commits per Month</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="totalContributors">0</div> | |
| <div class="stat-label">Contributors</div> | |
| </div> | |
| </div> | |
| <div class="tabs"> | |
| <div class="tab active" data-tab="overview">Activity Overview</div> | |
| <div class="tab" data-tab="authors">By Contributor</div> | |
| <div class="tab" data-tab="filetypes">By File Type</div> | |
| </div> | |
| <div class="tab-content active" id="overview-tab"> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label for="chartType">Chart Type:</label> | |
| <select id="chartType"> | |
| <option value="bar">Bar Chart</option> | |
| <option value="line">Line Chart</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label for="timeRange">Time Range:</label> | |
| <select id="timeRange"> | |
| <option value="all">All Time</option> | |
| <option value="year">Last Year</option> | |
| <option value="sixmonths">Last 6 Months</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <button id="downloadData">Download CSV</button> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="commitChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="authors-tab"> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label for="authorChartType">Chart Type:</label> | |
| <select id="authorChartType"> | |
| <option value="line">Line Chart</option> | |
| <option value="stacked">Stacked Bar Chart</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label for="authorTimeRange">Time Range:</label> | |
| <select id="authorTimeRange"> | |
| <option value="all">All Time</option> | |
| <option value="year">Last Year</option> | |
| <option value="sixmonths">Last 6 Months</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="authorChart"></canvas> | |
| </div> | |
| <h2>Contributor Commit Summary</h2> | |
| <table id="authorTable"> | |
| <thead> | |
| <tr> | |
| <th>Contributor</th> | |
| <th>Commits</th> | |
| <th>Percentage</th> | |
| <th>First Commit</th> | |
| <th>Last Commit</th> | |
| </tr> | |
| </thead> | |
| <tbody id="authorTableBody"> | |
| <!-- Will be populated by JavaScript --> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="tab-content" id="filetypes-tab"> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label for="fileTypeChartType">Chart Type:</label> | |
| <select id="fileTypeChartType"> | |
| <option value="doughnut">Doughnut Chart</option> | |
| <option value="bar">Bar Chart</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="fileTypeChart"></canvas> | |
| </div> | |
| <h2>File Type Statistics</h2> | |
| <table id="fileTypeTable"> | |
| <thead> | |
| <tr> | |
| <th>File Type</th> | |
| <th>Changes</th> | |
| <th>Percentage</th> | |
| </tr> | |
| </thead> | |
| <tbody id="fileTypeTableBody"> | |
| <!-- Will be populated by JavaScript --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <script> | |
| // Chart data | |
| const labels = CHART_LABELS; | |
| const data = CHART_DATA; | |
| const authorData = AUTHOR_DATA; | |
| const fileTypeData = FILE_TYPE_DATA; | |
| // Calculate stats | |
| const totalCommits = data.reduce((sum, val) => sum + val, 0); | |
| const activeMonths = data.filter(val => val > 0).length; | |
| const avgCommitsPerMonth = (totalCommits / Math.max(activeMonths, 1)).toFixed(1); | |
| const totalContributors = Object.keys(authorData).length; | |
| // Update stats display | |
| document.getElementById('totalCommits').textContent = totalCommits; | |
| document.getElementById('activeMonths').textContent = activeMonths; | |
| document.getElementById('avgCommitsPerMonth').textContent = avgCommitsPerMonth; | |
| document.getElementById('totalContributors').textContent = totalContributors; | |
| // Tab switching | |
| document.querySelectorAll('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| // Remove active class from all tabs | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); | |
| // Add active class to clicked tab | |
| tab.classList.add('active'); | |
| document.getElementById(tab.dataset.tab + '-tab').classList.add('active'); | |
| // Initialize or update chart for the active tab | |
| if (tab.dataset.tab === 'overview') { | |
| updateCommitChart(); | |
| } else if (tab.dataset.tab === 'authors') { | |
| updateAuthorChart(); | |
| updateAuthorTable(); | |
| } else if (tab.dataset.tab === 'filetypes') { | |
| updateFileTypeChart(); | |
| updateFileTypeTable(); | |
| } | |
| }); | |
| }); | |
| // Chart initialization | |
| let commitChart, authorChart, fileTypeChart; | |
| function initCharts() { | |
| const ctx1 = document.getElementById('commitChart').getContext('2d'); | |
| commitChart = new Chart(ctx1, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Number of Commits', | |
| data: data, | |
| backgroundColor: 'rgba(54, 162, 235, 0.7)', | |
| borderColor: 'rgba(54, 162, 235, 1)', | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: true, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Commits' | |
| } | |
| }, | |
| x: { | |
| title: { | |
| display: true, | |
| text: 'Month' | |
| } | |
| } | |
| }, | |
| plugins: { | |
| title: { | |
| display: true, | |
| text: 'Monthly Commit Activity', | |
| font: { | |
| size: 16 | |
| }, | |
| padding: { | |
| bottom: 30 | |
| } | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| title: function(context) { | |
| return context[0].label; | |
| }, | |
| label: function(context) { | |
| return context.raw + ' commits'; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| const ctx2 = document.getElementById('authorChart').getContext('2d'); | |
| authorChart = new Chart(ctx2, { | |
| type: 'line', | |
| data: { | |
| labels: labels, | |
| datasets: AUTHOR_DATASETS | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: true, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| stacked: document.getElementById('authorChartType').value === 'stacked', | |
| title: { | |
| display: true, | |
| text: 'Commits' | |
| } | |
| }, | |
| x: { | |
| title: { | |
| display: true, | |
| text: 'Month' | |
| } | |
| } | |
| }, | |
| plugins: { | |
| title: { | |
| display: true, | |
| text: 'Commit Activity by Contributor', | |
| font: { | |
| size: 16 | |
| }, | |
| padding: { | |
| bottom: 30 | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Calculate totals by file type | |
| const fileTypes = Object.keys(fileTypeData); | |
| const fileTypeTotals = fileTypes.map(type => { | |
| return Object.values(fileTypeData[type]).reduce((sum, val) => sum + val, 0); | |
| }); | |
| // Generate colors for file types | |
| const fileTypeColors = [ | |
| '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#d35400', '#34495e', '#16a085', '#c0392b', | |
| '#7f8c8d', '#27ae60', '#2980b9', '#8e44ad', '#c0392b', | |
| '#bdc3c7', '#2c3e50', '#16a085', '#d35400', '#7f8c8d' | |
| ]; | |
| const ctx3 = document.getElementById('fileTypeChart').getContext('2d'); | |
| fileTypeChart = new Chart(ctx3, { | |
| type: 'doughnut', | |
| data: { | |
| labels: fileTypes, | |
| datasets: [{ | |
| data: fileTypeTotals, | |
| backgroundColor: fileTypes.map((_, i) => fileTypeColors[i % fileTypeColors.length]), | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: true, | |
| plugins: { | |
| title: { | |
| display: true, | |
| text: 'File Types Changed', | |
| font: { | |
| size: 16 | |
| }, | |
| padding: { | |
| bottom: 30 | |
| } | |
| }, | |
| legend: { | |
| position: 'right' | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Update charts based on user selections | |
| function updateCommitChart() { | |
| const chartType = document.getElementById('chartType').value; | |
| const timeRange = document.getElementById('timeRange').value; | |
| // Filter data by time range | |
| let filteredLabels = [...labels]; | |
| let filteredData = [...data]; | |
| if (timeRange !== 'all') { | |
| const cutoffIndex = timeRange === 'year' ? | |
| Math.max(0, filteredLabels.length - 12) : | |
| Math.max(0, filteredLabels.length - 6); | |
| filteredLabels = filteredLabels.slice(cutoffIndex); | |
| filteredData = filteredData.slice(cutoffIndex); | |
| } | |
| // Update chart | |
| commitChart.data.labels = filteredLabels; | |
| commitChart.data.datasets[0].data = filteredData; | |
| commitChart.config.type = chartType; | |
| commitChart.update(); | |
| } | |
| function updateAuthorChart() { | |
| const chartType = document.getElementById('authorChartType').value; | |
| const timeRange = document.getElementById('authorTimeRange').value; | |
| // Filter data by time range | |
| let filteredLabels = [...labels]; | |
| if (timeRange !== 'all') { | |
| const cutoffIndex = timeRange === 'year' ? | |
| Math.max(0, filteredLabels.length - 12) : | |
| Math.max(0, filteredLabels.length - 6); | |
| filteredLabels = filteredLabels.slice(cutoffIndex); | |
| } | |
| // Update datasets to filtered range | |
| const datasets = JSON.parse(JSON.stringify(AUTHOR_DATASETS)); | |
| if (timeRange !== 'all') { | |
| const cutoffIndex = timeRange === 'year' ? | |
| Math.max(0, labels.length - 12) : | |
| Math.max(0, labels.length - 6); | |
| datasets.forEach(dataset => { | |
| dataset.data = dataset.data.slice(cutoffIndex); | |
| }); | |
| } | |
| // Update chart type | |
| const isStacked = chartType === 'stacked'; | |
| if (isStacked) { | |
| authorChart.config.type = 'bar'; | |
| datasets.forEach(dataset => { | |
| dataset.stack = 'Stack 0'; | |
| }); | |
| } else { | |
| authorChart.config.type = 'line'; | |
| datasets.forEach(dataset => { | |
| delete dataset.stack; | |
| }); | |
| } | |
| authorChart.options.scales.y.stacked = isStacked; | |
| authorChart.options.scales.x.stacked = isStacked; | |
| // Update chart | |
| authorChart.data.labels = filteredLabels; | |
| authorChart.data.datasets = datasets; | |
| authorChart.update(); | |
| } | |
| function updateFileTypeChart() { | |
| const chartType = document.getElementById('fileTypeChartType').value; | |
| // Calculate totals by file type | |
| const fileTypes = Object.keys(fileTypeData); | |
| const fileTypeTotals = fileTypes.map(type => { | |
| return Object.values(fileTypeData[type]).reduce((sum, val) => sum + val, 0); | |
| }); | |
| // Generate colors for file types | |
| const fileTypeColors = [ | |
| '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#d35400', '#34495e', '#16a085', '#c0392b', | |
| '#7f8c8d', '#27ae60', '#2980b9', '#8e44ad', '#c0392b', | |
| '#bdc3c7', '#2c3e50', '#16a085', '#d35400', '#7f8c8d' | |
| ]; | |
| // Update chart type | |
| fileTypeChart.config.type = chartType; | |
| if (chartType === 'doughnut') { | |
| fileTypeChart.options.scales = {}; | |
| fileTypeChart.options.plugins.legend.position = 'right'; | |
| } else { | |
| fileTypeChart.options.scales = { | |
| y: { | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Changes' | |
| } | |
| }, | |
| x: { | |
| title: { | |
| display: true, | |
| text: 'File Type' | |
| } | |
| } | |
| }; | |
| fileTypeChart.options.plugins.legend.position = 'top'; | |
| } | |
| // Update chart | |
| fileTypeChart.data.labels = fileTypes; | |
| fileTypeChart.data.datasets = [{ | |
| data: fileTypeTotals, | |
| backgroundColor: fileTypes.map((_, i) => fileTypeColors[i % fileTypeColors.length]), | |
| borderWidth: 1 | |
| }]; | |
| fileTypeChart.update(); | |
| } | |
| function updateAuthorTable() { | |
| const tableBody = document.getElementById('authorTableBody'); | |
| tableBody.innerHTML = ''; | |
| // Calculate total commits | |
| const total = Object.values(authorData).reduce((sum, monthData) => { | |
| return sum + Object.values(monthData).reduce((s, v) => s + v, 0); | |
| }, 0); | |
| // Calculate first and last commit month for each author | |
| const authorInfo = {}; | |
| Object.keys(authorData).forEach(author => { | |
| const months = Object.keys(authorData[author]).filter(month => authorData[author][month] > 0); | |
| months.sort(); | |
| const commitCount = Object.values(authorData[author]).reduce((sum, count) => sum + count, 0); | |
| const percentage = ((commitCount / total) * 100).toFixed(1); | |
| authorInfo[author] = { | |
| commits: commitCount, | |
| percentage: percentage, | |
| firstCommit: months[0] || 'N/A', | |
| lastCommit: months[months.length - 1] || 'N/A' | |
| }; | |
| }); | |
| // Sort by commit count | |
| const sortedAuthors = Object.keys(authorInfo).sort((a, b) => | |
| authorInfo[b].commits - authorInfo[a].commits | |
| ); | |
| // Generate colors for authors | |
| const authorColors = [ | |
| '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#d35400', '#34495e', '#16a085', '#c0392b' | |
| ]; | |
| // Build table rows | |
| sortedAuthors.forEach((author, i) => { | |
| const info = authorInfo[author]; | |
| const row = document.createElement('tr'); | |
| const colorDot = document.createElement('span'); | |
| colorDot.className = 'color-dot'; | |
| colorDot.style.backgroundColor = authorColors[i % authorColors.length]; | |
| const authorCell = document.createElement('td'); | |
| authorCell.appendChild(colorDot); | |
| authorCell.appendChild(document.createTextNode(author)); | |
| row.appendChild(authorCell); | |
| row.appendChild(createCell(info.commits)); | |
| row.appendChild(createCell(info.percentage + '%')); | |
| row.appendChild(createCell(info.firstCommit)); | |
| row.appendChild(createCell(info.lastCommit)); | |
| tableBody.appendChild(row); | |
| }); | |
| } | |
| function updateFileTypeTable() { | |
| const tableBody = document.getElementById('fileTypeTableBody'); | |
| tableBody.innerHTML = ''; | |
| // Calculate totals by file type | |
| const fileTypeInfo = {}; | |
| let totalChanges = 0; | |
| Object.keys(fileTypeData).forEach(fileType => { | |
| const changes = Object.values(fileTypeData[fileType]).reduce((sum, count) => sum + count, 0); | |
| fileTypeInfo[fileType] = { changes }; | |
| totalChanges += changes; | |
| }); | |
| // Calculate percentages | |
| Object.keys(fileTypeInfo).forEach(fileType => { | |
| fileTypeInfo[fileType].percentage = ((fileTypeInfo[fileType].changes / totalChanges) * 100).toFixed(1); | |
| }); | |
| // Sort by changes | |
| const sortedFileTypes = Object.keys(fileTypeInfo).sort((a, b) => | |
| fileTypeInfo[b].changes - fileTypeInfo[a].changes | |
| ); | |
| // Generate colors for file types | |
| const fileTypeColors = [ | |
| '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#d35400', '#34495e', '#16a085', '#c0392b' | |
| ]; | |
| // Build table rows | |
| sortedFileTypes.forEach((fileType, i) => { | |
| const info = fileTypeInfo[fileType]; | |
| const row = document.createElement('tr'); | |
| const colorDot = document.createElement('span'); | |
| colorDot.className = 'color-dot'; | |
| colorDot.style.backgroundColor = fileTypeColors[i % fileTypeColors.length]; | |
| const fileTypeCell = document.createElement('td'); | |
| fileTypeCell.appendChild(colorDot); | |
| fileTypeCell.appendChild(document.createTextNode(fileType || 'No extension')); | |
| row.appendChild(fileTypeCell); | |
| row.appendChild(createCell(info.changes)); | |
| row.appendChild(createCell(info.percentage + '%')); | |
| tableBody.appendChild(row); | |
| }); | |
| } | |
| function createCell(text) { | |
| const cell = document.createElement('td'); | |
| cell.textContent = text; | |
| return cell; | |
| } | |
| // Handle control events | |
| document.getElementById('chartType').addEventListener('change', updateCommitChart); | |
| document.getElementById('timeRange').addEventListener('change', updateCommitChart); | |
| document.getElementById('authorChartType').addEventListener('change', updateAuthorChart); | |
| document.getElementById('authorTimeRange').addEventListener('change', updateAuthorChart); | |
| document.getElementById('fileTypeChartType').addEventListener('change', updateFileTypeChart); | |
| // Download CSV functionality | |
| document.getElementById('downloadData').addEventListener('click', function() { | |
| // Build CSV content | |
| let csvContent = "Month,Commits\\n"; | |
| labels.forEach((month, i) => { | |
| csvContent += `${month},${data[i]}\\n`; | |
| }); | |
| // Create download link | |
| const encodedUri = "data:text/csv;charset=utf-8," + encodeURIComponent(csvContent); | |
| const link = document.createElement("a"); | |
| link.setAttribute("href", encodedUri); | |
| link.setAttribute("download", "commit_activity.csv"); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }); | |
| // Initialize charts on load | |
| window.addEventListener('load', function() { | |
| initCharts(); | |
| updateAuthorTable(); | |
| updateFileTypeTable(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Replace placeholders with actual data | |
| labels_json = json.dumps([d[0] for d in sorted_data]) | |
| data_json = json.dumps([d[1] for d in sorted_data]) | |
| # Author data for chart | |
| author_data_json = json.dumps(author_data) | |
| author_datasets_json = json.dumps(author_datasets) | |
| # File type data for chart | |
| file_type_data_json = json.dumps(file_type_data) | |
| html = html.replace('CHART_LABELS', labels_json) | |
| html = html.replace('CHART_DATA', data_json) | |
| html = html.replace('AUTHOR_DATA', author_data_json) | |
| html = html.replace('AUTHOR_DATASETS', author_datasets_json) | |
| html = html.replace('FILE_TYPE_DATA', file_type_data_json) | |
| # Save to file | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(html) | |
| return output_path | |
| def create_code_change_heatmap(self, commits: List[Dict], output_path: str = "code_changes.html") -> str: | |
| """ | |
| Create an enhanced heatmap showing which files are changed most frequently | |
| Args: | |
| commits: List of commit data | |
| output_path: Path to save the HTML visualization | |
| Returns: | |
| Path to the saved HTML file | |
| """ | |
| # Count file modifications | |
| file_changes = Counter() | |
| file_authors = defaultdict(Counter) | |
| file_dates = defaultdict(list) | |
| for commit in commits: | |
| author = commit.get('author', 'Unknown') | |
| date = commit.get('date') | |
| for file_data in commit.get('files', []): | |
| filename = file_data.get('filename', '') | |
| if filename: | |
| file_changes[filename] += 1 | |
| file_authors[filename][author] += 1 | |
| if date: | |
| file_dates[filename].append(date) |