mirror of
https://github.com/direct-img/gh.direct-img.link.git
synced 2026-10-29 00:54:51 +00:00
Add star history chart, hourly workflow and index page
stars.mjs renders dist/<owner>/<repo>.svg for each repo in repos.txt: a cumulative star chart with labelled breakouts, spikes and first star, ending in an isometric star. The hourly workflow commits only when the star count or UTC day changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
name: stars
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 * * * *'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency: stars
|
||||
jobs:
|
||||
render:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
- run: node stars.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.STARS_TOKEN || github.token }}
|
||||
- run: |
|
||||
git add dist
|
||||
git diff --cached --quiet && exit 0
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m 'Update star charts'
|
||||
git push
|
||||
@@ -0,0 +1,3 @@
|
||||
preview/*
|
||||
!preview/render.sh
|
||||
!preview/sim.mjs
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="500" viewBox="0 0 1180 500" data-key="57@2026.10.28">
|
||||
<style>
|
||||
text{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Helvetica Neue',Inter,Arial,'DejaVu Sans',sans-serif}
|
||||
.m{font-family:ui-monospace,'SF Mono','JetBrains Mono',Menlo,Consolas,'DejaVu Sans Mono',monospace;font-variant-numeric:tabular-nums}
|
||||
.bg{fill:#fff;stroke:#d0d7de}.fg{fill:#1f2328}.dim{fill:#656d76}.grid{stroke:#eaeef2}.axis{stroke:#d0d7de}
|
||||
.ln{stroke:#bf8700}.g0{stop-color:#d4a72c;stop-opacity:.3}.g1{stop-color:#d4a72c;stop-opacity:0}.pt{fill:#fff;stroke:#bf8700;stroke-width:2.5}
|
||||
.cap{letter-spacing:.14em}.lead{stroke:#8c959f;stroke-width:1;stroke-dasharray:2 3}
|
||||
</style>
|
||||
<defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" class="g0"/><stop offset="1" class="g1"/></linearGradient></defs>
|
||||
<rect class="bg" x=".5" y=".5" width="1179" height="499" rx="14"/>
|
||||
<text x="56" y="62" font-size="24"><tspan class="dim">direct-img / </tspan><tspan class="fg" font-weight="700">vibegif.direct-img.link</tspan></text>
|
||||
<text x="56" y="90" class="dim" font-size="14">Star history · gh.direct-img.link</text>
|
||||
<text x="1132" y="66" text-anchor="end"><tspan class="fg" font-size="34" font-weight="700">57</tspan><tspan class="dim" font-size="16"> stars</tspan></text>
|
||||
<line x1="84" y1="404" x2="1104" y2="404" class="axis"/>
|
||||
<text x="70" y="408.5" class="m dim" font-size="13" text-anchor="end">0</text>
|
||||
<line x1="84" y1="335" x2="1104" y2="335" class="grid"/>
|
||||
<text x="70" y="339.5" class="m dim" font-size="13" text-anchor="end">20</text>
|
||||
<line x1="84" y1="266" x2="1104" y2="266" class="grid"/>
|
||||
<text x="70" y="270.5" class="m dim" font-size="13" text-anchor="end">40</text>
|
||||
<line x1="84" y1="197" x2="1104" y2="197" class="grid"/>
|
||||
<text x="70" y="201.5" class="m dim" font-size="13" text-anchor="end">60</text>
|
||||
<line x1="84" y1="128" x2="1104" y2="128" class="grid"/>
|
||||
<text x="70" y="132.5" class="m dim" font-size="13" text-anchor="end">80</text>
|
||||
<line x1="84" y1="404" x2="84" y2="410" class="axis"/>
|
||||
<text x="84" y="430" class="m dim" font-size="13" text-anchor="start">2026.10.22</text>
|
||||
<text x="84" y="450" class="dim cap" font-size="10" text-anchor="start">BORN</text>
|
||||
<line x1="252" y1="404" x2="252" y2="410" class="axis"/>
|
||||
<text x="252" y="430" class="m dim" font-size="13" text-anchor="middle">2026.10.23</text>
|
||||
<line x1="420.1" y1="404" x2="420.1" y2="410" class="axis"/>
|
||||
<text x="420.1" y="430" class="m dim" font-size="13" text-anchor="middle">2026.10.24</text>
|
||||
<line x1="588.1" y1="404" x2="588.1" y2="410" class="axis"/>
|
||||
<text x="588.1" y="430" class="m dim" font-size="13" text-anchor="middle">2026.10.25</text>
|
||||
<line x1="756.2" y1="404" x2="756.2" y2="410" class="axis"/>
|
||||
<text x="756.2" y="430" class="m dim" font-size="13" text-anchor="middle">2026.10.26</text>
|
||||
<line x1="924.2" y1="404" x2="924.2" y2="410" class="axis"/>
|
||||
<text x="924.2" y="430" class="m dim" font-size="13" text-anchor="middle">2026.10.27</text>
|
||||
<line x1="1104" y1="404" x2="1104" y2="410" class="axis"/>
|
||||
<text x="1104" y="430" class="m dim" font-size="13" text-anchor="end">2026.10.28</text>
|
||||
<text x="1104" y="450" class="dim cap" font-size="10" text-anchor="end">TODAY</text>
|
||||
<path d="M84,404L386.6,404L386.6,400.6L386.9,397.1L391.7,393.6L393.9,390.2L395.9,386.8L400.1,383.3L401.7,379.9L407.4,376.4L414.7,372.9L426.4,369.5L431.2,366.1L432.6,362.6L435.1,359.1L437.6,355.7L449,352.3L451.6,348.8L454.3,345.4L466.9,341.9L467.1,338.4L471.9,335L490.1,335L490.1,331.6L494.9,328.1L494.9,324.6L504.1,321.2L508.9,317.8L509.5,314.3L516.4,310.9L524,307.4L524.3,303.9L567.7,303.9L567.7,300.5L578.7,297.1L596.3,297.1L596.3,293.6L609.6,293.6L609.6,290.1L614.6,286.7L718.4,286.7L718.4,283.3L741.9,283.3L741.9,279.8L751.6,276.4L831.3,276.4L831.3,272.9L935.2,272.9L935.2,269.5L993.2,269.5L993.2,266L996.7,262.6L997.4,259.1L1001.2,255.7L1001.8,252.2L1007.9,248.8L1008.4,245.3L1013.9,241.8L1018.8,238.4L1020.7,234.9L1022.3,231.5L1027.6,228.1L1037.9,224.6L1044.9,221.2L1051.5,217.7L1056.7,214.3L1067.3,210.8L1075.6,207.3L1104,207.3L1104,404L84,404Z" fill="url(#a)"/>
|
||||
<path d="M84,404L386.6,404L386.6,400.6L386.9,397.1L391.7,393.6L393.9,390.2L395.9,386.8L400.1,383.3L401.7,379.9L407.4,376.4L414.7,372.9L426.4,369.5L431.2,366.1L432.6,362.6L435.1,359.1L437.6,355.7L449,352.3L451.6,348.8L454.3,345.4L466.9,341.9L467.1,338.4L471.9,335L490.1,335L490.1,331.6L494.9,328.1L494.9,324.6L504.1,321.2L508.9,317.8L509.5,314.3L516.4,310.9L524,307.4L524.3,303.9L567.7,303.9L567.7,300.5L578.7,297.1L596.3,297.1L596.3,293.6L609.6,293.6L609.6,290.1L614.6,286.7L718.4,286.7L718.4,283.3L741.9,283.3L741.9,279.8L751.6,276.4L831.3,276.4L831.3,272.9L935.2,272.9L935.2,269.5L993.2,269.5L993.2,266L996.7,262.6L997.4,259.1L1001.2,255.7L1001.8,252.2L1007.9,248.8L1008.4,245.3L1013.9,241.8L1018.8,238.4L1020.7,234.9L1022.3,231.5L1027.6,228.1L1037.9,224.6L1044.9,221.2L1051.5,217.7L1056.7,214.3L1067.3,210.8L1075.6,207.3L1104,207.3" fill="none" class="ln" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="471.9" cy="335" r="4.5" class="pt"/>
|
||||
<text x="463.9" y="301" class="fg" font-size="13" font-weight="600" text-anchor="end">Biggest spike +31</text>
|
||||
<text x="463.9" y="318" class="m dim" font-size="12" text-anchor="end">2026.10.23 · 2 days</text>
|
||||
<circle cx="386.6" cy="400.6" r="4.5" class="pt"/>
|
||||
<text x="378.6" y="366.6" class="fg" font-size="13" font-weight="600" text-anchor="end">First star</text>
|
||||
<text x="378.6" y="383.6" class="m dim" font-size="12" text-anchor="end">2026.10.23</text>
|
||||
<circle cx="1018.8" cy="238.4" r="4.5" class="pt"/>
|
||||
<text x="1010.8" y="204.4" class="fg" font-size="13" font-weight="600" text-anchor="end">Spike +19</text>
|
||||
<text x="1010.8" y="221.4" class="m dim" font-size="12" text-anchor="end">2026.10.27</text>
|
||||
<polygon points="1092.2,218.2 1087.6,236.3 1084.3,229.2 1088.9,211.1" fill="rgb(255,209,63)" stroke="rgb(255,209,63)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1105.6,224.4 1123.7,230.7" fill="rgb(196,134,6)" stroke="rgb(196,134,6)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1123.7,230.7 1119.1,214.1" fill="rgb(207,142,7)" stroke="rgb(207,142,7)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1087.6,236.3 1105.6,224.4" fill="rgb(172,113,4)" stroke="rgb(172,113,4)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1134.9,199.9 1114,201.5 1110.7,194.4 1131.6,192.8" fill="rgb(255,215,85)" stroke="rgb(255,215,85)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1092.2,218.2 1087.6,236.3" fill="rgb(255,206,54)" stroke="rgb(255,206,54)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1119.1,214.1 1134.9,199.9" fill="rgb(172,113,4)" stroke="rgb(172,113,4)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1134.9,199.9 1114,201.5" fill="rgb(255,209,64)" stroke="rgb(255,209,64)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1097.3,204.1 1076.4,209 1073.1,201.8 1094,197" fill="rgb(255,224,115)" stroke="rgb(255,224,115)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1076.4,209 1092.2,218.2" fill="rgb(214,148,8)" stroke="rgb(214,148,8)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1114,201.5 1105.6,186.5 1102.4,179.4 1110.7,194.4" fill="rgb(172,113,4)" stroke="rgb(172,113,4)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1105.6,186.5 1097.3,204.1 1094,197 1102.4,179.4" fill="rgb(255,219,98)" stroke="rgb(255,219,98)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1097.3,204.1 1076.4,209" fill="rgb(255,213,79)" stroke="rgb(255,213,79)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1114,201.5 1105.6,186.5" fill="rgb(219,155,12)" stroke="rgb(219,155,12)" stroke-width=".6" stroke-linejoin="round"/><polygon points="1107.5,216.4 1105.6,186.5 1097.3,204.1" fill="rgb(255,211,71)" stroke="rgb(255,211,71)" stroke-width=".6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>gh.direct-img.link</title>
|
||||
<style>
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;text-align:center;background:#fff;color:#1f2328;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Arial,sans-serif}
|
||||
img{width:160px;height:160px}
|
||||
h1{margin:12px 0 4px;font-size:22px}
|
||||
p{margin:0;color:#656d76}
|
||||
</style>
|
||||
<main>
|
||||
<a href="https://vibegif.direct-img.link"><img src="https://vibegif.direct-img.link/locked/vault.gif" alt="locked vault"></a>
|
||||
<h1>gh.direct-img.link</h1>
|
||||
<p>GitHub star history charts. This tool is private for now.</p>
|
||||
</main>
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# render.sh <svg> <name> → preview/<name>.png
|
||||
HS=$(ls -d ~/.cache/ms-playwright/chromium_headless_shell-1248/*/)chrome-headless-shell
|
||||
"$HS" --no-sandbox --hide-scrollbars --force-device-scale-factor=2 --window-size=1180,500 --screenshot="$(cd "$(dirname "$0")" && pwd)/$2.png" "file://$(realpath "$1")" 2>/dev/null
|
||||
@@ -0,0 +1,24 @@
|
||||
// Synthetic star histories → preview/sim-<name>.png, to stress-test scaling and spike detection
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { svg, markers } from '../stars.mjs'
|
||||
const DAY = 864e5, now = Date.parse('2026-10-28T12:00:00Z')
|
||||
let seed = 7; const rnd = () => (seed = seed * 16807 % 2147483647) / 2147483647
|
||||
const pois = l => { if (l > 30) return Math.max(0, Math.round(l + Math.sqrt(l) * (rnd() + rnd() + rnd() - 1.5) * 2)); let k = 0, p = 1, L = Math.exp(-l); while ((p *= rnd()) > L) k++; return k }
|
||||
const sim = (born, rate) => { const b = Date.parse(born), out = []; for (let d = 0; b + d * DAY < now; d++) for (let k = pois(rate(d, Math.floor((now - b) / DAY))); k--;) out.push(Math.min(now - 1, b + (d + rnd()) * DAY)); return { born: b, stars: out.sort((x, y) => x - y) } }
|
||||
const bump = (d, at, h, w = 2) => d >= at ? h * Math.exp(-(d - at) / w) : 0
|
||||
const S = {
|
||||
'old-steady-hn': ['acme/old-steady', sim('2022-11-01T09:00:00Z', d => bump(d, 3, 120, 3) + 2 + d / 200 + bump(d, 500, 500, 2))],
|
||||
'late-viral': ['acme/late-viral', sim('2024-09-10T09:00:00Z', (d, n) => .15 + bump(d, n - 5, 700, 1.5))],
|
||||
'many-bumps': ['acme/many-bumps', sim('2025-04-01T09:00:00Z', d => 1.5 + [40, 95, 160, 210, 260, 330, 400, 470, 520].reduce((s, a, i) => s + bump(d, a, 20 + i * 8, 1), 0))],
|
||||
'huge': ['acme/huge-framework', sim('2019-03-01T09:00:00Z', d => bump(d, 1, 3000, 4) + 40 + 30 * Math.sin(d / 180) + bump(d, 900, 4000, 3) + bump(d, 1700, 2500, 2))],
|
||||
'step-change': ['acme/step-change', sim('2024-06-01T09:00:00Z', d => d < 400 ? 1 : 15)],
|
||||
'one-star': ['acme/lonely', sim('2026-06-01T09:00:00Z', d => d == 30 ? 1 : 0)],
|
||||
'zero': ['acme/nothing-yet', { born: Date.parse('2026-10-20T09:00:00Z'), stars: [] }],
|
||||
}
|
||||
for (const [k, [repo, d]] of Object.entries(S)) {
|
||||
const f = `${import.meta.dirname}/.sim-${k}.svg`
|
||||
writeFileSync(f, svg({ repo, ...d }, now))
|
||||
execFileSync(`${import.meta.dirname}/render.sh`, [f, 'sim-' + k])
|
||||
console.log(k.padEnd(14), String(d.stars.length).padStart(7), markers(d, now).map(m => `${m.title} (${m.sub})`).join(" | "))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Star-history chart for each repo in repos.txt (or argv) → dist/stars/<owner>/<repo>.svg
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const W = 1180, H = 500, DAY = 864e5
|
||||
const tok = process.env.GITHUB_TOKEN
|
||||
const gh = async (p, accept = 'application/vnd.github+json') => {
|
||||
const r = await fetch('https://api.github.com/' + p, {
|
||||
headers: { accept, 'user-agent': 'gh.direct-img.link', ...tok && { authorization: 'Bearer ' + tok } }
|
||||
})
|
||||
if (!r.ok) throw Error(`${r.status} ${p}`)
|
||||
return r.json()
|
||||
}
|
||||
const date = t => new Date(t).toISOString().slice(0, 10).replaceAll('-', '.')
|
||||
const fmt = n => n.toLocaleString('en-US')
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => `&#${c.charCodeAt()};`)
|
||||
const median = a => (a = a.toSorted((x, y) => x - y), a.length ? (a[a.length - 1 >> 1] + a[a.length >> 1]) / 2 : 0)
|
||||
const nice = v => (p => (f => (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * p)(v / p))(10 ** Math.floor(Math.log10(v)))
|
||||
const f1 = v => +v.toFixed(1)
|
||||
|
||||
async function load(repo) {
|
||||
const m = await gh('repos/' + repo), stars = []
|
||||
for (let p = 1; ; p++) {
|
||||
const page = await gh(`repos/${repo}/stargazers?per_page=100&page=${p}`, 'application/vnd.github.star+json')
|
||||
stars.push(...page.map(s => Date.parse(s.starred_at)))
|
||||
if (page.length < 100) break
|
||||
}
|
||||
return { repo: m.full_name, born: Date.parse(m.created_at), stars: stars.sort((a, b) => a - b) }
|
||||
}
|
||||
|
||||
// Notable points (rank: lower = kept first): breakouts, spikes, first star
|
||||
function markers({ born, stars }, now) {
|
||||
const b0 = Math.floor(born / DAY) * DAY, n = Math.floor((now - b0) / DAY) + 1, di = t => Math.floor((t - b0) / DAY)
|
||||
const daily = Array(n).fill(0), first = [], out = []
|
||||
stars.forEach((t, i) => (daily[di(t)]++, first[di(t)] ??= i))
|
||||
const sum = (a, b) => daily.slice(Math.max(0, a), Math.max(0, b)).reduce((s, v) => s + v, 0)
|
||||
const mid = i => (k => ({ t: stars[k], v: k + 1 }))(first[i] + (daily[i] - 1 >> 1))
|
||||
const day = i => date(b0 + i * DAY), rate = r => r < 1 ? r.toFixed(1).replace('.0', '') : fmt(Math.round(r))
|
||||
// Spike: a run of days each ≥ max(5, 3× the median of its prior 28 days), 1-day gaps allowed;
|
||||
// ranked by stars above that baseline so a burst on a busy repo competes fairly with one on a quiet repo
|
||||
const runs = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const base = median(daily.slice(Math.max(0, i - 28), i)), r = runs.at(-1)
|
||||
if (daily[i] < Math.max(5, 3 * base)) continue
|
||||
r && i - r.end <= 2 ? (r.end = i, r.size += daily[i], r.excess += daily[i] - base, daily[i] > daily[r.peak] && (r.peak = i))
|
||||
: runs.push({ start: i, end: i, peak: i, size: daily[i], excess: daily[i] - base })
|
||||
}
|
||||
// Breakout: a surge that sticks. The day ≥ max(3, 3× the prior-90-day pace), its week ≥ max(20, 4× that pace),
|
||||
// and a month later (days 30–60, so a big burst's tail doesn't count) still ≥ max(1/day, 2× the old pace).
|
||||
// Paces skip spike days so regular bumps don't fake a lasting change. Needs ≥ 44 days of hindsight; one per 180 days
|
||||
const hot = new Set(runs.flatMap(r => Array.from({ length: r.end - r.start + 1 }, (_, k) => r.start + k)))
|
||||
const calm = (a, b) => (d => d.length ? d.reduce((s, v) => s + v, 0) / d.length : 0)(daily.slice(Math.max(0, a), Math.max(0, b)).filter((_, k) => !hot.has(Math.max(0, a) + k)))
|
||||
const brk = []
|
||||
for (let i = 0, prev = -999; i < n - 44; i++) {
|
||||
const pace = calm(i - 90, i), after = calm(i + 30, i + 60)
|
||||
i - prev > 180 && daily[i] >= Math.max(3, 3 * pace) && sum(i, i + 7) >= Math.max(20, 28 * pace) &&
|
||||
after >= Math.max(1, 2 * pace) && brk.push({ i: prev = i, pace, after })
|
||||
}
|
||||
runs.sort((a, b) => b.excess - a.excess).filter(r => r.excess >= runs[0].excess / 10).slice(0, 3).forEach((r, j) => {
|
||||
const b = brk.find(b => b.i >= r.start - 1 && b.i <= r.end)
|
||||
b && (b.merged = 1)
|
||||
out.push({ ...mid(r.peak), rank: b ? 1 : j ? 4 : 2,
|
||||
title: `${b ? 'Breakout · ' : ''}${j || runs.length < 2 ? 'Spike' : 'Biggest spike'} +${fmt(r.size)}`,
|
||||
sub: day(r.start) + (r.end > r.start ? ` · ${r.end - r.start + 1} days` : '') })
|
||||
})
|
||||
brk.filter(b => !b.merged).forEach(b => out.push({ ...mid(b.i), rank: 1, title: 'Breakout', sub: `${day(b.i)} · ${rate(b.pace)} → ${rate(b.after)}/day` }))
|
||||
stars.length && out.push({ t: stars[0], v: 1, rank: 3, title: 'First star', sub: date(stars[0]) })
|
||||
return out.sort((a, b) => a.rank - b.rank)
|
||||
}
|
||||
|
||||
// Nice date ticks: days → weeks → months → years, at most `max` of them
|
||||
function ticks(a, b, max = 8) {
|
||||
for (const [k, u] of [[1, 'd'], [2, 'd'], [7, 'd'], [14, 'd'], [1, 'm'], [2, 'm'], [3, 'm'], [6, 'm'], [1, 'y'], [2, 'y'], [5, 'y'], [10, 'y']]) {
|
||||
const d = new Date(a), out = []
|
||||
d.setUTCHours(0, 0, 0, 0)
|
||||
u != 'd' && d.setUTCDate(1)
|
||||
u == 'm' && d.setUTCMonth(d.getUTCMonth() - d.getUTCMonth() % k)
|
||||
u == 'y' && d.setUTCMonth(0, 1)
|
||||
u == 'y' && d.setUTCFullYear(d.getUTCFullYear() - d.getUTCFullYear() % k)
|
||||
for (; +d <= b; u == 'd' ? d.setUTCDate(d.getUTCDate() + k) : u == 'm' ? d.setUTCMonth(d.getUTCMonth() + k) : d.setUTCFullYear(d.getUTCFullYear() + k))
|
||||
+d >= a && out.push(+d)
|
||||
if (out.length <= max) return out
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Isometric-projected, bevelled 3D star, Lambert-shaded gold; painter-sorted faces
|
||||
const V3 = [1, 1, 1].map(v => v / Math.sqrt(3))
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
const sub = (a, b) => a.map((v, i) => v - b[i])
|
||||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
const unit = a => (l => a.map(v => v / l))(Math.hypot(...a))
|
||||
const GOLD = [[122, 72, 0], [214, 148, 8], [255, 202, 40], [255, 240, 170]]
|
||||
const gold = t => (i => GOLD[i].map((v, j) => Math.round(v + (GOLD[i + 1][j] - v) * (t * 3 - i))))(Math.min(2, Math.floor(t * 3)))
|
||||
function isoStar(cx, cy, s, { alpha = 30, depth = .4, bulge = .22, inner = .46, light = [.1, 1.1, 1.4] } = {}) {
|
||||
const a = alpha * Math.PI / 180, n = [Math.cos(a), Math.sin(a), 0], h = [Math.sin(a), -Math.cos(a), 0], L = unit(light)
|
||||
const P = (u, v, w) => [0, 1, 2].map(i => u * h[i] + v * (i == 2) + w * n[i])
|
||||
const scr = p => [cx + (p[0] - p[1]) * .866 * s, cy + ((p[0] + p[1]) * .5 - p[2]) * s]
|
||||
const ring = [...Array(10)].map((_, k) => (r => [r * Math.cos(Math.PI / 2 + k * Math.PI / 5), r * Math.sin(Math.PI / 2 + k * Math.PI / 5) - .06])(k % 2 ? inner : 1))
|
||||
const F = ring.map(([u, v]) => P(u, v, depth / 2)), B = ring.map(([u, v]) => P(u, v, -depth / 2)), A = P(0, -.06, depth / 2 + bulge)
|
||||
const faces = ring.flatMap(([u, v], k) => {
|
||||
const j = (k + 1) % 10, [du, dv] = sub(ring[j], [u, v]), tri = [A, F[k], F[j]]
|
||||
return [{ q: tri, n: (c => dot(c, n) < 0 ? c.map(v => -v) : c)(unit(cross(sub(F[k], A), sub(F[j], A)))) },
|
||||
{ q: [F[k], F[j], B[j], B[k]], n: unit(P(dv, -du, 0)) }]
|
||||
})
|
||||
return faces.filter(f => dot(f.n, V3) > 1e-6)
|
||||
.map(f => ({ ...f, z: dot(f.q.reduce((m, p) => m.map((v, i) => v + p[i]), [0, 0, 0]), V3) / f.q.length }))
|
||||
.sort((x, y) => x.z - y.z).map(f => {
|
||||
const c = `rgb(${gold(Math.min(1, .18 + .82 * Math.max(0, dot(f.n, L))))})`
|
||||
return `<polygon points="${f.q.map(p => scr(p).map(f1).join(',')).join(' ')}" fill="${c}" stroke="${c}" stroke-width=".6" stroke-linejoin="round"/>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function svg({ repo, born, stars }, now) {
|
||||
const [owner, name] = repo.split('/'), total = stars.length
|
||||
const x0 = 84, x1 = W - 76, y0 = 128, y1 = 404
|
||||
const step = nice(Math.max(total, 4) * 1.15 / 4), yMax = Math.ceil(Math.max(total, 4) * 1.15 / step) * step
|
||||
const b0 = Math.floor(born / DAY) * DAY, X = t => x0 + (t - b0) / (now - b0) * (x1 - x0), Y = v => y1 - v / yMax * (y1 - y0)
|
||||
// Step through long gaps, ramp through dense runs; thin out huge repos
|
||||
const gap = (now - b0) / 80, every = Math.ceil(total / 1500), pts = [[b0, 0]]
|
||||
stars.forEach((t, i) => (i + 1) % every && i < total - 1 || (t - pts.at(-1)[0] > gap && pts.push([t, pts.at(-1)[1]]), pts.push([t, i + 1])))
|
||||
pts.push([now, total])
|
||||
const line = pts.map(([t, v], i) => `${i ? 'L' : 'M'}${f1(X(t))},${f1(Y(v))}`).join('')
|
||||
// Labels sit just above the curve across their own width (it only rises, so its max is at the right edge),
|
||||
// trying left of / centred on / right of the point; lifted labels get a leader line; skip on collision
|
||||
const S = 26, ex = X(now), ey = Math.min(Y(total), y1 - S), boxes = [[ex - S - 6, ey - S - 6, ex + S + 6, ey + S + 6]], notes = []
|
||||
const upto = t => { let lo = 0, hi = total; while (lo < hi) { const m = lo + hi >> 1; stars[m] <= t ? lo = m + 1 : hi = m } return lo }
|
||||
const curveY = x => Y(upto(b0 + (Math.min(x, x1) - x0) / (x1 - x0) * (now - b0)))
|
||||
const hit = b => b[0] < x0 + 4 || b[2] > W - 30 || b[1] < y0 - 24 || boxes.some(o => b[0] < o[2] && b[2] > o[0] && b[1] < o[3] && b[3] > o[1])
|
||||
for (const m of markers({ born, stars }, now)) {
|
||||
if (notes.length >= 4) break
|
||||
const x = X(m.t), y = Y(m.v), w = Math.max(m.title.length * 13 * .6, m.sub.length * 12 * .62)
|
||||
const spot = [['end', x - 8 - w], ['middle', x - w / 2], ['start', x + 8]]
|
||||
.map(([a, bx], _, __, top = Math.min(y - 12, curveY(bx + w) - 10) - 36) => [bx, top, bx + w, top + 36, a]).find(b => !hit(b))
|
||||
if (!spot) continue
|
||||
boxes.push(spot)
|
||||
const tx = spot[4] == 'end' ? spot[2] : spot[4] == 'start' ? spot[0] : x
|
||||
notes.push(`${spot[3] < y - 18 ? `<line x1="${f1(x)}" y1="${f1(y - 6)}" x2="${f1(x)}" y2="${f1(spot[4] == 'middle' ? spot[3] + 2 : spot[1] + 4)}" class="lead"/>` : ''}<circle cx="${f1(x)}" cy="${f1(y)}" r="4.5" class="pt"/>
|
||||
<text x="${f1(tx)}" y="${f1(spot[1] + 14)}" class="fg" font-size="13" font-weight="600" text-anchor="${spot[4]}">${esc(m.title)}</text>
|
||||
<text x="${f1(tx)}" y="${f1(spot[1] + 31)}" class="m dim" font-size="12" text-anchor="${spot[4]}">${esc(m.sub)}</text>`)
|
||||
}
|
||||
const yt = [...Array(yMax / step + 1)].map((_, i) => i * step)
|
||||
const xt = ticks(b0, now).filter(t => X(t) - x0 > 140 && x1 - X(t) > 140)
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" data-key="${key(stars, now)}">
|
||||
<style>
|
||||
text{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Helvetica Neue',Inter,Arial,'DejaVu Sans',sans-serif}
|
||||
.m{font-family:ui-monospace,'SF Mono','JetBrains Mono',Menlo,Consolas,'DejaVu Sans Mono',monospace;font-variant-numeric:tabular-nums}
|
||||
.bg{fill:#fff;stroke:#d0d7de}.fg{fill:#1f2328}.dim{fill:#656d76}.grid{stroke:#eaeef2}.axis{stroke:#d0d7de}
|
||||
.ln{stroke:#bf8700}.g0{stop-color:#d4a72c;stop-opacity:.3}.g1{stop-color:#d4a72c;stop-opacity:0}.pt{fill:#fff;stroke:#bf8700;stroke-width:2.5}
|
||||
.cap{letter-spacing:.14em}.lead{stroke:#8c959f;stroke-width:1;stroke-dasharray:2 3}
|
||||
</style>
|
||||
<defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" class="g0"/><stop offset="1" class="g1"/></linearGradient></defs>
|
||||
<rect class="bg" x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="14"/>
|
||||
<text x="${x0 - 28}" y="62" font-size="24"><tspan class="dim">${esc(owner)} / </tspan><tspan class="fg" font-weight="700">${esc(name)}</tspan></text>
|
||||
<text x="${x0 - 28}" y="90" class="dim" font-size="14">Star history · gh.direct-img.link</text>
|
||||
<text x="${W - 48}" y="66" text-anchor="end"><tspan class="fg" font-size="34" font-weight="700">${fmt(total)}</tspan><tspan class="dim" font-size="16"> stars</tspan></text>
|
||||
${yt.map(v => `<line x1="${x0}" y1="${f1(Y(v))}" x2="${x1}" y2="${f1(Y(v))}" class="${v ? 'grid' : 'axis'}"/>
|
||||
<text x="${x0 - 14}" y="${f1(Y(v) + 4.5)}" class="m dim" font-size="13" text-anchor="end">${fmt(v)}</text>`).join('\n')}
|
||||
${[[b0, 'start', 'BORN'], ...xt.map(t => [t, 'middle']), [now, 'end', 'TODAY']].map(([t, a, cap]) => `<line x1="${f1(X(t))}" y1="${y1}" x2="${f1(X(t))}" y2="${y1 + 6}" class="axis"/>
|
||||
<text x="${f1(X(t))}" y="${y1 + 26}" class="m dim" font-size="13" text-anchor="${a}">${date(t)}</text>${cap ? `
|
||||
<text x="${f1(X(t))}" y="${y1 + 46}" class="dim cap" font-size="10" text-anchor="${a}">${cap}</text>` : ''}`).join('\n')}
|
||||
<path d="${line}L${x1},${y1}L${x0},${y1}Z" fill="url(#a)"/>
|
||||
<path d="${line}" fill="none" class="ln" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${notes.join('\n')}
|
||||
${isoStar(ex, ey, S)}
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
// Only redraw when the star count or the UTC day changes, so the hourly job doesn't commit (and deploy) every run
|
||||
const key = (stars, now) => `${stars.length}@${date(now)}`
|
||||
|
||||
export { svg, markers }
|
||||
if (import.meta.url == pathToFileURL(process.argv[1]).href) {
|
||||
const now = Date.now()
|
||||
for (const repo of (process.argv[2] ? process.argv.slice(2) : readFileSync('repos.txt', 'utf8').split('\n')).map(s => s.trim()).filter(Boolean)) {
|
||||
try {
|
||||
const d = await load(repo), out = `dist/${d.repo}.svg`
|
||||
if (existsSync(out) && readFileSync(out, 'utf8').includes(`data-key="${key(d.stars, now)}"`)) { console.log(out, 'unchanged'); continue }
|
||||
mkdirSync(out.replace(/\/[^/]+$/, ''), { recursive: true })
|
||||
writeFileSync(out, svg(d, now))
|
||||
console.log(out)
|
||||
} catch (e) { console.error(repo, e.message), process.exitCode = 1 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user