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, `<html data-print-scope="chart">` 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 `<FEFF${hex}>`; +} + +/** + * 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<Blob>((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<Paper, { label: string; size: string; width: number; height: number }> = + { + 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({ </p> } > - <InteractiveChart - key={active.interactive.path} - variant={active} - title={chart.title} - provenance={provenance} - roomy={lead} - leading={ - axis ? undefined : ( - <VariantTabs - appearance="segmented" - labels={chart.variants.map((option) => option.label)} - active={variant} - onSelect={setOwn} - ariaLabel={`${chart.title} view`} - /> - ) - } - /> + <ChartSectionTitle.Provider value={section}> + <InteractiveChart + key={active.interactive.path} + variant={active} + title={chart.title} + provenance={provenance} + roomy={lead} + leading={ + axis ? undefined : ( + <VariantTabs + appearance="segmented" + labels={chart.variants.map((option) => option.label)} + active={variant} + onSelect={setOwn} + ariaLabel={`${chart.title} view`} + /> + ) + } + /> + </ChartSectionTitle.Provider> </Suspense> ) : ( <div @@ -278,6 +284,7 @@ export function ChartSectionCard({ <Figure key={chart.title} chart={chart} + section={section.title} stateKey={`${section.id}.${index}.tab`} provenance={provenance} slide @@ -294,6 +301,7 @@ export function ChartSectionCard({ <Figure key={chart.title} chart={chart} + section={section.title} stateKey={`${section.id}.${index}.tab`} provenance={provenance} stretch={stretched[index]} diff --git a/web/src/components/ContributorCell.tsx b/web/src/components/ContributorCell.tsx index 80c455ee4..3926379f0 100644 --- a/web/src/components/ContributorCell.tsx +++ b/web/src/components/ContributorCell.tsx @@ -1,7 +1,14 @@ import { useState } from 'react'; /** A GitHub login with its avatar; initials stand in when the image fails. */ -export function ContributorCell({ login }: { login: string }) { +export function ContributorCell({ + login, + compact = false, +}: { + login: string; + /** A smaller avatar, for dense grids such as a printed heatmap. */ + compact?: boolean; +}) { const [failed, setFailed] = useState(false); if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}(\[bot\])?$/.test(login)) return <>{login}</>; return ( @@ -9,10 +16,10 @@ export function ContributorCell({ login }: { login: string }) { href={`https://github.com/${encodeURIComponent(login)}`} target="_blank" rel="noopener noreferrer" - className="group/person inline-flex items-center gap-2.5 rounded-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={`group/person inline-flex items-center ${compact ? 'gap-1.5' : 'gap-2.5'} rounded-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring`} > <span - className="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-xs text-muted-foreground" + className={`relative flex ${compact ? 'size-5 text-[8px]' : 'size-8 text-xs'} shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-muted-foreground`} aria-hidden="true" > {login.slice(0, 2).toUpperCase()} @@ -23,7 +30,8 @@ export function ContributorCell({ login }: { login: string }) { alt="" width={32} height={32} - loading="lazy" + // A printed grid has no scroll to trigger a lazy load before the page is captured. + loading={compact ? 'eager' : 'lazy'} referrerPolicy="no-referrer" onError={() => setFailed(true)} /> diff --git a/web/src/components/PrintFilter.tsx b/web/src/components/PrintFilter.tsx new file mode 100644 index 000000000..92b98c2b8 --- /dev/null +++ b/web/src/components/PrintFilter.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +/** A setting or selection stated on a printed chart: "Chart style Bars". */ +export function PrintFilter({ label, value }: { label: string; value: ReactNode }) { + return ( + <p + data-print-filter + className="inline-flex max-w-full items-baseline gap-1.5 rounded-md border px-1.5 py-0.5 text-[11px] leading-snug" + > + <span className="text-muted-foreground">{label}</span>{' '} + <span className="font-medium text-foreground">{value}</span> + </p> + ); +} diff --git a/web/src/components/VariantTabs.tsx b/web/src/components/VariantTabs.tsx index 75899af50..6369199fa 100644 --- a/web/src/components/VariantTabs.tsx +++ b/web/src/components/VariantTabs.tsx @@ -4,10 +4,12 @@ * Separate from `PeriodTabs`, whose "All time" null state has no role-axis equivalent. */ -import type { ReactNode } from 'react'; +import { useContext, type ReactNode } from 'react'; import { cn } from 'cn'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { usePrintMode } from '../printContext'; +import { filterLabel } from '../chartPrint'; +import { PrintSheetContext, usePrintMode } from '../printContext'; +import { PrintFilter } from './PrintFilter'; export function VariantTabs({ labels, @@ -32,9 +34,19 @@ export function VariantTabs({ printSelection?: boolean; }) { const printing = usePrintMode(); + const sheet = useContext(PrintSheetContext); if (labels.length < 2) { return null; } + // A printed chart's sheet is its own copy: the choice as a filter, no control. + if (sheet) { + return printSelection ? ( + <PrintFilter + label={filterLabel(ariaLabel, sheet.title)} + value={labels[active] ?? labels[0]} + /> + ) : null; + } // Paper states the selection in words; the control stays mounted (hidden) so // focus and the choice survive print/cancel. const selection = printing && printSelection && ( diff --git a/web/src/components/charts/AdjacencyMatrix.tsx b/web/src/components/charts/AdjacencyMatrix.tsx index 757c1f566..ce12b306e 100644 --- a/web/src/components/charts/AdjacencyMatrix.tsx +++ b/web/src/components/charts/AdjacencyMatrix.tsx @@ -25,6 +25,7 @@ export function AdjacencyMatrix({ member, selected, onSelect, + fit = false, }: { nodes: NetworkNode[]; edges: { source: string; target: string; shared: number }[]; @@ -32,6 +33,8 @@ export function AdjacencyMatrix({ member: string; selected: string | null; onSelect: (id: string) => void; + /** Shrink to the height it is given (a printed page) instead of scrolling. */ + fit?: boolean; }) { const [hover, setHover] = useState<{ row: number; col: number } | null>(null); @@ -105,18 +108,20 @@ export function AdjacencyMatrix({ }; return ( - <div className="space-y-2"> - <p className="min-h-5 text-xs text-foreground" aria-live="polite"> - {readout} - </p> - <div className="overflow-auto rounded-lg border bg-card"> + <div className={fit ? 'flex min-h-0 flex-1 flex-col gap-2' : 'space-y-2'}> + {!fit && ( + <p className="min-h-5 text-xs text-foreground" aria-live="polite"> + {readout} + </p> + )} + <div className={`rounded-lg border bg-card ${fit ? 'flex min-h-0 flex-1' : 'overflow-auto'}`}> {/* Scrolls rather than shrinks when narrow; grows up to 1.5× in a wide card. */} <svg viewBox={`0 0 ${width} ${height}`} - style={{ minWidth: width, maxWidth: width * 1.5 }} + style={fit ? undefined : { minWidth: width, maxWidth: width * 1.5 }} role="group" aria-label={`Shared ${member} between every pair of ${n} repositories, grouped by type. Use Data for the full list.`} - className="mx-auto block w-full" + className={fit ? 'block h-full min-h-0 w-full' : 'mx-auto block w-full'} onPointerLeave={() => setHover(null)} > {order.map((node, i) => ( diff --git a/web/src/components/charts/ChartPrintDialog.tsx b/web/src/components/charts/ChartPrintDialog.tsx new file mode 100644 index 000000000..ba8440c7e --- /dev/null +++ b/web/src/components/charts/ChartPrintDialog.tsx @@ -0,0 +1,407 @@ +/** + * One chart's print preview. The chart is laid out as a sheet of paper at the + * size it prints — only scaled down on screen — so the preview is the printout. + * While the preview is open the page prints that sheet alone (print.css, chart + * scope), on the paper size and orientation chosen here. + */ + +import { useCallback, useEffect, useRef, useState, type ReactNode, type Ref } from 'react'; +import { useElementSize, type ElementSize } from '@/hooks/use-element-size'; +import { CircleAlertIcon, DownloadIcon, PrinterIcon, TriangleAlertIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + bestOrientation, + contentBox, + MARGIN, + pageRule, + PAPERS, + paperBox, + PX_PER_MM, + storedPaper, + storePaper, + type Orientation, + type Paper, +} from '../../chartPrint'; +import { exportName, exportSheet, FORMATS, saveBlob, type FileFormat } from '../../chartExport'; +import { stamp } from '../../format'; +import { PrintSheetContext } from '../../printContext'; +import { VariantTabs } from '../VariantTabs'; + +/** The room a view has for its chart on the sheet, in CSS pixels. */ +export type SheetBox = ElementSize; + +/** Space between the sheet's header, chart and footer. */ +const GAP = 14; +/** A chart never gets less height than this, even under a long header. */ +const MIN_CHART = 200; +/** Header and footer heights until they are first measured. */ +const HEADER_ESTIMATE = 150; +const FOOTER_ESTIMATE = 90; + +const PAPER_ORDER: Paper[] = ['a4', 'letter']; +const ORIENTATIONS = ['auto', 'portrait', 'landscape'] as const; + +interface SheetContent { + title: string; + /** Where the chart sits: organisation and card. */ + eyebrow: string; + subtitle: string; + /** The selection as `PrintFilter`s, and the switches that render as them. */ + filters: ReactNode; + legend?: ReactNode; + chart: (box: SheetBox) => ReactNode; + /** Who is counted and over which window: always printed. */ + notes: ReactNode; + /** How to read it and how it is measured: printed on request. */ + explanation?: ReactNode; +} + +function Sheet({ + ref, + paper, + orientation, + withExplanation, + withDate, + title, + eyebrow, + subtitle, + filters, + legend, + chart, + notes, + explanation, +}: SheetContent & { + ref: Ref<HTMLElement>; + paper: Paper; + orientation: Orientation; + withExplanation: boolean; + withDate: boolean; +}) { + const content = contentBox(paper, orientation); + const [headerRef, header] = useElementSize(); + const [footerRef, footer] = useElementSize(); + // When the preview opened: the moment this printout describes. + const [printedAt] = useState(() => new Date().toISOString()); + const box = { + width: Math.floor(content.width * PX_PER_MM), + height: Math.max( + MIN_CHART, + Math.floor( + content.height * PX_PER_MM - + (header?.height ?? HEADER_ESTIMATE) - + (footer?.height ?? FOOTER_ESTIMATE) - + 2 * GAP, + ), + ), + }; + return ( + <article + ref={ref} + data-print-sheet + className="flex flex-col" + style={{ width: `${content.width}mm`, minHeight: `${content.height}mm`, gap: GAP }} + > + <header ref={headerRef} className="space-y-2"> + {eyebrow && ( + <p className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> + {eyebrow} + </p> + )} + <div> + <h1 className="font-display text-[20px] leading-tight font-semibold tracking-tight"> + {title} + </h1> + <p className="mt-0.5 text-[12px] text-muted-foreground">{subtitle}</p> + </div> + <PrintSheetContext.Provider value={{ title }}> + <div data-print-filters className="flex flex-wrap gap-1.5"> + {filters} + </div> + {legend && <div data-print-legend-row>{legend}</div>} + </PrintSheetContext.Provider> + </header> + <div data-print-chart className="min-w-0"> + <PrintSheetContext.Provider value={{ title }}>{chart(box)}</PrintSheetContext.Provider> + </div> + <footer + ref={footerRef} + className="mt-auto space-y-1 border-t pt-2 text-[10px] leading-snug text-muted-foreground" + > + {notes} + {withExplanation && explanation} + {withDate && <p>Printed {stamp(printedAt)} UTC</p>} + </footer> + </article> + ); +} + +function Setting({ label, children }: { label: string; children: ReactNode }) { + return ( + <div className="space-y-1.5"> + <p className="text-xs font-medium">{label}</p> + {children} + </div> + ); +} + +export function ChartPrintDialog({ + open, + onOpenChange, + onClosed, + aspect, + ...content +}: SheetContent & { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Return focus to the action that opened the preview (it has no DialogTrigger). */ + onClosed: () => void; + /** The chart's natural width ÷ height: it picks the automatic orientation. */ + aspect: number; +}) { + const [paper, setPaper] = useState<Paper>(storedPaper); + const [choice, setChoice] = useState<(typeof ORIENTATIONS)[number]>('auto'); + // Off by default: a long methodology would take the chart's room. One click adds it. + const [withExplanation, setWithExplanation] = useState(false); + // Off by default too: the sheet already states when its data was generated. + const [withDate, setWithDate] = useState(false); + const [measureSheet, sheet] = useElementSize(); + // The sheet element itself, which Download renders to a file. + const sheetNode = useRef<HTMLElement | null>(null); + const sheetRef = useCallback( + (element: HTMLElement | null) => { + sheetNode.current = element; + return measureSheet(element); + }, + [measureSheet], + ); + const [format, setFormat] = useState<FileFormat>('pdf'); + const [saving, setSaving] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + const [stageRef, stage] = useElementSize(); + const sheetHeight = sheet?.height ?? 0; + const recommended = bestOrientation(aspect, paper); + const orientation = choice === 'auto' ? recommended : choice; + // Escape that dismisses the browser's print dialog must not close the preview too. + const printingRef = useRef(false); + + // While open, printing (the button below, or the browser's own command) prints this sheet. + useEffect(() => { + if (!open) return; + const root = document.documentElement; + root.dataset.printScope = 'chart'; + const settle = () => requestAnimationFrame(() => (printingRef.current = false)); + window.addEventListener('afterprint', settle); + return () => { + delete root.dataset.printScope; + window.removeEventListener('afterprint', settle); + }; + }, [open]); + + const page = paperBox(paper, orientation); + const pageWidth = page.width * PX_PER_MM; + const pageHeight = page.height * PX_PER_MM; + const contentHeight = contentBox(paper, orientation).height * PX_PER_MM; + const margin = MARGIN * PX_PER_MM; + // A tall selection (every row of a long ranking) runs on; the browser breaks it into pages. + const pages = Math.max(1, Math.ceil(sheetHeight / contentHeight - 0.001)); + // The whole first page fits the stage's width and its height cap (64dvh, at most 680px). + const stageCap = Math.min(window.innerHeight * 0.64, 680); + const scale = stage + ? Math.min(1, (stage.width - 32) / pageWidth, (stageCap - 32) / pageHeight) + : 0.5; + const frameHeight = Math.max(pageHeight, sheetHeight + 2 * margin); + const paperName = `${PAPERS[paper].label} ${orientation}`; + + const print = async () => { + await document.fonts?.ready; + printingRef.current = true; + window.print(); + }; + // A file made here, not by the print dialog: no browser header, footer or URL. + const download = async () => { + if (!sheetNode.current || saving) return; + setSaving(true); + setSaveFailed(false); + try { + const blob = await exportSheet(sheetNode.current, { + format, + paper, + orientation, + title: content.title, + }); + saveBlob(blob, exportName(content.title, paper, orientation, format)); + } catch { + setSaveFailed(true); + } finally { + setSaving(false); + } + }; + const formatLabel = FORMATS.find((option) => option.format === format)!.label; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent + data-print-preview + data-print-path + className="max-h-[94dvh] overflow-y-auto sm:max-w-[min(96vw,1180px)]" + onEscapeKeyDown={(event) => printingRef.current && event.preventDefault()} + onCloseAutoFocus={(event) => { + event.preventDefault(); + onClosed(); + }} + > + <style>{pageRule(paper, orientation)}</style> + <DialogHeader data-print-chrome> + <DialogTitle>Print preview</DialogTitle> + <DialogDescription> + {content.title} on {paperName} paper, {pages === 1 ? 'one page' : `${pages} pages`}. The + chart prints exactly as shown, with the selection it has on the dashboard. + </DialogDescription> + </DialogHeader> + <div data-print-path className="grid min-w-0 gap-4 md:grid-cols-[minmax(0,1fr)_15rem]"> + <div + ref={stageRef} + data-print-path + className="max-h-[min(64dvh,680px)] min-w-0 overflow-auto rounded-lg bg-muted p-4" + > + {/* The page, scaled to fit; inert so its chart is not a second set of controls. */} + <div + data-print-path + inert + className="relative mx-auto bg-paper shadow-md ring-1 ring-foreground/10" + style={{ width: pageWidth * scale, height: frameHeight * scale }} + > + <div + data-print-path + data-print-scale + className="origin-top-left" + style={{ width: pageWidth, padding: margin, transform: `scale(${scale})` }} + > + <Sheet + {...content} + ref={sheetRef} + paper={paper} + orientation={orientation} + withExplanation={withExplanation} + withDate={withDate} + /> + </div> + {Array.from({ length: pages - 1 }, (_, index) => ( + <div + key={index} + data-print-chrome + aria-hidden="true" + className="absolute inset-x-0 border-t border-dashed border-link" + style={{ top: (margin + (index + 1) * contentHeight) * scale }} + /> + ))} + </div> + </div> + <div data-print-chrome className="space-y-4"> + <Setting label="Paper size"> + <VariantTabs + appearance="segmented" + labels={PAPER_ORDER.map((option) => PAPERS[option].label)} + active={PAPER_ORDER.indexOf(paper)} + onSelect={(index) => { + setPaper(PAPER_ORDER[index]); + storePaper(PAPER_ORDER[index]); + }} + ariaLabel="Paper size" + /> + </Setting> + <Setting label="Orientation"> + <VariantTabs + appearance="segmented" + labels={['Best fit', 'Portrait', 'Landscape']} + active={ORIENTATIONS.indexOf(choice)} + onSelect={(index) => setChoice(ORIENTATIONS[index])} + ariaLabel="Orientation" + /> + <p className="text-xs text-muted-foreground"> + Best fit is {recommended}: it gives this chart the most room. + </p> + </Setting> + {content.explanation && ( + <Setting label="How to read it and methodology"> + <VariantTabs + appearance="segmented" + labels={['Include', 'Leave out']} + active={withExplanation ? 0 : 1} + onSelect={(index) => setWithExplanation(index === 0)} + ariaLabel="How to read it and methodology" + /> + </Setting> + )} + <Setting label="Printed date"> + <VariantTabs + appearance="segmented" + labels={['Include', 'Leave out']} + active={withDate ? 0 : 1} + onSelect={(index) => setWithDate(index === 0)} + ariaLabel="Printed date" + /> + </Setting> + <Setting label="Save as"> + <VariantTabs + appearance="segmented" + labels={FORMATS.map((option) => option.label)} + active={FORMATS.findIndex((option) => option.format === format)} + onSelect={(index) => setFormat(FORMATS[index].format)} + ariaLabel="File type" + /> + <p className="text-xs text-muted-foreground"> + {format === 'pdf' + ? `One ${PAPERS[paper].label} page per printed page.` + : `The whole sheet as one image, at print resolution.`} + </p> + </Setting> + {saveFailed && ( + <Alert variant="destructive"> + <CircleAlertIcon /> + <AlertTitle>Could not create the {formatLabel} file.</AlertTitle> + <AlertDescription>Try again, or use Print instead.</AlertDescription> + </Alert> + )} + {pages > 1 && ( + <p + role="status" + className="flex gap-1.5 rounded-md border border-dashed px-2.5 py-2 text-xs text-warn-ink" + > + <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" /> + This selection needs {pages} pages. Portrait, or fewer rows on the dashboard, can + fit it on one. + </p> + )} + <p className="text-xs leading-relaxed text-muted-foreground"> + Download saves exactly this page as a file. Print sends it to your browser’s print + dialog: keep the paper at {paperName} and the scale at 100%. + </p> + </div> + </div> + <DialogFooter data-print-chrome> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button variant="outline" disabled={saving} onClick={() => void download()}> + <DownloadIcon data-icon="inline-start" /> + {saving ? `Saving ${formatLabel}…` : `Download ${formatLabel}`} + </Button> + <Button onClick={() => void print()}> + <PrinterIcon data-icon="inline-start" /> + Print + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/web/src/components/charts/ChartShell.tsx b/web/src/components/charts/ChartShell.tsx index d05e66a67..906e26e77 100644 --- a/web/src/components/charts/ChartShell.tsx +++ b/web/src/components/charts/ChartShell.tsx @@ -1,23 +1,39 @@ /** * The frame every interactive chart shares: heading, Chart / Data switch, CSV, - * expanded dialog and explanatory footer. Views build the chart, table and CSV - * from one selection, so the three can never disagree. + * print preview, expanded dialog and explanatory footer. Views build the chart, + * table, CSV and printed sheet from one selection, so the four can never disagree. */ -import { useContext, useRef, useState, type ReactNode } from 'react'; -import { ChartLeading } from './leading'; -import { CrosshairIcon } from 'lucide-react'; -import { Maximize2Icon } from 'lucide-react'; +import { lazy, Suspense, useContext, useRef, useState, type ReactNode } from 'react'; +import { ChartLeading, ChartSectionTitle } from './leading'; +import { CrosshairIcon, Maximize2Icon, PrinterIcon, XIcon } from 'lucide-react'; +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import type { ChartDocumentMeta, Manifest } from '../../api'; import type { CsvExport } from '../../csv'; import { DIMENSION_LABELS, dimensionOf, useFocus } from '../../focus'; import { stamp } from '../../format'; +import { OrgContext } from '../../orgContext'; import { usePrintMode } from '../../printContext'; import { useUrlIndex } from '../../urlState'; import { CsvDownloadButton } from '../CsvDownloadButton'; +import { PrintFilter } from '../PrintFilter'; import { VariantTabs } from '../VariantTabs'; +import type { SheetBox } from './ChartPrintDialog'; + +// Loaded when a reader first opens a print preview. +const ChartPrintDialog = lazy(() => + import('./ChartPrintDialog').then((module) => ({ default: module.ChartPrintDialog })), +); + +export type { SheetBox }; + +/** A selection the printed sheet states that no switch shows: a search, hidden series, a span. */ +export interface PrintFilterSpec { + label: string; + value: string; +} export interface ViewProps<T> { data: T; @@ -43,13 +59,16 @@ export function ChartShell({ empty, emptyText = 'No data is available for this selection.', focusFound = true, + printFilters = [], + printAspect = 2, }: ViewProps<ChartDocumentMeta> & { subtitle: string; /** Segmented settings (mark, scale, span, cohort), shown for chart and table alike. */ toolbar?: ReactNode; /** Series toggles, search or comparisons under the toolbar, for chart and table alike. */ controls?: ReactNode; - chart: (expanded: boolean) => ReactNode; + /** The chart itself; `sheet` is its room on the printed page when drawing the print preview. */ + chart: (expanded: boolean, sheet?: SheetBox) => ReactNode; table: ReactNode; csv: () => Omit<CsvExport, 'total' | 'dataAsOf'>; windowNote: string; @@ -57,6 +76,10 @@ export function ChartShell({ emptyText?: string; /** Whether the focused value appears in this chart (when it has the dimension). */ focusFound?: boolean; + /** Printed as filters beside the switches' own choices. */ + printFilters?: (PrintFilterSpec | false | null | undefined)[]; + /** The chart's natural width ÷ height, which picks the printed orientation. */ + printAspect?: number; }) { // Chart / Data lives in the URL, so a shared link opens the same view. const [view, setView] = useUrlIndex(`${data.id}.view`); @@ -66,6 +89,14 @@ export function ChartShell({ const printing = usePrintMode(); const leading = useContext(ChartLeading); const expandRef = useRef<HTMLButtonElement>(null); + const printRef = useRef<HTMLButtonElement>(null); + const org = useContext(OrgContext); + const section = useContext(ChartSectionTitle); + const [previewing, setPreviewing] = useState(false); + // The URL at the moment Print found nothing to print: every selection lives + // there, so the message stands until the reader changes one. + const [nothingAt, setNothingAt] = useState<string | null>(null); + const nothingToPrint = empty && nothingAt === window.location.hash; const heading = period !== title ? `${title} · ${period}` : title; // Lead with the unit, dropping a first part that merely repeats it ("Maintainers" vs "Maintainers (share …)"). const unit = data.unit.toLowerCase(); @@ -110,6 +141,23 @@ export function ChartShell({ return { ...source, total: source.rows.length, dataAsOf: data.generated_at }; }} /> + <Button + ref={printRef} + variant="outline" + size="sm" + aria-label={`Print chart: ${title}`} + title="Print chart" + onClick={() => { + if (empty) { + setNothingAt(window.location.hash); + } else { + setNothingAt(null); + setPreviewing(true); + } + }} + > + <PrinterIcon /> <span className="hidden @lg:inline">Print</span> + </Button> {!expanded && ( <Button ref={expandRef} @@ -134,6 +182,26 @@ export function ChartShell({ {toolbar} </div> )} + {nothingToPrint && ( + <Alert data-print-hide> + <PrinterIcon /> + <AlertTitle>Nothing to print for this selection</AlertTitle> + <AlertDescription> + {emptyText} Choose another period or view, show a hidden series, or clear the search or + focus, then print again. + </AlertDescription> + <AlertAction> + <Button + variant="ghost" + size="icon-xs" + aria-label="Dismiss" + onClick={() => setNothingAt(null)} + > + <XIcon /> + </Button> + </AlertAction> + </Alert> + )} {controls} {focus && (!supported || !focusFound) && ( <p className="flex flex-wrap items-center gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground"> @@ -182,8 +250,74 @@ export function ChartShell({ </div> </div> ); + // The printed sheet: the same switches, controls and chart, drawn for paper. + const explanation = + data.note || data.methodology?.length ? ( + <> + {data.note && ( + <p> + <span className="font-medium text-foreground">How to read this. </span> + {data.note} + </p> + )} + {data.methodology?.length ? ( + <div> + <p className="font-medium text-foreground">How it is measured</p> + <ol className="list-decimal pl-4"> + {data.methodology.map((step) => ( + <li key={step}>{step}</li> + ))} + </ol> + </div> + ) : null} + </> + ) : undefined; + const filters = ( + <> + {/* A card's shared tab names the variant; a chart's own tabs print themselves. */} + {!leading && period !== title && <PrintFilter label="View" value={period} />} + {leading} + {toolbar} + {printFilters.map( + (filter) => + filter && <PrintFilter key={filter.label} label={filter.label} value={filter.value} />, + )} + {focus && ( + <PrintFilter + label="Focus" + value={`${DIMENSION_LABELS[focus.dimension]} ${focus.value}${ + supported && focusFound ? '' : ' (not in this chart)' + }`} + /> + )} + </> + ); + return ( <> + {previewing && ( + <Suspense fallback={null}> + <ChartPrintDialog + open={previewing} + onOpenChange={setPreviewing} + onClosed={() => requestAnimationFrame(() => printRef.current?.focus())} + aspect={printAspect} + title={title} + eyebrow={[org, section].filter(Boolean).join(' · ')} + subtitle={detail} + filters={filters} + legend={controls} + chart={(box) => chart(false, box)} + notes={ + <> + <p>{data.population}</p> + <p>{windowNote}</p> + </> + } + explanation={explanation} + /> + </Suspense> + )} {/* 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<Events const controls = data.types.length > 1 ? ( - <div className="flex flex-wrap gap-1" role="group" aria-label="Visible release types"> + <div + className="flex flex-wrap gap-1" + role="group" + aria-label="Visible release types" + data-print-legend + > {data.types.map((type) => ( <Button key={type.key} @@ -105,91 +114,101 @@ export function EventsView({ data, title, period, provenance }: ViewProps<Events </div> ) : null; - const chart = () => ( - <> - <ChartContainer - config={config} - className="aspect-auto w-full" - style={{ height: Math.max(order.length, 3) * ROW_HEIGHT + AXIS_HEIGHT }} - > - <ScatterChart - aria-label={`${title}: ${integer.format(rows.length)} releases across ${order.length} ${nouns}. Choose Data for the full list.`} - margin={{ top: 8, right: 16, left: 0, bottom: 4 }} + const chart = (_expanded: boolean, sheet?: SheetBox) => { + const lines = Math.max(order.length, 3); + const rowHeight = sheet + ? Math.min( + PRINT_ROW.max, + Math.max(PRINT_ROW.min, Math.floor((sheet.height - AXIS_HEIGHT - CAPTION) / lines)), + ) + : ROW_HEIGHT; + return ( + <> + <ChartContainer + config={config} + className="aspect-auto w-full" + style={{ height: lines * rowHeight + AXIS_HEIGHT }} > - <CartesianGrid horizontal={false} strokeDasharray="3 3" /> - <XAxis - type="number" - dataKey="t" - domain={[start, end]} - scale="time" - tickLine={false} - axisLine={false} - minTickGap={32} - tickFormatter={(value) => tick.format(new Date(Number(value)))} - /> - <YAxis - type="number" - dataKey="y" - domain={[-0.5, order.length - 0.5]} - ticks={order.map((_, i) => i)} - interval={0} - tickLine={false} - axisLine={false} - width={190} - tickFormatter={(value) => { - const name = order[order.length - 1 - Number(value)]; - return name ? `${shorten(name, 22)} (${counts.get(name) ?? 0})` : ''; - }} - /> - <ZAxis range={[48, 48]} /> - <ChartTooltip - cursor={false} - content={({ active, payload }) => { - const point = payload?.[0]?.payload as Point | undefined; - if (!active || !point) return null; - return ( - <div className="grid gap-1 rounded-lg border bg-card px-3 py-2 text-xs shadow-xl"> - <p className="font-medium">{String(point[data.label.key]) || '(untagged)'}</p> - <p className="text-muted-foreground">{String(point[key])}</p> - <p className="tabular-nums">{stamp(String(point.time))} UTC</p> - <p>{typeLabel.get(String(point.type))}</p> - </div> - ); - }} - /> - {visible.map((type) => ( - <Scatter - key={type.key} - name={type.label} - data={points(type.key)} - fill={type.color} - fillOpacity={type.key === 'prerelease' ? 0.7 : 0.55} - shape={type.key === 'prerelease' ? 'diamond' : 'circle'} - isAnimationActive={false} - className={dimension ? 'cursor-pointer' : undefined} - onClick={ - dimension - ? (point: { payload?: Point }) => - point.payload && setFocus(toggle(focus, dimension, point.payload[key])) - : undefined - } - > - {anyFocused && - points(type.key).map((point) => ( - <Cell - key={`${String(point[key])}|${String(point.time)}`} - fillOpacity={isFocused(point) ? 0.9 : 0.12} - /> - ))} - </Scatter> - ))} - </ScatterChart> - </ChartContainer> - <p className="text-xs text-muted-foreground"> - Each mark is one release; the number beside a repository is its count in this window. - </p> - </> - ); + <ScatterChart + aria-label={`${title}: ${integer.format(rows.length)} releases across ${order.length} ${nouns}. Choose Data for the full list.`} + margin={{ top: 8, right: 16, left: 0, bottom: 4 }} + > + <CartesianGrid horizontal={false} strokeDasharray="3 3" /> + <XAxis + type="number" + dataKey="t" + domain={[start, end]} + scale="time" + tickLine={false} + axisLine={false} + minTickGap={32} + tickFormatter={(value) => tick.format(new Date(Number(value)))} + /> + <YAxis + type="number" + dataKey="y" + domain={[-0.5, order.length - 0.5]} + ticks={order.map((_, i) => i)} + interval={0} + tickLine={false} + axisLine={false} + width={190} + fontSize={rowHeight < 16 ? 9 : undefined} + tickFormatter={(value) => { + const name = order[order.length - 1 - Number(value)]; + return name ? `${shorten(name, 22)} (${counts.get(name) ?? 0})` : ''; + }} + /> + <ZAxis range={[48, 48]} /> + <ChartTooltip + cursor={false} + content={({ active, payload }) => { + const point = payload?.[0]?.payload as Point | undefined; + if (!active || !point) return null; + return ( + <div className="grid gap-1 rounded-lg border bg-card px-3 py-2 text-xs shadow-xl"> + <p className="font-medium">{String(point[data.label.key]) || '(untagged)'}</p> + <p className="text-muted-foreground">{String(point[key])}</p> + <p className="tabular-nums">{stamp(String(point.time))} UTC</p> + <p>{typeLabel.get(String(point.type))}</p> + </div> + ); + }} + /> + {visible.map((type) => ( + <Scatter + key={type.key} + name={type.label} + data={points(type.key)} + fill={type.color} + fillOpacity={type.key === 'prerelease' ? 0.7 : 0.55} + shape={type.key === 'prerelease' ? 'diamond' : 'circle'} + isAnimationActive={false} + className={dimension ? 'cursor-pointer' : undefined} + onClick={ + dimension + ? (point: { payload?: Point }) => + point.payload && setFocus(toggle(focus, dimension, point.payload[key])) + : undefined + } + > + {anyFocused && + points(type.key).map((point) => ( + <Cell + key={`${String(point[key])}|${String(point.time)}`} + fillOpacity={isFocused(point) ? 0.9 : 0.12} + /> + ))} + </Scatter> + ))} + </ScatterChart> + </ChartContainer> + <p className="text-xs text-muted-foreground"> + Each mark is one release; the number beside a repository is its count in this window. + </p> + </> + ); + }; const table = ( <Table containerClassName={TABLE_CONTAINER}> @@ -232,6 +251,17 @@ export function EventsView({ data, title, period, provenance }: ViewProps<Events empty={!rows.length} focusFound={!focus || focus.dimension !== dimension || anyFocused} emptyText="No releases were published in this window." + printFilters={[ + { label: 'Date range', value: rangeText(data.window) }, + hidden.length > 0 && { + label: 'Hidden release types', + value: data.types + .filter((type) => hidden.includes(type.key)) + .map((type) => type.label) + .join(', '), + }, + ]} + printAspect={900 / (Math.max(order.length, 3) * 24 + AXIS_HEIGHT)} windowNote={windowText(data.window)} csv={() => ({ name: `${data.id}-selected`, diff --git a/web/src/components/charts/MatrixView.tsx b/web/src/components/charts/MatrixView.tsx index e26c18a67..820976c34 100644 --- a/web/src/components/charts/MatrixView.tsx +++ b/web/src/components/charts/MatrixView.tsx @@ -5,6 +5,7 @@ */ import { useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import { useElementSize } from '@/hooks/use-element-size'; import { CrosshairIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -20,10 +21,19 @@ import type { ColumnSpec, MatrixDocument, Row } from '../../api'; import { dimensionOf, matches as focusMatches, toggle, useFocus } from '../../focus'; import { useUrlFlag, useUrlParam } from '../../urlState'; import { ContributorCell } from '../ContributorCell'; -import { ChartShell, TABLE_CONTAINER, type ViewProps } from './ChartShell'; -import { decimal, formatBucket, integer, plural, shade, windowText } from './format'; +import { ChartShell, TABLE_CONTAINER, type SheetBox, type ViewProps } from './ChartShell'; +import { decimal, formatBucket, integer, plural, rangeText, shade, windowText } from './format'; const MONTH = /^\d{4}-\d{2}$/; +/** Printed rows shrink to fit the page between these heights, then run onto another page. */ +const PRINT_ROW = { min: 16, max: 30, avatar: 22 }; +/** Rough width of a 10px printed character, for sizing the printed name column. */ +const PRINT_CHAR = 6; +/** The printed grid's column headings and colour scale, until measured. */ +const PRINT_FRAME = { head: 48, legend: 24 }; +/** Between printed rows (border-spacing), and around the grid (its border, the legend's gap). */ +const PRINT_ROW_GAP = 2; +const PRINT_CHROME = 16; function cellStyle(level: number | null, steps: number) { if (level === null || level === 0) return undefined; @@ -43,6 +53,9 @@ export function MatrixView({ data, title, period, provenance }: ViewProps<Matrix const [active, setActive] = useState<[number, number]>([0, 0]); const [readout, setReadout] = useState<[number, number] | null>(null); const gridRef = useRef<HTMLTableElement>(null); + // Measured on the printed sheet only, where the rows share out the height they leave. + const [headRef, head] = useElementSize(); + const [legendRef, legendBox] = useElementSize(); const format = (value: unknown) => (data.value_format === 'integer' ? integer : decimal).format(Number(value)); const columnLabel = (label: string) => (MONTH.test(label) ? formatBucket(label, 'month') : label); @@ -126,150 +139,213 @@ export function MatrixView({ data, title, period, provenance }: ViewProps<Matrix /> ); - const chart = () => ( - <div className="space-y-3"> - <p aria-live="polite" className="min-h-5 text-xs text-muted-foreground"> - {focused && readout - ? `${describe(focused, data.columns[readout[1]])} (${data.value_label.toLowerCase()})` - : 'Hover a cell, or focus the grid and use the arrow keys, to read exact values.'} - </p> - <div className="overflow-x-auto rounded-lg border"> - <table - ref={gridRef} - role="grid" - aria-label={`${title}: ${data.value_label} by ${data.row.label.toLowerCase()}`} - aria-rowcount={shown.length + 1} - className="w-full border-separate border-spacing-0.5 text-xs" - > - <thead> - <tr> - <th scope="col" className="sticky left-0 bg-card px-2 py-1.5 text-left font-medium"> - {data.row.label} - </th> - {data.columns.map((column) => ( + // Paper fits every column to the page width, and the rows to its height where they can. + const chart = (_expanded: boolean, sheet?: SheetBox) => { + const rowHeight = sheet + ? Math.min( + PRINT_ROW.max, + Math.max( + data.avatars ? PRINT_ROW.avatar : PRINT_ROW.min, + Math.floor( + (sheet.height - + (head?.height ?? PRINT_FRAME.head) - + (legendBox?.height ?? PRINT_FRAME.legend) - + PRINT_CHROME) / + Math.max(shown.length, 1), + ) - PRINT_ROW_GAP, + ), + ) + : undefined; + // Wide enough that names print on one line, with the role beside them. + const nameWidth = sheet + ? Math.min( + sheet.width * 0.28, + 24 + + (data.avatars ? 26 : 0) + + PRINT_CHAR * + Math.max( + data.row.label.length, + ...shown.map( + (row) => + String(row[data.row.key]).length + + (data.sublabel ? String(row[data.sublabel.key] ?? '').length + 3 : 0), + ), + ), + ) + : undefined; + return ( + <div className="space-y-3"> + {!sheet && ( + <p aria-live="polite" className="min-h-5 text-xs text-muted-foreground"> + {focused && readout + ? `${describe(focused, data.columns[readout[1]])} (${data.value_label.toLowerCase()})` + : 'Hover a cell, or focus the grid and use the arrow keys, to read exact values.'} + </p> + )} + <div className={sheet ? 'rounded-lg border' : 'overflow-x-auto rounded-lg border'}> + <table + // The printed copy is not the grid a reader navigates. + ref={sheet ? undefined : gridRef} + role="grid" + aria-label={`${title}: ${data.value_label} by ${data.row.label.toLowerCase()}`} + aria-rowcount={shown.length + 1} + className={ + sheet + ? 'w-full table-fixed border-separate border-spacing-0.5 text-[10px] leading-tight' + : 'w-full border-separate border-spacing-0.5 text-xs' + } + > + <thead ref={sheet ? headRef : undefined}> + <tr> <th - key={column.key} scope="col" - className="max-w-24 px-1 py-1.5 text-center font-medium break-words" + className="sticky left-0 bg-card px-2 py-1.5 text-left font-medium" + style={sheet ? { width: nameWidth } : undefined} > - {columnLabel(column.label)} - </th> - ))} - {data.total && ( - <th scope="col" className="px-2 py-1.5 text-right font-medium"> - {data.total.label} + {data.row.label} </th> - )} - </tr> - </thead> - <tbody> - {shown.map((row, r) => ( - <tr - key={String(row[data.row.key])} - className={ - row === focusRow ? 'outline-2 -outline-offset-1 outline-link' : undefined - } - > - <th - scope="row" - className="sticky left-0 max-w-56 bg-card px-2 py-1 text-left font-normal" - > - <span className="flex items-center gap-1"> - {dimension && ( - <Button - variant="ghost" - size="icon-sm" - className={row === focusRow ? 'text-link' : 'text-soft'} - aria-pressed={row === focusRow} - aria-label={`Focus the dashboard on ${String(row[data.row.key])}`} - title="Focus the dashboard on this row" - onClick={() => setFocus(toggle(focus, dimension, row[data.row.key]))} - > - <CrosshairIcon /> - </Button> - )} - {data.avatars ? ( - <ContributorCell login={String(row[data.row.key])} /> - ) : ( - <span className="font-medium">{String(row[data.row.key])}</span> - )} - </span> - {data.sublabel && row[data.sublabel.key] ? ( - <span className="mt-0.5 block text-[11px] text-soft"> - {String(row[data.sublabel.key])} - </span> - ) : null} - </th> - {data.columns.map((column, c) => { - const value = row[column.key]; - const level = shade(value, data.scale); - return ( - <td - key={column.key} - role="gridcell" - data-cell={`${r}:${c}`} - tabIndex={r === activeRow && c === activeColumn ? 0 : -1} - aria-label={describe(row, column)} - title={describe(row, column)} - onKeyDown={(event) => move(event, r, c)} - onFocus={() => { - setActive([r, c]); - setReadout([r, c]); - }} - onMouseEnter={() => setReadout([r, c])} - style={cellStyle(level, data.scale.steps)} - className={`min-w-14 rounded-sm px-1.5 py-1.5 text-center tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring ${ - level === null - ? 'border border-dashed text-soft' - : level === 0 - ? 'bg-muted/40 text-soft' - : '' - }`} - > - {value === null ? '?' : format(value)} - </td> - ); - })} + {data.columns.map((column) => ( + <th + key={column.key} + scope="col" + className={ + sheet + ? 'px-0 py-1 text-center text-[9px] font-medium break-words hyphens-auto' + : 'max-w-24 px-1 py-1.5 text-center font-medium break-words' + } + > + {columnLabel(column.label)} + </th> + ))} {data.total && ( - <td className="px-2 text-right font-semibold tabular-nums"> - {format(row[data.total.key])} - </td> + <th + scope="col" + className="px-2 py-1.5 text-right font-medium" + style={sheet ? { width: 56 } : undefined} + > + {data.total.label} + </th> )} </tr> - ))} - </tbody> - </table> - </div> - <div className="flex flex-wrap items-center justify-between gap-2"> + </thead> + <tbody> + {shown.map((row, r) => ( + <tr + key={String(row[data.row.key])} + className={ + row === focusRow ? 'outline-2 -outline-offset-1 outline-link' : undefined + } + style={rowHeight ? { height: rowHeight } : undefined} + > + <th + scope="row" + className={`sticky left-0 bg-card px-2 text-left font-normal ${sheet ? 'py-0' : 'max-w-56 py-1'}`} + > + <span + className={sheet ? 'flex items-baseline gap-1' : 'flex items-center gap-1'} + > + {dimension && ( + <Button + variant="ghost" + size="icon-sm" + className={row === focusRow ? 'text-link' : 'text-soft'} + aria-pressed={row === focusRow} + aria-label={`Focus the dashboard on ${String(row[data.row.key])}`} + title="Focus the dashboard on this row" + onClick={() => setFocus(toggle(focus, dimension, row[data.row.key]))} + > + <CrosshairIcon /> + </Button> + )} + {data.avatars ? ( + <ContributorCell login={String(row[data.row.key])} compact={!!sheet} /> + ) : ( + <span className="font-medium">{String(row[data.row.key])}</span> + )} + {sheet && data.sublabel && row[data.sublabel.key] ? ( + <span className="text-soft">· {String(row[data.sublabel.key])}</span> + ) : null} + </span> + {!sheet && data.sublabel && row[data.sublabel.key] ? ( + <span className="mt-0.5 block text-[11px] text-soft"> + {String(row[data.sublabel.key])} + </span> + ) : null} + </th> + {data.columns.map((column, c) => { + const value = row[column.key]; + const level = shade(value, data.scale); + return ( + <td + key={column.key} + role="gridcell" + data-cell={`${r}:${c}`} + tabIndex={r === activeRow && c === activeColumn ? 0 : -1} + aria-label={describe(row, column)} + title={describe(row, column)} + onKeyDown={(event) => move(event, r, c)} + onFocus={() => { + setActive([r, c]); + setReadout([r, c]); + }} + onMouseEnter={() => setReadout([r, c])} + style={cellStyle(level, data.scale.steps)} + className={`${sheet ? 'px-0 py-0' : 'min-w-14 px-1.5 py-1.5'} rounded-sm text-center tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring ${ + level === null + ? 'border border-dashed text-soft' + : level === 0 + ? 'bg-muted/40 text-soft' + : '' + }`} + > + {value === null ? '?' : format(value)} + </td> + ); + })} + {data.total && ( + <td className="px-2 text-right font-semibold tabular-nums"> + {format(row[data.total.key])} + </td> + )} + </tr> + ))} + </tbody> + </table> + </div> <div - className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground" - aria-label="Colour scale" + ref={sheet ? legendRef : undefined} + className="flex flex-wrap items-center justify-between gap-2" > - <span>{data.value_label}:</span> - <span className="inline-flex items-center gap-1"> - <i className="inline-block size-3 rounded-sm bg-muted/40" /> {format(data.scale.min)} - </span> - {legend.map(({ level, text }) => ( - <span key={level} className="inline-flex items-center gap-1"> - <i - className="inline-block size-3 rounded-sm" - style={cellStyle(level, data.scale.steps)} - /> - {text} + <div + className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground" + aria-label="Colour scale" + > + <span>{data.value_label}:</span> + <span className="inline-flex items-center gap-1"> + <i className="inline-block size-3 rounded-sm bg-muted/40" /> {format(data.scale.min)} </span> - ))} - {data.missing && <span>? {data.missing}</span>} + {legend.map(({ level, text }) => ( + <span key={level} className="inline-flex items-center gap-1"> + <i + className="inline-block size-3 rounded-sm" + style={cellStyle(level, data.scale.steps)} + /> + {text} + </span> + ))} + {data.missing && <span>? {data.missing}</span>} + </div> + {limited && !sheet && ( + <Button variant="outline" size="sm" onClick={() => setShowAll(!showAll)}> + {showAll + ? `Show top ${data.top_n}` + : `Show all ${integer.format(matches.length)} ${nouns}`} + </Button> + )} </div> - {limited && ( - <Button variant="outline" size="sm" onClick={() => setShowAll(!showAll)}> - {showAll - ? `Show top ${data.top_n}` - : `Show all ${integer.format(matches.length)} ${nouns}`} - </Button> - )} </div> - </div> - ); + ); + }; const table = ( <Table containerClassName={TABLE_CONTAINER}> @@ -316,6 +392,23 @@ export function MatrixView({ data, title, period, provenance }: ViewProps<Matrix empty={!matches.length} emptyText={query ? `No ${nouns} match “${query}”.` : undefined} focusFound={!focus || focus.dimension !== dimension || !!focusRow} + printFilters={[ + { label: 'Date range', value: rangeText(data.window) }, + !!query && { + label: 'Search', + value: `“${query}” · ${integer.format(matches.length)} ${nouns}`, + }, + limited && { + label: 'Showing', + value: showAll + ? `all ${integer.format(matches.length)} ${nouns}` + : `top ${data.top_n} of ${integer.format(matches.length)} ${nouns}`, + }, + ]} + // Wide when it has many columns, tall when it has many rows. + printAspect={ + (200 + data.columns.length * 44 + (data.total ? 60 : 0)) / (40 + shown.length * 24) + } windowNote={windowText(data.window)} csv={() => ({ name: `${data.id}-selected`, diff --git a/web/src/components/charts/NetworkView.tsx b/web/src/components/charts/NetworkView.tsx index 4025811bf..80da8d1fe 100644 --- a/web/src/components/charts/NetworkView.tsx +++ b/web/src/components/charts/NetworkView.tsx @@ -21,8 +21,8 @@ import { matches as focusMatches, useFocus } from '../../focus'; import { useUrlParam } from '../../urlState'; import { VariantTabs } from '../VariantTabs'; import { AdjacencyMatrix } from './AdjacencyMatrix'; -import { ChartShell, TABLE_CONTAINER, type ViewProps } from './ChartShell'; -import { integer, shortRepo as short, windowText } from './format'; +import { ChartShell, TABLE_CONTAINER, type SheetBox, type ViewProps } from './ChartShell'; +import { integer, rangeText, shortRepo as short, windowText } from './format'; import { relaxLayout, strengthSteps } from './networkLayout'; /** SVG units per layout unit: roughly plotting/network.py's points per unit at 16 inches wide. */ @@ -226,7 +226,8 @@ export function NetworkView({ data, title, period, provenance }: ViewProps<Netwo /> )} {minShared > (steps[0] ?? 0) && ( - <span className="text-xs text-muted-foreground" aria-live="polite"> + // Printed as a filter instead. + <span className="text-xs text-muted-foreground" aria-live="polite" data-sheet-hide> Showing {integer.format(edges.length)} of {integer.format(data.edges.length)} links </span> )} @@ -253,10 +254,14 @@ export function NetworkView({ data, title, period, provenance }: ViewProps<Netwo </div> ); - const chart = (expanded: boolean) => { + const chart = (expanded: boolean, sheet?: SheetBox) => { const focusNode = focus ? data.nodes.find((node) => node.id === focus) : undefined; return ( - <div className="space-y-3"> + // On paper the graph (or matrix) takes the page's height left by its legend and focus. + <div + className={sheet ? 'flex flex-col gap-3' : 'space-y-3'} + style={sheet ? { height: sheet.height } : undefined} + > {matrix ? ( <AdjacencyMatrix nodes={data.nodes} @@ -265,41 +270,47 @@ export function NetworkView({ data, title, period, provenance }: ViewProps<Netwo member={member} selected={focus} onSelect={select} + fit={!!sheet} /> ) : ( - <div className="relative overflow-hidden rounded-lg border bg-card"> - <div className="absolute top-2 right-2 z-10 flex gap-1"> - <Button - variant="outline" - size="icon" - aria-label="Zoom in" - onClick={() => zoomBy(1.25)} - > - <PlusIcon /> - </Button> - <Button - variant="outline" - size="icon" - aria-label="Zoom out" - onClick={() => zoomBy(0.8)} - > - <MinusIcon /> - </Button> - <Button - variant="outline" - size="icon" - aria-label="Reset view" - onClick={() => setView({ zoom: 1, x: 0, y: 0 })} - > - <RotateCcwIcon /> - </Button> - </div> + <div + className={`relative overflow-hidden rounded-lg border bg-card ${sheet ? 'flex min-h-0 flex-1' : ''}`} + > + {!sheet && ( + <div className="absolute top-2 right-2 z-10 flex gap-1"> + <Button + variant="outline" + size="icon" + aria-label="Zoom in" + onClick={() => zoomBy(1.25)} + > + <PlusIcon /> + </Button> + <Button + variant="outline" + size="icon" + aria-label="Zoom out" + onClick={() => zoomBy(0.8)} + > + <MinusIcon /> + </Button> + <Button + variant="outline" + size="icon" + aria-label="Reset view" + onClick={() => setView({ zoom: 1, x: 0, y: 0 })} + > + <RotateCcwIcon /> + </Button> + </div> + )} <svg - ref={attachSvg} + // The printed copy neither zooms nor pans; the reader's own zoom still applies. + ref={sheet ? undefined : attachSvg} role="group" aria-label={`${title}: ${data.nodes.length} repositories, ${edges.length} links. Tab to a repository and press Enter to show its links.`} viewBox={`0 0 ${bounds.width} ${bounds.height}`} - className={`block w-full cursor-grab touch-none select-none active:cursor-grabbing ${expanded ? 'max-h-[74vh]' : 'max-h-[640px]'}`} + className={`block w-full cursor-grab touch-none select-none active:cursor-grabbing ${sheet ? 'h-full min-h-0' : expanded ? 'max-h-[74vh]' : 'max-h-[640px]'}`} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={() => (drag.current = null)} @@ -452,11 +463,13 @@ export function NetworkView({ data, title, period, provenance }: ViewProps<Netwo )} </div> ) : ( - <p className="text-xs text-muted-foreground"> - {matrix - ? 'Hover a cell to read a pair; select a repository name to show its links.' - : 'Hover, select or search a repository to show its links. Drag to pan; zoom with the buttons or Ctrl/⌘ + scroll.'} - </p> + !sheet && ( + <p className="text-xs text-muted-foreground"> + {matrix + ? 'Hover a cell to read a pair; select a repository name to show its links.' + : 'Hover, select or search a repository to show its links. Drag to pan; zoom with the buttons or Ctrl/⌘ + scroll.'} + </p> + ) )} </div> ); @@ -512,6 +525,20 @@ export function NetworkView({ data, title, period, provenance }: ViewProps<Netwo controls={controls} empty={!data.nodes.length} focusFound={!dashboardFocus || dashboardFocus.dimension !== 'repo' || !!selected} + printFilters={[ + { label: 'Date range', value: rangeText(data.window) }, + !!needle && { label: 'Search', value: `“${query.trim()}”` }, + minShared > (steps[0] ?? 0) && { + label: 'Links shown', + value: `${integer.format(edges.length)} of ${integer.format(data.edges.length)}`, + }, + !matrix && + (view.zoom !== 1 || view.x !== 0 || view.y !== 0) && { + label: 'Zoom', + value: `${Math.round(view.zoom * 100)}%, as on screen`, + }, + ]} + printAspect={matrix ? 1.1 : bounds.width / bounds.height} windowNote={windowText(data.window)} csv={() => ({ name: `${data.id}${focus ? `-${focus}` : ''}-selected`, diff --git a/web/src/components/charts/SeriesView.tsx b/web/src/components/charts/SeriesView.tsx index 2b6d2d968..809049c19 100644 --- a/web/src/components/charts/SeriesView.tsx +++ b/web/src/components/charts/SeriesView.tsx @@ -8,6 +8,7 @@ import { CartesianGrid, Cell, ComposedChart, + LabelList, Line, ReferenceLine, XAxis, @@ -33,11 +34,13 @@ import { ChartShell, TABLE_CONTAINER, type ViewProps } from './ChartShell'; import { Funnel, Meter } from './Composition'; import { cumulative, overviewTotals, share, viewRows, type ViewRow } from './seriesRows'; import { + bucketLong, decimal, formatBucket, integer, percent, plural, + rangeText, shorten, spanOf, windowText, @@ -60,6 +63,12 @@ const labelWidth = (labels: string[]) => Math.max(64, Math.ceil(Math.max(0, ...labels.map((l) => shorten(l).length)) * 7.2) + 12), ); +/** A printed ranking squeezes its rows to fit the page down to this, then runs onto another. */ +const PRINT_ROW_MIN = 10; +const PRINT_ROW_MAX = 40; +/** Printed value labels: at most this many marks carry one, so none collide. */ +const LABELS_MAX = { bars: 36, ranked: 60, points: 24 }; + /** Series longer than this get an overview strip to brush a span of buckets. */ const OVERVIEW_MIN = 24; const PRESETS = ['all', '24', '12']; @@ -237,6 +246,43 @@ export function SeriesView({ .filter(Boolean) .join(' · '); + // What the printed sheet states beside the switches' own choices. + const printFilters = [ + { + label: 'Date range', + value: + timeseries && buckets.length + ? `${bucketLong(buckets[spanStart], data.frequency)} – ${bucketLong(buckets[spanEnd], data.frequency)} · ${ + spanned ? `${spanEnd - spanStart + 1} of ${buckets.length}` : buckets.length + } buckets` + : 'first' in data.window + ? windowText(data.window) + : rangeText(data.window), + }, + hidden.length > 0 && { + label: 'Hidden series', + value: data.series + .filter((series) => hidden.includes(series.key)) + .map((series) => series.label) + .join(', '), + }, + limited && { + label: 'Showing', + value: showAll + ? `all ${integer.format(rows.length)} ${nouns}` + : `top ${topN} of ${integer.format(rows.length)} ${nouns}`, + }, + ]; + // The shape the chart reads best at: wide over time, as tall as its rows when ranked. + const printAspect = + mark === 'funnel' + ? 900 / (rows.length * 64) + : horizontal && !composition + ? 900 / (chartRows.length * 22 + AXIS_HEIGHT) + : mark === 'meter' + ? 3 + : 2; + const valueAxis = { type: 'number' as const, allowDecimals: data.value_format === 'decimal' && !normalized, @@ -316,7 +362,8 @@ export function SeriesView({ /> )} {spanned && ( - <span className="text-xs text-muted-foreground" aria-live="polite"> + // Printed as the date range instead. + <span className="text-xs text-muted-foreground" aria-live="polite" data-sheet-hide> {formatBucket(buckets[spanStart], data.frequency)} –{' '} {formatBucket(buckets[spanEnd], data.frequency)} ({rows.length} of {data.rows.length}{' '} buckets) @@ -343,6 +390,7 @@ export function SeriesView({ className="flex flex-wrap gap-x-1 gap-y-1" role="group" aria-label="Visible series" + data-print-legend > {data.series.map((series) => { const off = hidden.includes(series.key); @@ -389,6 +437,8 @@ export function SeriesView({ } empty={!rows.length} focusFound={!dimension || !focus || focus.dimension !== dimension || !!focused} + printFilters={printFilters} + printAspect={printAspect} windowNote={[ windowText(data.window, anyPartial), normalized @@ -412,8 +462,67 @@ export function SeriesView({ Object.fromEntries(columns.map((column) => [column.key, row[column.key]])), ), })} - chart={(expanded) => - composition ? ( + chart={(expanded, sheet) => { + // A printed ranking fits its rows to the page; bars over time take the page's height. + const rowHeight = sheet + ? Math.min( + PRINT_ROW_MAX, + Math.max( + PRINT_ROW_MIN, + Math.floor( + (sheet.height - AXIS_HEIGHT - (data.reference ? 16 : 0)) / chartRows.length, + ), + ), + ) + : ROW_HEIGHT; + // Paper cannot hover: print the values on the marks while they stay legible, + // and otherwise a series over time still states its latest value. + const labelling = (all: boolean) => + !sheet || normalized ? null : all ? 'all' : timeseries ? 'latest' : null; + const barLabels = labelling( + chartRows.length * (data.stacked ? 1 : visible.length) <= + (horizontal ? LABELS_MAX.ranked : LABELS_MAX.bars), + ); + // A stack labels its top line only; separate lines are labelled while few. + const topOnly = mark === 'line' ? stackedLines : data.stacked; + const pointLabels = + topOnly || visible.length <= 3 ? labelling(chartRows.length <= LABELS_MAX.points) : null; + const valueLabel = ( + dataKey: string, + position: 'top' | 'right', + mode: 'all' | 'latest', + bars = false, + ) => { + // A zero bar is not drawn, so a bar chart names its latest drawn bar. + const latest = + (bars ? chartRows.findLast((row) => Number(row[dataKey]) !== 0) : undefined) ?? + chartRows.at(-1); + return mode === 'all' ? ( + <LabelList + dataKey={dataKey} + position={position} + offset={4} + fontSize={10} + fill="var(--ink)" + formatter={(value: unknown) => format(Number(value))} + /> + ) : ( + // Only the latest bucket's mark: its value names where the series ends. + <LabelList + valueAccessor={(entry: { payload?: ViewRow }) => + entry.payload && entry.payload.category === latest?.category + ? format(Number(entry.payload[dataKey])) + : null + } + position="top" + offset={4} + fontSize={10} + fontWeight={600} + fill="var(--ink)" + /> + ); + }; + return composition ? ( mark === 'meter' ? ( <Meter rows={rows} series={visible[0]} unit={data.unit} format={format} /> ) : ( @@ -423,14 +532,16 @@ export function SeriesView({ <> <ChartContainer config={config} - className={`${horizontal ? '' : expanded ? 'h-[min(55vh,520px)]' : 'h-[340px]'} w-full aspect-auto`} + className={`${horizontal || sheet ? '' : expanded ? 'h-[min(55vh,520px)]' : 'h-[340px]'} w-full aspect-auto`} style={ horizontal ? { height: - chartRows.length * ROW_HEIGHT + AXIS_HEIGHT + (data.reference ? 16 : 0), + chartRows.length * rowHeight + AXIS_HEIGHT + (data.reference ? 16 : 0), } - : undefined + : sheet + ? { height: sheet.height } + : undefined } > {/* The name goes on the chart's SVG, the element that takes focus (role @@ -443,7 +554,8 @@ export function SeriesView({ // A reference line's label needs its own band above the first bar. margin={{ top: horizontal && !data.reference ? 4 : 20, - right: 16, + // Room for the printed value beyond the longest bar. + right: barLabels && horizontal ? 44 : 16, left: 0, bottom: 5, }} @@ -461,6 +573,7 @@ export function SeriesView({ {...categoryAxis} width={labelWidth(chartRows.map((row) => row.category))} interval={0} + fontSize={rowHeight < 16 ? 9 : undefined} /> </> ) : ( @@ -550,7 +663,16 @@ export function SeriesView({ strokeWidth={2} dot={chartRows.length <= 24} isAnimationActive={false} - /> + > + {/* Stacked lines label the top one only: it is the visible total. */} + {pointLabels && + (!stackedLines || i === visible.length - 1) && + valueLabel( + stackedLines ? cumulative(series.key) : key, + 'top', + pointLabels, + )} + </Line> ); } if (mark === 'area') { @@ -564,7 +686,11 @@ export function SeriesView({ fill={series.color} fillOpacity={0.4} isAnimationActive={false} - /> + > + {pointLabels && + (!data.stacked || i === visible.length - 1) && + valueLabel(data.stacked ? 'total' : key, 'top', pointLabels)} + </Area> ); } const top = !data.stacked || i === visible.length - 1; @@ -590,12 +716,21 @@ export function SeriesView({ chartRows.map((row) => ( <Cell key={row.category} fillOpacity={dim(row) ? 0.3 : 1} /> ))} + {/* A stack is labelled once, with its total, above its top segment. */} + {barLabels && + top && + valueLabel( + data.stacked ? 'total' : key, + horizontal ? 'right' : 'top', + barLabels, + true, + )} </Bar> ); })} </ComposedChart> </ChartContainer> - {overview && ( + {overview && !sheet && ( // A control, not a figure: the chosen span prints in the header line. <div className="rounded-lg border px-2 pt-2" data-print-hide> <p className="px-1 text-xs text-muted-foreground"> @@ -644,22 +779,24 @@ export function SeriesView({ </ChartContainer> </div> )} - <div className="flex flex-wrap items-center justify-between gap-2"> - {/* Hover and arrow keys are announced by the chart's label; only clicking is spelled out. */} - <p className="text-xs text-muted-foreground"> - {dimension ? 'Click a bar to focus the dashboard on it.' : ''} - </p> - {limited && ( - <Button variant="outline" size="sm" onClick={() => setShowAll(!showAll)}> - {showAll - ? `Show top ${topN}` - : `Show all ${integer.format(rows.length)} ${nouns}`} - </Button> - )} - </div> + {!sheet && ( + <div className="flex flex-wrap items-center justify-between gap-2"> + {/* Hover and arrow keys are announced by the chart's label; only clicking is spelled out. */} + <p className="text-xs text-muted-foreground"> + {dimension ? 'Click a bar to focus the dashboard on it.' : ''} + </p> + {limited && ( + <Button variant="outline" size="sm" onClick={() => setShowAll(!showAll)}> + {showAll + ? `Show top ${topN}` + : `Show all ${integer.format(rows.length)} ${nouns}`} + </Button> + )} + </div> + )} </> - ) - } + ); + }} table={ <Table containerClassName={TABLE_CONTAINER}> <TableHeader className="sticky top-0 bg-muted"> diff --git a/web/src/components/charts/format.ts b/web/src/components/charts/format.ts index 34682842e..88781e4fd 100644 --- a/web/src/components/charts/format.ts +++ b/web/src/components/charts/format.ts @@ -42,6 +42,30 @@ export function windowText( return until ? `Snapshot as of ${until}.` : 'Snapshot; its date is unavailable.'; } +/** A bucket in full, for a printed date range: "Mar 2026" rather than the axis's "Mar 26". */ +export function bucketLong(bucket: string, frequency: TimeseriesDocument['frequency']) { + if (frequency === 'year' || frequency === 'week') return bucket; + return new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'short', + year: 'numeric', + ...(frequency === 'month' ? {} : { day: 'numeric' as const }), + }).format(new Date(`${bucket}${frequency === 'month' ? '-01' : ''}T00:00:00Z`)); +} + +const isoDay = (time: number) => new Date(time).toISOString().slice(0, 10); + +/** The dates a window covers, stated for paper (the window note says how they were chosen). */ +export function rangeText(window: ChartWindow) { + const end = window.end ? Date.parse(window.end) : NaN; + if (Number.isNaN(end)) + return window.kind === 'trailing' ? `Last ${window.days} days` : 'Not stated'; + if (window.kind === 'trailing' && window.days) + return `${isoDay(end - window.days * 86_400_000)} – ${isoDay(end)} (${window.days} days)`; + if (window.kind === 'all') return `All recorded activity to ${isoDay(end)}`; + return `Snapshot on ${isoDay(end)}`; +} + /** The shade for a value: 0 = none, 1..steps on the linear scale, null = missing. */ export function shade(value: unknown, scale: MatrixDocument['scale']): number | null { if (value === null || value === undefined) return null; diff --git a/web/src/components/charts/leading.ts b/web/src/components/charts/leading.ts index 448d96a78..6835a59e6 100644 --- a/web/src/components/charts/leading.ts +++ b/web/src/components/charts/leading.ts @@ -2,3 +2,6 @@ import { createContext, type ReactNode } from 'react'; /** The container's own switches (a card's period tabs), placed first in every view's toolbar. */ export const ChartLeading = createContext<ReactNode>(null); + +/** The chart card's own title, which a printed chart names above its heading. */ +export const ChartSectionTitle = createContext<string | null>(null); diff --git a/web/src/hooks/use-element-size.ts b/web/src/hooks/use-element-size.ts new file mode 100644 index 000000000..fcbcc942c --- /dev/null +++ b/web/src/hooks/use-element-size.ts @@ -0,0 +1,31 @@ +import { useCallback, useState } from 'react'; + +export interface ElementSize { + width: number; + height: number; +} + +/** + * An element's border-box size, kept current by a ResizeObserver; null until + * first measured. Layout size, so an element inside a CSS-scaled preview + * reports the size it prints at. + */ +export function useElementSize() { + const [size, setSize] = useState<ElementSize | null>(null); + const ref = useCallback((element: HTMLElement | null) => { + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + const box = entry?.borderBoxSize?.[0]; + const next = { + width: box ? box.inlineSize : element.offsetWidth, + height: box ? box.blockSize : element.offsetHeight, + }; + setSize((current) => + current?.width === next.width && current.height === next.height ? current : next, + ); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + return [ref, size] as const; +} diff --git a/web/src/print.css b/web/src/print.css index d7bad7dbf..39ac833ea 100644 --- a/web/src/print.css +++ b/web/src/print.css @@ -86,293 +86,403 @@ font-size: 10pt; line-height: 1.4; } - [data-print-only] { - display: block; - } + /* The whole tab on paper (Print tab, or the browser's own print command). + :where() adds no specificity, so these rules cascade as they always have. */ + :where(html:not([data-print-scope='chart'])) { + [data-print-only] { + display: block; + } - /* Chrome, controls and overlays: none of them work on paper. */ - [data-print-hide], - [data-slot='sidebar'], - [data-slot='sidebar-gap'], - [data-slot='sidebar-container'], - [data-slot^='dialog-'], - [data-slot^='sheet-'], - [data-radix-popper-content-wrapper], - .recharts-tooltip-wrapper, - button:not([data-print-keep]), - input, - select { - display: none !important; - } - /* A kept button carries data (a chart legend, a row's name): print it as text. */ - button[data-print-keep] { - color: var(--ink); - background: transparent; - border-color: transparent; - cursor: auto; - } + /* Chrome, controls and overlays: none of them work on paper. */ + [data-print-hide], + [data-slot='sidebar'], + [data-slot='sidebar-gap'], + [data-slot='sidebar-container'], + [data-slot^='dialog-'], + [data-slot^='sheet-'], + [data-radix-popper-content-wrapper], + .recharts-tooltip-wrapper, + button:not([data-print-keep]), + input, + select { + display: none !important; + } + /* A kept button carries data (a chart legend, a row's name): print it as text. */ + button[data-print-keep] { + color: var(--ink); + background: transparent; + border-color: transparent; + cursor: auto; + } - h1 { - font-size: 21pt; - } - h2 { - font-size: 13pt; - } - h3 { - font-size: 11pt; - } - h1, - h2, - h3, - summary, - .print-title, - .print-group, - .print-selection, - [data-slot='card-header'] { - break-after: avoid; - page-break-after: avoid; - } - .print-title { - font-size: 15pt; - font-weight: 600; - margin: 4mm 0; - } - .print-group { - font-size: 12pt; - font-weight: 600; - border-bottom: 1px solid var(--edge-strong); - padding: 2mm 0; - margin: 5mm 0 3mm; - } - p { - orphans: 3; - widows: 3; - } + h1 { + font-size: 21pt; + } + h2 { + font-size: 13pt; + } + h3 { + font-size: 11pt; + } + h1, + h2, + h3, + summary, + .print-title, + .print-group, + .print-selection, + [data-slot='card-header'] { + break-after: avoid; + page-break-after: avoid; + } + .print-title { + font-size: 15pt; + font-weight: 600; + margin: 4mm 0; + } + .print-group { + font-size: 12pt; + font-weight: 600; + border-bottom: 1px solid var(--edge-strong); + padding: 2mm 0; + margin: 5mm 0 3mm; + } + p { + orphans: 3; + widows: 3; + } - /* Cards become plain page flow: no frame, no clipping. */ - [data-slot='card'] { - display: block; - border: 0; - border-radius: 0; - padding: 0; - margin: 0 0 6mm; - background: transparent; - overflow: visible; - } - [data-slot='card-header'], - [data-slot='card-content'], - [data-slot='card-footer'] { - padding-left: 0; - padding-right: 0; - } - details::details-content { - content-visibility: visible; - } - summary { - list-style: none; - font-weight: 600; - } - summary::marker, - summary::-webkit-details-marker { - content: ''; - display: none; - } - .print-selection, - .print-chart-status { - font-size: 9pt; - white-space: normal; - overflow-wrap: anywhere; - margin: 2mm 0; - } - [data-print-truncated], - [data-print-incomplete], - .print-chart-status { - border-left: 2pt solid var(--warn-ink); - padding-left: 3mm; - } + /* Cards become plain page flow: no frame, no clipping. */ + [data-slot='card'] { + display: block; + border: 0; + border-radius: 0; + padding: 0; + margin: 0 0 6mm; + background: transparent; + overflow: visible; + } + [data-slot='card-header'], + [data-slot='card-content'], + [data-slot='card-footer'] { + padding-left: 0; + padding-right: 0; + } + details::details-content { + content-visibility: visible; + } + summary { + list-style: none; + font-weight: 600; + } + summary::marker, + summary::-webkit-details-marker { + content: ''; + display: none; + } + .print-selection, + .print-chart-status { + font-size: 9pt; + white-space: normal; + overflow-wrap: anywhere; + margin: 2mm 0; + } + [data-print-truncated], + [data-print-incomplete], + .print-chart-status { + border-left: 2pt solid var(--warn-ink); + padding-left: 3mm; + } - /* Tables: every row in normal flow, headers repeated on each page. */ - [data-slot='table-container'], - .hipmx-wrap { - width: 100%; - max-width: 100%; - min-width: 0; - max-height: none; - overflow: visible; - border: 0; - border-radius: 0; - } - table { - width: 100%; - max-width: 100%; - min-width: 0; - table-layout: fixed; - font-size: 8.5pt; - border-collapse: collapse; - } - /* Give repository names and long employer lists room without squeezing - short values into vertical strings. The printed HIP matrix keeps its fixed - four-column layout below. */ - [data-slot='table'] { - table-layout: auto; - } - [data-slot='table'] th, - [data-slot='table'] td { - min-width: 8mm; - max-width: 40mm; - } - thead { - display: table-header-group; - position: static; - } - tbody { - display: table-row-group; - } - tr { - break-inside: avoid; - page-break-inside: avoid; - } - th, - td { - position: static; - width: auto; - height: auto; - min-width: 0; - white-space: normal; - overflow-wrap: anywhere; - word-break: normal; - text-overflow: clip; - overflow: visible; - letter-spacing: normal; - text-transform: none; - text-align: left; - vertical-align: top; - padding: 1.5mm 1mm; - border-bottom: 0.25mm solid var(--row-line); - background: transparent; - backdrop-filter: none; - } - th { - color: var(--ink); - background: var(--raise); - } - [data-numeric] { - text-align: right; - white-space: nowrap; - } - a { - color: var(--link-ink); - text-decoration: underline; - } + /* Tables: every row in normal flow, headers repeated on each page. */ + [data-slot='table-container'], + .hipmx-wrap { + width: 100%; + max-width: 100%; + min-width: 0; + max-height: none; + overflow: visible; + border: 0; + border-radius: 0; + } + table { + width: 100%; + max-width: 100%; + min-width: 0; + table-layout: fixed; + font-size: 8.5pt; + border-collapse: collapse; + } + /* Give repository names and long employer lists room without squeezing + short values into vertical strings. The printed HIP matrix keeps its fixed + four-column layout below. */ + [data-slot='table'] { + table-layout: auto; + } + [data-slot='table'] th, + [data-slot='table'] td { + min-width: 8mm; + max-width: 40mm; + } + thead { + display: table-header-group; + position: static; + } + tbody { + display: table-row-group; + } + tr { + break-inside: avoid; + page-break-inside: avoid; + } + th, + td { + position: static; + width: auto; + height: auto; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + word-break: normal; + text-overflow: clip; + overflow: visible; + letter-spacing: normal; + text-transform: none; + text-align: left; + vertical-align: top; + padding: 1.5mm 1mm; + border-bottom: 0.25mm solid var(--row-line); + background: transparent; + backdrop-filter: none; + } + th { + color: var(--ink); + background: var(--raise); + } + [data-numeric] { + text-align: right; + white-space: nowrap; + } + a { + color: var(--link-ink); + text-decoration: underline; + } - /* Chart galleries print one figure per row, whole. */ - figure { - display: block; - width: 100%; - max-width: 100%; - min-width: 0; - margin: 4mm 0; - padding: 0; - border: 0; - background: transparent; - break-inside: avoid; - page-break-inside: avoid; - } - figure:has(figure) { - break-inside: auto; - } - [data-slot='card-content'] > .grid { - display: block; - } - [data-slot='chart'] { - width: 100%; - max-width: 100%; - max-height: 220mm; - } - [data-slot='chart'] svg { - max-width: 100%; - overflow: visible; - } + /* Chart galleries print one figure per row, whole. */ + figure { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + margin: 4mm 0; + padding: 0; + border: 0; + background: transparent; + break-inside: avoid; + page-break-inside: avoid; + } + figure:has(figure) { + break-inside: auto; + } + [data-slot='card-content'] > .grid { + display: block; + } + [data-slot='chart'] { + width: 100%; + max-width: 100%; + max-height: 220mm; + } + [data-slot='chart'] svg { + max-width: 100%; + overflow: visible; + } - /* Headline tiles: three to a row, never split. */ - section[aria-label='Headline figures'] > div { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 3mm; - } - section[aria-label='Headline figures'] > div > * { - break-inside: avoid; - padding: 3mm; - border: 1px solid var(--edge-strong); - } - .glossary { - border: 0; - } + /* Headline tiles: three to a row, never split. */ + section[aria-label='Headline figures'] > div { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3mm; + } + section[aria-label='Headline figures'] > div > * { + break-inside: avoid; + padding: 3mm; + border: 1px solid var(--edge-strong); + } + .glossary { + border: 0; + } - [data-print-board] { - display: block; - } - .print-board-list { - padding-left: 5mm; - list-style: disc; - } - .print-board-list li { - break-inside: avoid; - margin: 1mm 0; - } - [data-print-matrix] { - table-layout: fixed; - } - [data-print-matrix] caption { - caption-side: top; - text-align: left; - font-size: 9pt; - margin-bottom: 2mm; - } - [data-print-matrix] th:first-child { - width: 20%; - } - [data-print-matrix] th:nth-child(2) { - width: 12%; - } - [data-print-matrix] th:nth-child(3) { - width: 48%; - } - [data-print-matrix] th:nth-child(4) { - width: 20%; - } - [data-print-matrix] tbody th { - font-weight: 400; - background: transparent; - } - .print-matrix-band { - margin-bottom: 2mm; - } + [data-print-board] { + display: block; + } + .print-board-list { + padding-left: 5mm; + list-style: disc; + } + .print-board-list li { + break-inside: avoid; + margin: 1mm 0; + } + [data-print-matrix] { + table-layout: fixed; + } + [data-print-matrix] caption { + caption-side: top; + text-align: left; + font-size: 9pt; + margin-bottom: 2mm; + } + [data-print-matrix] th:first-child { + width: 20%; + } + [data-print-matrix] th:nth-child(2) { + width: 12%; + } + [data-print-matrix] th:nth-child(3) { + width: 48%; + } + [data-print-matrix] th:nth-child(4) { + width: 20%; + } + [data-print-matrix] tbody th { + font-weight: 400; + background: transparent; + } + .print-matrix-band { + margin-bottom: 2mm; + } - /* Native repeating tfoot fallback: reserve footer space, never overlay data. */ - [data-print-layout] { - display: table; - table-layout: auto; - } - [data-print-layout] > tbody { - display: table-row-group; - } - [data-print-layout] > tbody > tr { - display: table-row; - break-inside: auto; + /* Native repeating tfoot fallback: reserve footer space, never overlay data. */ + [data-print-layout] { + display: table; + table-layout: auto; + } + [data-print-layout] > tbody { + display: table-row-group; + } + [data-print-layout] > tbody > tr { + display: table-row; + break-inside: auto; + } + [data-print-layout] > tbody > tr > td { + display: table-cell; + padding: 0; + border: 0; + background: transparent; + } + [data-print-layout] > tfoot { + display: table-footer-group; + } + [data-print-layout] > tfoot > tr > td { + font-size: 8pt; + padding: 3mm 0 0; + border: 0; + background: transparent; + } } - [data-print-layout] > tbody > tr > td { - display: table-cell; - padding: 0; - border: 0; - background: transparent; - } - [data-print-layout] > tfoot { - display: table-footer-group; - } - [data-print-layout] > tfoot > tr > td { - font-size: 8pt; - padding: 3mm 0 0; - border: 0; - background: transparent; +} + +/* ---- One chart on paper ------------------------------------------------ */ + +/* The sheet a chart's print preview draws (ChartPrintDialog): the same page on + screen, scaled into the dialog, and on paper. Its switches already render as + stated filters; here the controls that remain go, and kept buttons read as text. */ +article[data-print-sheet] { + color-scheme: light; + --page: #fff; + --surface: #fff; + --ink: #1b1b1b; + --muted: #444; + --soft: #555; + --row-line: #ccc; + /* The base border colour resolves on :root; resolve it here, on paper's palette. */ + --color-border: var(--edge); + box-sizing: border-box; + background: var(--paper); + color: var(--ink); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} +[data-print-sheet] + :is( + [data-print-hide], + [data-sheet-hide], + button:not([data-print-keep]), + input, + select, + .recharts-tooltip-wrapper + ) { + display: none !important; +} +[data-print-sheet] button[data-print-keep] { + height: auto; + padding: 0 0.5rem 0 0; + border-color: transparent; + background: transparent; + color: var(--ink); + opacity: 1; +} +/* A legend lists what is drawn; series switched off are named among the filters. */ +[data-print-sheet] [data-print-legend] > [aria-pressed='false'] { + display: none; +} +[data-print-sheet] a { + color: inherit; + text-decoration: none; +} +[data-print-sheet] [data-slot='chart'] svg { + overflow: visible; +} + +@media print { + /* While a chart's preview is open the page prints that sheet alone: the + dashboard, other layers and the dialog's own controls are left out, and the + scaled page it sits in unwinds to the real one (@page from the dialog). */ + html[data-print-scope='chart'] { + #root, + [data-slot='dialog-overlay'], + [data-slot='dialog-close'], + [data-slot='dialog-content']:not([data-print-preview]), + [data-slot^='sheet-'], + [data-radix-popper-content-wrapper], + [data-print-chrome] { + display: none !important; + } + [data-print-path] { + display: block !important; + position: static !important; + inset: auto !important; + width: auto !important; + height: auto !important; + min-width: 0 !important; + max-width: none !important; + max-height: none !important; + margin: 0 !important; + padding: 0 !important; + overflow: visible !important; + border: 0 !important; + border-radius: 0 !important; + background: none !important; + outline: 0 !important; + transform: none !important; + /* Tailwind's centring (-translate-1/2) sets the translate property through + these; resetting `translate` itself would be folded into the transform + shorthand by the CSS minifier, and lost. */ + --tw-translate-x: 0 !important; + --tw-translate-y: 0 !important; + } + /* The page margin, on the sheet rather than the page (see pageRule): each + printed page of it gets the same 12mm, and the browser's own header and + footer have no margin to print in. */ + [data-print-sheet] { + box-sizing: content-box; + padding: 12mm; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + break-inside: auto; + } + [data-print-sheet] tr { + break-inside: avoid; + } } } diff --git a/web/src/printContext.ts b/web/src/printContext.ts index ec774b796..27f70662f 100644 --- a/web/src/printContext.ts +++ b/web/src/printContext.ts @@ -5,3 +5,9 @@ export const PRINT_ROW_LIMIT = 500; export const PrintContext = createContext({ printing: false, begin: () => {}, finish: () => {} }); export const usePrintMode = () => useContext(PrintContext).printing; + +/** + * Set inside one chart's printed sheet (`ChartPrintDialog`): its switches print + * as "Label: value" filters, named without the chart title they repeat. + */ +export const PrintSheetContext = createContext<{ title: string } | null>(null); diff --git a/web/src/printing.tsx b/web/src/printing.tsx index 747569cf5..f65ff65a7 100644 --- a/web/src/printing.tsx +++ b/web/src/printing.tsx @@ -25,7 +25,8 @@ export function PrintProvider({ children }: { children: ReactNode }) { const restorePending = useRef(false); const scroll = useRef<{ element: HTMLElement; top: number; left: number }[]>([]); const begin = useCallback(() => { - if (active.current) return; + // A chart's print preview prints its own sheet: the tab stays as it is on screen. + if (active.current || document.documentElement.dataset.printScope === 'chart') return; active.current = true; restorePending.current = false; if (focusFrame.current !== undefined) cancelAnimationFrame(focusFrame.current); diff --git a/web/src/test/chartPrint.test.tsx b/web/src/test/chartPrint.test.tsx new file mode 100644 index 000000000..8cc9fb60d --- /dev/null +++ b/web/src/test/chartPrint.test.tsx @@ -0,0 +1,377 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import InteractiveChart from '../components/InteractiveChart'; +import { exportName } from '../chartExport'; +import { VariantTabs } from '../components/VariantTabs'; +import { bucketLong, rangeText } from '../components/charts/format'; +import { + bestOrientation, + contentBox, + defaultPaper, + filterLabel, + PAPER_STORAGE_KEY, + pageRule, + storedPaper, +} from '../chartPrint'; +import { PrintSheetContext } from '../printContext'; +import { PrintProvider } from '../printing'; +import type { ChartVariant, MatrixDocument, TimeseriesDocument } from '../api'; + +const roles: TimeseriesDocument = { + schema_version: 1, + id: 'roles', + org: 'test', + kind: 'timeseries', + source: 'roles.csv', + metric: 'active_contributors_by_role', + unit: 'Unique active contributors', + population: 'Each person is counted once per bucket.', + dimensions: ['period', 'series'], + note: 'Read the bars left to right.', + methodology: ['Resolve roles per bucket.'], + generated_at: '2026-02-12T12:00:00Z', + mark: 'bar', + stacked: true, + normalize: false, + orientation: 'vertical', + value_format: 'integer', + rank: false, + top_n: null, + reference: null, + details: [], + series: [ + { key: 'general_user', label: 'General contributors', color: 'var(--chart-general)' }, + { key: 'triage', label: 'Triage', color: 'var(--chart-triage)' }, + { key: 'maintainer', label: 'Maintainers', color: 'var(--chart-maintainer)' }, + ], + frequency: 'month', + timezone: 'UTC', + comparison: null, + group: null, + category: { key: 'bucket', label: 'Period (UTC)' }, + window: { kind: 'calendar', first: '2026-01', last: '2026-02' }, + rows: [ + { bucket: '2026-01', general_user: 10, triage: 2, maintainer: 5, partial: false }, + { bucket: '2026-02', general_user: 4, triage: 0, maintainer: 6, partial: true }, + ], +}; + +const heatmap: MatrixDocument = { + schema_version: 1, + id: 'heat', + org: 'test', + kind: 'matrix', + source: 'activity.csv', + metric: 'activity', + unit: 'Weighted activity', + population: 'Every repository with activity.', + dimensions: ['repo', 'month'], + row: { key: 'repo', label: 'Repository' }, + sublabel: null, + total: null, + columns: [ + { key: '2026-01', label: '2026-01' }, + { key: '2026-02', label: '2026-02' }, + ], + value_label: 'Score', + scale: { min: 0, max: 10, steps: 5 }, + missing: null, + avatars: false, + top_n: null, + value_format: 'integer', + window: { kind: 'trailing', days: 60, end: '2026-02-28T00:00:00Z' }, + rows: [ + { repo: 'hiero-sdk-js', '2026-01': 4, '2026-02': 8 }, + { repo: 'hiero-docs', '2026-01': 1, '2026-02': 0 }, + ], +}; + +const provenance = { git_sha: 'abc1234', data_as_of: '2026-02-12T12:00:00Z' }; +const variant = (path: string): ChartVariant => ({ + label: '1 year', + file: 'legacy.png', + interactive: { kind: 'timeseries', path }, +}); +const serve = (document: unknown) => + vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => document } as Response); +const sheet = () => document.querySelector<HTMLElement>('[data-print-sheet]')!; +const printedFilters = () => + [...sheet().querySelectorAll('[data-print-filter]')].map((filter) => + filter.textContent?.replace(/\s+/g, ' '), + ); + +beforeEach(() => { + // Every element reports a laid-out size, so charts draw and the sheet measures itself. + vi.stubGlobal( + 'ResizeObserver', + class { + report: ResizeObserverCallback; + constructor(report: ResizeObserverCallback) { + this.report = report; + } + observe(target: Element) { + const size = { inlineSize: 900, blockSize: 120 }; + const entry = { + target, + contentRect: { width: 900, height: 340 }, + borderBoxSize: [size], + }; + this.report([entry as unknown as ResizeObserverEntry], this as never); + } + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal('fetch', vi.fn()); + window.print = vi.fn(); + window.localStorage.clear(); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('paper', () => { + it('prints wide charts landscape and tall or square ones portrait', () => { + expect(bestOrientation(2, 'a4')).toBe('landscape'); + expect(bestOrientation(1, 'a4')).toBe('portrait'); + expect(bestOrientation(0.5, 'letter')).toBe('portrait'); + expect(bestOrientation(3, 'letter')).toBe('landscape'); + }); + + it('keeps the printable area inside the margins of the chosen paper', () => { + const portrait = contentBox('a4', 'portrait'); + const landscape = contentBox('a4', 'landscape'); + expect(portrait.width).toBe(186); + expect(landscape.width).toBe(273); + // A millimetre in hand, so rounding never spills onto a blank second page. + expect(portrait.height).toBeLessThan(297 - 24); + expect(contentBox('letter', 'portrait').width).toBeCloseTo(191.9); + }); + + it('writes the page rule the browser prints with', () => { + expect(pageRule('a4', 'landscape')).toContain('size: A4 landscape'); + expect(pageRule('letter', 'portrait')).toContain('size: letter portrait'); + // No page margin: the browser gets no room for its own date, title and URL. + expect(pageRule('a4', 'portrait')).toContain('margin: 0;'); + expect(pageRule('a4', 'portrait')).toContain('@bottom-left { content: none; }'); + }); + + it('defaults to the reader’s regional paper and remembers their choice', () => { + expect(defaultPaper('en-US')).toBe('letter'); + expect(defaultPaper('en-CA')).toBe('letter'); + expect(defaultPaper('de-DE')).toBe('a4'); + expect(defaultPaper('en-GB')).toBe('a4'); + expect(defaultPaper('not a locale')).toBe('a4'); + window.localStorage.setItem(PAPER_STORAGE_KEY, 'a4'); + expect(storedPaper()).toBe('a4'); + }); + + it('names downloaded files after the chart and its paper', () => { + expect(exportName('Role activity', 'a4', 'landscape', 'pdf')).toBe( + 'role-activity-a4-landscape.pdf', + ); + expect(exportName('Org scorecard — 2026', 'letter', 'portrait', 'png')).toBe( + 'org-scorecard-2026-letter-portrait.png', + ); + expect(exportName('★', 'a4', 'portrait', 'jpg')).toBe('chart-a4-portrait.jpg'); + }); + + it('names printed filters without repeating the chart title', () => { + expect(filterLabel('Role activity chart style', 'Role activity')).toBe('Chart style'); + expect(filterLabel('Network: minimum shared members', 'Network')).toBe( + 'Minimum shared members', + ); + expect(filterLabel('Time range', 'Role activity')).toBe('Time range'); + }); + + it('states the dates a chart covers', () => { + expect(bucketLong('2026-03', 'month')).toBe('Mar 2026'); + expect(bucketLong('2026-03-05', 'day')).toBe('Mar 5, 2026'); + expect(bucketLong('2026', 'year')).toBe('2026'); + expect(rangeText({ kind: 'trailing', days: 30, end: '2026-02-28T00:00:00Z' })).toBe( + '2026-01-29 – 2026-02-28 (30 days)', + ); + expect(rangeText({ kind: 'snapshot', days: null, end: '2026-02-28T00:00:00Z' })).toBe( + 'Snapshot on 2026-02-28', + ); + expect(rangeText({ kind: 'all', days: null, end: '2026-02-28T00:00:00Z' })).toBe( + 'All recorded activity to 2026-02-28', + ); + }); + + it('turns a switch into a stated filter on the printed sheet', () => { + render( + <PrintSheetContext.Provider value={{ title: 'Role activity' }}> + <VariantTabs + appearance="segmented" + labels={['Bars', 'Line']} + active={1} + onSelect={() => {}} + ariaLabel="Role activity chart style" + /> + </PrintSheetContext.Provider>, + ); + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(screen.getByText('Chart style')).toBeInTheDocument(); + expect(screen.getByText('Line')).toBeInTheDocument(); + }); +}); + +describe('Print chart', () => { + it('previews the chart with its title, selection, date range and legend', async () => { + serve({ ...roles, id: 'preview' }); + render( + <InteractiveChart + variant={variant('test/preview.json')} + title="Role activity" + provenance={provenance} + />, + ); + await userEvent.click(await screen.findByRole('radio', { name: 'Line' })); + await userEvent.click(screen.getByRole('button', { name: 'Triage' })); + await userEvent.click(screen.getByRole('button', { name: 'Print chart: Role activity' })); + + const dialog = await screen.findByRole('dialog', { name: 'Print preview' }); + expect(within(sheet()).getByRole('heading', { name: 'Role activity' })).toBeInTheDocument(); + expect(printedFilters()).toEqual([ + 'View 1 year', + 'Chart style Line', + 'Line stacking Cumulative', + 'Scale Counts', + 'Date range Jan 2026 – Feb 2026 · 2 buckets', + 'Hidden series Triage', + ]); + // The legend names what is drawn; the hidden series is listed among the filters. + const legend = within(sheet()).getByRole('group', { name: 'Visible series' }); + expect(within(legend).getByRole('button', { name: 'Triage' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(sheet()).toHaveTextContent(roles.population); + // No source line, link or date to crowd the chart; the date is added on request. + expect(sheet()).not.toHaveTextContent('abc1234'); + expect(sheet()).not.toHaveTextContent('Source generated'); + expect(sheet()).not.toHaveTextContent(window.location.href); + expect(sheet()).not.toHaveTextContent(/Printed \d{4}/); + await userEvent.click( + within(within(dialog).getByRole('radiogroup', { name: 'Printed date' })).getByRole('radio', { + name: 'Include', + }), + ); + expect(sheet()).toHaveTextContent(/Printed \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/); + // The printed copy offers no controls of its own. + expect(within(sheet()).queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(within(dialog).getByText(/on Letter landscape paper, one page/)).toBeInTheDocument(); + }); + + it('prints the sheet alone on the chosen paper and restores the page afterwards', async () => { + serve({ ...roles, id: 'paper' }); + render( + <InteractiveChart + variant={variant('test/paper.json')} + title="Role activity" + provenance={provenance} + />, + ); + await userEvent.click( + await screen.findByRole('button', { name: 'Print chart: Role activity' }), + ); + const dialog = await screen.findByRole('dialog', { name: 'Print preview' }); + expect(document.documentElement.dataset.printScope).toBe('chart'); + const pageStyle = () => dialog.querySelector('style')?.textContent; + expect(pageStyle()).toContain('size: letter landscape'); + + await userEvent.click(within(dialog).getByRole('radio', { name: 'A4' })); + await userEvent.click(within(dialog).getByRole('radio', { name: 'Portrait' })); + expect(pageStyle()).toContain('size: A4 portrait'); + expect(window.localStorage.getItem(PAPER_STORAGE_KEY)).toBe('a4'); + expect(within(dialog).getByText(/on A4 portrait paper/)).toBeInTheDocument(); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Print' })); + await waitFor(() => expect(window.print).toHaveBeenCalledTimes(1)); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Print preview' })).not.toBeInTheDocument(), + ); + expect(document.documentElement.dataset.printScope).toBeUndefined(); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Print chart: Role activity' })).toHaveFocus(), + ); + }); + + it('adds the reading notes and methodology only on request', async () => { + serve({ ...roles, id: 'notes' }); + render( + <InteractiveChart + variant={variant('test/notes.json')} + title="Role activity" + provenance={provenance} + />, + ); + await userEvent.click( + await screen.findByRole('button', { name: 'Print chart: Role activity' }), + ); + const dialog = await screen.findByRole('dialog', { name: 'Print preview' }); + expect(sheet()).not.toHaveTextContent('Resolve roles per bucket.'); + await userEvent.click( + within( + within(dialog).getByRole('radiogroup', { name: 'How to read it and methodology' }), + ).getByRole('radio', { name: 'Include' }), + ); + expect(sheet()).toHaveTextContent('Read the bars left to right.'); + expect(sheet()).toHaveTextContent('Resolve roles per bucket.'); + }); + + it('explains that there is nothing to print instead of opening an empty preview', async () => { + serve(heatmap); + render( + <InteractiveChart + variant={{ ...variant('test/heat.json'), interactive: { kind: 'matrix', path: 'x' } }} + title="Activity" + provenance={provenance} + />, + ); + await userEvent.type( + await screen.findByRole('textbox', { name: 'Search repositories' }), + 'nothing-matches', + ); + const print = screen.getByRole('button', { name: 'Print chart: Activity' }); + await userEvent.click(print); + const notice = screen.getByRole('alert'); + expect(notice).toHaveTextContent('Nothing to print for this selection'); + expect(notice).toHaveTextContent('No repositories match “nothing-matches”.'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(window.print).not.toHaveBeenCalled(); + + // The message stands for that selection only. + await userEvent.clear(screen.getByRole('textbox', { name: 'Search repositories' })); + expect(screen.queryByText('Nothing to print for this selection')).not.toBeInTheDocument(); + await userEvent.type(screen.getByRole('textbox', { name: 'Search repositories' }), 'docs'); + await userEvent.click(print); + await screen.findByRole('dialog', { name: 'Print preview' }); + expect(printedFilters()).toContain('Search “docs” · 1 repositories'); + expect(printedFilters()).toContain('Date range 2025-12-30 – 2026-02-28 (60 days)'); + }); + + it('leaves the tab’s own print mode alone while a chart prints', () => { + render( + <PrintProvider> + <p>tab</p> + </PrintProvider>, + ); + document.documentElement.dataset.printScope = 'chart'; + try { + window.dispatchEvent(new Event('beforeprint')); + expect(document.documentElement.dataset.printing).toBeUndefined(); + } finally { + delete document.documentElement.dataset.printScope; + } + window.dispatchEvent(new Event('beforeprint')); + expect(document.documentElement.dataset.printing).toBe('true'); + window.dispatchEvent(new Event('afterprint')); + expect(document.documentElement.dataset.printing).toBeUndefined(); + }); +}); diff --git a/web/src/test/printComponents.test.tsx b/web/src/test/printComponents.test.tsx index 3d53cef53..bde1b0f62 100644 --- a/web/src/test/printComponents.test.tsx +++ b/web/src/test/printComponents.test.tsx @@ -12,7 +12,8 @@ import { VariantTabs } from '../components/VariantTabs'; import { BOARD_DOC, MANIFEST, MATRIX_DOC } from './fixtures'; const printState = vi.hoisted(() => ({ printing: false })); -vi.mock('../printContext', () => ({ +vi.mock('../printContext', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../printContext')>()), usePrintMode: () => printState.printing, PRINT_ROW_LIMIT: 500, })); From 9f24406d210bf8a3400cb45374f73062eae61a7a Mon Sep 17 00:00:00 2001 From: Ntege Daniel <danientege785@gmail.com> Date: Wed, 30 Sep 2026 01:13:14 +0300 Subject: [PATCH 2/2] fix(web): break downloaded chart PDFs between rows A downloaded PDF that ran past one page was cut at fixed heights, so a page could end through a ranking bar, its label or a heatmap row. * Find the safe breaks on the sheet: under its header, footer notes and heatmap rows, and midway between two rows of a ranking or timeline * End each PDF page at the last break that fits, leaving white space rather than splitting a row * Draw the preview's page-break guides from the same breaks, recomputed as the charts inside redraw * Check in Chromium and Firefox that a long ranking's page break falls between two rows Signed-off-by: Ntege Daniel <danientege785@gmail.com> --- docs/printing.md | 7 +++ web/e2e/chartPrint.spec.ts | 26 ++++++++- web/src/chartExport.ts | 15 +++-- web/src/chartPrint.ts | 44 +++++++++++++++ .../components/charts/ChartPrintDialog.tsx | 56 ++++++++++++++----- web/src/components/charts/EventsView.tsx | 2 + web/src/components/charts/SeriesView.tsx | 2 + web/src/test/chartPrint.test.tsx | 10 ++++ 8 files changed, 142 insertions(+), 20 deletions(-) diff --git a/docs/printing.md b/docs/printing.md index 7be26015d..eb90ed8e3 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -263,6 +263,13 @@ with no PDF library. GitHub avatars are left out, because the page's CSP The PDF is an image of the page, so its text cannot be selected; Print keeps the text. +A PDF that runs past one page breaks only between rows: under a heatmap row, or +midway between two rows of a ranking or timeline (`breakPoints` and `pageTops` +in `chartPrint.ts`). A page ends at the last break that fits and leaves white +space rather than splitting a row. The preview's page-break guides use the same +breaks. Native Print still breaks rankings and timelines where the page ends, +because the browser cannot split a single SVG between its rows. + **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 diff --git a/web/e2e/chartPrint.spec.ts b/web/e2e/chartPrint.spec.ts index ef34344db..0d29cf736 100644 --- a/web/e2e/chartPrint.spec.ts +++ b/web/e2e/chartPrint.spec.ts @@ -204,7 +204,31 @@ test('a long ranking prints portrait and states when it needs more than one page 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. + // Pages break between rows, never through one: each break sits midway between + // two neighbouring row labels, measured at print size. + const breaks = await sheet(page).evaluate((element: HTMLElement) => { + const box = element.getBoundingClientRect(); + const ratio = box.height / element.offsetHeight; + const at = (y: number) => (y - box.top) / ratio; + const centres = [ + ...element.querySelectorAll( + '[data-print-rows] .recharts-yAxis-tick-labels .recharts-cartesian-axis-tick-value', + ), + ] + .map((label) => { + const { top, bottom } = label.getBoundingClientRect(); + return at((top + bottom) / 2); + }) + .sort((a, b) => a - b); + return [...document.querySelectorAll('[data-page-break]')].map((line) => { + const y = at(line.getBoundingClientRect().top); + const below = centres.findIndex((centre) => centre > y); + return below > 0 ? Math.abs(y - (centres[below - 1] + centres[below]) / 2) : Infinity; + }); + }); + expect(breaks).toHaveLength(1); + expect(breaks[0]).toBeLessThan(0.5); + // The downloaded PDF breaks the sheet at the same places, into the same pages. expect(pdfPages((await download(page, 'PDF')).bytes).pages).toBe(2); }); diff --git a/web/src/chartExport.ts b/web/src/chartExport.ts index 698fc2b33..09be99e70 100644 --- a/web/src/chartExport.ts +++ b/web/src/chartExport.ts @@ -6,7 +6,9 @@ */ import { + breakPoints, contentBox, + pageTops, MARGIN, PAPERS, paperBox, @@ -78,13 +80,14 @@ async function layOut(sheet: HTMLElement, paper: Paper, orientation: Orientation 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) => { + // The same pages as the preview: each ends between rows, never through one. + const tops = pageTops(breakPoints(sheet), sheet.offsetHeight, content.height * PX_PER_MM); + return tops.map((top, 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); + const from = Math.round(top * scale); + const to = Math.round((tops[index + 1] ?? sheet.offsetHeight) * scale); + const height = Math.min(to, drawn.height) - from; + context.drawImage(drawn, 0, from, drawn.width, height, margin, margin, drawn.width, height); return canvas; }); } diff --git a/web/src/chartPrint.ts b/web/src/chartPrint.ts index e7aae3d9e..8ae9b3930 100644 --- a/web/src/chartPrint.ts +++ b/web/src/chartPrint.ts @@ -53,6 +53,50 @@ export function pageRule(paper: Paper, orientation: Orientation) { return `@page { size: ${PAPERS[paper].size} ${orientation}; margin: 0; @bottom-left { content: none; } @bottom-right { content: none; } }`; } +/** + * Where a sheet may break between pages, in its own CSS pixels from the top: + * under its header or a footer note, under a heatmap row, or between two rows + * of a ranking or timeline (charts marked `data-print-rows`), never through one. + */ +export function breakPoints(sheet: HTMLElement): number[] { + const box = sheet.getBoundingClientRect(); + // The preview scales the sheet on screen; positions are wanted at print size. + const ratio = box.height / sheet.offsetHeight || 1; + const at = (y: number) => (y - box.top) / ratio; + const points = [...sheet.querySelectorAll('header, footer > *, [data-print-chart] tr')].map( + (element) => at(element.getBoundingClientRect().bottom), + ); + // Recharts draws an axis's labels in their own layer, apart from the axis group. + for (const labels of sheet.querySelectorAll('[data-print-rows] .recharts-yAxis-tick-labels')) { + const centres = [...labels.querySelectorAll('.recharts-cartesian-axis-tick-value')] + .map((tick) => { + const { top, bottom } = tick.getBoundingClientRect(); + return at((top + bottom) / 2); + }) + .sort((a, b) => a - b); + // Midway between two row labels is the gap between their bars. + for (let index = 1; index < centres.length; index++) { + points.push((centres[index - 1] + centres[index]) / 2); + } + } + return points.sort((a, b) => a - b); +} + +/** + * Where each page starts, for a sheet `height` tall with `page` of it fitting + * on each page. A page ends at the last break that fits, leaving white space + * rather than splitting a row; only a row taller than half a page is cut. + */ +export function pageTops(breaks: number[], height: number, page: number): number[] { + const tops = [0]; + while (height - tops[tops.length - 1] > page + 0.5) { + const top = tops[tops.length - 1]; + const fits = breaks.filter((point) => point > top + page / 2 && point <= top + page); + tops.push(fits.length ? fits[fits.length - 1] : top + page); + } + return tops; +} + /** 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']); diff --git a/web/src/components/charts/ChartPrintDialog.tsx b/web/src/components/charts/ChartPrintDialog.tsx index ba8440c7e..162be290d 100644 --- a/web/src/components/charts/ChartPrintDialog.tsx +++ b/web/src/components/charts/ChartPrintDialog.tsx @@ -20,9 +20,11 @@ import { } from '@/components/ui/dialog'; import { bestOrientation, + breakPoints, contentBox, MARGIN, pageRule, + pageTops, PAPERS, paperBox, PX_PER_MM, @@ -173,23 +175,51 @@ export function ChartPrintDialog({ const [withExplanation, setWithExplanation] = useState(false); // Off by default too: the sheet already states when its data was generated. const [withDate, setWithDate] = useState(false); - const [measureSheet, sheet] = useElementSize(); - // The sheet element itself, which Download renders to a file. + const recommended = bestOrientation(aspect, paper); + const orientation = choice === 'auto' ? recommended : choice; + const contentHeight = contentBox(paper, orientation).height * PX_PER_MM; + // The sheet (which Download renders to a file), its height, and where each of + // its pages starts: between rows, the same breaks the downloaded PDF uses. const sheetNode = useRef<HTMLElement | null>(null); + const [layout, setLayout] = useState({ height: 0, tops: [0] }); const sheetRef = useCallback( (element: HTMLElement | null) => { sheetNode.current = element; - return measureSheet(element); + if (!element) return; + let frame = 0; + // Once the charts inside have drawn, whenever the sheet resizes or they redraw. + const measure = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const next = { + height: element.offsetHeight, + tops: pageTops(breakPoints(element), element.offsetHeight, contentHeight), + }; + // Unchanged layouts are dropped, so re-rendering cannot feed back into itself. + setLayout((current) => + current.height === next.height && current.tops.join() === next.tops.join() + ? current + : next, + ); + }); + }; + const resized = new ResizeObserver(measure); + const redrawn = new MutationObserver(measure); + resized.observe(element); + redrawn.observe(element, { subtree: true, childList: true, attributes: true }); + return () => { + cancelAnimationFrame(frame); + resized.disconnect(); + redrawn.disconnect(); + }; }, - [measureSheet], + [contentHeight], ); const [format, setFormat] = useState<FileFormat>('pdf'); const [saving, setSaving] = useState(false); const [saveFailed, setSaveFailed] = useState(false); const [stageRef, stage] = useElementSize(); - const sheetHeight = sheet?.height ?? 0; - const recommended = bestOrientation(aspect, paper); - const orientation = choice === 'auto' ? recommended : choice; + const sheetHeight = layout.height; // Escape that dismisses the browser's print dialog must not close the preview too. const printingRef = useRef(false); @@ -209,10 +239,9 @@ export function ChartPrintDialog({ const page = paperBox(paper, orientation); const pageWidth = page.width * PX_PER_MM; const pageHeight = page.height * PX_PER_MM; - const contentHeight = contentBox(paper, orientation).height * PX_PER_MM; const margin = MARGIN * PX_PER_MM; - // A tall selection (every row of a long ranking) runs on; the browser breaks it into pages. - const pages = Math.max(1, Math.ceil(sheetHeight / contentHeight - 0.001)); + // A tall selection (every row of a long ranking) runs on to further pages. + const pages = layout.tops.length; // The whole first page fits the stage's width and its height cap (64dvh, at most 680px). const stageCap = Math.min(window.innerHeight * 0.64, 680); const scale = stage @@ -295,13 +324,14 @@ export function ChartPrintDialog({ withDate={withDate} /> </div> - {Array.from({ length: pages - 1 }, (_, index) => ( + {layout.tops.slice(1).map((top) => ( <div - key={index} + key={top} data-print-chrome + data-page-break aria-hidden="true" className="absolute inset-x-0 border-t border-dashed border-link" - style={{ top: (margin + (index + 1) * contentHeight) * scale }} + style={{ top: (margin + top) * scale }} /> ))} </div> diff --git a/web/src/components/charts/EventsView.tsx b/web/src/components/charts/EventsView.tsx index 56189a44b..156ab1097 100644 --- a/web/src/components/charts/EventsView.tsx +++ b/web/src/components/charts/EventsView.tsx @@ -126,6 +126,8 @@ export function EventsView({ data, title, period, provenance }: ViewProps<Events <> <ChartContainer config={config} + // One row per repository: a printed page may break between them (breakPoints). + data-print-rows className="aspect-auto w-full" style={{ height: lines * rowHeight + AXIS_HEIGHT }} > diff --git a/web/src/components/charts/SeriesView.tsx b/web/src/components/charts/SeriesView.tsx index 809049c19..1223a00d3 100644 --- a/web/src/components/charts/SeriesView.tsx +++ b/web/src/components/charts/SeriesView.tsx @@ -532,6 +532,8 @@ export function SeriesView({ <> <ChartContainer config={config} + // One row per category: a printed page may break between them (breakPoints). + data-print-rows={horizontal || undefined} className={`${horizontal || sheet ? '' : expanded ? 'h-[min(55vh,520px)]' : 'h-[340px]'} w-full aspect-auto`} style={ horizontal diff --git a/web/src/test/chartPrint.test.tsx b/web/src/test/chartPrint.test.tsx index 8cc9fb60d..884884811 100644 --- a/web/src/test/chartPrint.test.tsx +++ b/web/src/test/chartPrint.test.tsx @@ -12,6 +12,7 @@ import { filterLabel, PAPER_STORAGE_KEY, pageRule, + pageTops, storedPaper, } from '../chartPrint'; import { PrintSheetContext } from '../printContext'; @@ -168,6 +169,15 @@ describe('paper', () => { expect(storedPaper()).toBe('a4'); }); + it('breaks pages between rows, leaving space rather than splitting one', () => { + // Rows end at 100, 190, 290 and 390px; 200px fit on a page. + expect(pageTops([100, 190, 290, 390], 400, 200)).toEqual([0, 190, 390]); + // A sheet that fits needs no break. + expect(pageTops([100], 180, 200)).toEqual([0]); + // With no break in the lower half of a page, the page is cut where it ends. + expect(pageTops([20], 450, 200)).toEqual([0, 200, 400]); + }); + it('names downloaded files after the chart and its paper', () => { expect(exportName('Role activity', 'a4', 'landscape', 'pdf')).toBe( 'role-activity-a4-landscape.pdf',