325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
|
Chart generation module for website analysis reports.
|
|
Uses consultancy brand colors: lime-500 and lime-900.
|
|
"""
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import io
|
|
|
|
# Consultancy brand colors (lime palette)
|
|
BRAND = {
|
|
"primary": "#84cc16", # lime-500 (oklch 76.8% 0.233 130.85)
|
|
"primary_dark": "#365314", # lime-900 (oklch 40.5% 0.101 131.063)
|
|
"accent": "#a3e635", # lime-400
|
|
"dark": "#1a2e05", # lime-950
|
|
}
|
|
|
|
# Score colors (traffic light)
|
|
SCORE_COLORS = {
|
|
"excellent": "#22c55e", # green-500
|
|
"needs_work": "#f59e0b", # amber-500
|
|
"poor": "#ef4444", # red-500
|
|
}
|
|
|
|
# UI colors
|
|
UI = {
|
|
"text": "#1f2937", # gray-800
|
|
"muted": "#6b7280", # gray-500
|
|
"light_bg": "#f9fafb", # gray-50
|
|
"border": "#e5e7eb", # gray-200
|
|
"white": "#ffffff",
|
|
}
|
|
|
|
# Swedish category names
|
|
CATEGORY_NAMES_SV = {
|
|
"performance": "Prestanda",
|
|
"accessibility": "Tillgänglighet",
|
|
"best-practices": "Bästa praxis",
|
|
"seo": "Sökmotoroptimering (SEO)",
|
|
}
|
|
|
|
|
|
def get_score_color(score: float) -> str:
|
|
"""Get color based on score (0-100)."""
|
|
if score >= 90:
|
|
return SCORE_COLORS["excellent"]
|
|
elif score >= 50:
|
|
return SCORE_COLORS["needs_work"]
|
|
return SCORE_COLORS["poor"]
|
|
|
|
|
|
def create_gauge_chart(score: float, label: str) -> io.BytesIO:
|
|
"""Create a semi-circular gauge chart with brand colors."""
|
|
fig, ax = plt.subplots(figsize=(6, 6), dpi=150)
|
|
|
|
color = get_score_color(score)
|
|
|
|
# Background arc
|
|
theta_bg = np.linspace(np.pi, 0, 100)
|
|
r = 0.75
|
|
ax.plot(r * np.cos(theta_bg), r * np.sin(theta_bg),
|
|
color=UI["border"], linewidth=25, solid_capstyle="round", zorder=1)
|
|
|
|
# Score arc
|
|
score_ratio = score / 100
|
|
theta_score = np.linspace(np.pi, np.pi - (np.pi * score_ratio), 100)
|
|
ax.plot(r * np.cos(theta_score), r * np.sin(theta_score),
|
|
color=color, linewidth=25, solid_capstyle="round", zorder=2)
|
|
|
|
# Score text
|
|
ax.text(0, 0.05, f"{int(score)}", fontsize=56, fontweight="bold",
|
|
ha="center", va="center", color=color)
|
|
ax.text(0, -0.4, label, fontsize=16, ha="center", va="center",
|
|
color=UI["muted"])
|
|
|
|
# Scale markers
|
|
for val in [0, 50, 100]:
|
|
angle = np.pi - (np.pi * val / 100)
|
|
x = (r + 0.15) * np.cos(angle)
|
|
y = (r + 0.15) * np.sin(angle)
|
|
ax.text(x, y, str(val), fontsize=12, ha="center", va="center",
|
|
color=UI["muted"])
|
|
|
|
ax.set_xlim(-1.1, 1.1)
|
|
ax.set_ylim(-0.6, 1.1)
|
|
ax.axis("off")
|
|
ax.set_aspect("equal")
|
|
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", transparent=True, dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def create_overview_chart(categories: dict, category_names: dict = None) -> io.BytesIO:
|
|
"""Create main overview chart with 0-100 scale."""
|
|
if category_names is None:
|
|
category_names = CATEGORY_NAMES_SV
|
|
|
|
fig, ax = plt.subplots(figsize=(10, 4), dpi=150)
|
|
|
|
names = [category_names.get(k, k) for k in categories.keys()]
|
|
scores = [v["averageScore"] * 100 for v in categories.values()]
|
|
|
|
y_pos = np.arange(len(names))
|
|
|
|
# Background bars (100%)
|
|
ax.barh(y_pos, [100] * len(names), color=UI["border"], height=0.5, zorder=1)
|
|
|
|
# Score bars with traffic light colors
|
|
colors_list = [get_score_color(s) for s in scores]
|
|
bars = ax.barh(y_pos, scores, color=colors_list, height=0.5, zorder=2)
|
|
|
|
ax.set_yticks(y_pos)
|
|
ax.set_yticklabels(names, fontsize=11, color=UI["text"])
|
|
ax.set_xlim(0, 100)
|
|
ax.set_xlabel("Poäng (0-100)", fontsize=11, color=UI["muted"])
|
|
|
|
for i, (bar, score) in enumerate(zip(bars, scores)):
|
|
ax.text(score + 2, i, f"{score:.0f}", va="center", fontsize=11,
|
|
fontweight="bold", color=UI["text"])
|
|
|
|
# Threshold lines
|
|
ax.axvline(x=90, color=SCORE_COLORS["excellent"], linestyle="--", alpha=0.5, linewidth=1)
|
|
ax.axvline(x=50, color=SCORE_COLORS["needs_work"], linestyle="--", alpha=0.5, linewidth=1)
|
|
|
|
ax.spines["top"].set_visible(False)
|
|
ax.spines["right"].set_visible(False)
|
|
ax.spines["left"].set_color(UI["border"])
|
|
ax.spines["bottom"].set_color(UI["border"])
|
|
|
|
plt.tight_layout()
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def create_issues_chart(all_audits: dict, total_pages: int, top_n: int = 12) -> io.BytesIO:
|
|
"""Create horizontal bar chart of most common issues."""
|
|
fig, ax = plt.subplots(figsize=(10, 6), dpi=150)
|
|
|
|
sorted_audits = sorted(all_audits.items(), key=lambda x: x[1]["count"], reverse=True)[:top_n]
|
|
|
|
def wrap_title(t, max_len=35):
|
|
if len(t) <= max_len:
|
|
return t
|
|
words = t.split()
|
|
lines, current = [], ""
|
|
for w in words:
|
|
if len(current + " " + w) <= max_len:
|
|
current = (current + " " + w).strip()
|
|
else:
|
|
if current:
|
|
lines.append(current)
|
|
current = w
|
|
if current:
|
|
lines.append(current)
|
|
return "\n".join(lines[:2])
|
|
|
|
names = [wrap_title(a[1]["title"]) for a in sorted_audits]
|
|
counts = [a[1]["count"] for a in sorted_audits]
|
|
percentages = [(c / total_pages) * 100 for c in counts]
|
|
|
|
y_pos = np.arange(len(names))
|
|
|
|
# Background bars
|
|
ax.barh(y_pos, [100] * len(names), color=UI["border"], height=0.6, zorder=1)
|
|
|
|
# Issue bars with severity colors
|
|
colors_list = [SCORE_COLORS["poor"] if p > 80 else SCORE_COLORS["needs_work"] if p > 50
|
|
else BRAND["primary"] for p in percentages]
|
|
bars = ax.barh(y_pos, percentages, color=colors_list, height=0.6, zorder=2)
|
|
|
|
ax.set_yticks(y_pos)
|
|
ax.set_yticklabels(names, fontsize=9, color=UI["text"])
|
|
ax.set_xlim(0, 100)
|
|
ax.set_xlabel("Andel av sidor med problem (%)", fontsize=10, color=UI["muted"])
|
|
|
|
# Add labels - bars and data are in same order
|
|
for i, (pct, cnt) in enumerate(zip(percentages, counts)):
|
|
ax.text(pct + 2, i, f"{pct:.0f}% ({cnt})", va="center", fontsize=9, color=UI["text"])
|
|
|
|
ax.spines["top"].set_visible(False)
|
|
ax.spines["right"].set_visible(False)
|
|
ax.spines["left"].set_color(UI["border"])
|
|
ax.spines["bottom"].set_color(UI["border"])
|
|
|
|
plt.tight_layout()
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def create_savings_chart(all_audits: dict) -> io.BytesIO:
|
|
"""Create chart showing potential savings in MB."""
|
|
audits_with_savings = [(k, v) for k, v in all_audits.items() if v["total_wasted_bytes"] > 10000]
|
|
audits_with_savings.sort(key=lambda x: x[1]["total_wasted_bytes"], reverse=True)
|
|
audits_with_savings = audits_with_savings[:8]
|
|
|
|
if not audits_with_savings:
|
|
return None
|
|
|
|
fig, ax = plt.subplots(figsize=(9, 4), dpi=150)
|
|
|
|
def wrap_title(t, max_len=30):
|
|
return t[:max_len] + "..." if len(t) > max_len else t
|
|
|
|
names = [wrap_title(a[1]["title"]) for a in audits_with_savings]
|
|
savings_mb = [a[1]["total_wasted_bytes"] / (1024 * 1024) for a in audits_with_savings]
|
|
|
|
y_pos = np.arange(len(names))
|
|
bars = ax.barh(y_pos, savings_mb, color=SCORE_COLORS["needs_work"], height=0.6)
|
|
|
|
ax.set_yticks(y_pos)
|
|
ax.set_yticklabels(names, fontsize=9, color=UI["text"])
|
|
ax.set_xlabel("Potentiell besparing (MB)", fontsize=10, color=UI["muted"])
|
|
|
|
# Place labels at end of each bar
|
|
max_val = max(savings_mb) if savings_mb else 1
|
|
for i, s in enumerate(savings_mb):
|
|
ax.text(s + max_val * 0.02, i, f"{s:.1f} MB", va="center", fontsize=9, color=UI["text"])
|
|
|
|
ax.spines["top"].set_visible(False)
|
|
ax.spines["right"].set_visible(False)
|
|
ax.spines["left"].set_color(UI["border"])
|
|
ax.spines["bottom"].set_color(UI["border"])
|
|
|
|
plt.tight_layout()
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def create_score_histogram(routes: list, category_names: dict = None) -> io.BytesIO:
|
|
"""Create 4 histograms showing score distribution (0-100 scale)."""
|
|
if category_names is None:
|
|
category_names = CATEGORY_NAMES_SV
|
|
|
|
fig, axes = plt.subplots(2, 2, figsize=(10, 7), dpi=150)
|
|
|
|
categories = ["performance", "accessibility", "best-practices", "seo"]
|
|
titles = [category_names.get(c, c) for c in categories]
|
|
|
|
for ax, cat, title in zip(axes.flatten(), categories, titles):
|
|
scores = [r.get("categories", {}).get(cat, {}).get("score", 0) * 100 for r in routes]
|
|
|
|
bins = np.arange(0, 105, 10)
|
|
n, bins_out, patches = ax.hist(scores, bins=bins, edgecolor="white", alpha=0.9)
|
|
|
|
for patch in patches:
|
|
bin_center = patch.get_x() + patch.get_width() / 2
|
|
patch.set_facecolor(get_score_color(bin_center))
|
|
|
|
avg = np.mean(scores)
|
|
ax.axvline(x=avg, color=BRAND["primary_dark"], linestyle="--", linewidth=2)
|
|
|
|
ax.set_xlim(0, 100)
|
|
ax.set_xlabel("Poäng", fontsize=9, color=UI["muted"])
|
|
ax.set_ylabel("Antal sidor", fontsize=9, color=UI["muted"])
|
|
ax.set_title(f"{title} (medel: {avg:.0f})", fontsize=11, fontweight="bold", color=UI["text"])
|
|
ax.spines["top"].set_visible(False)
|
|
ax.spines["right"].set_visible(False)
|
|
ax.spines["left"].set_color(UI["border"])
|
|
ax.spines["bottom"].set_color(UI["border"])
|
|
|
|
plt.tight_layout()
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def create_core_web_vitals_chart(metrics: dict) -> io.BytesIO:
|
|
"""Create Core Web Vitals visualization."""
|
|
fig, axes = plt.subplots(1, 3, figsize=(10, 3), dpi=150)
|
|
|
|
vitals = [
|
|
("LCP", metrics.get("largest-contentful-paint", {}).get("averageNumericValue", 0) / 1000,
|
|
"s", 2.5, 4.0),
|
|
("CLS", metrics.get("cumulative-layout-shift", {}).get("averageNumericValue", 0),
|
|
"", 0.1, 0.25),
|
|
("FCP", metrics.get("first-contentful-paint", {}).get("averageNumericValue", 0) / 1000,
|
|
"s", 1.8, 3.0),
|
|
]
|
|
|
|
for ax, (abbr, value, unit, good, bad) in zip(axes, vitals):
|
|
if value <= good:
|
|
color = SCORE_COLORS["excellent"]
|
|
status = "BRA"
|
|
elif value <= bad:
|
|
color = SCORE_COLORS["needs_work"]
|
|
status = "BEHÖVER ARBETE"
|
|
else:
|
|
color = SCORE_COLORS["poor"]
|
|
status = "DÅLIGT"
|
|
|
|
ax.text(0.5, 0.65, f"{value:.2f}{unit}", fontsize=28, fontweight="bold",
|
|
ha="center", va="center", transform=ax.transAxes, color=color)
|
|
ax.text(0.5, 0.35, abbr, fontsize=14, ha="center", va="center",
|
|
transform=ax.transAxes, color=UI["muted"])
|
|
ax.text(0.5, 0.15, status, fontsize=10, ha="center", va="center",
|
|
transform=ax.transAxes, color=color, fontweight="bold")
|
|
ax.text(0.5, 0.02, f"(bra: ≤{good}{unit})", fontsize=8, ha="center", va="center",
|
|
transform=ax.transAxes, color=UI["muted"])
|
|
|
|
ax.axis("off")
|
|
|
|
plt.tight_layout()
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150)
|
|
plt.close()
|
|
buf.seek(0)
|
|
return buf
|
|
|