import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; // THE GROWTH CHART'S LAYERS AND SURFACE GAPS, worked out (ArchiveGrowthChart.tsx // draws them). Pure: no React, no DOM, so a unit test can check every layer and // every segment. // // THE FOLD. A site under FOLD_PERCENT % of the placed total — every site's // transcripts over the whole plotted range, the sum the bands are placed from // — is drawn in ONE "Other" band on top of the stack, in the chart's neutral // grey (OTHER_COLOR). Exactly FOLD_PERCENT % keeps its band; the comparison is // on integers (100 × a site's sum < FOLD_PERCENT × the total), so the edge is // exact. A fold of one is no fold: with a single site under the line, every // site keeps its band. The kept sites keep their colours: each is its // siteChartColors slot over EVERY site, folded or not, so a site's colour never // changes because another one folded. The legend shows the kept sites and // Other; the hover title and the "Numbers by year" table still name every site. // // THE GAPS. The marks spec parts touching bands by a 2 px gap in the colour // behind the plot. The chart draws it centred on each band's upper edge, so // each of the two bands gives 1 px. A band too thin to give its pixel and keep // one of its own colour must touch its neighbour instead, or the gap erases // it. "Thin" is measured AT RIGHT ANGLES to the edge, where the stroke's 2 px // are measured: on a steep edge a band's perpendicular thickness is its // vertical height × cos θ, and on a one-month dip at a phone's width it is // nearly nothing. And it is measured at the NARROWEST plot each of the chart's // three heights is drawn at, where the edges are steepest. A gap is drawn along // a segment only where both bands keep at least MIN_KEEP_PX there after every // gap that touches them. The gaps work on bands, not sites: Other is one band, // the top one, so the gap under it parts it from the kept site beneath (the // highest with a height that month), and its own upper edge, where nothing // sits, has none. export const GAP_PX = 2; export const MIN_KEEP_PX = 1; // A band that gives half a gap on each of its edges gives GAP_PX in all. const MIN_BAND_PX = GAP_PX + MIN_KEEP_PX; // A run of gap shorter than this many months is dropped: a speck of gap reads // as noise, not as a parting. export const MIN_GAP_RUN = 3; // The plot's three heights (the box is `h-[200px] sm:h-[260px] lg:h-[300px]`), // the narrowest plot width each is drawn at (the narrowest viewport of its // breakpoint less the container's padding: 280 − 2 × 20, 640 − 2 × 24, // 1024 − 2 × 24), and the class that shows its set of gaps. export const PLOT_SIZES = [ { px: 200, minWidth: 240, className: "sm:hidden" }, { px: 260, minWidth: 592, className: "hidden sm:inline lg:hidden" }, { px: 300, minWidth: 976, className: "hidden lg:inline" }, ] as const; export type PlotSize = (typeof PLOT_SIZES)[number]; // The stack, bottom-up: each band's lower and upper value per month. export type Band = { lo: readonly number[]; hi: readonly number[] }; type Month = { bySite: Record }; // The layers stack in the summary's order (the first five as they come, any // more after them). const STACK_ORDER = [0, 1, 2, 3, 4]; 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]; } // ── The fold ────────────────────────────────────────────────────────────────── export const FOLD_PERCENT = 5; export const OTHER_LABEL = "Other"; // Other's own chart token (tokens.css `--chart-other`), a near-neutral grey: // 7.36:1 on the Light ground and 3.22:1 on the Dark one (7.99 / 3.06 on the // chart surface). tokens.css has its separation from each chart slot. export const OTHER_COLOR = "var(--chart-other)"; // One band of the stack: a site's own (`sites` is its index in the summary's // list), or Other (the indexes of every folded site, in the list's order). // `key` is the site's id, or OTHER_KEY, which no site id can be (an id is // `[a-z0-9][a-z0-9-]*`). export type GrowthLayer = { key: string; sites: readonly number[]; other: boolean }; export const OTHER_KEY = "(other)"; // Each site's transcripts over the whole plotted range. function siteSums(months: readonly Month[], sites: readonly { siteId: string }[]): number[] { return sites.map((s) => months.reduce((a, m) => a + (m.bySite[s.siteId] ?? 0), 0)); } // The sites folded into Other, as indexes into `sites` in its order: every // site under FOLD_PERCENT % of the placed total, when there are two or more of // them; none otherwise. export function foldedSites(months: readonly Month[], sites: readonly { siteId: string }[]): number[] { const sums = siteSums(months, sites); const all = sums.reduce((a, b) => a + b, 0); if (all <= 0) return []; const small = sums.flatMap((v, i) => (100 * v < FOLD_PERCENT * all ? [i] : [])); return small.length >= 2 ? small : []; } // Every site its own band, in stack order: the chart before the fold. export function ownLayers(sites: readonly { siteId: string }[]): GrowthLayer[] { return stackOrder(sites.length).map((i) => ({ key: sites[i].siteId, sites: [i], other: false })); } // The chart's bands, bottom-up: the kept sites in stack order, then Other on // top when anything folds. export function growthLayers( months: readonly Month[], sites: readonly { siteId: string }[], ): GrowthLayer[] { const folded = foldedSites(months, sites); if (folded.length === 0) return ownLayers(sites); const out = new Set(folded); return [ ...ownLayers(sites).filter((l) => !out.has(l.sites[0])), { key: OTHER_KEY, sites: folded, other: true }, ]; } // Each layer's colour: a kept site its siteChartColors slot over EVERY site // (so folding never repaints it), Other the neutral grey. export function layerColors( layers: readonly GrowthLayer[], sites: readonly { accentId?: string }[], ): string[] { const chart = siteChartColors(sites); return layers.map((l) => (l.other ? OTHER_COLOR : chart[l.sites[0]])); } // 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; } // ── The stack ───────────────────────────────────────────────────────────────── // The chart's numbers: each month's total (every site, folded or not), the // peak, the value scale (yMax and its gridlines), and one band per layer, // bottom-up (`stack[k]` is `layers[k]`'s; a layer's value in a month is the sum // of its sites'). Without `layers`, every site is its own band. export function growthStack( months: readonly Month[], sites: readonly { siteId: string }[], layers: readonly GrowthLayer[] = ownLayers(sites), ) { const n = months.length; const totals = months.map((m) => sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0)); const peak = totals.length ? Math.max(...totals) : 0; const step = peak > 0 ? niceStep(peak) : 1; const yMax = Math.ceil(peak / step) * step; const ticks: number[] = []; if (peak > 0) for (let t = step; t <= yMax; t += step) ticks.push(t); const base = new Array(n).fill(0); const stack: Band[] = layers.map((layer) => { const lo = base.slice(); const hi = months.map( (m, i) => (base[i] += layer.sites.reduce((a, si) => a + (m.bySite[sites[si].siteId] ?? 0), 0)), ); return { lo, hi }; }); return { totals, peak, yMax, ticks, layers, stack }; } // ── The gaps ────────────────────────────────────────────────────────────────── const height = (b: Band, i: number) => b.hi[i] - b.lo[i]; // The band a gap along band k's upper edge would share at month i: the next // band up with a height there (a band of height 0 lies on the edge), or none — // the stack's top meets the surface itself. export function bandAbove(stack: readonly Band[], k: number, i: number): number | null { for (let m = k + 1; m < stack.length; m++) if (height(stack[m], i) > 0) return m; return null; } // A band's thickness at right angles to band k's upper edge over the segment // i → i + 1, in px at `size`: the smaller of its two vertical heights there // × cos θ of the edge. function perpendicular( stack: readonly Band[], k: number, band: number, i: number, yMax: number, size: PlotSize, n: number, ): number { const sy = size.px / yMax; const dx = size.minWidth / Math.max(1, n - 1); const dy = (stack[k].hi[i + 1] - stack[k].hi[i]) * sy; const cos = dx / Math.hypot(dx, dy); return Math.min(height(stack[band], i), height(stack[band], i + 1)) * sy * cos; } // For each band, the month segments (their start index i, for i → i + 1) // along its upper edge where a gap is drawn at `size`. export function gapSegments(stack: readonly Band[], yMax: number, size: PlotSize): number[][] { const n = stack[0]?.hi.length ?? 0; return stack.map((band, k) => { const parted = (i: number) => { if (height(band, i) <= 0 || height(band, i + 1) <= 0) return false; const up = bandAbove(stack, k, i); if (up === null || up !== bandAbove(stack, k, i + 1)) return false; return ( perpendicular(stack, k, k, i, yMax, size, n) >= MIN_BAND_PX && perpendicular(stack, k, up, i, yMax, size, n) >= MIN_BAND_PX ); }; const out: number[] = []; let run: number[] = []; const close = () => { if (run.length >= MIN_GAP_RUN) out.push(...run); run = []; }; for (let i = 0; i < n - 1; i++) { if (parted(i)) run.push(i); else close(); } close(); return out; }); } // The segments grouped into runs of consecutive months, for drawing each run // as one polyline. export function runsOf(segments: readonly number[]): number[][] { const runs: number[][] = []; for (const i of segments) { const last = runs.at(-1); if (last && last.at(-1) === i - 1) last.push(i); else runs.push([i]); } return runs; }