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>();
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 (
{layers.map((l) => (
{l.label}
))}
{/* Value scale: the number sits on its gridline, left edge. */}
{ticks.map((t) => (
{t.toLocaleString()}
))}
{years.map((t) => (
{t.year}
))}
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
Year
{sites.map((s) => (
{s.siteTitle}
))}
Total
{[...byYear.entries()].map(([yr, row]) => (
{yr}
{sites.map((s) => (
{(row[s.siteId] ?? 0).toLocaleString()}
))}
{Object.values(row).reduce((a, b) => a + b, 0).toLocaleString()}