// Star-history chart for each repo in repos.txt (or argv) → dist/stars//.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 `` }).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 ? `` : ''} ${esc(m.title)} ${esc(m.sub)}`) } 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 ` ${esc(owner)} / ${esc(name)} Star history ${fmt(total)} stars ${yt.map(v => ` ${fmt(v)}`).join('\n')} ${[[b0, 'start', 'BORN'], ...xt.map(t => [t, 'middle']), [now, 'end', 'TODAY']].map(([t, a, cap]) => ` ${date(t)}${cap ? ` ${cap}` : ''}`).join('\n')} ${notes.join('\n')} ${isoStar(ex, ey, S)} gh.direct-img.link ` } // 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 } } }