AnyPS5/tools/progress_history.py

140 lines
6.5 KiB
Python

import argparse
import io
import json
import subprocess
import tarfile
import tempfile
from datetime import date
from html import escape
from pathlib import Path
import progress
SERIES = (("libraries", "System libraries", "#2f81f7"), ("shaders", "GPU shader instructions", "#2ea043"))
SHORT = {"libraries": "Libraries", "shaders": "Shaders"}
WIDTH, HEIGHT, LEFT, RIGHT, TOP, BOTTOM = 900, 380, 50, 130, 20, 60
MAX_DOTS = 45
BACKGROUND, GRID, TEXT = "#0d1117", "#30363d", "#c9d1d9"
def git(*args):
return subprocess.run(["git", "-C", str(progress.ROOT), *args], check=True, capture_output=True).stdout
def daily_commits(ref):
seen = {}
for line in git("log", "--first-parent", "--format=%H %cs", ref).decode().splitlines():
sha, day = line.split()
seen.setdefault(day, sha)
return sorted(seen.items())
def measure(sha):
paths = [progress.PRX.relative_to(progress.ROOT).as_posix(), progress.OPCODES.relative_to(progress.ROOT).as_posix()]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
try:
tarfile.open(fileobj=io.BytesIO(git("archive", sha, "--", *paths))).extractall(root, filter="data")
except subprocess.CalledProcessError:
return None
saved = progress.PRX, progress.OPCODES
progress.PRX, progress.OPCODES = root / paths[0], root / paths[1]
try:
libraries, shaders = progress.collect_libraries(), progress.collect_shaders()
except (OSError, AttributeError):
return None
finally:
progress.PRX, progress.OPCODES = saved
return {name: {key: data[key] for key in ("done", "total", "percent")}
for name, data in (("libraries", libraries), ("shaders", shaders))}
def collect(ref):
points = []
for day, sha in daily_commits(ref):
counts = measure(sha)
if counts:
points.append({"date": day, "commit": sha, **counts})
return points
def labelled(day, span):
if span <= 31:
return day.strftime("%m-%d")
if span <= 150:
return day.strftime("%m-%d") if day.weekday() == 0 else ""
return day.strftime("%Y-%m") if day.day <= 7 and day.weekday() == 0 else ""
def chart(points):
days = [date.fromisoformat(p["date"]).toordinal() for p in points]
first, span = days[0], max(days[-1] - days[0], 1)
top = max(10, int(-(-max(p[name]["percent"] for p in points for name, _, _ in SERIES) // 10) * 10))
px = lambda d: LEFT + (d - first) / span * (WIDTH - LEFT - RIGHT)
py = lambda v: TOP + (1 - v / top) * (HEIGHT - TOP - BOTTOM)
base = py(0)
out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" '
f'aria-label="Progress over time" font-family="sans-serif" font-size="12" fill="{TEXT}">',
f'<rect width="{WIDTH}" height="{HEIGHT}" rx="6" fill="{BACKGROUND}"/>']
for tick in range(0, top + 1, 10):
out.append(f'<line x1="{LEFT}" x2="{WIDTH - RIGHT}" y1="{py(tick):.1f}" y2="{py(tick):.1f}" stroke="{GRID}"/>'
f'<text x="{LEFT - 6}" y="{py(tick) + 4:.1f}" text-anchor="end">{tick}%</text>')
for d, p in zip(days, points):
x = px(d)
label = labelled(date.fromordinal(d), span)
if label:
out.append(f'<line x1="{x:.1f}" x2="{x:.1f}" y1="{TOP}" y2="{base:.1f}" stroke="{GRID}" stroke-width="0.5"/>'
f'<text transform="translate({x + 4:.1f} {base + 8:.1f}) rotate(60)" font-size="10">{label}</text>')
for name, label, color in SERIES:
coords = [(px(d), py(p[name]["percent"])) for d, p in zip(days, points)]
line = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
out.append(f'<polygon points="{coords[0][0]:.1f},{base:.1f} {line} {coords[-1][0]:.1f},{base:.1f}" fill="{color}" opacity="0.15"/>'
f'<polyline points="{line}" fill="none" stroke="{color}" stroke-width="2"/>')
for (x, y), p in zip(coords, points) if len(points) <= MAX_DOTS else ():
out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="3" fill="{color}"><title>{p["date"]}: {label} '
f'{p[name]["percent"]}% ({p[name]["done"]}/{p[name]["total"]})</title></circle>')
last = points[-1][name]
out.append(f'<text x="{WIDTH - RIGHT + 8}" y="{coords[-1][1] + 4:.1f}" fill="{color}" font-weight="bold">'
f'{SHORT[name]} {last["percent"]}%</text>')
out.append("</svg>")
return "\n".join(out)
def page(points):
rows = "\n".join(f'<tr><td>{p["date"]}</td>' + "".join(
f'<td>{p[name]["done"]}/{p[name]["total"]}</td><td>{p[name]["percent"]}%</td>' for name, _, _ in SERIES) + "</tr>"
for p in reversed(points))
return "\n".join([
'<!DOCTYPE html>',
'<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<title>AnyPS5 progress history</title>',
'<style>body{font-family:sans-serif;max-width:1000px;margin:auto;padding:16px}svg{max-width:100%;height:auto}'
'table{border-collapse:collapse}th,td{border:1px solid #ccc;padding:2px 8px}td+td,th+th{text-align:right}</style>',
'</head><body>',
'<h1>Progress history</h1>',
chart(points),
f'<p>One point per day with commits on <code>main</code>, taken at the last commit of the day. Every point is measured '
f'with the current <a href="{progress.SOURCE}/tools/progress.py">tools/progress.py</a> rules and instruction list, '
'so the series is comparable across days. Hover a point for the exact numbers. Raw numbers: <a href="history.json">history.json</a>. '
'<a href="progress.html">Back to the current progress</a>.</p>',
'<table><tr><th>Date</th>' + "".join(f'<th>{escape(label)}</th><th>%</th>' for _, label, _ in SERIES) + '</tr>',
rows,
'</table>',
'</body></html>',
]) + "\n"
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--ref", default="HEAD", help="branch or commit whose first-parent history is measured")
args = parser.parse_args()
points = collect(args.ref)
if not points:
parser.error("no commit with a measurable tree")
args.output.mkdir(parents=True, exist_ok=True)
(args.output / "history.json").write_text(json.dumps(points, indent=2))
(args.output / "history.svg").write_text(chart(points) + "\n")
(args.output / "history.html").write_text(page(points))
print(f"{len(points)} days, {points[0]['date']} .. {points[-1]['date']}")