From 2c91aaf23e8fc7e14da21e8ae1c55d88e6112230 Mon Sep 17 00:00:00 2001
From: Ntege Daniel
Date: Wed, 30 Sep 2026 00:42:36 +0300
Subject: [PATCH 1/2] feat(web): print or download a single chart
Every interactive chart gets a Print action that opens a preview of the
chart laid out as a page (A4 or Letter, portrait or landscape, best fit by
default), which can be printed or saved as PDF, PNG or JPG.
* Add a print preview drawn at printed size from the chart's own switches,
legend and render function, so chart type and selections carry over
* State title, filters, date range and legend on the page, and print
values on the marks
* Fit each chart kind to the page; long selections run onto further pages
instead of being clipped
* Show a message instead of an empty preview when no data is selected
* Print the page alone, with no page margin for the browser's own header
and footer
* Add PDF, PNG and JPG downloads rendered in the browser at 288 dpi
* Scope tab-print rules so Print tab is unchanged
* Cover with Vitest and Playwright; document in docs/printing.md
Fixes #501
Signed-off-by: Ntege Daniel
---
docs/printing.md | 97 +++
web/e2e/chartPrint.spec.ts | 295 ++++++++
web/package-lock.json | 7 +
web/package.json | 1 +
web/src/app.css | 7 +-
web/src/chartExport.ts | 236 +++++++
web/src/chartPrint.ts | 98 +++
web/src/components/ChartSectionCard.tsx | 44 +-
web/src/components/ContributorCell.tsx | 16 +-
web/src/components/PrintFilter.tsx | 14 +
web/src/components/VariantTabs.tsx | 16 +-
web/src/components/charts/AdjacencyMatrix.tsx | 19 +-
.../components/charts/ChartPrintDialog.tsx | 407 +++++++++++
web/src/components/charts/ChartShell.tsx | 148 +++-
web/src/components/charts/EventsView.tsx | 204 +++---
web/src/components/charts/MatrixView.tsx | 361 ++++++----
web/src/components/charts/NetworkView.tsx | 105 ++-
web/src/components/charts/SeriesView.tsx | 187 ++++-
web/src/components/charts/format.ts | 24 +
web/src/components/charts/leading.ts | 3 +
web/src/hooks/use-element-size.ts | 31 +
web/src/print.css | 668 ++++++++++--------
web/src/printContext.ts | 6 +
web/src/printing.tsx | 3 +-
web/src/test/chartPrint.test.tsx | 377 ++++++++++
web/src/test/printComponents.test.tsx | 3 +-
26 files changed, 2772 insertions(+), 605 deletions(-)
create mode 100644 web/e2e/chartPrint.spec.ts
create mode 100644 web/src/chartExport.ts
create mode 100644 web/src/chartPrint.ts
create mode 100644 web/src/components/PrintFilter.tsx
create mode 100644 web/src/components/charts/ChartPrintDialog.tsx
create mode 100644 web/src/hooks/use-element-size.ts
create mode 100644 web/src/test/chartPrint.test.tsx
diff --git a/docs/printing.md b/docs/printing.md
index e280dd345..7be26015d 100644
--- a/docs/printing.md
+++ b/docs/printing.md
@@ -201,3 +201,100 @@ Local verification after the port: 167 Vitest tests, and 21 Playwright tests in
each of Chromium and Firefox. Lint, Prettier and the typecheck/build pass; lint
keeps its five existing warnings and adds none. Printed output from real data was
not re-reviewed in this pass.
+
+## Printing one chart (30 September 2026)
+
+Every interactive chart has a **Print** action beside Download CSV and Expand
+(icon-only in a narrow card; its accessible name is "Print chart: "). It
+opens a print preview rather than the browser dialog, so the reader sees the page
+before printing it.
+
+**One renderer.** The preview does not redraw the chart from a copy of its state.
+`ChartShell` hands its own view's switches, legend and `chart()` render function
+to `ChartPrintDialog`, which lays them out as a sheet of paper. Every selection
+already lives in the URL (variant, chart style, scale, span, hidden series,
+cohort, search, focus, show-all) or in the view's closure (the network's zoom),
+so the sheet shows exactly what the card shows. The views receive the sheet's
+chart area as `sheet` and size themselves to it: bars over time take its height,
+rankings and timelines fit their rows between a minimum and maximum row height,
+heatmaps switch to a fixed-width grid with every column on the page, and the
+network graph and adjacency matrix scale into the area.
+
+**The sheet is the printout.** It is drawn at its printed size in millimetres and
+only scaled down (CSS transform) on screen, so the measured layout, the chart's
+pixel size and the page count in the preview are the ones printed. While the
+preview is open, `` and a page rule
+(`@page { size: A4|letter portrait|landscape; margin: 12mm }`) are in place, and
+print.css prints the sheet alone: the dashboard, overlays and the dialog's own
+controls are left out, and the tab's print mode (`PrintProvider`) is skipped. The
+browser's own print command prints the same sheet while the preview is open.
+Every tab-print rule is scoped to `:where(html:not([data-print-scope='chart']))`,
+which adds no specificity, so tab printing cascades as before.
+
+**On the page:** the chart title with its organisation and card, the unit line,
+the selection as filters ("Chart style Line", "Hidden series Triage", "Showing
+top 10 of 40 repositories", "Search …", "Focus …"), the date range (the buckets
+shown for a timeseries; the window's dates otherwise), the legend (series drawn;
+switched-off ones are listed among the filters), the chart, the population and
+window notes. The source line (data date, code revision), the source's
+generated time and the view's URL were dropped from the sheet as clutter; the printed date is optional and off by default.
+Reading notes and methodology are optional and off by default, so they do not
+take the chart's room. Paper cannot hover, so values are printed on the marks:
+stack totals on bars, point values on short lines and areas, the latest value on
+long series. Heatmaps keep their cell values; funnels and meters are labelled
+already. The sheet always uses the light palette, including in dark mode.
+
+**Paper.** A4 or Letter, remembered per browser (the default follows the
+reader's region). Orientation is _Best fit_ unless chosen: each view states its
+natural shape (`printAspect`), and the orientation that draws it largest wins.
+Wide timeseries print landscape; long rankings, timelines, networks and tall
+heatmaps print portrait. A selection that cannot fit one page even at the
+minimum row height (every row of a very long ranking) runs onto further pages;
+the preview marks the page breaks and says how many pages it needs.
+
+**Download.** The preview also saves the page as a file, without the print
+dialog: a PDF (one page per printed page, at the paper's exact size), or a PNG
+or JPG of the whole sheet. `chartExport.ts` renders the sheet in the browser at
+288 dpi with `modern-screenshot` (loaded only when a file is saved). It lays the
+sheet on paper with the print margins, and writes the PDF itself: one
+losslessly compressed image per page, using the browser's `CompressionStream`,
+with no PDF library. GitHub avatars are left out, because the page's CSP
+(`connect-src 'self'`) does not let a script read them; their initials stand in.
+The PDF is an image of the page, so its text cannot be selected; Print keeps
+the text.
+
+**No browser header or footer.** The browser prints its own date, title and URL
+in the page margin. The chart's page rule sets the margin to 0, and the sheet
+pads itself by 12 mm instead (`box-decoration-break: clone`, so every printed
+page gets it). The browser then has no margin to print them in. Checked in
+Chromium with its header and footer option switched on.
+
+**Nothing to print.** If the selection has no data (an empty period, a search
+with no match), Print shows "Nothing to print for this selection" with the
+chart's own empty-state reason, instead of opening an empty preview. The message
+lasts until the selection changes.
+
+The CSS minifier (Lightning CSS) folds `translate: none` into the `transform`
+shorthand, which would leave the dialog's centring offset on paper; print.css
+resets Tailwind's `--tw-translate-*` variables instead.
+
+### Verification
+
+- Vitest: 12 new tests. They cover paper geometry and orientation, the page
+ rule, regional defaults and storage, filter naming, date ranges, the preview's
+ filters, legend and notes, printing and restoring the page, and the empty
+ message. Also that tab printing is skipped while a chart prints.
+- Playwright (Chromium and Firefox): 5 new tests. In Chromium, PDFs are generated
+ for A4 and Letter in both orientations and checked for page size and one page.
+ The tests also cover the sheet-only print document, a 120-row ranking that
+ prints portrait on two pages, the empty-selection message, dark mode, and focus
+ and tab printing after the preview closes.
+- Real data (hiero-ledger and hiero-hackers): every chart on every tab was
+ previewed and printed to PDF in Chromium. That covers bars, stacked bars,
+ lines, areas, horizontal rankings, funnel, meter, heatmaps (with and without
+ avatars), network graph, adjacency matrix and release timeline. Each fits one
+ page, with no content past the sheet's edges, and the preview's page count
+ matched the PDF. Filter combinations were also printed: variant, chart style,
+ hidden series, latest-24 span, show-all with share scale, network threshold
+ and focus, hidden release types, dark mode, A4/Letter portrait/landscape.
+ Safari's native print was not verified.
diff --git a/web/e2e/chartPrint.spec.ts b/web/e2e/chartPrint.spec.ts
new file mode 100644
index 000000000..ef34344db
--- /dev/null
+++ b/web/e2e/chartPrint.spec.ts
@@ -0,0 +1,295 @@
+import { readFile } from 'node:fs/promises';
+import type { Page } from '@playwright/test';
+import type { CategoriesDocument } from '../src/api';
+import { PIPELINE_DOCS } from './fixtures';
+import { test, expect } from './browser';
+
+const CHART = 'Unique active contributors by role';
+const NEXT_CHART = 'The next pipeline chart';
+const printButton = (page: Page, title = CHART) =>
+ page.getByRole('button', { name: `Print chart: ${title}`, exact: true });
+const preview = (page: Page) => page.getByRole('dialog', { name: 'Print preview' });
+const sheet = (page: Page) => page.locator('[data-print-sheet]');
+
+/** PDF points (1/72 in) of each paper, as Chromium writes the MediaBox. */
+const PAPER_PT = {
+ 'A4 portrait': [595, 842],
+ 'A4 landscape': [842, 595],
+ 'Letter portrait': [612, 792],
+ 'Letter landscape': [792, 612],
+} as const;
+
+async function openGovernance(page: Page) {
+ await page.goto('./#tab=Governance');
+ await expect(page.locator('#pipeline figure').first()).toBeVisible();
+ await expect(page.locator('[data-print-pending]')).toHaveCount(0);
+}
+
+/** Pages and page size of the PDF the browser prints from the current document. */
+async function printedPdf(page: Page, path: string) {
+ await page.pdf({ path, preferCSSPageSize: true, printBackground: true });
+ const pdf = await readFile(path, 'latin1');
+ const boxes = [...pdf.matchAll(/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/g)];
+ return {
+ pages: (pdf.match(/\/Type\s*\/Page[^s]/g) ?? []).length,
+ size: boxes.length ? [Number(boxes[0][3]), Number(boxes[0][4])] : [],
+ };
+}
+
+/** Save the preview as `format` and read the file back. */
+async function download(page: Page, format: 'PDF' | 'PNG' | 'JPG') {
+ await preview(page).getByRole('radio', { name: format, exact: true }).click();
+ const [file] = await Promise.all([
+ page.waitForEvent('download'),
+ preview(page)
+ .getByRole('button', { name: `Download ${format}` })
+ .click(),
+ ]);
+ return { name: file.suggestedFilename(), bytes: await readFile(await file.path()) };
+}
+
+/** Page count and first page size of a PDF the dashboard wrote itself. */
+function pdfPages(bytes: Buffer) {
+ const text = bytes.toString('latin1');
+ const box = /\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/.exec(text);
+ return {
+ pages: (text.match(/\/Type \/Page /g) ?? []).length,
+ size: box ? [Number(box[1]), Number(box[2])] : [],
+ };
+}
+
+/** Width and height from a PNG's IHDR, or a JPEG's start-of-frame. */
+function pixels(bytes: Buffer) {
+ if (bytes.readUInt32BE(0) === 0x89504e47) return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
+ for (let at = 2; at < bytes.length; at += 2 + bytes.readUInt16BE(at + 2)) {
+ if (bytes[at + 1] >= 0xc0 && bytes[at + 1] <= 0xc2)
+ return [bytes.readUInt16BE(at + 7), bytes.readUInt16BE(at + 5)];
+ }
+ return [];
+}
+
+test('Print chart previews the selected chart with its filters, legend and data labels', async ({
+ page,
+ browserErrors,
+}) => {
+ await openGovernance(page);
+ const figure = page.getByRole('figure', { name: `${CHART} — By year` });
+ await figure.getByRole('radio', { name: 'Line', exact: true }).click();
+ await figure.getByRole('button', { name: 'Triage', exact: true }).click();
+ await printButton(page).click();
+
+ await expect(preview(page)).toBeVisible();
+ await expect(page.locator('html')).toHaveAttribute('data-print-scope', 'chart');
+ await expect(sheet(page).getByRole('heading', { name: CHART })).toBeVisible();
+ await expect(sheet(page).locator('[data-print-filter]')).toHaveText([
+ 'View By year',
+ 'Chart style Line',
+ 'Line stacking Cumulative',
+ 'Scale Counts',
+ 'Date range 2021 – 2026 · 6 buckets',
+ 'Hidden series Triage',
+ ]);
+ // The legend lists the three series drawn; the chart keeps the dashboard's line style.
+ await expect(
+ sheet(page).locator('[data-print-legend] > button').locator('visible=true'),
+ ).toHaveText(['General contributors', 'Committers', 'Maintainers']);
+ await expect(sheet(page).locator('.recharts-line')).toHaveCount(3);
+ // Six yearly points on the cumulative top line, each labelled with its value.
+ await expect(sheet(page).locator('.recharts-label-list text')).toHaveCount(6);
+ // Paper has no controls: the sheet shows no switch, button or input.
+ await expect(
+ sheet(page).locator('[role="radiogroup"], input').locator('visible=true'),
+ ).toHaveCount(0);
+ await expect(
+ sheet(page).locator('button:not([data-print-keep])').locator('visible=true'),
+ ).toHaveCount(0);
+ await expect(preview(page).getByText(/one page/)).toBeVisible();
+ expect(browserErrors).toEqual([]);
+});
+
+test('the printed document is the sheet alone, on the chosen paper and orientation', async ({
+ page,
+ browserName,
+}, testInfo) => {
+ await openGovernance(page);
+ await printButton(page).click();
+ await expect(preview(page)).toBeVisible();
+ await expect(sheet(page).locator('.recharts-bar-rectangle').first()).toBeVisible();
+
+ await page.emulateMedia({ media: 'print' });
+ await expect(page.locator('#root')).toBeHidden();
+ await expect(sheet(page)).toBeVisible();
+ await expect(preview(page).getByRole('button', { name: 'Print', exact: true })).toBeHidden();
+ await expect(preview(page).getByRole('radiogroup', { name: 'Paper size' })).toBeHidden();
+ // The tab's own print mode stays off: nothing on the dashboard re-renders for paper.
+ await expect(page.locator('html')).not.toHaveAttribute('data-printing', 'true');
+ // Nothing reaches past the sheet's edges.
+ const overflow = await sheet(page).evaluate((element) => {
+ const bounds = element.getBoundingClientRect();
+ return [...element.querySelectorAll('*')].filter((child) => {
+ const box = child.getBoundingClientRect();
+ return box.width > 0 && (box.left < bounds.left - 1 || box.right > bounds.right + 1);
+ }).length;
+ });
+ expect(overflow).toBe(0);
+ // Back to the default: page.pdf() prints with screen styles once screen is emulated.
+ await page.emulateMedia({ media: null });
+
+ if (browserName !== 'chromium') return;
+ for (const [paper, orientation] of [
+ ['A4', 'Landscape'],
+ ['A4', 'Portrait'],
+ ['Letter', 'Landscape'],
+ ['Letter', 'Portrait'],
+ ] as const) {
+ await preview(page).getByRole('radio', { name: paper, exact: true }).click();
+ await preview(page).getByRole('radio', { name: orientation, exact: true }).click();
+ const name = `${paper} ${orientation.toLowerCase()}` as keyof typeof PAPER_PT;
+ await expect(preview(page).getByText(`on ${name} paper, one page`)).toBeVisible();
+ const path = testInfo.outputPath(`chart-${paper}-${orientation}.pdf`);
+ const printed = await printedPdf(page, path);
+ expect(printed.pages).toBe(1);
+ expect(printed.size[0]).toBeCloseTo(PAPER_PT[name][0], 0);
+ expect(printed.size[1]).toBeCloseTo(PAPER_PT[name][1], 0);
+ await testInfo.attach(`chart-${paper}-${orientation}.pdf`, {
+ path,
+ contentType: 'application/pdf',
+ });
+ }
+});
+
+test('a long ranking prints portrait and states when it needs more than one page', async ({
+ page,
+ browserName,
+}, testInfo) => {
+ const ranking: CategoriesDocument = {
+ ...PIPELINE_DOCS['hiero-ledger/charts/pipeline_next.json'],
+ id: 'ranking',
+ kind: 'categories',
+ orientation: 'horizontal',
+ rank: true,
+ top_n: 10,
+ dimensions: ['repo', 'series'],
+ category: { key: 'repo', label: 'Repository' },
+ group: null,
+ window: { kind: 'trailing', days: 365, end: '2026-07-25T10:00:00+00:00' },
+ rows: Array.from({ length: 120 }, (_, index) => ({
+ repo: `hiero-repository-${String(index + 1).padStart(3, '0')}`,
+ general_user: 200 - index,
+ triage: 3,
+ committer: 5,
+ maintainer: 2,
+ })),
+ };
+ await page.route('**/charts/pipeline_next.json', (route) => route.fulfill({ json: ranking }));
+ await openGovernance(page);
+ await page.getByRole('button', { name: 'Next', exact: true }).click();
+ const figure = page.getByRole('figure', { name: `${NEXT_CHART} — By month` });
+ await figure.getByRole('button', { name: 'Show all 120 repositories' }).click();
+ await printButton(page, NEXT_CHART).click();
+
+ await expect(preview(page)).toBeVisible();
+ await expect(preview(page).getByText('Best fit is portrait')).toBeVisible();
+ await expect(sheet(page).locator('[data-print-filter]')).toHaveText([
+ 'Scale Counts',
+ 'Date range 2025-07-25 – 2026-07-25 (365 days)',
+ 'Showing all 120 repositories',
+ ]);
+ // Every selected row is drawn, and labelled, rather than clipped to the page.
+ await expect(sheet(page).locator('.recharts-yAxis .recharts-cartesian-axis-tick')).toHaveCount(
+ 120,
+ );
+ await expect(preview(page).getByRole('status')).toHaveText(/needs 2 pages/);
+ if (browserName === 'chromium') {
+ const printed = await printedPdf(page, testInfo.outputPath('ranking.pdf'));
+ expect(printed.pages).toBe(2);
+ }
+ // The downloaded PDF breaks the same sheet into the same pages.
+ expect(pdfPages((await download(page, 'PDF')).bytes).pages).toBe(2);
+});
+
+test('with no data selected, Print chart explains why instead of opening a preview', async ({
+ page,
+}) => {
+ const yearly = PIPELINE_DOCS['hiero-ledger/charts/pipeline_yearly.json'];
+ await page.route('**/charts/pipeline_yearly.json', (route) =>
+ route.fulfill({
+ json: { ...yearly, rows: [], window: { kind: 'calendar', first: null, last: null } },
+ }),
+ );
+ await page.addInitScript(() => {
+ window.print = () => {
+ (window as unknown as { printed: boolean }).printed = true;
+ };
+ });
+ await openGovernance(page);
+ await printButton(page).click();
+ const notice = page.getByRole('alert').filter({ hasText: 'Nothing to print for this selection' });
+ await expect(notice).toBeVisible();
+ await expect(notice).toContainText('No data is available for this selection.');
+ await expect(preview(page)).toHaveCount(0);
+ expect(await page.evaluate(() => (window as unknown as { printed?: boolean }).printed)).toBe(
+ undefined,
+ );
+ await notice.getByRole('button', { name: 'Dismiss' }).click();
+ await expect(notice).toHaveCount(0);
+});
+
+test('the preview is paper in dark mode, and closing it restores tab printing', async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await openGovernance(page);
+ await printButton(page).click();
+ await expect(preview(page)).toBeVisible();
+ await expect(sheet(page)).toHaveCSS('background-color', 'rgb(255, 255, 255)');
+ await expect(sheet(page)).toHaveCSS('color', 'rgb(27, 27, 27)');
+ // The light palette's maintainer blue, though the dashboard around it is dark.
+ const fills = await sheet(page)
+ .locator('.recharts-bar-rectangle path')
+ .evaluateAll((paths) => [...new Set(paths.map((path) => getComputedStyle(path).fill))]);
+ expect(fills).toContain('rgb(57, 117, 220)');
+
+ await page.keyboard.press('Escape');
+ await expect(preview(page)).toHaveCount(0);
+ await expect(printButton(page)).toBeFocused();
+ await expect(page.locator('html')).not.toHaveAttribute('data-print-scope', 'chart');
+ await page.emulateMedia({ media: 'print' });
+ await expect(page.locator('#root')).toBeVisible();
+ await expect(page.locator('#roles')).toBeVisible();
+ await expect(sheet(page)).toHaveCount(0);
+});
+
+test('Download saves the page as PDF, PNG or JPG without the print dialog', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.print = () => {
+ (window as unknown as { printed: boolean }).printed = true;
+ };
+ });
+ await openGovernance(page);
+ await printButton(page).click();
+ await preview(page).getByRole('radio', { name: 'A4', exact: true }).click();
+ await preview(page).getByRole('radio', { name: 'Landscape', exact: true }).click();
+ await expect(sheet(page).locator('.recharts-bar-rectangle').first()).toBeVisible();
+
+ const pdf = await download(page, 'PDF');
+ expect(pdf.name).toBe('unique-active-contributors-by-role-a4-landscape.pdf');
+ expect(pdf.bytes.subarray(0, 5).toString()).toBe('%PDF-');
+ const printed = pdfPages(pdf.bytes);
+ expect(printed.pages).toBe(1);
+ expect(printed.size[0]).toBeCloseTo(841.89, 1);
+ expect(printed.size[1]).toBeCloseTo(595.28, 1);
+
+ // A4 landscape at 288 dots per inch.
+ const png = await download(page, 'PNG');
+ expect(png.name).toBe('unique-active-contributors-by-role-a4-landscape.png');
+ expect(pixels(png.bytes)).toEqual([3368, 2381]);
+ const jpg = await download(page, 'JPG');
+ expect(jpg.name).toBe('unique-active-contributors-by-role-a4-landscape.jpg');
+ expect(pixels(jpg.bytes)).toEqual([3368, 2381]);
+
+ expect(await page.evaluate(() => (window as unknown as { printed?: boolean }).printed)).toBe(
+ undefined,
+ );
+ await expect(preview(page)).toBeVisible();
+});
diff --git a/web/package-lock.json b/web/package-lock.json
index b411431db..364f91626 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lucide-react": "^1.48.0",
+ "modern-screenshot": "^4.7.0",
"radix-ui": "^1.6.7",
"react": "^19.2.7",
"react-dom": "^19.3.0",
@@ -7394,6 +7395,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/modern-screenshot": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz",
+ "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/web/package.json b/web/package.json
index 68b5d0e95..7c58c6cae 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lucide-react": "^1.48.0",
+ "modern-screenshot": "^4.7.0",
"radix-ui": "^1.6.7",
"react": "^19.2.7",
"react-dom": "^19.3.0",
diff --git a/web/src/app.css b/web/src/app.css
index 65387f947..71cda34d0 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -41,7 +41,10 @@
color-scheme: dark;
}
-:root {
+/* A printed chart's sheet (print.css) keeps the light palette on screen too:
+ its preview is paper, whatever the reader's theme. */
+:root,
+[data-print-sheet] {
/* Every text role is ≥ 4.5:1 (WCAG AA) on Paper and Sheet in both themes. */
--header-h: 72px;
--brand: #a7184e;
@@ -49,6 +52,7 @@
--sidebar-surface: #f8f9fb;
--page: #f3f5f8; /* Paper: page ground */
--surface: #fff; /* Sheet: cards, inputs, popovers */
+ --paper: #fff; /* A printed page, previewed on screen: white in every theme */
--ink: #15181c; /* Ink: primary text; also the active fill (--accent) */
--muted: #59616c; /* Graphite: secondary text (5.9:1 on Paper) */
--edge: #e2e5e9; /* Rule: card & divider borders */
@@ -174,6 +178,7 @@
--color-background: var(--page);
--color-foreground: var(--ink);
--color-card: var(--surface);
+ --color-paper: var(--paper);
--color-card-foreground: var(--ink);
--color-popover: var(--surface);
--color-popover-foreground: var(--ink);
diff --git a/web/src/chartExport.ts b/web/src/chartExport.ts
new file mode 100644
index 000000000..698fc2b33
--- /dev/null
+++ b/web/src/chartExport.ts
@@ -0,0 +1,236 @@
+/**
+ * Saving a printed chart's sheet as a file, drawn in the browser without the
+ * print dialog (so no browser header, footer or URL): a PNG or JPG image of the
+ * page, or a PDF of its pages. The sheet is rendered once, at print resolution,
+ * then laid on paper with the same margins as the printout.
+ */
+
+import {
+ contentBox,
+ MARGIN,
+ PAPERS,
+ paperBox,
+ PX_PER_MM,
+ type Orientation,
+ type Paper,
+} from './chartPrint';
+
+export type FileFormat = 'png' | 'jpg' | 'pdf';
+
+export const FORMATS: { format: FileFormat; label: string }[] = [
+ { format: 'pdf', label: 'PDF' },
+ { format: 'png', label: 'PNG' },
+ { format: 'jpg', label: 'JPG' },
+];
+
+/** 3 × 96 = 288 dots per inch: sharp on paper, and at any zoom on screen. */
+const SCALE = 3;
+/** Keep every canvas side well inside what each browser can allocate. */
+const MAX_SIDE = 16_000;
+const PAPER_WHITE = '#ffffff';
+const PT_PER_MM = 72 / 25.4;
+
+const sameOrigin = (url: string) =>
+ url.startsWith('data:') || new URL(url, location.href).origin === location.origin;
+
+/** The sheet drawn at `scale`: the browser's own layout, rasterised via SVG. */
+async function drawSheet(sheet: HTMLElement, scale: number) {
+ const { domToCanvas } = await import('modern-screenshot');
+ return domToCanvas(sheet, {
+ // Its layout size: the preview scales the sheet on screen, the file must not.
+ width: sheet.offsetWidth,
+ height: sheet.offsetHeight,
+ scale,
+ backgroundColor: PAPER_WHITE,
+ // Avatars are third-party images the page policy does not let a script
+ // read; they are left out, and the initials drawn beneath them stand in.
+ filter: (node) =>
+ !(node instanceof HTMLImageElement && !sameOrigin(node.currentSrc || node.src)),
+ });
+}
+
+function blank(width: number, height: number) {
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(width);
+ canvas.height = Math.round(height);
+ const context = canvas.getContext('2d')!;
+ context.fillStyle = PAPER_WHITE;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ return { canvas, context };
+}
+
+/**
+ * The sheet laid on paper: one tall image (`single`), or one canvas per page,
+ * each continuing where the last page's printable area ended.
+ */
+async function layOut(sheet: HTMLElement, paper: Paper, orientation: Orientation, single: boolean) {
+ const page = paperBox(paper, orientation);
+ const content = contentBox(paper, orientation);
+ const pageHeight = page.height * PX_PER_MM;
+ const sheetHeight = sheet.offsetHeight + 2 * MARGIN * PX_PER_MM;
+ const tallest = single ? Math.max(pageHeight, sheetHeight) : pageHeight;
+ const scale = Math.min(SCALE, MAX_SIDE / tallest, MAX_SIDE / (page.width * PX_PER_MM));
+ const drawn = await drawSheet(sheet, scale);
+ const margin = MARGIN * PX_PER_MM * scale;
+ const width = page.width * PX_PER_MM * scale;
+ if (single) {
+ const { canvas, context } = blank(width, tallest * scale);
+ context.drawImage(drawn, margin, margin);
+ return [canvas];
+ }
+ const slice = content.height * PX_PER_MM * scale;
+ const count = Math.max(1, Math.ceil(drawn.height / slice - 0.001));
+ return Array.from({ length: count }, (_, index) => {
+ const { canvas, context } = blank(width, pageHeight * scale);
+ const top = index * slice;
+ const height = Math.min(slice, drawn.height - top);
+ context.drawImage(drawn, 0, top, drawn.width, height, margin, margin, drawn.width, height);
+ return canvas;
+ });
+}
+
+const encoder = new TextEncoder();
+
+async function deflate(bytes: Uint8Array) {
+ // zlib-wrapped, which is exactly PDF's FlateDecode.
+ const stream = new Blob([bytes as BlobPart])
+ .stream()
+ .pipeThrough(new CompressionStream('deflate'));
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+}
+
+/** A canvas as 8-bit RGB samples, the layout a PDF image stream expects. */
+function rgb(canvas: HTMLCanvasElement) {
+ const { data } = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height);
+ const samples = new Uint8Array(canvas.width * canvas.height * 3);
+ for (let pixel = 0, out = 0; pixel < data.length; pixel += 4) {
+ samples[out++] = data[pixel];
+ samples[out++] = data[pixel + 1];
+ samples[out++] = data[pixel + 2];
+ }
+ return samples;
+}
+
+/** A PDF text string, as UTF-16 so titles keep their dashes and accents. */
+function pdfText(text: string) {
+ let hex = '';
+ for (let index = 0; index < text.length; index++) {
+ hex += text.charCodeAt(index).toString(16).padStart(4, '0');
+ }
+ return ``;
+}
+
+/**
+ * A minimal PDF: one losslessly compressed image per page, at the paper's exact
+ * size. Hand-written rather than a library, since that is all it needs to hold.
+ */
+export async function pagesToPdf(
+ pages: HTMLCanvasElement[],
+ size: { width: number; height: number },
+ title: string,
+) {
+ const width = (size.width * PT_PER_MM).toFixed(2);
+ const height = (size.height * PT_PER_MM).toFixed(2);
+ const chunks: Uint8Array[] = [];
+ const offsets: number[] = [];
+ let length = 0;
+ const push = (part: string | Uint8Array) => {
+ const bytes = typeof part === 'string' ? encoder.encode(part) : part;
+ chunks.push(bytes);
+ length += bytes.length;
+ };
+ const object = (id: number, body: string, stream?: Uint8Array) => {
+ offsets[id] = length;
+ push(`${id} 0 obj\n${body}\n`);
+ if (stream) {
+ push('stream\n');
+ push(stream);
+ push('\nendstream\n');
+ }
+ push('endobj\n');
+ };
+
+ // 1 catalog, 2 page tree, 3 info; then page, contents and image per page.
+ const pageId = (index: number) => 4 + index * 3;
+ push('%PDF-1.4\n%âãÏÓ\n');
+ object(1, '<< /Type /Catalog /Pages 2 0 R >>');
+ object(
+ 2,
+ `<< /Type /Pages /Count ${pages.length} /Kids [${pages.map((_, i) => `${pageId(i)} 0 R`).join(' ')}] >>`,
+ );
+ object(3, `<< /Title ${pdfText(title)} /Producer ${pdfText('Hiero analytics')} >>`);
+ for (const [index, page] of pages.entries()) {
+ const id = pageId(index);
+ object(
+ id,
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] /Resources << /XObject << /Im0 ${id + 2} 0 R >> >> /Contents ${id + 1} 0 R >>`,
+ );
+ const drawing = encoder.encode(`q ${width} 0 0 ${height} 0 0 cm /Im0 Do Q`);
+ object(id + 1, `<< /Length ${drawing.length} >>`, drawing);
+ const image = await deflate(rgb(page));
+ object(
+ id + 2,
+ `<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length ${image.length} >>`,
+ image,
+ );
+ }
+ const count = pageId(pages.length);
+ const xref = length;
+ push(`xref\n0 ${count}\n0000000000 65535 f \n`);
+ for (let id = 1; id < count; id++) push(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
+ push(`trailer\n<< /Size ${count} /Root 1 0 R /Info 3 0 R >>\nstartxref\n${xref}\n%%EOF\n`);
+ return new Blob(chunks as BlobPart[], { type: 'application/pdf' });
+}
+
+const toBlob = (canvas: HTMLCanvasElement, type: string) =>
+ new Promise((resolve, reject) =>
+ canvas.toBlob(
+ (blob) => (blob ? resolve(blob) : reject(new Error('Image encoding failed'))),
+ type,
+ 0.92,
+ ),
+ );
+
+/** The file for one sheet: its pages as a PDF, or the page as one image. */
+export async function exportSheet(
+ sheet: HTMLElement,
+ {
+ format,
+ paper,
+ orientation,
+ title,
+ }: { format: FileFormat; paper: Paper; orientation: Orientation; title: string },
+) {
+ await document.fonts?.ready;
+ if (format === 'pdf') {
+ const pages = await layOut(sheet, paper, orientation, false);
+ return pagesToPdf(pages, paperBox(paper, orientation), title);
+ }
+ const [image] = await layOut(sheet, paper, orientation, true);
+ return toBlob(image, format === 'png' ? 'image/png' : 'image/jpeg');
+}
+
+/** "Role activity" on A4 landscape → role-activity-a4-landscape.pdf */
+export function exportName(
+ title: string,
+ paper: Paper,
+ orientation: Orientation,
+ format: FileFormat,
+) {
+ const slug =
+ title
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'chart';
+ return `${slug}-${PAPERS[paper].label.toLowerCase()}-${orientation}.${format}`;
+}
+
+export function saveBlob(blob: Blob, filename: string) {
+ const anchor = document.createElement('a');
+ anchor.href = URL.createObjectURL(blob);
+ anchor.download = filename;
+ anchor.click();
+ // Some browsers read the URL after click returns; release it once they have.
+ setTimeout(() => URL.revokeObjectURL(anchor.href), 1000);
+}
diff --git a/web/src/chartPrint.ts b/web/src/chartPrint.ts
new file mode 100644
index 000000000..e7aae3d9e
--- /dev/null
+++ b/web/src/chartPrint.ts
@@ -0,0 +1,98 @@
+/**
+ * Paper for printing one chart (`ChartPrintDialog`): sizes, the page rule, the
+ * orientation a chart fits best, and the reader's remembered paper size. All
+ * lengths are millimetres; CSS pixels are 1/96 in on paper as on screen.
+ */
+
+export type Paper = 'a4' | 'letter';
+export type Orientation = 'portrait' | 'landscape';
+
+export const PAPERS: Record =
+ {
+ a4: { label: 'A4', size: 'A4', width: 210, height: 297 },
+ letter: { label: 'Letter', size: 'letter', width: 215.9, height: 279.4 },
+ };
+
+/** The page margin on every side. */
+export const MARGIN = 12;
+export const PX_PER_MM = 96 / 25.4;
+/** Kept off the printable height, so rounding can never spill a blank second page. */
+const SAFETY = 1;
+/** The title, filters, legend and notes around the chart, when comparing orientations. */
+const CHROME = 60;
+
+export function paperBox(paper: Paper, orientation: Orientation) {
+ const { width, height } = PAPERS[paper];
+ return orientation === 'portrait' ? { width, height } : { width: height, height: width };
+}
+
+/** The printable area inside the margins. */
+export function contentBox(paper: Paper, orientation: Orientation) {
+ const { width, height } = paperBox(paper, orientation);
+ return { width: width - 2 * MARGIN, height: height - 2 * MARGIN - SAFETY };
+}
+
+/**
+ * The orientation that draws a chart of this shape (width ÷ height) largest:
+ * the one giving it the most height once it is also narrow enough to fit.
+ */
+export function bestOrientation(aspect: number, paper: Paper): Orientation {
+ const fit = (orientation: Orientation) => {
+ const { width, height } = contentBox(paper, orientation);
+ return Math.min(width / aspect, height - CHROME);
+ };
+ return fit('portrait') > fit('landscape') ? 'portrait' : 'landscape';
+}
+
+/**
+ * The page rule the browser prints with. The page itself has no margin, so the
+ * browser has no room for its own header and footer (date, title, URL); the
+ * sheet pads itself by `MARGIN` on every printed page instead (print.css).
+ */
+export function pageRule(paper: Paper, orientation: Orientation) {
+ return `@page { size: ${PAPERS[paper].size} ${orientation}; margin: 0; @bottom-left { content: none; } @bottom-right { content: none; } }`;
+}
+
+/** Regions whose default office paper is Letter; everywhere else prints on A4. */
+const LETTER_REGIONS = new Set(['US', 'CA', 'MX', 'PH', 'CL', 'CO', 'VE', 'GT', 'CR', 'PA', 'PR']);
+
+export function defaultPaper(language = globalThis.navigator?.language ?? 'en'): Paper {
+ try {
+ const region = new Intl.Locale(language).maximize().region;
+ return region && LETTER_REGIONS.has(region) ? 'letter' : 'a4';
+ } catch {
+ return 'a4';
+ }
+}
+
+export const PAPER_STORAGE_KEY = 'hiero-analytics-print-paper';
+
+/** The paper size the reader last printed on, or their region's default. */
+export function storedPaper(): Paper {
+ try {
+ const stored = window.localStorage.getItem(PAPER_STORAGE_KEY);
+ if (stored === 'a4' || stored === 'letter') return stored;
+ } catch {
+ // Storage can be blocked; the regional default still applies.
+ }
+ return defaultPaper();
+}
+
+export function storePaper(paper: Paper) {
+ try {
+ window.localStorage.setItem(PAPER_STORAGE_KEY, paper);
+ } catch {
+ // Not remembered, but printing is unaffected.
+ }
+}
+
+/**
+ * A control's accessible name as a printed filter label: "Role activity chart
+ * style" on a chart titled "Role activity" prints as "Chart style".
+ */
+export function filterLabel(ariaLabel: string, title: string) {
+ const rest = ariaLabel.startsWith(title)
+ ? ariaLabel.slice(title.length).replace(/^[\s:·—-]+/, '')
+ : ariaLabel;
+ return rest ? `${rest[0].toUpperCase()}${rest.slice(1)}` : ariaLabel;
+}
diff --git a/web/src/components/ChartSectionCard.tsx b/web/src/components/ChartSectionCard.tsx
index 4e6c5b611..e7c9e341c 100644
--- a/web/src/components/ChartSectionCard.tsx
+++ b/web/src/components/ChartSectionCard.tsx
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { fetchApiText, type ChartSection, type ChartSpec, type Manifest } from '../api';
import { downloadCsvText } from '../csv';
import { usePrintMode } from '../printContext';
+import { ChartSectionTitle } from './charts/leading';
import { CopyLinkButton } from './CopyLinkButton';
import { DownloadButton } from './CsvDownloadButton';
import { SectionCard } from './SectionCard';
@@ -32,6 +33,7 @@ const axisOf = (chart: ChartSpec) => JSON.stringify(chart.variants.map((variant)
function Figure({
chart,
+ section,
stateKey,
slide = false,
stretch = false,
@@ -41,6 +43,8 @@ function Figure({
hidden = false,
}: {
chart: ChartSpec;
+ /** The card's title, which the printed chart names above its own. */
+ section: string;
/** URL key for this figure's own tab, so a shared link opens the same variant. */
stateKey: string;
provenance: Manifest['provenance'];
@@ -86,24 +90,26 @@ function Figure({
+ >
+ }
+ explanation={explanation}
+ />
+
+ )}
{/* While printing, an expanded chart also prints in place. Its dialog keeps
its own copy mounted (hidden by print.css), so the open explanation and
scroll position are still there afterwards. */}
diff --git a/web/src/components/charts/EventsView.tsx b/web/src/components/charts/EventsView.tsx
index fe2193da9..56189a44b 100644
--- a/web/src/components/charts/EventsView.tsx
+++ b/web/src/components/charts/EventsView.tsx
@@ -15,11 +15,15 @@ import type { ColumnSpec, EventsDocument, Row } from '../../api';
import { dimensionOf, matches, toggle, useFocus } from '../../focus';
import { stamp } from '../../format';
import { useUrlList } from '../../urlState';
-import { ChartShell, TABLE_CONTAINER, type ViewProps } from './ChartShell';
-import { integer, plural, shorten, windowText } from './format';
+import { ChartShell, TABLE_CONTAINER, type SheetBox, type ViewProps } from './ChartShell';
+import { integer, plural, rangeText, shorten, windowText } from './format';
const ROW_HEIGHT = 28;
const AXIS_HEIGHT = 48;
+/** A printed timeline fits its rows to the page between these heights, then runs on. */
+const PRINT_ROW = { min: 11, max: 40 };
+/** The caption under the printed timeline. */
+const CAPTION = 24;
const DAY = 86_400_000;
type Point = Row & { t: number; y: number };
@@ -72,7 +76,12 @@ export function EventsView({ data, title, period, provenance }: ViewProps 1 ? (
-