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>
137 lines
4.6 KiB
Python
137 lines
4.6 KiB
Python
#!/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)
|