1
Files
2025/tools/lib/render_stats_charts.py
2025-11-28 16:05:31 +01:00

529 lines
15 KiB
Python

#!/usr/bin/env python3
import argparse
import json
import math
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import matplotlib.colors as mcolors # noqa: E402
import numpy as np # noqa: E402
PALETTE = [
"#467FFF", # blue-500
"#40C474", # green-500
"#FF4D5A", # red-500
"#FFA93D", # amber-500
"#9E63E9", # purple-500
"#2FC4FF", # cyan-500
"#98C0FF", # blue-300
"#8FE4A2", # green-300
"#FF939B", # red-300
"#FFD08C", # amber-300
"#D2AAF7", # purple-300
"#8EE8FF", # cyan-300
]
BACKGROUND = "#0F1114" # gray-900
TEXT = "#D9E0E8" # gray-300
GRID = (1.0, 1.0, 1.0, 0.16) # soft white grid
FIG_WIDTH = 20.0 # ~1920px at DPI=96
FIG_HEIGHT = 10.8 # 16:9 ratio
DPI = 96
BASE_FONT_SIZE = 16
TICK_FONT_SIZE = 15
LEGEND_FONT_SIZE = 14
TITLE_FONT_SIZE = 18
def setup_rcparams():
matplotlib.rcParams.update(
{
"figure.figsize": (FIG_WIDTH, FIG_HEIGHT),
"figure.dpi": DPI,
"axes.facecolor": BACKGROUND,
"figure.facecolor": BACKGROUND,
"axes.edgecolor": TEXT,
"axes.labelcolor": TEXT,
"xtick.color": TEXT,
"ytick.color": TEXT,
"text.color": TEXT,
"font.size": BASE_FONT_SIZE,
}
)
def new_axes():
fig, ax = plt.subplots()
fig.set_facecolor(BACKGROUND)
ax.set_facecolor(BACKGROUND)
ax.grid(True, axis="y", color=GRID, linestyle="--", linewidth=0.7)
return fig, ax
def render_articles_per_month(data, output):
labels = data.get("labels") or []
series = data.get("series") or []
title = data.get("title") or "Articles par mois"
if not labels or not series:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax = new_axes()
bottoms = np.zeros(len(labels))
for index, serie in enumerate(series):
values = np.array(serie.get("values") or [0] * len(labels), dtype=float)
color = PALETTE[index % len(PALETTE)]
ax.bar(x, values, bottom=bottoms, label=str(serie.get("label", "")), color=color, linewidth=0)
bottoms += values
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_articles_per_year(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Articles par an"
if not labels or not values:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax = new_axes()
ax.bar(x, values, color=PALETTE[0])
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=0, fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_articles_per_section(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Articles par section"
if not labels or not values:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
fig, ax = new_axes()
# Donut chart
wedges, _ = ax.pie(
values,
labels=None,
colors=[PALETTE[i % len(PALETTE)] for i in range(len(values))],
startangle=90,
counterclock=False,
)
centre_circle = plt.Circle((0, 0), 0.60, fc=BACKGROUND)
fig.gca().add_artist(centre_circle)
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(
wedges,
labels,
title="Sections",
loc="center left",
bbox_to_anchor=(1.0, 0.5),
fontsize=LEGEND_FONT_SIZE,
title_fontsize=LEGEND_FONT_SIZE,
)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_cumulative(data, output):
labels = data.get("labels") or []
articles = data.get("articles") or []
words = data.get("words") or []
title = data.get("title") or "Cumul articles / mots"
if not labels or (not articles and not words):
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax_words = new_axes()
ax_articles = ax_words.twinx()
lines = []
labels_for_legend = []
if words:
lw = ax_words.plot(
x,
words,
label="Mots cumulés",
color=PALETTE[1],
linewidth=2.2,
marker="o",
markersize=4,
)
lines += lw
labels_for_legend += ["Mots cumulés"]
if articles:
la = ax_articles.plot(
x,
articles,
label="Articles cumulés",
color=PALETTE[0],
linewidth=2.2,
marker="o",
markersize=4,
)
lines += la
labels_for_legend += ["Articles cumulés"]
ax_words.set_xticks(x)
ax_words.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax_words.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[1])
ax_articles.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[0])
ax_words.set_ylabel("Mots cumulés", color=PALETTE[1])
ax_articles.set_ylabel("Articles cumulés", color=PALETTE[0])
ax_words.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax_articles.grid(False)
ax_words.grid(True, axis="y", color=GRID, linestyle="--", linewidth=0.7)
fig.legend(lines, labels_for_legend, loc="upper left", fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_words_histogram(data, output):
values = data.get("values") or []
title = data.get("title") or "Distribution des longueurs d'article"
bins = data.get("bins") or 20
fig, ax = new_axes()
if not values:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
ax.hist(values, bins=bins, color=PALETTE[0], edgecolor=TEXT, alpha=0.9)
ax.set_xlabel("Nombre de mots")
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_top_requests(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Top requêtes"
fig, ax = new_axes()
if not labels or not values:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
y_pos = np.arange(len(labels))
ax.barh(y_pos, values, color=PALETTE[0])
ax.set_yticks(y_pos)
ax.set_yticklabels(labels, fontsize=TICK_FONT_SIZE)
ax.invert_yaxis()
ax.set_xlabel("Hits")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_weather_hexbin(data, output):
temps = data.get("temps") or []
hums = data.get("hums") or []
presses = data.get("presses") or []
title = data.get("title") or "Météo à la publication"
fig, ax = new_axes()
if not temps or not hums:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
# If pressures are provided, use them for color; otherwise density
if presses and len(presses) == len(temps):
hb = ax.scatter(temps, hums, c=presses, cmap="viridis", alpha=0.75, s=50, edgecolors="none")
cbar = fig.colorbar(hb, ax=ax)
cbar.set_label("Pression (hPa)", color=TEXT)
cbar.ax.yaxis.set_tick_params(color=TEXT, labelsize=LEGEND_FONT_SIZE)
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color=TEXT)
else:
norm = mcolors.LogNorm() if len(temps) > 0 else None
hb = ax.hexbin(
temps,
hums,
gridsize=28,
cmap="plasma",
mincnt=1,
linewidths=0.2,
edgecolors="none",
alpha=0.9,
norm=norm,
)
cbar = fig.colorbar(hb, ax=ax)
cbar.set_label("Densité", color=TEXT)
cbar.ax.yaxis.set_tick_params(color=TEXT, labelsize=LEGEND_FONT_SIZE)
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color=TEXT)
ax.set_xlabel("Température (°C)")
ax.set_ylabel("Humidité (%)")
ax.tick_params(axis="x", labelsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_weekday_activity(data, output):
labels = data.get("labels") or []
articles = data.get("articles") or []
words = data.get("words") or []
title = data.get("title") or "Activité par jour"
fig, ax_left = new_axes()
ax_right = ax_left.twinx()
if not labels or (not articles and not words):
ax_left.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
x = np.arange(len(labels))
width = 0.38
bars_articles = ax_left.bar(
x - width / 2,
articles,
width=width,
label="Articles",
color=PALETTE[0],
)
bars_words = ax_right.bar(
x + width / 2,
words,
width=width,
label="Mots",
color=PALETTE[1],
)
ax_left.set_xticks(x)
ax_left.set_xticklabels(labels, rotation=0, fontsize=TICK_FONT_SIZE)
ax_left.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[0])
ax_right.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[1])
ax_left.set_ylabel("Articles", color=PALETTE[0])
ax_right.set_ylabel("Mots", color=PALETTE[1])
lines = [bars_articles, bars_words]
labels_for_legend = ["Articles", "Mots"]
fig.legend(lines, labels_for_legend, loc="upper right", fontsize=LEGEND_FONT_SIZE)
ax_left.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_words_per_article(data, output):
labels = data.get("labels") or []
series = data.get("series") or []
title = data.get("title") or "Moyenne de mots par article (par mois)"
if not labels or not series:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
n_series = len(series)
width = 0.8 / max(n_series, 1)
fig, ax = new_axes()
for index, serie in enumerate(series):
values = np.array(serie.get("values") or [0] * len(labels), dtype=float)
color = PALETTE[index % len(PALETTE)]
offset = (index - (n_series - 1) / 2) * width
ax.bar(x + offset, values, width=width, label=str(serie.get("label", "")), color=color, linewidth=0)
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Mots par article (moyenne)")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def main():
parser = argparse.ArgumentParser(description="Render stats charts from JSON data.")
parser.add_argument(
"--type",
required=True,
choices=[
"articles_per_month",
"articles_per_year",
"articles_per_section",
"words_per_article",
"cumulative",
"words_histogram",
"top_requests",
"weather_hexbin",
"weekday_activity",
],
)
parser.add_argument("--output", required=True)
args = parser.parse_args()
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON from stdin: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
chart_type = args.type
if chart_type == "articles_per_month":
render_articles_per_month(payload, args.output)
elif chart_type == "articles_per_year":
render_articles_per_year(payload, args.output)
elif chart_type == "articles_per_section":
render_articles_per_section(payload, args.output)
elif chart_type == "words_per_article":
render_words_per_article(payload, args.output)
elif chart_type == "cumulative":
render_cumulative(payload, args.output)
elif chart_type == "words_histogram":
render_words_histogram(payload, args.output)
elif chart_type == "top_requests":
render_top_requests(payload, args.output)
elif chart_type == "weather_hexbin":
render_weather_hexbin(payload, args.output)
elif chart_type == "weekday_activity":
render_weekday_activity(payload, args.output)
else:
print(f"Unknown chart type: {chart_type}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()