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