import type { HomepageMonth, HomepageSummarySite, } from "yt-dlp-transcript-common/lib/homepageSummary"; import { monthLabel } from "yt-dlp-transcript-common/lib/homepageChart"; import { FOLD_PERCENT, OTHER_LABEL, PLOT_SIZES, gapSegments, growthLayers, growthStack, layerColors, runsOf, } from "../lib/growthGaps"; // The front page's showpiece: every official instance's back catalogue as // stacked strata, one month per step, from the oldest upload to the last // complete month (the build month is partial, so the summary never emits it). // // 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 , 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: siteChartColors // (common/lib/siteColor.ts) gives each site a slot of the per-base --chart-1..6 // palette (tokens.css: blue, green, violet, amber, magenta, rust) by the hue // family of its accent — blue → blue, green → green, violet → violet, brass → // amber, sakura → magenta, vermilion → rust — so a layer shares its hue with // the instance card below (ArchiveCards, the accent's own value). A site with // no family (Signal, a custom hex, no accent) takes seriesColor(i), and no two // layers ever share a colour; up to six sites wear only the validated slots. // The legend above the plot wears the same colours. The layers stack in the // summary's order (STACK_ORDER, unchanged). // // OTHER (release 14, slice CF; lib/growthGaps.ts). Two or more sites each // under 5 % of the chart's total fold into ONE band, "Other", on top of the // stack. The kept sites keep their slots — siteChartColors runs over EVERY // site, so a fold never repaints one — and Other wears its own near-neutral // grey, `--chart-other` (tokens.css: Light #3e545c, Dark #62625c; 7.36 / // 3.22:1 on the ground, 7.99 / 3.06:1 on the chart surface), not the axis // labels' colour. The legend shows the kept sites and Other; the hover title // and the table name every site, and the image's label and the caption say // what Other holds. A grey is below the validator's chroma floor by // definition (it is the de-emphasis role, not a categorical slot); against // each slot it can sit on, Other is (normal ΔE / worst of protan and deutan, // light · dark): // blue 21.9 / 21.4 · 17.8 / 17.9 — the only neighbour in today's data // (Bonnellyzer, the top kept site in every month Other has data); // green 17.4 / 15.2 · 19.2 / 15.7; violet 16.2 / 13.4 · 23.4 / 21.5; // amber 22.1 / 18.2 · 20.3 / 18.1; magenta 24.5 / 9.1 · 19.5 / 7.3; // rust 14.1 / 10.5 · 13.5 / 9.3. // Every CVD pair clears the floor (6), and on Light the target (8); Dark's // magenta is in the 6–8 band and rust is under the normal-vision 15 on both // bases: there the gap between the bands, Other's place on top, the legend // and the table carry it. // // VALIDATED (the dataviz skill's validator, each base's values on its // --chart-surface and on the page ground), with the operator's accents // (Jeralyzer Brass, Anilyzer Sakura, Bonnellyzer Blue, Hasanalyzer Violet, // Rekietalyzer Green; Jasolyzer Vermilion once it is published): // • adjacent — the stack — in today's order (Jeralyzer, Anilyzer, // Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on both bases, worst CVD // ΔE 13.1 / 11.0 and normal 17.7 / 16.7 (light / dark; today's mapping — // release 10's slice MC, re-run in release 11); // with Jasolyzer at any place in it: PASS, worst CVD // 12.5 / 11.0, normal 16.6 / 16.3 (release 11); // • all pairs, all six: the palette's own borderline in any order — CVD 6.2 // / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with the // legend, the surface-gap edges and the table), normal ≥ 15.3. The sixth // slot adds no pair under the target (measured on three bases, before the // third was retired): its worst normal pair is 16.3 (dark, against // amber). // The accents' own values fail as a chart palette (Brass↔Vermilion ΔE 1.0 // deutan, Blue↔Violet 8.5 normal), which is why the chart wears their hue // families rather than the accents. const W = 1000; const H = 300; export function ArchiveGrowthChart({ months, sites, }: { months: HomepageMonth[]; sites: HomepageSummarySite[]; }) { const n = months.length; if (n === 0 || sites.length === 0) return null; // The bands, bottom-up: the kept sites, then Other when two or more fold. const { totals, peak, yMax, ticks, layers: bands, stack: stacked, } = growthStack(months, sites, growthLayers(months, sites)); if (peak === 0) return null; 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; // One colour per band, in stack order (the legend's too). const colors = layerColors(bands, sites); const folded = bands.find((b) => b.other)?.sites.map((si) => sites[si].siteTitle) ?? []; const layers = stacked.map(({ lo, hi }, k) => { 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 { key: bands[k].key, label: bands[k].other ? OTHER_LABEL : sites[bands[k].sites[0]].siteTitle, color: colors[k], top, area: `M${top.join("L")}L${bottom.join("L")}Z`, }; }); // The gaps, one set per plot height (lib/growthGaps.ts says where a gap is // drawn, and why a thin band gets none). const gapSets = PLOT_SIZES.map((size) => { const segments = gapSegments(stacked, yMax, size); const paths = layers.map((l, k) => ({ key: l.key, d: runsOf(segments[k]) .map((run) => `M${[...run, run.at(-1)! + 1].map((i) => l.top[i]).join("L")}`) .join(""), })); return { px: size.px, className: size.className, paths: paths.filter((p) => p.d) }; }); // 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); // The legend is hidden from assistive tech, so the image's label says what // Other holds; the caption says what it is. const otherNote = folded.length ? `${folded.slice(0, -1).join(", ")} and ${folded.at(-1)}, each under ` + `${FOLD_PERCENT}% of the total, are drawn together as ${OTHER_LABEL}.` : ""; 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()}.` + (otherNote ? ` ${otherNote}` : ""); // 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"> {layers.map((l) => ( <li key={l.key} 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: l.color }} /> {l.label} </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.key} d={l.area} fill={l.color} /> ))} {/* THE SURFACE GAP (the marks spec): touching bands are parted by a 2 px gap in the colour behind the plot — the page ground, as the chart sits on it — never by a line of their own. It runs along each band's upper edge, centred, so each neighbour gives 1 px. Where nothing sits on a band (the stack's top meets the surface itself), or where either band is too thin, measured at right angles to the edge at the narrowest plot of that height, to give its pixel and keep one of its own colour (one set of gaps per height, shown by its class; lib/growthGaps.ts), there is no gap: thin bands touch rather than vanish. Colour and width are `.growth-gap` in globals.css (Canvas in forced colours), so they follow the theme with no script. */} {gapSets.map((set) => ( <g key={set.px} data-plot-height={set.px} className={set.className}> {set.paths.map((p) => ( <path key={p.key} d={p.d} className="growth-gap" fill="none" strokeLinejoin="round" vectorEffect="non-scaling-stroke" /> ))} </g> ))} {months.map((m, i) => { const w = W / n; // Every site with data that month, by name — a folded one too: // the title and the table are where Other's sites are found. 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")}` : "")} ); })} {/* Value scale: the number sits on its gridline, left edge. */} {ticks.map((t) => ( ))} {years.map((t) => ( ))}
Transcripts by the month each video was published, all official instances. {folded.length > 0 && ` Instances under ${FOLD_PERCENT}% of the total are drawn together as ${OTHER_LABEL}.`}
Numbers by year
{sites.map((s) => ( ))} {[...byYear.entries()].map(([yr, row]) => ( {sites.map((s) => ( ))} ))}
Year {s.siteTitle} Total
{yr} {(row[s.siteId] ?? 0).toLocaleString()} {Object.values(row).reduce((a, b) => a + b, 0).toLocaleString()}
); }