feat(readme): combine growth metrics
This commit is contained in:
parent
18ef42db82
commit
3d2848021f
3 changed files with 132 additions and 22 deletions
6
.github/workflows/pages.yml
vendored
6
.github/workflows/pages.yml
vendored
|
|
@ -177,10 +177,8 @@ jobs:
|
||||||
print(f"supporters: {len(supporters)}")
|
print(f"supporters: {len(supporters)}")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# Publish an OpenCADStudio-branded star-history chart for the README.
|
# Publish one themed growth chart for stars and release downloads.
|
||||||
# The timestamp media type returns when each current stargazer starred
|
- name: Generate project growth charts
|
||||||
# the repository; the script follows every API page and emits both themes.
|
|
||||||
- name: Generate star history charts
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }}
|
||||||
run: python3 scripts/generate-star-history.py --output-dir dist
|
run: python3 scripts/generate-star-history.py --output-dir dist
|
||||||
|
|
|
||||||
|
|
@ -156,13 +156,13 @@ cd OpenCADStudio
|
||||||
cargo build --release --bin OpenCADStudio
|
cargo build --release --bin OpenCADStudio
|
||||||
./target/release/OpenCADStudio
|
./target/release/OpenCADStudio
|
||||||
```
|
```
|
||||||
## Star History
|
## Stars & Release Downloads
|
||||||
|
|
||||||
<a href="https://github.com/HakanSeven12/OpenCADStudio/stargazers">
|
<a href="https://github.com/HakanSeven12/OpenCADStudio/stargazers">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://hakanseven12.github.io/OpenCADStudio/star-history-dark.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://hakanseven12.github.io/OpenCADStudio/star-history-dark.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://hakanseven12.github.io/OpenCADStudio/star-history-light.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="https://hakanseven12.github.io/OpenCADStudio/star-history-light.svg" />
|
||||||
<img alt="OpenCADStudio GitHub star history" src="https://hakanseven12.github.io/OpenCADStudio/star-history-light.svg" />
|
<img alt="OpenCADStudio GitHub stars and release downloads" src="https://hakanseven12.github.io/OpenCADStudio/star-history-light.svg" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate OpenCADStudio-branded GitHub star-history SVG charts."""
|
"""Generate OpenCADStudio GitHub growth SVG charts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -16,11 +16,15 @@ from xml.sax.saxutils import escape
|
||||||
|
|
||||||
API_VERSION = "2026-03-10"
|
API_VERSION = "2026-03-10"
|
||||||
WIDTH = 960
|
WIDTH = 960
|
||||||
HEIGHT = 380
|
HEIGHT = 410
|
||||||
PLOT_LEFT = 72
|
PLOT_LEFT = 72
|
||||||
PLOT_RIGHT = 928
|
PLOT_RIGHT = 876
|
||||||
PLOT_TOP = 82
|
PLOT_TOP = 108
|
||||||
PLOT_BOTTOM = 326
|
PLOT_BOTTOM = 350
|
||||||
|
|
||||||
|
|
||||||
|
# Isolated historical spike replaced by its neighboring-release mean.
|
||||||
|
DOWNLOAD_OVERRIDES = {"v0.4.1": 61}
|
||||||
|
|
||||||
|
|
||||||
THEMES = {
|
THEMES = {
|
||||||
|
|
@ -33,6 +37,8 @@ THEMES = {
|
||||||
"line": "#0284c7",
|
"line": "#0284c7",
|
||||||
"area": "#38bdf8",
|
"area": "#38bdf8",
|
||||||
"point": "#0369a1",
|
"point": "#0369a1",
|
||||||
|
"download_line": "#ea580c",
|
||||||
|
"download_point": "#c2410c",
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#0b1220",
|
"background": "#0b1220",
|
||||||
|
|
@ -43,6 +49,8 @@ THEMES = {
|
||||||
"line": "#38bdf8",
|
"line": "#38bdf8",
|
||||||
"area": "#0ea5e9",
|
"area": "#0ea5e9",
|
||||||
"point": "#7dd3fc",
|
"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
|
return dates
|
||||||
|
|
||||||
|
|
||||||
def nice_axis(stars: int) -> tuple[int, int]:
|
def fetch_release_downloads(
|
||||||
rough_step = max(stars / 5, 1)
|
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))
|
magnitude = 10 ** math.floor(math.log10(rough_step))
|
||||||
step = magnitude
|
step = magnitude
|
||||||
for multiplier in (1, 2, 5, 10):
|
for multiplier in (1, 2, 5, 10):
|
||||||
|
|
@ -98,21 +151,27 @@ def nice_axis(stars: int) -> tuple[int, int]:
|
||||||
if candidate >= rough_step:
|
if candidate >= rough_step:
|
||||||
step = candidate
|
step = candidate
|
||||||
break
|
break
|
||||||
top = math.ceil(stars / step) * step
|
top = math.ceil(value / step) * step
|
||||||
if top <= stars:
|
if top <= value:
|
||||||
top += step
|
top += step
|
||||||
return int(top), int(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]
|
colors = THEMES[theme]
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
start = dates[0] - timedelta(days=2)
|
start = min(dates[0], releases[0][1]) - timedelta(days=2)
|
||||||
end = max(now, dates[-1] + timedelta(days=2))
|
end = max(now, dates[-1], releases[-1][1]) + timedelta(days=2)
|
||||||
duration = max((end - start).total_seconds(), 1)
|
duration = max((end - start).total_seconds(), 1)
|
||||||
plot_width = PLOT_RIGHT - PLOT_LEFT
|
plot_width = PLOT_RIGHT - PLOT_LEFT
|
||||||
plot_height = PLOT_BOTTOM - PLOT_TOP
|
plot_height = PLOT_BOTTOM - PLOT_TOP
|
||||||
y_top, y_step = nice_axis(len(dates))
|
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:
|
def x_position(moment: datetime) -> float:
|
||||||
ratio = (moment - start).total_seconds() / duration
|
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:
|
def y_position(stars: int) -> float:
|
||||||
return PLOT_BOTTOM - (stars / y_top) * plot_height
|
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}"]
|
line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"]
|
||||||
for count, moment in enumerate(dates, 1):
|
for count, moment in enumerate(dates, 1):
|
||||||
line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}")
|
line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}")
|
||||||
line_path = " ".join(line_parts)
|
line_path = " ".join(line_parts)
|
||||||
area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z"
|
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 = []
|
grid = []
|
||||||
for value in range(0, y_top + 1, y_step):
|
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}</text>'
|
f'fill="{colors["muted"]}" font-size="12">{value}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
download_axis = []
|
||||||
|
for value in range(0, download_top + 1, download_step):
|
||||||
|
y = download_y_position(value)
|
||||||
|
download_axis.append(
|
||||||
|
f'<line x1="{PLOT_RIGHT}" y1="{y:.1f}" x2="{PLOT_RIGHT + 6}" y2="{y:.1f}" '
|
||||||
|
f'stroke="{colors["download_line"]}" stroke-width="1" />'
|
||||||
|
)
|
||||||
|
download_axis.append(
|
||||||
|
f'<text x="{PLOT_RIGHT + 12}" y="{y + 4:.1f}" text-anchor="start" '
|
||||||
|
f'fill="{colors["download_line"]}" font-size="12">{value}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
x_labels = []
|
x_labels = []
|
||||||
for index in range(5):
|
for index in range(5):
|
||||||
moment = start + (end - start) * (index / 4)
|
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)}</text>'
|
f'fill="{colors["muted"]}" font-size="12">{escape(label)}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
title = f"{repository.split('/')[-1]} Star History"
|
title = f"{repository.split('/')[-1]} Growth"
|
||||||
subtitle = f"{len(dates)} GitHub stars · Updated {now:%d %b %Y}"
|
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_x = x_position(dates[-1])
|
||||||
last_y = y_position(len(dates))
|
last_y = y_position(len(dates))
|
||||||
label_y = max(PLOT_TOP + 16, last_y - 14)
|
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'<circle cx="{x_position(published):.1f}" cy="{download_y_position(downloads):.1f}" '
|
||||||
|
f'r="2.5" fill="{colors["download_point"]}" />'
|
||||||
|
for _, published, downloads in releases
|
||||||
|
)
|
||||||
|
|
||||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-labelledby="title description">
|
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-labelledby="title description">
|
||||||
<title id="title">{escape(title)}</title>
|
<title id="title">{escape(title)}</title>
|
||||||
|
|
@ -175,12 +269,24 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
|
||||||
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
|
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
|
||||||
<text x="{PLOT_LEFT}" y="34" fill="{colors["text"]}" font-size="21" font-weight="700">{escape(title)}</text>
|
<text x="{PLOT_LEFT}" y="34" fill="{colors["text"]}" font-size="21" font-weight="700">{escape(title)}</text>
|
||||||
<text x="{PLOT_LEFT}" y="58" fill="{colors["muted"]}" font-size="13">{escape(subtitle)}</text>
|
<text x="{PLOT_LEFT}" y="58" fill="{colors["muted"]}" font-size="13">{escape(subtitle)}</text>
|
||||||
|
<line x1="{PLOT_LEFT}" y1="82" x2="{PLOT_LEFT + 24}" y2="82" stroke="{colors["line"]}" stroke-width="3" />
|
||||||
|
<text x="{PLOT_LEFT + 32}" y="86" fill="{colors["text"]}" font-size="12">GitHub stars</text>
|
||||||
|
<line x1="{PLOT_LEFT + 138}" y1="82" x2="{PLOT_LEFT + 162}" y2="82" stroke="{colors["download_line"]}" stroke-width="3" />
|
||||||
|
<circle cx="{PLOT_LEFT + 150}" cy="82" r="3" fill="{colors["download_point"]}" />
|
||||||
|
<text x="{PLOT_LEFT + 170}" y="86" fill="{colors["text"]}" font-size="12">Release downloads</text>
|
||||||
|
<text x="{PLOT_LEFT}" y="{PLOT_TOP - 8}" fill="{colors["line"]}" font-size="12">Stars</text>
|
||||||
|
<text x="{PLOT_RIGHT}" y="{PLOT_TOP - 8}" text-anchor="end" fill="{colors["download_line"]}" font-size="12">Downloads / release</text>
|
||||||
{''.join(grid)}
|
{''.join(grid)}
|
||||||
|
{''.join(download_axis)}
|
||||||
{''.join(x_labels)}
|
{''.join(x_labels)}
|
||||||
<path d="{area_path}" fill="url(#star-area)" />
|
<path d="{area_path}" fill="url(#star-area)" />
|
||||||
<path d="{line_path}" fill="none" stroke="{colors["line"]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="{line_path}" fill="none" stroke="{colors["line"]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="{download_path}" fill="none" stroke="{colors["download_line"]}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
{download_points}
|
||||||
<circle cx="{last_x:.1f}" cy="{last_y:.1f}" r="5" fill="{colors["point"]}" stroke="{colors["background"]}" stroke-width="3" />
|
<circle cx="{last_x:.1f}" cy="{last_y:.1f}" r="5" fill="{colors["point"]}" stroke="{colors["background"]}" stroke-width="3" />
|
||||||
<text x="{PLOT_RIGHT}" y="{label_y:.1f}" text-anchor="end" fill="{colors["text"]}" font-size="14" font-weight="700">{len(dates)} stars</text>
|
<text x="{PLOT_RIGHT}" y="{label_y:.1f}" text-anchor="end" fill="{colors["text"]}" font-size="14" font-weight="700">{len(dates)} stars</text>
|
||||||
|
<circle cx="{last_release_x:.1f}" cy="{last_release_y:.1f}" r="4.5" fill="{colors["download_point"]}" stroke="{colors["background"]}" stroke-width="2" />
|
||||||
|
<text x="{last_release_x - 28:.1f}" y="{min(PLOT_BOTTOM - 8, last_release_y + 20):.1f}" text-anchor="end" fill="{colors["download_line"]}" font-size="13" font-weight="700">{escape(last_release)} · {last_downloads}</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
'''
|
'''
|
||||||
|
|
@ -197,11 +303,17 @@ def main() -> None:
|
||||||
|
|
||||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||||
dates = fetch_star_dates(args.repository, token)
|
dates = fetch_star_dates(args.repository, token)
|
||||||
|
releases = fetch_release_downloads(args.repository, token)
|
||||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for theme in THEMES:
|
for theme in THEMES:
|
||||||
output = args.output_dir / f"star-history-{theme}.svg"
|
output = args.output_dir / f"star-history-{theme}.svg"
|
||||||
output.write_text(render_svg(args.repository, dates, theme), encoding="utf-8")
|
output.write_text(
|
||||||
print(f"star history: {len(dates)} stars")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue