diff --git a/docs/printing.md b/docs/printing.md index e280dd345..eb90ed8e3 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -201,3 +201,107 @@ 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. + +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 +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..0d29cf736 --- /dev/null +++ b/web/e2e/chartPrint.spec.ts @@ -0,0 +1,319 @@ +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); + } + // 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); +}); + +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..09be99e70 --- /dev/null +++ b/web/src/chartExport.ts @@ -0,0 +1,239 @@ +/** + * 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 { + breakPoints, + contentBox, + pageTops, + 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]; + } + // 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 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; + }); +} + +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..8ae9b3930 --- /dev/null +++ b/web/src/chartPrint.ts @@ -0,0 +1,142 @@ +/** + * 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; } }`; +} + +/** + * 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']); + +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..162be290d --- /dev/null +++ b/web/src/components/charts/ChartPrintDialog.tsx @@ -0,0 +1,437 @@ +/** + * 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, + breakPoints, + contentBox, + MARGIN, + pageRule, + pageTops, + 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 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; + 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(); + }; + }, + [contentHeight], + ); + const [format, setFormat] = useState<FileFormat>('pdf'); + const [saving, setSaving] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + const [stageRef, stage] = useElementSize(); + const sheetHeight = layout.height; + // 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 margin = MARGIN * PX_PER_MM; + // 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 + ? 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> + {layout.tops.slice(1).map((top) => ( + <div + 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 + top) * 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..156ab1097 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,103 @@ 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} + // 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 }} > - <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 +253,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..1223a00d3 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,18 @@ export function SeriesView({ <> <ChartContainer config={config} - className={`${horizontal ? '' : expanded ? 'h-[min(55vh,520px)]' : 'h-[340px]'} w-full aspect-auto`} + // 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 ? { 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 +556,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 +575,7 @@ export function SeriesView({ {...categoryAxis} width={labelWidth(chartRows.map((row) => row.category))} interval={0} + fontSize={rowHeight < 16 ? 9 : undefined} /> </> ) : ( @@ -550,7 +665,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 +688,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 +718,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 +781,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..884884811 --- /dev/null +++ b/web/src/test/chartPrint.test.tsx @@ -0,0 +1,387 @@ +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, + pageTops, + 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('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', + ); + 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, }));