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:
parent
88e8e15c6d
commit
c3d0b1f5a4
1 changed files with 137 additions and 0 deletions
137
scripts/contributor-graph.py
Normal file
137
scripts/contributor-graph.py
Normal 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)
|
||||
Loading…
Reference in a new issue