Refactoring
This commit is contained in:
1
tools/lib/__init__.py
Normal file
1
tools/lib/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared Python helpers for tools."""
|
||||
528
tools/lib/render_stats_charts.py
Normal file
528
tools/lib/render_stats_charts.py
Normal file
@@ -0,0 +1,528 @@
|
||||
#!/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()
|
||||
@@ -3,7 +3,7 @@ const path = require("path");
|
||||
|
||||
async function renderWithPython({ type, data, outputPath }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const scriptPath = path.resolve(__dirname, "../../render_stats_charts.py");
|
||||
const scriptPath = path.resolve(__dirname, "../render_stats_charts.py");
|
||||
const child = spawn("python3", [scriptPath, "--type", type, "--output", outputPath], {
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
@@ -29,4 +29,3 @@ async function renderWithPython({ type, data, outputPath }) {
|
||||
module.exports = {
|
||||
renderWithPython,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user