Archilyzer · Source

archilyzer

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

commit 3601780504a245c737acb81cba4e426ecc678a8b
parent 70d18eb9a9c87a7161156908ac4b11e278725851
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 22:00:33 -0400

homepage: the growth chart folds sites under 5 % of its total into one Other band on top

A site under 5 % of the chart's placed total (every site's transcripts over
the whole plotted range) is drawn in one "Other" band, on top of the stack,
in the chart's neutral grey (`--chart-axis`). Exactly 5 % keeps its band;
a fold of one is no fold. The kept sites keep their siteChartColors slots,
computed over every site, so a fold never repaints one. The legend shows the
kept sites and Other; the hover titles and the "Numbers by year" table name
every site; the image's label names the folded sites and the caption says
what Other is.

`lib/growthGaps.ts`: `foldedSites`, `growthLayers`, `ownLayers`,
`layerColors`; `growthStack` stacks layers (every site its own band by
default) and returns them. The surface gaps work on bands, so the gap under
Other parts it from the top kept site and its own top has none.

The e2e fixture's fifth site transcribes 4 a day, not 5, so the fixture's
last two sites are under 5 % (4.35 and 3.26 %) and the chart folds them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mhomepage/app/components/ArchiveGrowthChart.tsx | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mhomepage/app/lib/growthGaps.test.ts | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mhomepage/app/lib/growthGaps.ts | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mhomepage/e2e/fixture-summary.ts | 5++++-
4 files changed, 339 insertions(+), 39 deletions(-)

diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx @@ -3,8 +3,16 @@ import type { HomepageSummarySite, } from "yt-dlp-transcript-common/lib/homepageSummary"; import { monthLabel } from "yt-dlp-transcript-common/lib/homepageChart"; -import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; -import { PLOT_SIZES, gapSegments, growthStack, runsOf } from "../lib/growthGaps"; +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 @@ -36,6 +44,25 @@ import { PLOT_SIZES, gapSegments, growthStack, runsOf } from "../lib/growthGaps" // 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 the chart's neutral +// grey, `--chart-axis` (Light #55646e, Dark #8a8170; 5.63 / 5.13:1 on the +// ground, 6.11 / 4.88:1 on the chart surface). 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 touches, Other is (normal ΔE / +// worst of protan and deutan, light · dark): +// blue 17.6 / 17.6 · 21.3 / 19.8 — the only neighbour in today's data +// (Bonnellyzer, the top kept site in every month Other has data); +// violet 16.1 / 14.3 · 18.5 / 17.1; amber 17.5 / 14.9 · 11.0 / 10.3; +// green 13.6 / 9.8 · 12.3 / 4.1; magenta 20.2 / 4.5 · 16.9 / 4.2; +// rust 14.4 / 11.7 · 14.6 / 11.4. +// Where a pair is under a floor, 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, @@ -69,23 +96,32 @@ export function ArchiveGrowthChart({ const n = months.length; if (n === 0 || sites.length === 0) return null; - const { totals, peak, yMax, ticks, order, stack: stacked } = growthStack(months, sites); + // 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 site, in `sites` order (the legend's too). - const colors = siteChartColors(sites); + // 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 si = order[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 { - site: sites[si], - color: colors[si], + 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`, }; @@ -95,7 +131,7 @@ export function ArchiveGrowthChart({ const gapSets = PLOT_SIZES.map((size) => { const segments = gapSegments(stacked, yMax, size); const paths = layers.map((l, k) => ({ - key: l.site.siteId, + key: l.key, d: runsOf(segments[k]) .map((run) => `M${[...run, run.at(-1)! + 1].map((i) => l.top[i]).join("L")}`) .join(""), @@ -113,11 +149,18 @@ export function ArchiveGrowthChart({ 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()}.`; + `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>>(); @@ -130,10 +173,10 @@ export function ArchiveGrowthChart({ 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: colors[i] }} /> - {s.siteTitle} + {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> @@ -163,7 +206,7 @@ export function ArchiveGrowthChart({ /> ))} {layers.map((l) => ( - <path key={l.site.siteId} d={l.area} fill={l.color} /> + <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 @@ -194,6 +237,8 @@ export function ArchiveGrowthChart({ ))} {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) @@ -245,6 +290,8 @@ export function ArchiveGrowthChart({ <figcaption className="text-sm text-[var(--muted-foreground)]"> 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}.`} </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)]"> diff --git a/homepage/app/lib/growthGaps.test.ts b/homepage/app/lib/growthGaps.test.ts @@ -1,13 +1,20 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; import { buildFixtureSummary } from "../../e2e/fixture-summary"; import { GAP_PX, MIN_KEEP_PX, + OTHER_COLOR, + OTHER_KEY, PLOT_SIZES, bandAbove, + foldedSites, gapSegments, + growthLayers, growthStack, + layerColors, + ownLayers, type Band, type PlotSize, } from "./growthGaps"; @@ -83,15 +90,30 @@ function slivers(): { stack: Band[]; yMax: number } { return { stack, yMax }; } -test("the fixture summary: gaps are drawn, and no band is ever covered, at every height", () => { +test("the fixture summary, as the chart draws it (Other on top): gaps are drawn, and no band is ever covered, at every height", () => { const summary = buildFixtureSummary(); - const { stack, yMax } = growthStack(summary.monthly ?? [], summary.sites); + const months = summary.monthly ?? []; + const layers = growthLayers(months, summary.sites); + // The fixture's last two sites are under 5 %: they fold. + assert.deepEqual(foldedSites(months, summary.sites), [4, 5]); + assert.equal(layers.at(-1)!.key, OTHER_KEY); + const { stack, yMax } = growthStack(months, summary.sites, layers); let drawn = 0; + let underOther = 0; for (const size of PLOT_SIZES) { - drawn += gapSegments(stack, yMax, size).flat().length; + const segs = gapSegments(stack, yMax, size); + drawn += segs.flat().length; + // The gap under Other parts it from the top kept site; nothing sits on + // Other, so its own upper edge has none. + underOther += segs[stack.length - 2].filter((i) => bandAbove(stack, stack.length - 2, i) === stack.length - 1).length; + assert.deepEqual(segs[stack.length - 1], [], `${size.px}px: a gap along Other's top`); assert.deepEqual(covered(stack, yMax, size), [], `${size.px}px`); } assert.ok(drawn > 0, "no gap drawn at all"); + assert.ok(underOther > 0, "no gap between the top kept site and Other"); + // Unfolded, the same summary keeps its promise too. + const own = growthStack(months, summary.sites); + for (const size of PLOT_SIZES) assert.deepEqual(covered(own.stack, own.yMax, size), [], `unfolded ${size.px}px`); }); test("slivers beside large bands, with one-month spikes: no band is ever covered", () => { @@ -126,3 +148,142 @@ test("no gap along the stack's top, and none under the run length", () => { assert.deepEqual(gapSegments(stack, yMax, size), [[], []], `${size.px}px`); } }); + +// ── The fold ────────────────────────────────────────────────────────────────── +// +// A site under 5 % of the placed total (every site's transcripts over the whole +// plotted range) folds into ONE Other band on top, when two or more do. The +// range's sums are what count: `monthsOf` spreads each site's sum over three +// months, so no single month decides. + +type Named = { siteId: string; siteTitle: string; accentId?: string }; + +function sitesOf(ids: readonly string[], accents: readonly (string | undefined)[] = []): Named[] { + return ids.map((siteId, i) => ({ siteId, siteTitle: siteId.toUpperCase(), accentId: accents[i] })); +} + +function monthsOf(sites: readonly Named[], sums: readonly number[]) { + // Thirds, the remainder in the last month. + return [0, 1, 2].map((k) => ({ + bySite: Object.fromEntries( + sites.map((s, i) => [s.siteId, k < 2 ? Math.floor(sums[i] / 3) : sums[i] - 2 * Math.floor(sums[i] / 3)]), + ), + })); +} + +test("the fold with today's proportions: the three largest keep their bands, the other three are one Other band on top", () => { + // The family's shares in the 2026-09-28 summary, in its order and with its + // accents: 42.03, 38.40, 9.18, 4.33, 3.79, 2.26 %. + const sites = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); + const sums = [4203, 3840, 918, 433, 379, 226]; + const months = monthsOf(sites, sums); + assert.deepEqual(foldedSites(months, sites), [3, 4, 5]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites, l.other]), + [ + ["a", [0], false], + ["b", [1], false], + ["c", [2], false], + [OTHER_KEY, [3, 4, 5], true], + ], + ); + // Other is the sum of its sites, on top: the stack's top is every month's + // total, as before the fold. + const { stack, totals } = growthStack(months, sites, layers); + assert.deepEqual(stack[3].hi.map((v, i) => v - stack[3].lo[i]), months.map((m) => m.bySite.d + m.bySite.e + m.bySite.f)); + assert.deepEqual(stack[3].hi, totals); + // Colours: the kept sites their families' slots, Other the neutral grey. + assert.deepEqual(layerColors(layers, sites), ["var(--chart-4)", "var(--chart-5)", "var(--chart-1)", OTHER_COLOR]); + assert.equal(OTHER_COLOR, "var(--chart-axis)"); +}); + +test("the threshold's edge: exactly 5 % keeps its band, just under folds", () => { + const sites = sitesOf(["a", "b", "c", "d"]); + // b and c are 999 of 20,000 (4.995 %), d exactly 1,000 (5 %). + assert.deepEqual(foldedSites(monthsOf(sites, [17_002, 999, 999, 1_000]), sites), [1, 2]); + // Two sites at exactly 5 %: neither folds. + const three = sitesOf(["a", "b", "c"]); + assert.deepEqual(foldedSites(monthsOf(three, [18_000, 1_000, 1_000]), three), []); + assert.deepEqual(growthLayers(monthsOf(three, [18_000, 1_000, 1_000]), three), ownLayers(three)); +}); + +test("a fold of one is no fold: a single site under 5 % keeps its band", () => { + const sites = sitesOf(["a", "b", "c"]); + const months = monthsOf(sites, [60, 36, 4]); + assert.deepEqual(foldedSites(months, sites), []); + assert.deepEqual(growthLayers(months, sites), ownLayers(sites)); + assert.ok(growthLayers(months, sites).every((l) => !l.other)); +}); + +test("no site under 5 %: every site keeps its band, as before the fold", () => { + const sites = sitesOf(["a", "b", "c"]); + const months = monthsOf(sites, [50, 30, 20]); + assert.deepEqual(foldedSites(months, sites), []); + const layers = growthLayers(months, sites); + assert.deepEqual(layers, ownLayers(sites)); + // The same stack as growthStack's default. + assert.deepEqual(growthStack(months, sites, layers), growthStack(months, sites)); +}); + +test("all but one under 5 %: one kept band and one Other band", () => { + const sites = sitesOf(["a", "b", "c", "d", "e", "f"]); + const months = monthsOf(sites, [80, 4, 4, 4, 4, 4]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites]), + [ + ["a", [0]], + [OTHER_KEY, [1, 2, 3, 4, 5]], + ], + ); + const { stack, totals } = growthStack(months, sites, layers); + assert.equal(stack.length, 2); + assert.deepEqual(stack[1].hi, totals); +}); + +test("a site with nothing in the plotted range is under 5 %, and folds with another", () => { + const sites = sitesOf(["a", "b", "c"]); + assert.deepEqual(foldedSites(monthsOf(sites, [96, 4, 0]), sites), [1, 2]); + // Nothing plotted at all: nothing folds. + assert.deepEqual(foldedSites(monthsOf(sites, [0, 0, 0]), sites), []); +}); + +test("every site under 5 % (over twenty sites): every site folds, one Other band", () => { + const ids = Array.from({ length: 25 }, (_, i) => `s${i}`); + const sites = sitesOf(ids); + const layers = growthLayers(monthsOf(sites, ids.map(() => 40)), sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites.length]), + [[OTHER_KEY, 25]], + ); +}); + +test("a kept site's colour is the one it wears unfolded, whoever folds", () => { + // No accents: each site's slot is its index's (siteChartColors), so a list + // of the kept sites alone would repaint d — the chart runs it over them all. + const sites = sitesOf(["a", "b", "c", "d"]); + const months = monthsOf(sites, [500, 20, 20, 460]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => l.key), + ["a", "d", OTHER_KEY], + ); + const unfolded = layerColors(ownLayers(sites), sites); + assert.deepEqual(unfolded, siteChartColors(sites)); + const colours = layerColors(layers, sites); + assert.deepEqual(colours, [unfolded[0], unfolded[3], OTHER_COLOR]); + assert.notEqual(colours[1], siteChartColors([sites[0], sites[3]])[1], "the kept list alone would repaint d"); + // With accents, the same: each kept site keeps its family's slot. + const named = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); + const all = siteChartColors(named); + const nm = monthsOf(named, [4203, 3840, 918, 433, 379, 226]); + const nl = growthLayers(nm, named); + assert.deepEqual( + layerColors(nl, named).slice(0, -1), + nl.slice(0, -1).map((l) => all[l.sites[0]]), + ); + // The fold never hands a kept site the grey, nor Other a site's slot. + assert.ok(!layerColors(nl, named).slice(0, -1).includes(OTHER_COLOR)); + assert.ok(!all.includes(OTHER_COLOR)); +}); diff --git a/homepage/app/lib/growthGaps.ts b/homepage/app/lib/growthGaps.ts @@ -1,16 +1,34 @@ -// THE GROWTH CHART'S SURFACE GAPS, worked out (ArchiveGrowthChart.tsx draws -// them). Pure: no React, no DOM, so a unit test can check every segment. +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 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. 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; @@ -34,6 +52,8 @@ 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<string, number | undefined> }; + // 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]; @@ -43,6 +63,68 @@ function stackOrder(n: number): number[] { return [...head, ...tail]; } +// ── The fold ────────────────────────────────────────────────────────────────── + +export const FOLD_PERCENT = 5; +export const OTHER_LABEL = "Other"; +// The chart's neutral grey (tokens.css), on each base: 5.63:1 on the Light +// ground and 5.13:1 on the Dark one (6.11 / 4.88 on the chart surface). +// ArchiveGrowthChart.tsx has its separation from each chart slot. +export const OTHER_COLOR = "var(--chart-axis)"; + +// 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; @@ -53,12 +135,16 @@ function niceStep(max: number): number { return 10 * pow; } -// The chart's numbers: each month's total, the peak, the value scale (yMax -// and its gridlines), and the bands bottom-up (`order[k]` is band k's index in -// `sites`). +// ── 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 { bySite: Record<string, number | undefined> }[], + 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)); @@ -67,16 +153,19 @@ export function growthStack( 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 order = stackOrder(sites.length); const base = new Array<number>(n).fill(0); - const stack: Band[] = order.map((si) => { + const stack: Band[] = layers.map((layer) => { const lo = base.slice(); - const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0)); + 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, order, stack }; + 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 diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts @@ -27,6 +27,9 @@ import type { VideoStat } from "../../common/lib/stats"; // • every site transcribes every day of the last 200, so every line is full // and every series has a non-zero start for Indexed; // • uploads spread over 2019–2026, for the growth chart's months; +// • the last two sites each under 5 % of the growth chart's total (4.35 and +// 3.26 %), so the chart folds them into its Other band (release 14 slice +// CF; fixture-five transcribes 4 a day, not 5, for it); // • a seventh site, UNLISTED (site.json `listed: false`, release 14 slice // HS): its own two channels transcribe every day like the rest, and it // also exposes the first site's first channel. The summary names it nowhere @@ -65,7 +68,7 @@ export const FIXTURE_SITES: readonly FixtureSite[] = [ { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, wordmarkLead: "Fixture", channels: 3, daily: 9 }, { siteId: "fixture-three", siteTitle: "Fixture Three", wordmarkLead: "Fix", channels: 3, daily: 7 }, { siteId: "fixture-four", siteTitle: "Fixture Four", channels: 2, daily: 8 }, - { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 5 }, + { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 4 }, { siteId: "fixture-six", siteTitle: "Fixture Six", accent: "vermilion", channels: 2, daily: 3 }, ];