Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 7f1b762f28b77c2a6cdb2738d1c06e417a80c559
parent 92841d1f0c314c32befd885c6093c96d1e1dba48
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 16:18:11 -0400

homepage: ArchiveGrowthChart — the official instances' back catalogue as stacked monthly strata, inline SVG at build time

Server component, no client JS: a fixed-height box with a stretched SVG plot
(non-scaling hairlines) and HTML axis labels, native <title> tooltips per
month, role=img with a sentence label, a per-year table behind a details.
Colour is seriesColor(i) per instance; the stack is interleaved (0,2,1,4,3)
so the palette's close teal/blue pair never share an edge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Ahomepage/app/components/ArchiveGrowthChart.tsx | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/globals.css | 9+++++++++
2 files changed, 270 insertions(+), 0 deletions(-)

diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx @@ -0,0 +1,261 @@ +import type { + HomepageMonth, + HomepageSummarySite, +} from "yt-dlp-transcript-common/lib/homepageSummary"; +import { + monthLabel, + seriesColor, +} from "yt-dlp-transcript-common/lib/homepageChart"; + +// The front page's showpiece: every official instance's back catalogue as +// stacked strata, one month per step, from the oldest upload to this month. +// +// BUILD-TIME ONLY. A server component that renders inline SVG from the summary; +// no chart library and no client JS ships on `/`. The hover layer is native: +// each month is a transparent column carrying a <title>, so a pointer gets a +// tooltip and the column tints, with nothing to hydrate. +// +// GEOMETRY. The plot is an SVG with preserveAspectRatio="none" inside a box of +// fixed CSS height, so it fills any width without a layout shift. Nothing in the +// SVG is text (text would stretch); the axis labels are HTML positioned in +// percent over the same box. Strokes use non-scaling-stroke so a hairline stays +// a hairline at every width. +// +// WHY AN AREA, not bars. ~200 months across ~1,100 px is ~5 px a month; a bar +// with the 2 px gap the marks spec asks for would be half gap. At this density +// the stack reads as strata, which is what it is. +// +// COLOUR follows the instance, never its rank on this chart: seriesColor(i) with +// i = the site's position in `sites`, the same index the instance cards use. +// The STACK order is different on purpose: the family palette's first two +// slots (teal, blue) are too close to share an edge, so the layers are +// interleaved (0, 2, 1, 4, 3) to keep every touching pair distinguishable. + +const W = 1000; +const H = 300; +const STACK_ORDER = [0, 2, 1, 4, 3]; + +function stackOrder(n: number): number[] { + const head = STACK_ORDER.filter((i) => i < n); + const tail = Array.from({ length: Math.max(0, n - 5) }, (_, k) => k + 5); + return [...head, ...tail]; +} + +// A clean tick step giving three or four gridlines under `max`. +function niceStep(max: number): number { + const raw = max / 3.5; + const pow = 10 ** Math.floor(Math.log10(raw)); + for (const m of [1, 2, 2.5, 5, 10]) { + if (m * pow >= raw) return m * pow; + } + return 10 * pow; +} + +export function ArchiveGrowthChart({ + months, + sites, +}: { + months: HomepageMonth[]; + sites: HomepageSummarySite[]; +}) { + const n = months.length; + if (n === 0 || sites.length === 0) return null; + + const totals = months.map((m) => + sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0), + ); + const peak = Math.max(...totals); + if (peak === 0) return null; + const step = niceStep(peak); + const yMax = Math.ceil(peak / step) * step; + const ticks: number[] = []; + for (let t = step; t <= yMax; t += step) ticks.push(t); + + const x = (i: number) => (n === 1 ? W / 2 : (i / (n - 1)) * W); + const y = (v: number) => H - (v / yMax) * H; + const r = (v: number) => Math.round(v * 10) / 10; + + // Layers bottom-up, each with its lower and upper edge per month. + const order = stackOrder(sites.length); + const base = new Array<number>(n).fill(0); + const layers = order.map((si) => { + const site = sites[si]; + const lo = base.slice(); + const hi = months.map((m, i) => (base[i] += m.bySite[site.siteId] ?? 0)); + const top = hi.map((v, i) => `${r(x(i))},${r(y(v))}`); + const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse(); + return { + site, + color: seriesColor(si), + area: `M${top.join("L")}L${bottom.join("L")}Z`, + edge: `M${top.join("L")}`, + }; + }); + + // Year ticks at each January. Every second year from sm up, every fourth on a + // phone; the ends are skipped so a label never hangs off the plot. + const years = months + .map((m, i) => ({ year: Number(m.month.slice(0, 4)), i, jan: m.month.endsWith("-01") })) + .filter((t) => t.jan && x(t.i) / W > 0.03 && x(t.i) / W < 0.97); + + const first = monthLabel(months[0].month); + const last = monthLabel(months[n - 1].month); + const peakIdx = totals.indexOf(peak); + const all = totals.reduce((a, b) => a + b, 0); + const label = + `Stacked area chart of ${all.toLocaleString()} transcripts by the month each ` + + `video was published, ${first} to ${last}, across ${sites.length} official ` + + `instances. The busiest month is ${monthLabel(months[peakIdx].month)}, ` + + `with ${peak.toLocaleString()}.`; + + // Per-year table for anyone who wants the numbers rather than the shape. + const byYear = new Map<string, Record<string, number>>(); + for (const m of months) { + const row = byYear.get(m.month.slice(0, 4)) ?? {}; + for (const s of sites) row[s.siteId] = (row[s.siteId] ?? 0) + (m.bySite[s.siteId] ?? 0); + byYear.set(m.month.slice(0, 4), row); + } + + return ( + <figure className="flex flex-col gap-4"> + <ul className="flex flex-wrap gap-x-5 gap-y-2 list-none" aria-hidden="true"> + {sites.map((s, i) => ( + <li key={s.siteId} className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]"> + <span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(i) }} /> + {s.siteTitle} + </li> + ))} + </ul> + + <div className="relative mt-4 pb-7"> + <div + role="img" + aria-label={label} + className="relative h-[200px] sm:h-[260px] lg:h-[300px] border-b border-[var(--border-strong)]" + > + <svg + viewBox={`0 0 ${W} ${H}`} + preserveAspectRatio="none" + className="absolute inset-0 h-full w-full overflow-visible" + aria-hidden="true" + > + {ticks.map((t) => ( + <line + key={t} + x1={0} + x2={W} + y1={y(t)} + y2={y(t)} + stroke="var(--chart-grid)" + strokeWidth={1} + vectorEffect="non-scaling-stroke" + /> + ))} + {layers.map((l) => ( + <path key={l.site.siteId} d={l.area} fill={l.color} /> + ))} + {/* The surface gap: each layer's upper edge is drawn in the page + colour, so touching strata separate by negative space. */} + {layers.map((l) => ( + <path + key={`${l.site.siteId}-edge`} + d={l.edge} + fill="none" + stroke="var(--background)" + strokeWidth={1.25} + strokeLinejoin="round" + vectorEffect="non-scaling-stroke" + /> + ))} + {months.map((m, i) => { + const w = W / n; + const parts = sites + .map((s) => [s.siteTitle, m.bySite[s.siteId] ?? 0] as const) + .filter(([, v]) => v > 0) + .map(([t, v]) => `${t} ${v.toLocaleString()}`); + return ( + <rect + key={m.month} + className="growth-hit" + x={Math.max(0, x(i) - w / 2)} + y={0} + width={w} + height={H} + > + <title> + {`${monthLabel(m.month)}: ${totals[i].toLocaleString()} transcripts` + + (parts.length ? `\n${parts.join("\n")}` : "")} + </title> + </rect> + ); + })} + </svg> + + {/* Value scale: the number sits on its gridline, left edge. */} + {ticks.map((t) => ( + <span + key={t} + aria-hidden="true" + className="tabular pointer-events-none absolute left-0 -translate-y-full pb-1 text-[0.6875rem] leading-none text-[var(--chart-axis)]" + style={{ top: `${(1 - t / yMax) * 100}%` }} + > + {t.toLocaleString()} + </span> + ))} + </div> + + {years.map((t) => ( + <span + key={t.year} + aria-hidden="true" + className={`tabular absolute bottom-0 -translate-x-1/2 text-[0.6875rem] leading-none text-[var(--chart-axis)] ${ + t.year % 4 === 0 ? "" : t.year % 2 === 0 ? "hidden sm:block" : "hidden" + }`} + style={{ left: `${(x(t.i) / W) * 100}%` }} + > + {t.year} + </span> + ))} + </div> + + <figcaption className="text-sm text-[var(--muted-foreground)]"> + Transcripts by the month each video was published, all official instances. + </figcaption> + <details className="text-sm"> + <summary className="cursor-pointer text-[var(--muted-foreground)] underline decoration-[var(--border-strong)] underline-offset-4 hover:text-[var(--foreground)]"> + Numbers by year + </summary> + <div className="mt-3 max-w-full overflow-x-auto"> + <table className="tabular w-full text-xs"> + <thead> + <tr className="text-[var(--faint)]"> + <th className="py-1 pr-4 text-left font-normal">Year</th> + {sites.map((s) => ( + <th key={s.siteId} className="py-1 pr-4 text-right font-normal"> + {s.siteTitle} + </th> + ))} + <th className="py-1 text-right font-normal">Total</th> + </tr> + </thead> + <tbody> + {[...byYear.entries()].map(([yr, row]) => ( + <tr key={yr} className="border-t border-[var(--border)] text-[var(--foreground)]"> + <td className="py-1 pr-4">{yr}</td> + {sites.map((s) => ( + <td key={s.siteId} className="py-1 pr-4 text-right"> + {(row[s.siteId] ?? 0).toLocaleString()} + </td> + ))} + <td className="py-1 text-right"> + {Object.values(row).reduce((a, b) => a + b, 0).toLocaleString()} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </details> + </figure> + ); +} diff --git a/homepage/app/globals.css b/homepage/app/globals.css @@ -95,3 +95,12 @@ body { .doc-measure { max-width: 68ch; } + +/* The growth chart's hover layer: one transparent column per month, tinted + under the pointer. Native <title> tooltips, no client JS. */ +.growth-hit { + fill: transparent; +} +.growth-hit:hover { + fill: var(--chart-grid); +}