mirror of
https://github.com/direct-img/gh.direct-img.link.git
synced 2026-10-29 00:54:51 +00:00
187 lines
13 KiB
JavaScript
187 lines
13 KiB
JavaScript
// 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 }
|
||
}
|
||
}
|