commit e50b7455a9a30815555cb12c467ff486f94fdacb
parent 88bcd4ec6196b14dd65da847449e62a596f78ac4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 22:58:35 -0400
homepage: the growth chart measures a band's thickness at right angles to the edge before it takes a gap (review M1)
lib/growthGaps.ts (new, pure) holds the stack, the value scale and the gap
rule; the chart draws from it. A gap segment is drawn only where both bands
are at least 3 px thick at right angles to the edge — min(vertical height) ×
cos θ, at the NARROWEST plot of each height (240, 592 and 976 px) — so each
keeps at least 1 px of its colour after the gaps on both its edges; runs under
three months are still dropped, and single-month holes are not bridged.
growthGaps.test.ts checks every drawn segment independently (each band's far
edge's distance to the gapped edge's line, less every gap touching it) on the
fixture summary, a slivers-beside-large-bands stand-in with one-month spikes,
and a one-month spike: zero covered band-segments. Under the old vertical rule
two of the four tests fail. growth-chart.spec: only the drawn height's set of
gaps shows (390, 768, 1280 px); read back from a screenshot, the peak's top is
within 1 px of the true total and every site with data shows its colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 365 insertions(+), 86 deletions(-)
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -8,7 +8,7 @@
- **Two grounds, Light and Dark.** The third ground, the warm paper one, is gone: the toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light.
- **Changelog is in the footer only.** The header's nav is Docs, Source, Downloads and Stats; the footer's Sections list and the 404 page keep Changelog.
- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5).
-- **The growth chart's bands are separated by a 2 px gap in the ground's colour.** The gap runs along each band's upper edge where another band sits on it, the same 2 px at every width (it was a 1.25 px line in the page colour); where a band is too thin to give its share and keep a pixel of its own colour at the size the chart is drawn, the two bands touch instead, so no band disappears. In high-contrast mode the gap is the system's background colour. The gridlines are unchanged. The `/stats` charts' stacked areas and stacked bars are separated by the same 2 px gap in the chart panel's colour.
+- **The growth chart's bands are separated by a 2 px gap in the ground's colour, where both bands can spare it.** The gap runs along a band's upper edge where another band sits on it, the same 2 px at every width (it was a 1.25 px line in the page colour). It is drawn only where both bands keep at least a pixel of their own colour, measured at right angles to the edge at the narrowest width the chart is drawn at: on a steep month, and beside the thinnest instances, the bands touch instead, so no band is ever covered, and the gap shows in stretches rather than all along. In high-contrast mode the gap is the system's background colour. The gridlines are unchanged. The `/stats` charts' stacked bars get the same 2 px gap in the chart panel's colour; their stacked areas keep their coloured top lines.
- **Larger social links, with a focus ring.** Each icon, in the header and the footer, is a 36 px target around its 20 px glyph (44 px on a touch screen), in the muted text colour and the text colour on hover; the footer's were 20 px, in the faint colour, with no ring. Keyboard focus draws a 2 px ring in the accent, and in high-contrast mode the browser's own focus outline. An icon of two or more colours keeps its colours, and every icon paints inside its own box. A stored icon that fails the check a save runs is shown as its label (at most 10rem, with an ellipsis) instead.
- **Tab no longer stops on the header's scrolling boxes in Firefox.** When the social icons' box or the nav's rule under the header overflows (a screen under about 300 px, or a large text size), Firefox made it a tab stop with no name of its own; the icons and links inside are the stops now, and each scrolls into view as it takes focus.
- **The e2e no longer reads the checkout's `settings.json` or `homepage.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient with an outline, one colour, and a two-colour disc pasted with only its size), put through the same check a save runs; and `SITES_DIR` points at an empty directory. `e2e/social.spec.ts` covers the header and footer rows (at every width, with 1, 3 and 4 links, both pointers; the scroll fallback; hostile stored icons that must neither run nor fetch), `e2e/toggle.spec.ts` the theme toggle and the pinned accent, `e2e/svg-vectors.spec.ts` that every accepted icon stays inside its `<svg>` in a real parse, `e2e/growth-chart.spec.ts` the chart's lines, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the ground through one helper, `chooseTheme` (`e2e/helpers.ts`).
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -4,6 +4,7 @@ import type {
} 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";
// 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
@@ -57,35 +58,6 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
const W = 1000;
const H = 300;
-// The surface gap between bands (the marks spec: 2 px, one width across the
-// stack), and the plot's rendered heights with the classes that show the gaps
-// worked out for each (the plot box is `h-[200px] sm:h-[260px] lg:h-[300px]`).
-const GAP_PX = 2;
-const PLOT_HEIGHTS = [
- { px: 200, className: "sm:hidden" },
- { px: 260, className: "hidden sm:inline lg:hidden" },
- { px: 300, className: "hidden lg:inline" },
-] as const;
-// A run of gap shorter than this many months is dropped: a speck of gap reads
-// as noise, not as a parting.
-const MIN_GAP_RUN = 3;
-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];
-}
-
-// 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,
@@ -97,15 +69,8 @@ export function ArchiveGrowthChart({
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);
+ const { totals, peak, yMax, ticks, order, stack: stacked } = growthStack(months, sites);
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;
@@ -114,22 +79,8 @@ export function ArchiveGrowthChart({
// One colour per site, in `sites` order (the legend's too).
const colors = siteChartColors(sites);
- // 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 stacked = order.map((si) => {
- const lo = base.slice();
- const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0));
- return { si, lo, hi };
- });
- const height = (k: number, i: number) => stacked[k].hi[i] - stacked[k].lo[i];
- // The band a gap along band k's upper edge would share with: the next band
- // up with a height this month, or none (the stack's top meets the surface).
- const nextUp = (k: number, i: number) => {
- for (let m = k + 1; m < stacked.length; m++) if (height(m, i) > 0) return height(m, i);
- return 0;
- };
- const layers = stacked.map(({ si, lo, hi }) => {
+ 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 {
@@ -139,32 +90,17 @@ export function ArchiveGrowthChart({
area: `M${top.join("L")}L${bottom.join("L")}Z`,
};
});
- // The gaps, per plot height. A band gives half a gap on each edge it
- // shares, so up to GAP_PX in all: it gives any only when it is at least
- // GAP_PX + 1 px tall at that height, keeping a pixel of its own colour. So a
- // gap runs along a band's upper edge where another band sits on it (the
- // stack's top meets the surface itself) and both are that tall; thinner
- // bands touch rather than vanish.
- const gapSets = PLOT_HEIGHTS.map(({ px, className }) => {
- const minBand = ((GAP_PX + 1) * yMax) / px;
- const paths = layers.map((l, k) => {
- const parted = (i: number) => height(k, i) >= minBand && nextUp(k, i) >= minBand;
- const runs: string[] = [];
- let run: string[] = [];
- const close = () => {
- if (run.length > MIN_GAP_RUN) runs.push(`M${run.join("L")}`);
- run = [];
- };
- for (let i = 0; i < n - 1; i++) {
- if (parted(i) && parted(i + 1)) {
- if (run.length === 0) run.push(l.top[i]);
- run.push(l.top[i + 1]);
- } else close();
- }
- close();
- return { key: l.site.siteId, d: runs.join("") };
- });
- return { px, className, paths: paths.filter((p) => p.d) };
+ // 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.site.siteId,
+ 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
@@ -234,15 +170,16 @@ export function ArchiveGrowthChart({
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 to give its
- pixel and keep one of its own colour at the height the plot
- is drawn (one set of gaps per height, shown by its class),
- there is no gap: thin bands touch rather than vanish. Colour
- and width are
+ 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} className={set.className}>
+ <g key={set.px} data-plot-height={set.px} className={set.className}>
{set.paths.map((p) => (
<path
key={p.key}
diff --git a/homepage/app/lib/growthGaps.test.ts b/homepage/app/lib/growthGaps.test.ts
@@ -0,0 +1,128 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildFixtureSummary } from "../../e2e/fixture-summary";
+import {
+ GAP_PX,
+ MIN_KEEP_PX,
+ PLOT_SIZES,
+ bandAbove,
+ gapSegments,
+ growthStack,
+ type Band,
+ type PlotSize,
+} from "./growthGaps";
+
+// Run with:
+// pnpm --filter homepage test
+//
+// THE PROMISE: wherever the growth chart draws a gap, both bands it parts keep
+// at least MIN_KEEP_PX of their own colour, measured at right angles to the
+// edge, at the narrowest plot of each height — so no band is ever fully
+// covered. Checked here independently of gapSegments' own test: each band's
+// extent under a gap segment is the distance from its FAR edge's two points to
+// the gapped edge's line, in px, less every gap that touches the band there.
+
+type Pt = [number, number];
+
+function px(size: PlotSize, yMax: number, n: number) {
+ const dx = size.minWidth / Math.max(1, n - 1);
+ return (i: number, v: number): Pt => [i * dx, (v / yMax) * size.px];
+}
+
+// Distance from p to the line through a and b.
+function dist(p: Pt, a: Pt, b: Pt): number {
+ const [x, y] = p;
+ const [x1, y1] = a;
+ const [x2, y2] = b;
+ return Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / Math.hypot(x2 - x1, y2 - y1);
+}
+
+// Every band-segment a gap leaves with less than MIN_KEEP_PX of its colour.
+function covered(stack: readonly Band[], yMax: number, size: PlotSize): string[] {
+ const n = stack[0].hi.length;
+ const at = px(size, yMax, n);
+ const segs = gapSegments(stack, yMax, size).map((s) => new Set(s));
+ // Band j loses GAP_PX / 2 on its lower edge at segment i when the band
+ // beneath it has a gap there whose band above is j.
+ const gapBelow = (j: number, i: number) =>
+ stack.some((_, k) => k < j && segs[k].has(i) && bandAbove(stack, k, i) === j);
+ const bad: string[] = [];
+ stack.forEach((band, k) => {
+ for (const i of segs[k]) {
+ const a = at(i, band.hi[i]);
+ const b = at(i + 1, band.hi[i + 1]);
+ // The band below the edge: its lower edge's points.
+ const below = Math.min(dist(at(i, band.lo[i]), a, b), dist(at(i + 1, band.lo[i + 1]), a, b));
+ const keepBelow = below - GAP_PX / 2 - (gapBelow(k, i) ? GAP_PX / 2 : 0);
+ // The band above: its upper edge's points.
+ const m = bandAbove(stack, k, i)!;
+ const above = Math.min(dist(at(i, stack[m].hi[i]), a, b), dist(at(i + 1, stack[m].hi[i + 1]), a, b));
+ const keepAbove = above - GAP_PX / 2 - (segs[m].has(i) ? GAP_PX / 2 : 0);
+ if (keepBelow < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${k} @${i}: ${keepBelow.toFixed(2)}`);
+ if (keepAbove < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${m} @${i}: ${keepAbove.toFixed(2)}`);
+ }
+ });
+ return bad;
+}
+
+// A deterministic stand-in for the published chart's shape: one large band
+// with one-month spikes and dips, one medium band, and four slivers of 0–12
+// that start late.
+function slivers(): { stack: Band[]; yMax: number } {
+ let seed = 7;
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31);
+ const n = 204;
+ const months = Array.from({ length: n }, (_, i) => {
+ const big = i < 60 ? rnd() * 20 : 80 + i * 3 + (i % 17 === 0 ? 900 : 0) - (i % 23 === 0 ? 70 : 0);
+ const mid = i < 120 ? rnd() * 10 : 60 + rnd() * 120 + (i % 29 === 0 ? 600 : 0);
+ const s = (from: number) => (i < from ? 0 : Math.round(rnd() * 12));
+ return { bySite: { a: Math.max(0, big), b: mid, c: s(90), d: s(130), e: s(150), f: s(190) } };
+ });
+ const sites = ["a", "b", "c", "d", "e", "f"].map((siteId) => ({ siteId }));
+ const { stack, yMax } = growthStack(months, sites);
+ return { stack, yMax };
+}
+
+test("the fixture summary: gaps are drawn, and no band is ever covered, at every height", () => {
+ const summary = buildFixtureSummary();
+ const { stack, yMax } = growthStack(summary.monthly ?? [], summary.sites);
+ let drawn = 0;
+ for (const size of PLOT_SIZES) {
+ drawn += gapSegments(stack, yMax, size).flat().length;
+ assert.deepEqual(covered(stack, yMax, size), [], `${size.px}px`);
+ }
+ assert.ok(drawn > 0, "no gap drawn at all");
+});
+
+test("slivers beside large bands, with one-month spikes: no band is ever covered", () => {
+ const { stack, yMax } = slivers();
+ for (const size of PLOT_SIZES) {
+ const bad = covered(stack, yMax, size);
+ assert.equal(bad.length, 0, bad.slice(0, 5).join("; "));
+ }
+});
+
+test("a one-month spike: its steep flanks get no gap at a phone's width, however tall the bands vertically", () => {
+ const n = 40;
+ const months = Array.from({ length: n }, (_, i) => ({
+ bySite: { a: i === 20 ? 3000 : 1000, b: 400 },
+ }));
+ const sites = [{ siteId: "a" }, { siteId: "b" }];
+ const { stack, yMax } = growthStack(months, sites);
+ const phone = gapSegments(stack, yMax, PLOT_SIZES[0])[0];
+ // b is 20 px tall vertically on the flanks, but about 1 px at right angles.
+ assert.ok(!phone.includes(19) && !phone.includes(20), `flank gaps: ${phone.join(",")}`);
+ // The flat stretches either side are parted.
+ assert.ok(phone.includes(5) && phone.includes(30), `flat gaps: ${phone.join(",")}`);
+ assert.deepEqual(covered(stack, yMax, PLOT_SIZES[0]), []);
+});
+
+test("no gap along the stack's top, and none under the run length", () => {
+ const n = 10;
+ const months = Array.from({ length: n }, (_, i) => ({ bySite: { a: 500, b: i === 4 ? 500 : 0 } }));
+ const { stack, yMax } = growthStack(months, [{ siteId: "a" }, { siteId: "b" }]);
+ for (const size of PLOT_SIZES) {
+ // b sits on a for one month only: a run of one (two segments) is dropped.
+ assert.deepEqual(gapSegments(stack, yMax, size), [[], []], `${size.px}px`);
+ }
+});
diff --git a/homepage/app/lib/growthGaps.ts b/homepage/app/lib/growthGaps.ts
@@ -0,0 +1,148 @@
+// 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.
+//
+// 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.
+
+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[] };
+
+// 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];
+}
+
+// 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 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`).
+export function growthStack(
+ months: readonly { bySite: Record<string, number | undefined> }[],
+ sites: readonly { siteId: string }[],
+) {
+ 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 order = stackOrder(sites.length);
+ const base = new Array<number>(n).fill(0);
+ const stack: Band[] = order.map((si) => {
+ const lo = base.slice();
+ const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0));
+ return { lo, hi };
+ });
+ return { totals, peak, yMax, ticks, order, stack };
+}
+
+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;
+}
diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts
@@ -1,4 +1,6 @@
import { test, expect, type Page } from "@playwright/test";
+import { buildFixtureSummary } from "./fixture-summary";
+import { painted, rgbOf } from "../../common/testing/chartPixels";
// The growth chart's bands are parted by the marks spec's SURFACE GAP: 2 px
// along each band's upper edge, in the colour behind the plot — the page
@@ -63,3 +65,67 @@ test("in forced colours the gap is the reader's Canvas", async ({ page }) => {
expect(seen.length).toBeGreaterThan(0);
for (const g of seen) expect(g.stroke).toBe(canvas);
});
+
+// One set of gaps per plot height (lib/growthGaps.ts), each shown only at its
+// own breakpoint: the set worked out for the height the plot is drawn at.
+for (const [width, shown] of [
+ [390, "200"],
+ [768, "260"],
+ [1280, "300"],
+] as const) {
+ test(`${width} px: only the ${shown} px plot's gaps are shown`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto("/");
+ const plot = page.locator('figure [role="img"]');
+ expect(Math.round((await plot.boundingBox())!.height)).toBe(Number(shown));
+ const sets = await page.locator("figure svg g[data-plot-height]").evaluateAll((els) =>
+ els.map((g) => [g.getAttribute("data-plot-height"), getComputedStyle(g).display !== "none"]),
+ );
+ expect(sets.filter(([, on]) => on).map(([h]) => h)).toEqual([shown]);
+ });
+}
+
+// THE DATA IS WHAT IS PAINTED. Read back from a screenshot of the plot: at the
+// busiest month the stack's topmost painted row is within 1 px of where the
+// month's true total sits on the value scale, and every site with data shows
+// pixels of its own colour — the gaps take no band away.
+for (const width of [390, 1280]) {
+ test(`${width} px: the chart paints its peak at its true height and every band`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto("/");
+ const summary = buildFixtureSummary();
+ const months = summary.monthly ?? [];
+ const sites = summary.sites;
+ const totals = months.map((m) => sites.reduce((a, x) => a + (m.bySite[x.siteId] ?? 0), 0));
+ const peak = Math.max(...totals);
+ const peakAt = totals.indexOf(peak);
+ const plot = page.locator('figure [role="img"]');
+ const box = (await plot.boundingBox())!;
+ // The value scale, from the labels on its gridlines (a label's `top` is
+ // its gridline's place, 1 − t / yMax of the plot's height).
+ const scale = await plot.evaluate((el) => {
+ const s = el.querySelector("span.tabular") as HTMLElement;
+ return { t: Number(s.textContent!.replace(/[^\d.]/g, "")), top: parseFloat(s.style.top) / 100 };
+ });
+ const yMax = scale.t / (1 - scale.top);
+ const expected = (1 - peak / yMax) * box.height;
+ const x = (peakAt / (months.length - 1)) * box.width;
+ const ground = rgbOf(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
+ const swatches = await page
+ .locator("figure ul li span")
+ .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor));
+ const withData = sites.map((st) => months.some((m) => (m.bySite[st.siteId] ?? 0) > 0));
+ const shot = await painted(page, plot, {
+ columns: [x - 1, x, x + 1],
+ ground,
+ colours: swatches.map(rgbOf),
+ tol: 12,
+ run: 2,
+ });
+ const top = Math.min(...shot.tops.filter((t): t is number => t !== null));
+ expect(Math.abs(top - expected), `top ${top} vs ${expected.toFixed(1)}`).toBeLessThanOrEqual(1);
+ sites.forEach((st, i) => {
+ if (withData[i]) expect(shot.counts[i].columns, `${st.siteTitle}'s colour`).toBeGreaterThan(0);
+ });
+ });
+}