feat: contributor graph PNG generator + API endpoint

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) <noreply@anthropic.com>
This commit is contained in:
m3taversal 2026-04-21 11:01:02 +01:00
parent 88e8e15c6d
commit c3d0b1f5a4

View file

@ -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)