mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
404 lines
12 KiB
Python
404 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Devour Lighthouse-Style Scorecard Generator.
|
|
Creates circular gauge charts and comprehensive metrics visualization.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple, Any, Optional
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
except ImportError:
|
|
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
|
sys.exit(1)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Visual constants
|
|
SCALE = 2
|
|
BG = (248, 248, 246)
|
|
FRAME = (222, 222, 220)
|
|
BORDER = (200, 200, 198)
|
|
ACCENT = (88, 166, 255)
|
|
TEXT = (40, 44, 52)
|
|
DIM = (140, 140, 140)
|
|
BG_SCORE = (255, 255, 255)
|
|
BG_TABLE = (255, 255, 255)
|
|
BG_ROW_ALT = (250, 250, 248)
|
|
|
|
# Color palette for gauges
|
|
COLORS = {
|
|
'excellent': (68, 120, 68), # deep sage
|
|
'good': (120, 140, 72), # olive green
|
|
'moderate': (145, 155, 80), # yellow-green
|
|
'poor': (255, 193, 7), # orange
|
|
'critical': (220, 38, 127), # red
|
|
}
|
|
|
|
@dataclass
|
|
class LighthouseData:
|
|
"""Data structure for Lighthouse-style visualization."""
|
|
project_name: str
|
|
version: str
|
|
overall_score: float
|
|
overall_grade: str
|
|
categories: Dict[str, Dict[str, Any]]
|
|
metrics: Dict[str, Any]
|
|
timestamp: str
|
|
|
|
|
|
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
|
"""Load font with fallback."""
|
|
size = size * SCALE
|
|
candidates = [
|
|
"/System/Library/Fonts/SFCompact.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"arial.ttf"
|
|
]
|
|
|
|
if bold:
|
|
candidates = [
|
|
"/System/Library/Fonts/SFCompact.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"arialbd.ttf"
|
|
]
|
|
|
|
for path in candidates:
|
|
try:
|
|
if os.path.exists(path):
|
|
return ImageFont.truetype(path, size)
|
|
except OSError:
|
|
continue
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def scale(value: int) -> int:
|
|
"""Scale value by retina factor."""
|
|
return value * SCALE
|
|
|
|
|
|
def score_to_color(score: float) -> Tuple[int, int, int]:
|
|
"""Convert score to color."""
|
|
if score >= 90:
|
|
return COLORS['excellent']
|
|
elif score >= 70:
|
|
return COLORS['good']
|
|
elif score >= 50:
|
|
return COLORS['moderate']
|
|
elif score >= 30:
|
|
return COLORS['poor']
|
|
else:
|
|
return COLORS['critical']
|
|
|
|
|
|
def draw_circular_gauge(
|
|
draw: ImageDraw.ImageDraw,
|
|
cx: int, cy: int, radius: int,
|
|
score: float, max_score: float = 100,
|
|
label: str = "SCORE"
|
|
) -> None:
|
|
"""Draw a circular gauge like Lighthouse."""
|
|
# Background circle
|
|
draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius],
|
|
fill=BG_TABLE, outline=BORDER, width=2)
|
|
|
|
# Calculate angle for score (270° to -90° range)
|
|
start_angle = 270
|
|
score_angle = start_angle - (score / max_score) * 360
|
|
|
|
# Draw colored arc
|
|
if score > 0:
|
|
draw.pieslice([cx - radius, cy - radius, cx + radius, cy + radius],
|
|
start=start_angle, end=score_angle,
|
|
fill=score_to_color(score))
|
|
|
|
# Inner circle
|
|
inner_radius = radius * 0.7
|
|
draw.ellipse([cx - inner_radius, cy - inner_radius, cx + inner_radius, cy + inner_radius],
|
|
fill=BG_SCORE, outline=BORDER, width=1)
|
|
|
|
# Score text
|
|
font_score = load_font(24, bold=True)
|
|
score_text = f"{int(score)}"
|
|
bbox = draw.textbbox((0, 0), score_text, font=font_score)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
|
|
draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]),
|
|
score_text, fill=TEXT, font=font_score)
|
|
|
|
# Label text
|
|
font_label = load_font(10)
|
|
label_bbox = draw.textbbox((0, 0), label, font=font_label)
|
|
label_width = label_bbox[2] - label_bbox[0]
|
|
|
|
draw.text((cx - label_width // 2, cy + radius * 0.4),
|
|
label, fill=DIM, font=font_label)
|
|
|
|
|
|
def draw_metric_bar(
|
|
draw: ImageDraw.ImageDraw,
|
|
x: int, y: int, width: int, height: int,
|
|
value: float, max_value: float,
|
|
label: str, color: Tuple[int, int, int]
|
|
) -> None:
|
|
"""Draw a horizontal metric bar."""
|
|
# Background
|
|
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
|
|
|
# Fill bar
|
|
if max_value > 0:
|
|
fill_width = int((value / max_value) * width)
|
|
draw.rectangle([x, y, x + fill_width, y + height], fill=color)
|
|
|
|
# Label
|
|
font = load_font(9)
|
|
text = f"{label}: {int(value)}/{int(max_value)}"
|
|
draw.text((x + 5, y + 2), text, fill=TEXT, font=font)
|
|
|
|
|
|
def draw_category_section(
|
|
draw: ImageDraw.ImageDraw,
|
|
x: int, y: int, width: int, height: int,
|
|
title: str, score: float, issues: List[Dict[str, Any]]
|
|
) -> None:
|
|
"""Draw a category section with gauge and issues."""
|
|
# Title
|
|
font_title = load_font(12, bold=True)
|
|
draw.text((x, y), title, fill=TEXT, font=font_title)
|
|
|
|
# Gauge
|
|
gauge_y = y + 25
|
|
gauge_size = 60
|
|
draw_circular_gauge(draw, x + gauge_size // 2, gauge_y + gauge_size // 2,
|
|
gauge_size // 2, score, label="")
|
|
|
|
# Issues list
|
|
issues_y = gauge_y + gauge_size + 20
|
|
font_issue = load_font(8)
|
|
|
|
for i, issue in enumerate(issues[:5]): # Limit to 5 issues
|
|
issue_text = f"• {issue.get('title', 'Unknown')}"
|
|
if len(issue_text) > 35:
|
|
issue_text = issue_text[:32] + "..."
|
|
|
|
draw.text((x, issues_y + i * 12), issue_text, fill=DIM, font=font_issue)
|
|
|
|
|
|
def generate_lighthouse_scorecard(data: LighthouseData, output_path: str | Path) -> Path:
|
|
"""Generate Lighthouse-style scorecard with circular gauges."""
|
|
output_path = Path(output_path)
|
|
|
|
# Layout
|
|
width = scale(800)
|
|
height = scale(600)
|
|
margin = scale(20)
|
|
|
|
# Create image
|
|
img = Image.new("RGB", (width, height), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Header
|
|
font_header = load_font(20, bold=True)
|
|
title = f"{data.project_name} - Code Health Report"
|
|
title_bbox = draw.textbbox((0, 0), title, font=font_header)
|
|
title_width = title_bbox[2] - title_bbox[0]
|
|
|
|
draw.text((margin, margin), title, fill=TEXT, font=font_header)
|
|
|
|
# Overall score gauge (large, centered)
|
|
overall_x = width // 2
|
|
overall_y = margin + 50
|
|
overall_radius = scale(80)
|
|
|
|
draw_circular_gauge(draw, overall_x, overall_y, overall_radius,
|
|
score=data.overall_score, label="OVERALL")
|
|
|
|
# Grade text
|
|
font_grade = load_font(32, bold=True)
|
|
grade_text = data.overall_grade
|
|
grade_bbox = draw.textbbox((0, 0), grade_text, font=font_grade)
|
|
grade_width = grade_bbox[2] - grade_bbox[0]
|
|
grade_height = grade_bbox[3] - grade_bbox[1]
|
|
|
|
grade_y = overall_y + overall_radius + scale(30)
|
|
draw.text((overall_x - grade_width // 2, grade_y - grade_bbox[1]),
|
|
grade_text, fill=score_to_color(data.overall_score), font=font_grade)
|
|
|
|
# Category gauges (2x2 grid)
|
|
categories_start_y = grade_y + grade_height + scale(40)
|
|
category_width = scale(180)
|
|
category_height = scale(150)
|
|
category_spacing = scale(20)
|
|
|
|
col_x = margin
|
|
row_y = categories_start_y
|
|
|
|
for i, (category_name, category_data) in enumerate(data.categories.items()):
|
|
if i > 0 and i % 2 == 0:
|
|
col_x += category_width + category_spacing
|
|
row_y = categories_start_y
|
|
|
|
if i % 2 == 1:
|
|
row_y += category_height + category_spacing
|
|
|
|
draw_category_section(
|
|
draw, col_x, row_y, category_width, category_height,
|
|
title=category_name.replace('_', ' ').title(),
|
|
score=category_data.get('score', 0),
|
|
issues=category_data.get('issues', [])
|
|
)
|
|
|
|
# Metrics summary
|
|
metrics_y = row_y + category_height + scale(40)
|
|
font_metrics = load_font(10)
|
|
|
|
metrics_text = [
|
|
f"Generated: {data.timestamp}",
|
|
f"Total Issues: {data.metrics.get('total_issues', 0)}",
|
|
f"Critical: {data.metrics.get('critical_issues', 0)}",
|
|
f"Resolution Rate: {data.metrics.get('resolution_rate', 0):.1f}%"
|
|
]
|
|
|
|
for i, text in enumerate(metrics_text):
|
|
draw.text((margin, metrics_y + i * 15), text, fill=DIM, font=font_metrics)
|
|
|
|
# Save
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(str(output_path), "PNG", optimize=True)
|
|
return output_path
|
|
|
|
|
|
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
|
"""Load font with fallback."""
|
|
size = size * SCALE
|
|
candidates = [
|
|
"/System/Library/Fonts/SFCompact.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"arial.ttf"
|
|
]
|
|
|
|
if bold:
|
|
candidates = [
|
|
"/System/Library/Fonts/SFCompact.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"arialbd.ttf"
|
|
]
|
|
|
|
for path in candidates:
|
|
try:
|
|
if os.path.exists(path):
|
|
return ImageFont.truetype(path, size)
|
|
except OSError:
|
|
continue
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def load_devour_lighthouse_data(json_path: str) -> LighthouseData:
|
|
"""Load Devour data and convert to Lighthouse format."""
|
|
with open(json_path, 'r') as f:
|
|
data = json.load(f)
|
|
|
|
findings = data.get('findings', [])
|
|
|
|
# Calculate overall score
|
|
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
|
overall_score = max(0, 100 - (total_score / 1000 * 100))
|
|
|
|
# Grade
|
|
if overall_score >= 90:
|
|
grade = "A"
|
|
elif overall_score >= 80:
|
|
grade = "B"
|
|
elif overall_score >= 70:
|
|
grade = "C"
|
|
elif overall_score >= 60:
|
|
grade = "D"
|
|
else:
|
|
grade = "F"
|
|
|
|
# Group by category
|
|
categories = {}
|
|
type_counts = {}
|
|
type_scores = {}
|
|
|
|
for finding in findings:
|
|
ftype = finding.get('type', 'unknown')
|
|
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
|
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
|
|
|
# Create categories with scores and sample issues
|
|
for ftype, count in type_counts.items():
|
|
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
|
category_score = max(0, min(100, avg_score))
|
|
|
|
# Get sample issues for this category
|
|
category_issues = [
|
|
{
|
|
'title': f.get('title', 'Unknown'),
|
|
'score': f.get('score', 0)
|
|
}
|
|
for f in findings
|
|
if f.get('type') == ftype
|
|
][:3] # Top 3 issues
|
|
|
|
categories[ftype.replace('_', ' ').title()] = {
|
|
'score': category_score,
|
|
'issues': category_issues
|
|
}
|
|
|
|
# Metrics
|
|
metrics = {
|
|
'total_issues': len(findings),
|
|
'critical_issues': len([f for f in findings if f.get('severity') == 4]),
|
|
'resolution_rate': 0.0 # Would calculate from fixed/resolved
|
|
}
|
|
|
|
return LighthouseData(
|
|
project_name="Devour",
|
|
version="1.0.0",
|
|
overall_score=overall_score,
|
|
overall_grade=grade,
|
|
categories=categories,
|
|
metrics=metrics,
|
|
timestamp=data.get('timestamp', '')
|
|
)
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
if len(sys.argv) != 3:
|
|
print("Usage: python devour_lighthouse.py <devour_results.json> <output.png>")
|
|
sys.exit(1)
|
|
|
|
json_path = sys.argv[1]
|
|
output_path = sys.argv[2]
|
|
|
|
if not os.path.exists(json_path):
|
|
print(f"Error: Input file {json_path} not found")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
data = load_devour_lighthouse_data(json_path)
|
|
result_path = generate_lighthouse_scorecard(data, output_path)
|
|
print(f"Lighthouse scorecard generated: {result_path}")
|
|
|
|
except Exception as e:
|
|
print(f"Error generating Lighthouse scorecard: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|