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:
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);
+}