529 lines
15 KiB
Python
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()
|