From 71d4360c3c539aa6afaeb651ea69b308fe21f7db Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Wed, 28 Oct 2026 01:40:41 +0000 Subject: [PATCH] Add star history chart, hourly workflow and index page stars.mjs renders dist//.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 --- .github/workflows/stars.yml | 24 +++ .gitignore | 3 + dist/direct-img/vibegif.direct-img.link.svg | 52 ++++++ dist/index.html | 16 ++ preview/render.sh | 4 + preview/sim.mjs | 24 +++ stars.mjs | 185 ++++++++++++++++++++ 7 files changed, 308 insertions(+) create mode 100644 .github/workflows/stars.yml create mode 100644 .gitignore create mode 100644 dist/direct-img/vibegif.direct-img.link.svg create mode 100644 dist/index.html create mode 100755 preview/render.sh create mode 100644 preview/sim.mjs create mode 100644 stars.mjs diff --git a/.github/workflows/stars.yml b/.github/workflows/stars.yml new file mode 100644 index 0000000..f06dcc0 --- /dev/null +++ b/.github/workflows/stars.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c85c0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +preview/* +!preview/render.sh +!preview/sim.mjs diff --git a/dist/direct-img/vibegif.direct-img.link.svg b/dist/direct-img/vibegif.direct-img.link.svg new file mode 100644 index 0000000..c25dd15 --- /dev/null +++ b/dist/direct-img/vibegif.direct-img.link.svg @@ -0,0 +1,52 @@ + + + + +direct-img / vibegif.direct-img.link +Star history · gh.direct-img.link +57 stars + +0 + +20 + +40 + +60 + +80 + +2026.10.22 +BORN + +2026.10.23 + +2026.10.24 + +2026.10.25 + +2026.10.26 + +2026.10.27 + +2026.10.28 +TODAY + + + +Biggest spike +31 +2026.10.23 · 2 days + +First star +2026.10.23 + +Spike +19 +2026.10.27 + + diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..5942fbb --- /dev/null +++ b/dist/index.html @@ -0,0 +1,16 @@ + + + + +gh.direct-img.link + +
+locked vault +

gh.direct-img.link

+

GitHub star history charts. This tool is private for now.

+
diff --git a/preview/render.sh b/preview/render.sh new file mode 100755 index 0000000..6d120a1 --- /dev/null +++ b/preview/render.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# render.sh → preview/.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 diff --git a/preview/sim.mjs b/preview/sim.mjs new file mode 100644 index 0000000..2582c58 --- /dev/null +++ b/preview/sim.mjs @@ -0,0 +1,24 @@ +// Synthetic star histories → preview/sim-.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(" | ")) +} diff --git a/stars.mjs b/stars.mjs new file mode 100644 index 0000000..5a8a4c8 --- /dev/null +++ b/stars.mjs @@ -0,0 +1,185 @@ +// 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 · gh.direct-img.link +${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)} + +` +} + +// 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 } + } +}