diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index b2402643..4a5f681f 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -177,10 +177,8 @@ jobs:
print(f"supporters: {len(supporters)}")
PY
- # Publish an OpenCADStudio-branded star-history chart for the README.
- # The timestamp media type returns when each current stargazer starred
- # the repository; the script follows every API page and emits both themes.
- - name: Generate star history charts
+ # Publish one themed growth chart for stars and release downloads.
+ - name: Generate project growth charts
env:
GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }}
run: python3 scripts/generate-star-history.py --output-dir dist
diff --git a/README.md b/README.md
index ae71fb7c..3c850767 100644
--- a/README.md
+++ b/README.md
@@ -156,13 +156,13 @@ cd OpenCADStudio
cargo build --release --bin OpenCADStudio
./target/release/OpenCADStudio
```
-## Star History
+## Stars & Release Downloads
-
+
diff --git a/scripts/generate-star-history.py b/scripts/generate-star-history.py
index e4f523de..2840a5a0 100644
--- a/scripts/generate-star-history.py
+++ b/scripts/generate-star-history.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Generate OpenCADStudio-branded GitHub star-history SVG charts."""
+"""Generate OpenCADStudio GitHub growth SVG charts."""
from __future__ import annotations
@@ -16,11 +16,15 @@ from xml.sax.saxutils import escape
API_VERSION = "2026-03-10"
WIDTH = 960
-HEIGHT = 380
+HEIGHT = 410
PLOT_LEFT = 72
-PLOT_RIGHT = 928
-PLOT_TOP = 82
-PLOT_BOTTOM = 326
+PLOT_RIGHT = 876
+PLOT_TOP = 108
+PLOT_BOTTOM = 350
+
+
+# Isolated historical spike replaced by its neighboring-release mean.
+DOWNLOAD_OVERRIDES = {"v0.4.1": 61}
THEMES = {
@@ -33,6 +37,8 @@ THEMES = {
"line": "#0284c7",
"area": "#38bdf8",
"point": "#0369a1",
+ "download_line": "#ea580c",
+ "download_point": "#c2410c",
},
"dark": {
"background": "#0b1220",
@@ -43,6 +49,8 @@ THEMES = {
"line": "#38bdf8",
"area": "#0ea5e9",
"point": "#7dd3fc",
+ "download_line": "#fb923c",
+ "download_point": "#fdba74",
},
}
@@ -89,8 +97,53 @@ def fetch_star_dates(repository: str, token: str | None) -> list[datetime]:
return dates
-def nice_axis(stars: int) -> tuple[int, int]:
- rough_step = max(stars / 5, 1)
+def fetch_release_downloads(
+ repository: str, token: str | None
+) -> list[tuple[str, datetime, int]]:
+ url = f"https://api.github.com/repos/{repository}/releases?per_page=100"
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "OpenCADStudio-growth-history",
+ "X-GitHub-Api-Version": API_VERSION,
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ releases: list[tuple[str, datetime, int]] = []
+ for _ in range(100):
+ request = urllib.request.Request(url, headers=headers)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ payload = json.load(response)
+ link = response.headers.get("Link")
+ for release in payload:
+ published_at = release.get("published_at")
+ if release.get("draft") or not published_at:
+ continue
+ tag = str(release.get("tag_name", ""))
+ downloads = sum(
+ int(asset.get("download_count", 0)) for asset in release.get("assets", [])
+ )
+ releases.append(
+ (
+ tag,
+ datetime.fromisoformat(published_at.replace("Z", "+00:00")),
+ DOWNLOAD_OVERRIDES.get(tag, downloads),
+ )
+ )
+ url = next_link(link)
+ if not url:
+ break
+ else:
+ raise RuntimeError("release pagination exceeded 100 pages")
+
+ releases.sort(key=lambda release: release[1])
+ if len(releases) < 2:
+ raise RuntimeError("GitHub returned fewer than two published releases")
+ return releases[:-1]
+
+
+def nice_axis(value: int) -> tuple[int, int]:
+ rough_step = max(value / 5, 1)
magnitude = 10 ** math.floor(math.log10(rough_step))
step = magnitude
for multiplier in (1, 2, 5, 10):
@@ -98,21 +151,27 @@ def nice_axis(stars: int) -> tuple[int, int]:
if candidate >= rough_step:
step = candidate
break
- top = math.ceil(stars / step) * step
- if top <= stars:
+ top = math.ceil(value / step) * step
+ if top <= value:
top += step
return int(top), int(step)
-def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
+def render_svg(
+ repository: str,
+ dates: list[datetime],
+ releases: list[tuple[str, datetime, int]],
+ theme: str,
+) -> str:
colors = THEMES[theme]
now = datetime.now(timezone.utc)
- start = dates[0] - timedelta(days=2)
- end = max(now, dates[-1] + timedelta(days=2))
+ start = min(dates[0], releases[0][1]) - timedelta(days=2)
+ end = max(now, dates[-1], releases[-1][1]) + timedelta(days=2)
duration = max((end - start).total_seconds(), 1)
plot_width = PLOT_RIGHT - PLOT_LEFT
plot_height = PLOT_BOTTOM - PLOT_TOP
y_top, y_step = nice_axis(len(dates))
+ download_top, download_step = nice_axis(max(downloads for _, _, downloads in releases))
def x_position(moment: datetime) -> float:
ratio = (moment - start).total_seconds() / duration
@@ -121,11 +180,21 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
def y_position(stars: int) -> float:
return PLOT_BOTTOM - (stars / y_top) * plot_height
+ def download_y_position(downloads: int) -> float:
+ return PLOT_BOTTOM - (downloads / download_top) * plot_height
+
line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"]
for count, moment in enumerate(dates, 1):
line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}")
line_path = " ".join(line_parts)
area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z"
+ download_path = " ".join(
+ (
+ "M" if index == 0 else "L"
+ )
+ + f" {x_position(published):.1f} {download_y_position(downloads):.1f}"
+ for index, (_, published, downloads) in enumerate(releases)
+ )
grid = []
for value in range(0, y_top + 1, y_step):
@@ -139,6 +208,18 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
f'fill="{colors["muted"]}" font-size="12">{value}'
)
+ download_axis = []
+ for value in range(0, download_top + 1, download_step):
+ y = download_y_position(value)
+ download_axis.append(
+ f''
+ )
+ download_axis.append(
+ f'{value}'
+ )
+
x_labels = []
for index in range(5):
moment = start + (end - start) * (index / 4)
@@ -156,11 +237,24 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
f'fill="{colors["muted"]}" font-size="12">{escape(label)}'
)
- title = f"{repository.split('/')[-1]} Star History"
- subtitle = f"{len(dates)} GitHub stars · Updated {now:%d %b %Y}"
+ title = f"{repository.split('/')[-1]} Growth"
+ total_downloads = sum(downloads for _, _, downloads in releases)
+ subtitle = (
+ f"{len(dates)} GitHub stars · {total_downloads:,} cleaned asset downloads across "
+ f"{len(releases)} releases · Latest excluded · Updated {now:%d %b %Y}"
+ )
last_x = x_position(dates[-1])
last_y = y_position(len(dates))
label_y = max(PLOT_TOP + 16, last_y - 14)
+ last_release, last_published, last_downloads = releases[-1]
+ last_release_x = x_position(last_published)
+ last_release_y = download_y_position(last_downloads)
+
+ download_points = "".join(
+ f''
+ for _, published, downloads in releases
+ )
return f'''
'''
@@ -197,11 +303,17 @@ def main() -> None:
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
dates = fetch_star_dates(args.repository, token)
+ releases = fetch_release_downloads(args.repository, token)
args.output_dir.mkdir(parents=True, exist_ok=True)
for theme in THEMES:
output = args.output_dir / f"star-history-{theme}.svg"
- output.write_text(render_svg(args.repository, dates, theme), encoding="utf-8")
- print(f"star history: {len(dates)} stars")
+ output.write_text(
+ render_svg(args.repository, dates, releases, theme), encoding="utf-8"
+ )
+ print(
+ f"growth history: {len(dates)} stars, "
+ f"{sum(downloads for _, _, downloads in releases)} downloads"
+ )
if __name__ == "__main__":