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/chart.js@3.9.1/dist/chart.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/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) |