From c3d0b1f5a4e99a28085847c29343760fce3607e2 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 21 Apr 2026 11:01:02 +0100 Subject: [PATCH] feat: contributor graph PNG generator + API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matplotlib chart with dual axes — cumulative claims (#00d4aa) and contributors (#7c3aed) on dark background. 1200x630 for Twitter. Auto-regenerates hourly via /api/contributor-graph endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/contributor-graph.py | 137 +++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 scripts/contributor-graph.py diff --git a/scripts/contributor-graph.py b/scripts/contributor-graph.py new file mode 100644 index 0000000..3f20a49 --- /dev/null +++ b/scripts/contributor-graph.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Generate cumulative contributor + claims PNG for Twitter embedding.""" + +import json +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.ticker import MaxNLocator + +ACCENT = "#00d4aa" +PURPLE = "#7c3aed" +BG = "#0a0a0a" +TEXT = "#e0e0e0" +SUBTLE = "#555555" +OUTPUT = Path("/opt/teleo-eval/static/contributor-graph.png") + + +def get_data(): + """Fetch from local API.""" + import urllib.request + with urllib.request.urlopen("http://localhost:8081/api/contributor-growth") as r: + return json.loads(r.read()) + + +def build_continuous_series(milestones, start_date, end_date): + """Expand milestone-only contributor data into daily series.""" + dates = [] + values = [] + current = 0 + milestone_map = {} + for m in milestones: + d = datetime.strptime(m["date"], "%Y-%m-%d").date() + milestone_map[d] = m["cumulative"] + + d = start_date + while d <= end_date: + if d in milestone_map: + current = milestone_map[d] + dates.append(d) + values.append(current) + d += timedelta(days=1) + return dates, values + + +def render(data, output_path): + fig, ax1 = plt.subplots(figsize=(12, 6.3), dpi=100) + fig.patch.set_facecolor(BG) + ax1.set_facecolor(BG) + + claims = data["cumulative_claims"] + contribs = data["cumulative_contributors"] + + claim_dates = [datetime.strptime(c["date"], "%Y-%m-%d").date() for c in claims] + claim_values = [c["cumulative"] for c in claims] + + start = min(claim_dates) + end = max(claim_dates) + + contrib_dates, contrib_values = build_continuous_series(contribs, start, end) + + # Claims line (left y-axis) + ax1.fill_between(claim_dates, claim_values, alpha=0.15, color=ACCENT) + ax1.plot(claim_dates, claim_values, color=ACCENT, linewidth=2.5, label="Claims") + ax1.set_ylabel("Claims", color=ACCENT, fontsize=12, fontweight="bold") + ax1.tick_params(axis="y", colors=ACCENT, labelsize=10) + ax1.set_ylim(bottom=0) + + # Contributors line (right y-axis) + ax2 = ax1.twinx() + ax2.set_facecolor("none") + ax2.fill_between(contrib_dates, contrib_values, alpha=0.1, color=PURPLE, step="post") + ax2.step(contrib_dates, contrib_values, color=PURPLE, linewidth=2.5, + where="post", label="Contributors") + ax2.set_ylabel("Contributors", color=PURPLE, fontsize=12, fontweight="bold") + ax2.tick_params(axis="y", colors=PURPLE, labelsize=10) + ax2.yaxis.set_major_locator(MaxNLocator(integer=True)) + ax2.set_ylim(bottom=0, top=max(contrib_values) * 1.8) + + # Annotate contributor milestones with staggered offsets to avoid overlap + offsets = {} + for i, m in enumerate(contribs): + d = datetime.strptime(m["date"], "%Y-%m-%d").date() + val = m["cumulative"] + names = [n["name"] for n in m["new"]] + if len(names) <= 2: + label = ", ".join(names) + else: + label = f"+{len(names)}" + y_off = 8 + (i % 2) * 14 + ax2.annotate(label, (d, val), + textcoords="offset points", xytext=(5, y_off), + fontsize=7, color=PURPLE, alpha=0.8) + + # Hero stats + total_claims = data["summary"]["total_claims"] + total_contribs = data["summary"]["total_contributors"] + days = data["summary"]["days_active"] + fig.text(0.14, 0.88, f"{total_claims:,} claims", fontsize=22, + color=ACCENT, fontweight="bold", ha="left") + fig.text(0.14, 0.82, f"{total_contribs} contributors · {days} days", + fontsize=13, color=TEXT, ha="left", alpha=0.7) + + # X-axis + ax1.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) + ax1.xaxis.set_major_locator(mdates.WeekdayLocator(interval=2)) + ax1.tick_params(axis="x", colors=SUBTLE, labelsize=9, rotation=0) + + # Remove spines + for ax in [ax1, ax2]: + for spine in ax.spines.values(): + spine.set_visible(False) + + # Subtle grid on claims axis only + ax1.grid(axis="y", color=SUBTLE, alpha=0.2, linewidth=0.5) + ax1.set_axisbelow(True) + + # Branding + fig.text(0.98, 0.02, "livingip.xyz", fontsize=9, color=SUBTLE, + ha="right", style="italic") + + plt.tight_layout(rect=[0, 0.03, 1, 0.78]) + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, facecolor=BG, bbox_inches="tight", pad_inches=0.3) + plt.close(fig) + print(f"Saved to {output_path} ({output_path.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + out = Path(sys.argv[1]) if len(sys.argv) > 1 else OUTPUT + data = get_data() + render(data, out)