#!/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()