Files
gh.direct-img.link/stars.mjs
T
2026-10-28 14:19:47 +00:00

187 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}.wm{fill:#e3e7eb}.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</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)}
<text x="18" y="${H - 14}" class="wm" font-size="10">gh.direct-img.link</text>
</svg>
`
}
// Only redraw when the star count or the UTC day changes, so the scheduled 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 }
}
}