Files
webbanalys/lib/charts.py
2025-12-13 17:35:41 +01:00

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