diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e25f4b..6c2511a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Benchmarks: frame times in a browser. `apps/playground/bench.html` (built with the playground) pans, zooms and scrolls made-up trees of 125 to 10,000 tips as SVG and on the canvas and times every frame; open it in any browser, press Run and save the numbers. `node scripts/bench-browser.mjs` runs it in the Chrome already installed, headless, at pixel density 1 and 2 and with the main thread slowed 4 times, and writes `docs/benchmarks-browser.md`; `--from ` adds a section from numbers saved in another browser. It uses a throwaway profile that it deletes, listens on this machine only and sends nothing anywhere. First numbers (headless Chrome 157, M2 Max, other work running): the canvas held 60 frames a second at every size up to 19,999 nodes, slowed thread included; SVG held it through 1,999 nodes, and through 499 on the slowed thread. The 1,000-node switch is unchanged. Not measured: WebKit, Firefox, a window on a real screen, a real slow device, radial layouts. The benchmark page and script have no tests of their own. +- Payload plugin docs: moving a departed person's proposals to another account. The README's "When someone leaves" now says that `author` and `decidedBy` can't be changed through Payload, that the lock is a hook and so doesn't cover a host's own database code, and that a host making that move there must change only those two fields and leave `proposal`, `checksum` and the snapshot alone. A new test makes the move on MongoDB and checks that the proposal still matches its checksum, the version made from it still verifies, and the proposal stays locked. No code changed; an option to allow the move through Payload was considered and not built (ADR 0012). - Payload plugin: people are recorded by id, never by name (changes the unreleased proposals, before anyone used them). `proposals.nameOf` is gone. `proposals.personId({ req })` gives an opaque id for the signed-in person (default: their user document's id), and that id is all a proposal stores about its author and all an accepted proposal seals into the new snapshot for the author and the manager. An id is 1 to 64 letters, digits, `.`, `_`, `:` and `-`, starting with a letter or digit, so an email address or a name with a space in it is refused: the action stops, nothing is saved, and the refused value isn't written to the log. A one-word handle would pass; the plugin can't tell it from an id. `proposals.displayName({ req, personId })` is optional and gives the name to show: it is looked up each time a proposal is read (once per person per request), returned as `authorName` on the proposal and in the preview, and never stored or sealed, so a name changed or removed at the site changes what is shown at once and leaves every snapshot's checksum alone. Why: a name inside a sealed snapshot can't be taken out when a person leaves. Core is unchanged apart from its comments and one error text: `acceptIntoSnapshot` still seals the `author` and `acceptedBy` strings it is given. Still sealed as written, and not screened: a proposal's title, each move's note and the manager's note. No endpoint turns an id from a snapshot's record back into a name. Tested on Payload 3.93 with an in-memory MongoDB (142 plugin tests, 526 in all) and through the example site (47 checks); eight deliberate breaks of the new code each failed a test. Not tested: Postgres or SQLite, and a host with more than one auth collection. - React: names in the drawing are no longer struck through or covered. In rectangular layouts an inner node's name was drawn 6 units above the node, so when its children's rows were one row apart, a child's branch ran straight through it ("Reptilia" in the amniotes classes view). The name now goes in the first of three places the branches to its children leave clear: on the node's own row, just above it, or just below it; when rows are closer than a name is tall, it takes the place with the most room. A folded branch's name started 8 units out, on top of its 12-unit triangle; it now starts 16 units out, past it. The SVG and the canvas use the same rule (`innerLabelAt`, `leafLabelX`), so a name is in the same place in both. A leaf's name hasn't moved. Limits: a long name can still cross a branch further down the tree, since only the node's own children are looked at, and in a radial drawing a folded branch's triangle still points right whatever the branch's angle. - Example site: `apps/payload-example`, a small Payload 3.93 and Next.js 16 site that uses the Payload plugin (private, never published). It runs on this machine only, on a throwaway in-memory MongoDB that `pnpm dev` starts and seeds: two made-up accounts with a password made up per run, one published snapshot with two views, and one staged snapshot, all from the repository's illustrative fixtures. Accounts have two roles: a curator manages and decides, a member reads and proposes, only a curator hands out roles, and a member sees no account but their own. Proposals record people by account id, and `proposals.displayName` looks up a public `handle`, not the email address, when one is shown. The public pages are Server Components that read as an anonymous visitor and draw each published view with `TreeView` and `TreeOutline`, with what the view leaves out and its sources; a source's address is a link only when it is http or https. `pnpm smoke` builds the site, starts it on a free port and runs 47 checks over HTTP with sign-in cookies: what a visitor reads and can't do through the pages, REST and GraphQL; cookies that count only from the site's own pages; roles; a proposal made, shared, accepted into a staged, sealed version that records both people by id and holds no handle and no email address. CI runs it, and the CI workflow now cancels a run when a newer push arrives and stops a job after 20 minutes. Passed on `next dev` and on the built site on a Mac; the pages were looked at in headless Chrome in light and dark. Not tested: a person clicking through the admin, a screen reader, Postgres or SQLite, a deployed site. The site has no pages for proposals or imports. diff --git a/apps/playground/bench.html b/apps/playground/bench.html new file mode 100644 index 0000000..a9f59f9 --- /dev/null +++ b/apps/playground/bench.html @@ -0,0 +1,32 @@ + + + + + + OneTaxonomy renderer benchmark + + + + + +
+

OneTaxonomy renderer benchmark

+

Pans, zooms and scrolls made-up trees, drawn as SVG and on the canvas, and times each frame. It takes about four minutes. Keep this window in front and leave the mouse alone while it runs.

+

+
+
+ + + diff --git a/apps/playground/bench.tsx b/apps/playground/bench.tsx new file mode 100644 index 0000000..1264ea4 --- /dev/null +++ b/apps/playground/bench.tsx @@ -0,0 +1,267 @@ +// A browser benchmark for the two tree renderers: how long a frame takes while a made-up tree is panned and zoomed, +// drawn as SVG and on the canvas, at several sizes. `node scripts/bench-browser.mjs` runs it in headless Chrome; a +// person can also open dist/bench.html in any browser and press Run. +// +// Each frame sets a new viewBox, as dragging and zooming do in the explorer, and renders at once. Two things are timed: +// the script time of that render (React, and for the canvas the plan and the draw calls), and the time between +// animation frames, which also holds the browser's style, layout and paint work on the main thread. Raster and +// compositing happen on other threads and the graphics card. They show up here only once they fall behind and the +// browser holds the next frame back, so a steady frame time means the page kept up, not that painting was free. +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { createView, indexDataset, projectView, type DatasetSnapshot } from '@captiveorigins/onetaxonomy-core' +import { rectangular } from '@captiveorigins/onetaxonomy-layout' +import { canvasPlanOf, TreeView, type ViewBox } from '@captiveorigins/onetaxonomy-react' + +const STAGE = 1000 // CSS pixels, square +const FRAMES = 180 +const WARM_UP = 20 +/** Tip rows on the stage at reading size: 25 pixels a row, where every name is drawn. */ +const READING_ROWS = 40 +/** The fastest the reading scroll goes, in pixels a frame: a brisk drag. */ +const SCROLL_PX = 20 + +/** A made-up rooted tree (the same rule as scripts/bench-canvas.mjs): about a third of the tips go left at every split. */ +function madeUp(tips: number): DatasetSnapshot { + const nodes: { id: string; label?: string }[] = [] + const edges: { id: string; parent: string; child: string; length: number }[] = [] + let inner = 0 + let tip = 0 + const build = (count: number, parent: string | null) => { + let id: string + if (count === 1) { + tip++ + id = `t${tip}` + nodes.push({ id, label: `Taxon ${tip}` }) + } else { + id = `n${inner++}` + nodes.push({ id }) + } + if (parent !== null) edges.push({ id: `${parent}>${id}`, parent, child: id, length: 1 }) + if (count > 1) { + const left = Math.max(1, Math.floor(count / 3)) + build(left, id) + build(count - left, id) + } + } + build(tips, null) + return { + schemaVersion: 1, id: `made-up-${tips}`, version: '1', createdAt: '2026-11-11T00:00:00.000Z', + sources: [{ id: 'made-up', title: 'Made-up tree for benchmarks', citation: 'Generated. Not a scientific source.', license: 'CC0-1.0', illustrative: true }], + names: [], concepts: [], classifications: [], + phylogenies: [{ id: 'big', title: 'Made-up tree', version: '1', sources: ['made-up'], rooted: true, nodes, edges }], + } as unknown as DatasetSnapshot +} + +const MOTIONS = ['pan', 'zoom', 'read'] as const +type Motion = (typeof MOTIONS)[number] +export type Scenario = { renderer: 'svg' | 'canvas'; tips: number } +export type Timing = { median: number; p95: number; worst: number } +export type MotionResult = { frames: number; interval: Timing; script: Timing; late: number; drawn: Timing | null; labels: Timing | null } +export type Result = Scenario & { nodes: number; mountScript: number; elements: number | null } & Record +export type Report = { userAgent: string; dpr: number; stage: number; refresh: number; visibility: string; gpu: string; results: Result[] } + +// 500 tips is 999 nodes, the last size `renderer="auto"` still draws as SVG. The larger SVG runs show what the +// threshold avoids, and the smaller ones where SVG still keeps up on a slowed main thread. +const SCENARIOS: Scenario[] = [ + { renderer: 'svg', tips: 125 }, + { renderer: 'canvas', tips: 125 }, + { renderer: 'svg', tips: 250 }, + { renderer: 'canvas', tips: 250 }, + { renderer: 'svg', tips: 500 }, + { renderer: 'canvas', tips: 500 }, + { renderer: 'svg', tips: 1000 }, + { renderer: 'canvas', tips: 1000 }, + { renderer: 'svg', tips: 2500 }, + { renderer: 'canvas', tips: 2500 }, + { renderer: 'svg', tips: 5000 }, + { renderer: 'canvas', tips: 5000 }, + { renderer: 'canvas', tips: 10000 }, +] + +const timing = (ms: number[]): Timing => { + const s = [...ms].sort((a, b) => a - b) + const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))] ?? 0 + return { median: at(0.5), p95: at(0.95), worst: s[s.length - 1] ?? 0 } +} + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + +/** The display's frame time as this browser gives it, with nothing to draw. */ +async function refreshInterval(): Promise { + const stamps: number[] = [] + for (let i = 0; i < 40; i++) stamps.push(await nextFrame()) + return timing(stamps.slice(1).map((t, i) => t - stamps[i]!)).median +} + +type Frame = { whole: ViewBox; reading: number; target: { x: number; y: number } } + +/** + * The box for frame `i`. Every box is square, like the stage, so the picture never changes shape. + * - pan: the whole tree, sliding in a slow loop a tenth of the stage each way. Every node is in view on every frame. + * - zoom: from the whole tree in to reading size on a tip in the middle, and back out, so names come and go. + * - read: at reading size, scrolling up and down the tips at up to 20 pixels a frame. Every name in view is drawn. + */ +function boxAt(motion: Motion, i: number, { whole, reading, target }: Frame): ViewBox { + const a = (i / 90) * 2 * Math.PI + if (motion === 'pan') return { ...whole, x: whole.x + whole.width * 0.1 * Math.sin(a), y: whole.y + whole.height * 0.1 * Math.sin(2 * a) } + if (motion === 'read') { + const reach = ((SCROLL_PX * 90) / (2 * Math.PI) / STAGE) * reading // layout units either side of the target + return { x: target.x - reading / 2, y: target.y + reach * Math.sin(a) - reading / 2, width: reading, height: reading } + } + // The same step in zoom every frame (a steady pinch), holding the target where it would sit under a pointer. + const least = reading / whole.width + const k = Math.pow(least, (1 - Math.cos(a)) / 2) + const far = least < 1 ? (k - least) / (1 - least) : 0 // 1 at the whole tree, 0 at reading size + const side = whole.width * k + const cx = target.x + (whole.x + whole.width / 2 - target.x) * far + const cy = target.y + (whole.y + whole.height / 2 - target.y) * far + return { x: cx - side / 2, y: cy - side / 2, width: side, height: side } +} + +async function run(s: Scenario, stage: HTMLElement, refresh: number): Promise { + const dataset = indexDataset(madeUp(s.tips)) + const view = createView(dataset, { id: 'v', title: 'Made-up', target: { kind: 'phylogeny', id: 'big' } }) + const projection = projectView(dataset, view, { limit: Infinity }) + const layout = rectangular(projection) + // A square box holding the whole tree with a small margin, centred on it. + const side = Math.max(layout.width, layout.height) + 32 + const whole: ViewBox = { x: layout.width / 2 - side / 2, y: layout.height / 2 - side / 2, width: side, height: side } + const leaves = layout.nodes.filter((n) => n.leaf) + const rowGap = layout.height / Math.max(1, leaves.length - 1) + const target = leaves.reduce((best, n) => (Math.abs(n.y - layout.height / 2) < Math.abs(best.y - layout.height / 2) ? n : best), leaves[0]!) + const frame: Frame = { whole, reading: Math.min(side, READING_ROWS * rowGap), target: { x: target.x, y: target.y } } + + stage.replaceChildren() + const host = document.createElement('div') + stage.append(host) + const root = createRoot(host) + const draw = (box: ViewBox) => { + const t = performance.now() + flushSync(() => + root.render(), + ) + return performance.now() - t + } + + const mountScript = draw(whole) + await nextFrame() + await nextFrame() + const elements = s.renderer === 'svg' ? host.querySelectorAll('svg *').length : null + + const move = async (motion: Motion): Promise => { + const script: number[] = [] + const interval: number[] = [] + const drawn: number[] = [] + const labels: number[] = [] + let last = await nextFrame() + for (let i = 0; i < WARM_UP + FRAMES; i++) { + const ms = draw(boxAt(motion, i, frame)) + const now = await nextFrame() + if (i >= WARM_UP) { + script.push(ms) + interval.push(now - last) + const plan = s.renderer === 'canvas' ? canvasPlanOf(host.querySelector('canvas')) : undefined + if (plan) { + drawn.push(plan.counts.drawn) + labels.push(plan.counts.labelsShown) + } + } + last = now + } + return { + frames: interval.length, + interval: timing(interval), + script: timing(script), + // A frame is late when it took more than one and a half of the display's frame times. + late: interval.filter((ms) => ms > refresh * 1.5).length / interval.length, + drawn: drawn.length ? timing(drawn) : null, + labels: labels.length ? timing(labels) : null, + } + } + const pan = await move('pan') + const zoom = await move('zoom') + const read = await move('read') + root.unmount() + return { ...s, nodes: layout.nodes.length, mountScript, elements, pan, zoom, read } +} + +function gpu(): string { + try { + const gl = document.createElement('canvas').getContext('webgl') + const info = gl?.getExtension('WEBGL_debug_renderer_info') + return (gl && info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : '') || 'not reported' + } catch { + return 'not reported' + } +} + +const f = (ms: number) => (ms < 10 ? ms.toFixed(1) : ms.toFixed(0)) +const cell = (t: Timing) => `${f(t.median)} / ${f(t.p95)} / ${f(t.worst)}` + +function show(report: Report, out: HTMLElement) { + const rows = report.results.flatMap((r) => + MOTIONS.map((m) => { + const x = r[m] + return `${r.renderer}${r.tips.toLocaleString('en-US')}${r.nodes.toLocaleString('en-US')}${m}${x.drawn ? Math.round(x.drawn.median).toLocaleString('en-US') : (r.elements ?? 0).toLocaleString('en-US') + ' elements'}${cell(x.interval)}${(1000 / x.interval.median).toFixed(0)}${(x.late * 100).toFixed(0)}%${cell(x.script)}` + }), + ) + out.innerHTML = + `

Display frame time here: ${report.refresh.toFixed(1)} ms. Pixel density: ${report.dpr}. Stage: ${report.stage} × ${report.stage} CSS pixels. ${FRAMES} frames per motion. Times are median / 95th percentile / worst, in milliseconds.

` + + `${rows.join('')}
RendererTipsNodesMotionDrawnFrame timeFrames a secondLate framesScript time
` +} + +/** + * A link that saves the numbers as a file, in the shape `node scripts/bench-browser.mjs --from ` reads. The file + * is made in the page and saved by the browser; nothing is sent anywhere. + */ +function saveLink(report: Report): HTMLElement { + const run = { + date: new Date().toISOString().slice(0, 10), + machine: null, + cores: navigator.hardwareConcurrency ?? null, + loadBefore: null, + loadAfter: null, + headless: false, + reports: [{ ...report, browser: report.userAgent, slow: 1 }], + } + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob([JSON.stringify(run, null, 2)], { type: 'application/json' })) + a.download = 'onetaxonomy-bench.json' + a.textContent = 'Save the numbers (onetaxonomy-bench.json)' + const p = document.createElement('p') + p.append(a) + return p +} + +let running: Promise | null = null + +/** Runs every scenario once and gives the numbers. Asked for twice, it gives the same run. */ +function runBench(): Promise { + running ??= (async () => { + const stage = document.getElementById('stage')! + const status = document.getElementById('status')! + const refresh = await refreshInterval() + const results: Result[] = [] + for (const s of SCENARIOS) { + status.textContent = `Running ${s.renderer}, ${s.tips.toLocaleString('en-US')} tips…` + results.push(await run(s, stage, refresh)) + } + stage.replaceChildren() + const report: Report = { userAgent: navigator.userAgent, dpr: window.devicePixelRatio, stage: STAGE, refresh, visibility: document.visibilityState, gpu: gpu(), results } + status.textContent = 'Done.' + const out = document.getElementById('results')! + show(report, out) + out.append(saveLink(report)) + return report + })() + return running +} + +declare global { + interface Window { + runBench: typeof runBench + } +} +window.runBench = runBench +document.getElementById('run')?.addEventListener('click', () => void runBench()) diff --git a/apps/playground/package.json b/apps/playground/package.json index b7ecfb2..f90fdaa 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "build": "esbuild app.tsx --bundle --format=iife --minify --outfile=dist/app.js --jsx=automatic --define:process.env.NODE_ENV='\"production\"' && cp index.html dist/ && cp ../../packages/react/src/styles.css dist/" + "build": "esbuild app.tsx --bundle --format=iife --minify --outfile=dist/app.js --jsx=automatic --define:process.env.NODE_ENV='\"production\"' && esbuild bench.tsx --bundle --format=iife --minify --outfile=dist/bench.js --jsx=automatic --define:process.env.NODE_ENV='\"production\"' && cp index.html bench.html dist/ && cp ../../packages/react/src/styles.css dist/" }, "dependencies": { "@captiveorigins/onetaxonomy-core": "workspace:^", diff --git a/docs/architecture/adr/0012-payload-plugin.md b/docs/architecture/adr/0012-payload-plugin.md index 1a1ce40..b032a91 100644 --- a/docs/architecture/adr/0012-payload-plugin.md +++ b/docs/architecture/adr/0012-payload-plugin.md @@ -36,7 +36,7 @@ - **Phylogenies are never accepted.** A proposal on a phylogeny can be made, shared and previewed, as in the explorer, but accepting it is refused in core and in the plugin. A phylogeny is a published hypothesis with a citation; a tree rearranged by hand is a result its study never found, so it can't take that hypothesis's place. A new analysis is imported as its own hypothesis. - **Once, and together.** The new snapshot and the proposal's new status are written in one transaction where the database has them. The proposal is claimed with one conditional update (still shared, same checksum), so two managers accepting at once store one version and the other gets a 409, and an author who takes the proposal back in that moment wins: nothing is accepted. Without transactions the new snapshot is deleted again when the claim fails. Declining is the same conditional update without a snapshot. - **People are sealed by id, never by name (changed 2026-11-11, before any release).** The first version of this point sealed a name the host chose (`nameOf`, a public handle). That was wrong for a site whose people can ask to be forgotten: a name inside a checksum can't be taken out without making a new version, and a published version may already be pinned and cited. Now `proposals.personId({ req })` gives an opaque id (default: the signed-in user's document id), and only that id is stored on a proposal and, on acceptance, sealed into the new snapshot's source record and `derivedFrom`. An id is 1 to 64 letters, digits, `.`, `_`, `:` and `-`, starting with a letter or digit, so an email address or a name with a space in it is refused; a throw or any other value also stops the action, nothing is saved, and the log line never repeats the value. The pattern can't tell a one-word handle from an id, so the docs say not to give one. A name is shown through `proposals.displayName({ req, personId })`, called when a proposal is read (once per person per request) and returned as `authorName`: a field that is computed on read, dropped on write and never in the database. When a name is removed at the host, nothing shown or sealed names the person. The title, each move's note and the manager's note are free text and are sealed as written; the plugin doesn't screen them. - - **Kept and deleted.** An author can delete their own private and declined proposals. A shared one must be taken back first. An accepted one can't be changed or deleted, even by trusted code, because a snapshot version names it. If the staged version it made is deleted before publishing, the proposal goes back to shared with a note saying so. A host's own code can take back and delete a departed person's open proposals; their accepted ones stay, under their id, with the relationship to their user document unchanged. + - **Kept and deleted.** An author can delete their own private and declined proposals. A shared one must be taken back first. An accepted one can't be changed or deleted, even by trusted code, because a snapshot version names it. If the staged version it made is deleted before publishing, the proposal goes back to shared with a note saying so. A host's own code can take back and delete a departed person's open proposals; their accepted ones stay, under their id. The document's `author` and `decidedBy` (the user document's id, as text) can't be changed through Payload. A way to allow it for a host that moves a departed person's records to another account (a flag only server code can set, plus a host check) was considered on 2026-11-11 and not built: the one host that asked makes that move in its own database transaction, where no hook runs, so the option would have had no user. What the plugin owes such a host is that the move is safe, and a test shows it: with only those two fields changed underneath it, the proposal still matches its checksum and the version made from it still verifies, because neither field is inside either checksum. Build the option when a host needs to make the move through the Local API. - **Limits.** Each acceptance is made from the published base alone: two proposals accepted on one snapshot give two versions, not one with both. To combine them, publish the first and propose again on it. Without transactions, a crash between storing the snapshot and claiming the proposal can leave a staged version no proposal points to; a manager deletes it. The note from an earlier decision stays on a proposal that is shared again, until the next decision. The conditional update is one statement on MongoDB; it hasn't been checked on Postgres or SQLite. One person keeps at most `maxPerUser` proposals (default 200, not counting accepted ones) of at most `maxMoves` moves (default 500). In the admin a proposal is a JSON field. No endpoint turns an id read from a snapshot's record back into a name; a host that wants that on a snapshot page looks it up itself. **Later parts of M7, not built yet.** diff --git a/docs/architecture/backlog.md b/docs/architecture/backlog.md index 74b62a3..84f73e7 100644 --- a/docs/architecture/backlog.md +++ b/docs/architecture/backlog.md @@ -83,8 +83,8 @@ Task-level plan per milestone ([`phase-plan.md`](phase-plan.md)). `[x]` done wit - [x] Phylogeny comparison (`comparePhylogenies`), explicit rooting tools (`rerootPhylogeny`, `midpointRoot`), support-threshold collapsing (`collapseBelowSupport` view action) (2026-11-11; core only, no explorer or Mac app controls yet) - [ ] Deep-time rendering where calibrated; trait overlays; documented discrete-character parsimony (labelled *inferred*) - [x] Nexus and phyloXML adapters (2026-11-11; the Mac app doesn't open them yet) -- [ ] Canvas renderer with level of detail (1–2k visible nodes smoothly). Built 2026-11-11: `TreeView` switches to a canvas above 1,000 drawn nodes, with level of detail, disclosure and hit-testing; planning one frame takes under 11 ms in Node at 50,000 tips (`docs/benchmarks.md`). Not ticked: paint time and smoothness haven't been measured in a browser. -- [ ] Benchmarks: 10k+ taxa datasets, 1–2k visible nodes; documented devices/browsers. Started 2026-11-11: `scripts/bench-canvas.mjs` times projection, layout and the canvas plan on 1k, 10k and 50k made-up tips in Node and writes `docs/benchmarks.md` with the machine. Not ticked: no browser has been measured. +- [ ] Canvas renderer with level of detail (1–2k visible nodes smoothly). Built 2026-11-11: `TreeView` switches to a canvas above 1,000 drawn nodes, with level of detail, disclosure and hit-testing; planning one frame takes under 11 ms in Node at 50,000 tips (`docs/benchmarks.md`). Measured 2026-11-11 in headless Chrome 157 on an M2 Max Mac (`docs/benchmarks-browser.md`): the canvas held 60 frames a second while panning, zooming and scrolling at every size tried, up to 19,999 nodes, and still did with the main thread slowed 4 times. Not ticked: nothing has been measured in WebKit, which the Mac app and Safari use, or in a window on a real screen. +- [ ] Benchmarks: 10k+ taxa datasets, 1–2k visible nodes; documented devices/browsers. Started 2026-11-11: `scripts/bench-canvas.mjs` times projection, layout and the canvas plan on 1k, 10k and 50k made-up tips in Node and writes `docs/benchmarks.md` with the machine. `scripts/bench-browser.mjs` and `apps/playground/bench.html` time frames for both renderers in a browser and write `docs/benchmarks-browser.md`; one browser on one machine is in it (headless Chrome 157, M2 Max). Not ticked: WebKit and Firefox, a real slow device (only Chrome's 4 times throttle so far, where SVG dropped frames at 999 nodes, just under the 1,000-node switch; the default stays until a real device says otherwise), radial layouts, and a 10k+ dataset that isn't made up. - [x] Accessibility audit (code-level, WCAG 2.2 AA, 2026-11-11: `docs/test-audit.md`; small fixes made, gaps listed there; no screen reader or real browser run yet) ## Phase 2 (not started by design) diff --git a/docs/benchmarks-browser.md b/docs/benchmarks-browser.md new file mode 100644 index 0000000..473850f --- /dev/null +++ b/docs/benchmarks-browser.md @@ -0,0 +1,103 @@ +# Browser benchmarks + +How long a frame takes while a made-up tree is panned, zoomed and scrolled, drawn as SVG and on the canvas. Each tip is "Taxon n" and about a third of the tips go left at every split, the same trees as [benchmarks.md](benchmarks.md), which times the pure steps in Node. Written by `node scripts/bench-browser.mjs` after `pnpm -r build`. Rerun it to add another machine or browser; each run adds a section above the older ones. + +**What this measures.** The page (`apps/playground/bench.tsx`) sets a new view box on every animation frame, as dragging and zooming do in the explorer, and renders at once. It records the time between animation frames and the script time of each render (React, and for the canvas the plan and the draw calls). Three motions: **pan** slides the whole tree with every node in view; **zoom** goes from the whole tree in to reading size (40 tip rows on the drawing, every name drawn) and back; **read** scrolls the tips at reading size, at up to 20 pixels a frame. In the tables, a frame is given in milliseconds as median / 95th percentile / worst, **late** is the share of frames that took more than one and a half display frames, and script is the median. + +**What it doesn't.** + +- Other browser engines. Only the browsers with a section below were measured. WebKit, which the Mac app and Safari use, and Firefox may differ. To measure one, open `apps/playground/dist/bench.html` in it, press Run, save the numbers and run `node scripts/bench-browser.mjs --from onetaxonomy-bench.json`. +- A window on a real screen. A headless run draws with the graphics card but shows nothing. +- All of painting. Raster and compositing happen off the main thread. They show in the frame time only once they fall behind and the browser holds the next frame back, so a steady frame time means the page kept up, not that painting cost nothing. +- A slow device. "Main thread slowed 4 times" is Chrome's own throttle. It slows script, style and layout on the main thread and leaves the graphics card and the other threads at full speed, so it is a rough stand-in, not a phone or an old laptop. +- Radial layouts, highlights, traits, support values and folded branches. The trees are plain rectangular ones with names on the tips. +- The outline (`TreeOutline`), which isn't virtualized. + +## Chrome 157.0.8103.40, headless, 2026-11-11 + +- Machine: Mac14,13, Apple M2 Max, 12 cores. +- Browser: Chrome 157.0.8103.40, headless (no window on a screen). Graphics: ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version). +- Load: the machine was busy with other work. Load average 6.8, 8, 8.1 before and 11.6, 10.4, 9.3 after (1, 5 and 15 minutes). +- Drawing: 1000 × 1000 CSS pixels. 180 frames for each motion, after 20 warm-up frames. One run of each; not repeated. + +What the numbers say, counting a size as on time when under 5% of its frames were late in every motion: + +- **Pixel density 1:** SVG was on time up to 1,999 nodes; it first fell behind at 4,999 nodes (78% of frames late while zooming, median frame 33 ms, 30 frames a second). The canvas was on time at every size, up to 19,999 nodes. +- **Pixel density 2:** SVG was on time up to 1,999 nodes; it first fell behind at 4,999 nodes (79% of frames late while zooming, median frame 33 ms, 30 frames a second). The canvas was on time at every size, up to 19,999 nodes. +- **Pixel density 2, main thread slowed 4 times:** SVG was on time up to 499 nodes; it first fell behind at 999 nodes (58% of frames late while zooming, median frame 33 ms, 30 frames a second). The canvas was on time at every size, up to 19,999 nodes. + +`TreeView` draws as SVG up to 1,000 drawn nodes and on the canvas above that. The largest SVG tree measured at or under the switch: pixel density 1: 999 nodes, on time; pixel density 2: 999 nodes, on time; pixel density 2, main thread slowed 4 times: 999 nodes, 58% of frames late while zooming. + +### Pixel density 1 + +The display frame here, with nothing to draw: 16.7 ms (60 frames a second). + +| Renderer | Tips | Nodes | Pan: frame, late | Zoom: frame, late | Read: frame, late | Script (pan / zoom / read) | First render, script | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| svg | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.5 / 0.5 / 0.5 | 6.7 | +| canvas | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.3 / 0.3 / 0.2 | 3.3 | +| svg | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.0 / 1.0 / 1.0 | 2.6 | +| canvas | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.3 / 0.2 / 0.2 | 2.1 | +| svg | 500 | 999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.8 / 1.7 / 1.8 | 4.2 | +| canvas | 500 | 999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.4 / 0.3 / 0.3 | 2.0 | +| svg | 1,000 | 1,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 3.5 / 3.5 / 3.3 | 8.5 | +| canvas | 1,000 | 1,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.6 / 0.3 / 0.2 | 4.8 | +| svg | 2,500 | 4,999 | 33 / 33 / 33, 64% | 33 / 33 / 50, 78% | 33 / 33 / 34, 60% | 8.3 / 8.3 / 8.2 | 21 | +| canvas | 2,500 | 4,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.1 / 0.4 / 0.2 | 4.1 | +| svg | 5,000 | 9,999 | 67 / 67 / 83, 100% | 67 / 67 / 83, 100% | 50 / 67 / 67, 98% | 18 / 18 / 17 | 42 | +| canvas | 5,000 | 9,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.4 / 0.4 / 0.2 | 8.7 | +| canvas | 10,000 | 19,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.7 / 0.6 / 0.2 | 10 | + +### Pixel density 2 + +The display frame here, with nothing to draw: 16.7 ms (60 frames a second). + +| Renderer | Tips | Nodes | Pan: frame, late | Zoom: frame, late | Read: frame, late | Script (pan / zoom / read) | First render, script | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| svg | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.5 / 0.5 / 0.5 | 6.1 | +| canvas | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.3 / 0.3 / 0.2 | 3.9 | +| svg | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.0 / 1.0 / 1.0 | 2.6 | +| canvas | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.3 / 0.3 / 0.2 | 1.8 | +| svg | 500 | 999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.8 / 1.8 / 1.8 | 4.6 | +| canvas | 500 | 999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.4 / 0.3 / 0.2 | 2.1 | +| svg | 1,000 | 1,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 3.4 / 3.6 / 3.4 | 9.0 | +| canvas | 1,000 | 1,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 0.6 / 0.3 / 0.2 | 4.7 | +| svg | 2,500 | 4,999 | 33 / 33 / 34, 68% | 33 / 33 / 50, 79% | 33 / 33 / 34, 60% | 8.5 / 8.3 / 8.2 | 20 | +| canvas | 2,500 | 4,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.2 / 0.4 / 0.2 | 4.0 | +| svg | 5,000 | 9,999 | 67 / 67 / 83, 100% | 67 / 67 / 83, 100% | 67 / 67 / 67, 98% | 20 / 19 / 19 | 46 | +| canvas | 5,000 | 9,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.3 / 0.5 / 0.2 | 8.7 | +| canvas | 10,000 | 19,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.7 / 0.6 / 0.3 | 15 | + +### Pixel density 2, main thread slowed 4 times + +The display frame here, with nothing to draw: 16.7 ms (60 frames a second). + +| Renderer | Tips | Nodes | Pan: frame, late | Zoom: frame, late | Read: frame, late | Script (pan / zoom / read) | First render, script | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| svg | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 2.0 / 2.1 / 2.1 | 27 | +| canvas | 125 | 249 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.0 / 0.8 / 0.8 | 16 | +| svg | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 3.9 / 4.1 / 4.0 | 12 | +| canvas | 250 | 499 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.0 / 0.9 / 0.8 | 7.8 | +| svg | 500 | 999 | 17 / 33 / 34, 44% | 33 / 33 / 33, 58% | 17 / 33 / 34, 34% | 7.7 / 7.6 / 7.4 | 20 | +| canvas | 500 | 999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 1.3 / 1.2 / 0.8 | 9.3 | +| svg | 1,000 | 1,999 | 50 / 50 / 67, 100% | 50 / 67 / 67, 100% | 50 / 50 / 67, 98% | 15 / 15 / 15 | 36 | +| canvas | 1,000 | 1,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 2.4 / 1.2 / 0.7 | 18 | +| svg | 2,500 | 4,999 | 133 / 133 / 150, 100% | 133 / 150 / 167, 100% | 117 / 133 / 150, 100% | 37 / 37 / 36 | 91 | +| canvas | 2,500 | 4,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 4.4 / 1.5 / 0.8 | 20 | +| svg | 5,000 | 9,999 | 250 / 283 / 317, 100% | 250 / 283 / 300, 100% | 250 / 283 / 283, 100% | 76 / 73 / 74 | 185 | +| canvas | 5,000 | 9,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 5.4 / 1.7 / 0.7 | 30 | +| canvas | 10,000 | 19,999 | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 17 / 17 / 17, 0% | 7.0 / 2.5 / 0.9 | 55 | + +### What was on screen + +The SVG holds every element whatever the view. The canvas plans each frame: it leaves out what is out of view and merges subtrees too small to tell apart. Median for each frame, with the most in any frame in brackets. + +| Tips | Nodes | SVG elements | Canvas, pan: nodes drawn | Canvas, zoom: nodes drawn, names | Canvas, read: nodes drawn, names | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 125 | 249 | 875 | 249 (249) | 181 (249), 0 (55) | 113 (115), 40 (41) | +| 250 | 499 | 1,750 | 483 (499) | 243 (499), 0 (55) | 109 (111), 40 (41) | +| 500 | 999 | 3,500 | 949 (999) | 332 (999), 0 (55) | 114 (116), 40 (41) | +| 1,000 | 1,999 | 7,000 | 1,309 (1,389) | 460 (1,999), 0 (55) | 112 (113), 40 (41) | +| 2,500 | 4,999 | 17,500 | 2,320 (2,479) | 718 (2,686), 0 (55) | 115 (116), 39 (41) | +| 5,000 | 9,999 | 35,000 | 2,369 (2,519) | 1,010 (3,171), 0 (53) | 115 (116), 40 (41) | +| 10,000 | 19,999 | not run | 2,499 (2,679) | 1,429 (3,321), 0 (55) | 112 (114), 40 (41) | diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 8cdabc1..75ad6c8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -4,7 +4,7 @@ Made-up trees of 1,000, 10,000 and 50,000 tips (each tip "Taxon n", about a thir **What this measures:** CPU time in Node for the pure steps: projecting the view (with `projectView`'s 10,000-node limit lifted, so every node is drawn), laying it out, and the canvas renderer's level-of-detail plan (`prepareTree`, `planCanvas`) and hit-testing. Each figure is the median of 10 runs (5 for 50,000 tips) after two warm-up runs, in milliseconds, with the fastest run in brackets. -**What it doesn't:** painting. This repo has no canvas for Node (no `node-canvas` dependency), so the draw was run against a context that only counts calls, to show how much would be drawn. Paint time and frame rates while panning or zooming need a real browser (WebKit in the Mac app, Safari, Chrome, Firefox) and have **not** been measured. Nothing here says the canvas is smooth at 1–2k visible nodes yet: the plan for one frame is cheap enough to allow it, and a browser run has to confirm it. +**What it doesn't:** painting. This repo has no canvas for Node (no `node-canvas` dependency), so the draw was run against a context that only counts calls, to show how much would be drawn. Frame times while panning, zooming and scrolling in a browser are in [benchmarks-browser.md](benchmarks-browser.md), which also says which browsers were measured and which weren't. ## Mac14,13, 2026-11-11 @@ -37,8 +37,7 @@ Calls the ~1 px per tip plan makes on a 2D context (counted, not timed): Edges are batched into at most four paths (plain, weak, and each while dragging), so the stroke count stays small however many edges there are. -## Not measured yet +## Not measured here -- Paint time and frame rate in a browser, at 1–2k visible nodes, at 1× and 2× pixel density. Needs a person with the Mac app or a browser open, or a headless browser run. -- The SVG renderer at the threshold (1,000 drawn nodes), to check the threshold is in the right place. It was chosen by judgment: the explorer re-renders every SVG node on each pan step, and at 1,000 nodes that is 1,000 React elements per pointer move. +- Anything in a browser. [benchmarks-browser.md](benchmarks-browser.md) has frame times for both renderers in headless Chrome, and lists what is still unmeasured there: WebKit (the Mac app and Safari), a window on a real screen, and a real slow device. - The outline (`TreeOutline`) with 10,000+ rows: it isn't virtualized, and on large trees it may be slower than the drawing. diff --git a/docs/test-audit.md b/docs/test-audit.md index 9b2c62a..69a38ff 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,24 @@ Each audit lists what this repo promises, the tests that prove it, and the gaps: those closed in the same pass and those left, with why. Audits run after each feature or fix, and at least every 14 days while the repo has commits. Newest first. +## 2026-11-11: browser benchmark, and moving a departed person's proposals + +Scope: `apps/playground/bench.tsx` and `bench.html`, `scripts/bench-browser.mjs`, `docs/benchmarks-browser.md`, and the "When someone leaves" paragraph of `packages/payload/README.md`. Plugin tests went from 142 to 143, all unit tests from 526 to 527. + +### Covered + +- **A host can move an accepted proposal's `author` and `decidedBy` to another account in its own database code without breaking what was sealed** (README, ADR 0012 point 15). `proposals.test.ts`, "keeps the sealed record whole when the site moves an accepted proposal's two links to another account in its own database code": through Payload the change is refused for both fields, trusted code included; after an `updateOne` on the adapter's own collection the proposal still carries its author's person id and matches its checksum, the version made from it has the same checksum, verifies and names both people by id, readers still see it with the name looked up from that id, and it can still be neither edited nor deleted. MongoDB only, as the rest of the plugin's tests. + +### Gaps left + +- **The benchmark page and script have no tests.** They are measuring tools that ship in no package. What they print was checked by hand: the summary lines against the tables of the run in the doc, and two full runs under an hour apart agreed on which sizes kept up, with median frame times within one display frame of each other. The summary's rules are small (on time means under 5% late frames in every motion; the motion named is the one with the most late frames), and a wrong one would show against the table beside it. Worth a test if the script grows. +- **Nothing here is measured in WebKit, Firefox, a window on a real screen, or on a real slow device.** The doc says so above its numbers. A Safari run is on the owner's test list. +- **The README's advice to try `canvasThreshold={500}` on slow devices rests on Chrome's throttle alone.** It is worded as something to try, and the default wasn't changed. + +### Left out as overkill + +- Testing that headless Chrome starts and is cleaned up. The script deletes its throwaway profile in a `finally`, and a run that failed to start says so and exits non-zero. + ## 2026-11-11: people by id in proposals Scope: `packages/payload` (`src/proposals.ts`, `src/types.ts`) and `apps/payload-example`. The plugin promised that an accepted proposal sealed a name the host chose. It now promises that only an opaque person id is stored and sealed, and that a name is looked up when shown and never kept. This replaces the lines about handles in the two audits below ("example Payload site" and "Payload plugin part 4"). Plugin tests went from 139 to 142, all unit tests from 523 to 526, the example's smoke checks from 46 to 47. diff --git a/packages/payload/README.md b/packages/payload/README.md index c5fc4fd..82ac3ab 100644 --- a/packages/payload/README.md +++ b/packages/payload/README.md @@ -145,7 +145,7 @@ The new version is core's `acceptIntoSnapshot`: the classification gets a new ve A proposal's title and notes, each move's note and the manager's note are people's own words. On acceptance the title, the moves' notes and the manager's note are sealed into the snapshot, and the proposal itself is kept as it was. Don't put anything personal in them. The plugin doesn't check. -**When someone leaves.** Your server code can take back their shared proposals (`status: 'private'`) and delete their private and declined ones. Their accepted proposals stay, and so does their id in the snapshot. Stop giving a name for that id from `displayName`, and nothing shown names them. The accepted proposal's document still points to their user document (`author`, and `decidedBy` for a manager); that link can't be changed, so keep or delete the user document as your own rules say. +**When someone leaves.** Your server code can take back their shared proposals (`status: 'private'`) and delete their private and declined ones. Their accepted proposals stay, and so does their id in the snapshot. Stop giving a name for that id from `displayName`, and nothing shown names them. The accepted proposal's document still holds their user document's id, as text (`author`, and `decidedBy` for a manager). Nothing written through Payload can change those two, your own server code through the Local API included, and there is no option to allow it. The lock is a hook, so it doesn't cover a write made straight to the database. If your rules move a departed person's records to another account, change `author` and `decidedBy` there, in your own code, and change nothing else: leave `proposal` (it holds the sealed person id), `checksum` and the snapshot exactly as they are, or the proposal stops matching the version made from it. A test does this on MongoDB and checks that the proposal and the snapshot still verify. Otherwise keep or delete the user document as your own rules say; the two fields are plain text, so the database doesn't mind an id whose document is gone. Options: `slugs` (`snapshots`, `views`, `imports`, `proposals`), `access` (`read`, `manage`, `propose`), `tenant` (`collection`, `field`, `resolve`), `locale`, `limits`, `imports` (`uploads`, `fetch`, `maxBytes`, `staleAfterMinutes`, `runOnCreate`), `proposals` (`personId`, `displayName`, `maxMoves` default 500, `maxPerUser` default 200, not counting accepted ones), `admin.group`. diff --git a/packages/payload/test/proposals.test.ts b/packages/payload/test/proposals.test.ts index 58687ef..56b061a 100644 --- a/packages/payload/test/proposals.test.ts +++ b/packages/payload/test/proposals.test.ts @@ -666,6 +666,44 @@ describe('when someone leaves', () => { names.set(pid(ann), 'Ann Keeper') } }) + + it('keeps the sealed record whole when the site moves an accepted proposal\'s two links to another account in its own database code', async () => { + const [accepted] = (await payload.find({ collection: PROPOSALS as never, where: { and: [{ status: { equals: 'accepted' } }, { author: { equals: String(ann.id) } }] }, depth: 0, limit: 1 })).docs as Doc[] + expect(accepted).toBeTruthy() + const decider = String(accepted!.decidedBy) + const version = await snapshot(accepted!.acceptedInto) + // An account that stands in for people who have left. It has no person id of its own here. + const standIn = String((await makeUser(payload, 'reader', { tenant: tenantA })).id) + + // Through Payload, even trusted code is refused, and nothing changes. + await expect(payload.update({ collection: PROPOSALS as never, id: accepted!.id, data: { author: standIn } as never })).rejects.toThrow(/author can't change/) + await expect(payload.update({ collection: PROPOSALS as never, id: accepted!.id, data: { decidedBy: standIn } as never })).rejects.toThrow(/accept and decline actions/) + expect(await stored(accepted!.id)).toMatchObject({ author: String(ann.id), decidedBy: decider }) + + // The database adapter's own collection: no Payload hook runs. This is the host's code, not the plugin's. + const raw = (payload.db as unknown as { collections: Record Promise }> }).collections[PROPOSALS]! + await raw.updateOne({ _id: accepted!.id }, { $set: { author: standIn, decidedBy: standIn } }) + try { + const doc = await stored(accepted!.id) + expect(doc).toMatchObject({ status: 'accepted', author: standIn, decidedBy: standIn, checksum: accepted!.checksum, acceptedInto: accepted!.acceptedInto }) + // What was sealed is untouched: the proposal still carries its author's person id and still matches its checksum. + expect((doc.proposal as Proposal).author).toBe('P-1004') + expect(proposalChecksum(doc.proposal as Proposal)).toBe(accepted!.checksum) + // So is the version made from it: same checksum, still verifies, still names both people by id. + const after = await snapshot(accepted!.acceptedInto) + expect(after.checksum).toBe(version.checksum) + expect(verifySnapshot(after.snapshot as DatasetSnapshot)).toBe(true) + expect(JSON.stringify(after.snapshot)).toContain('P-1004') + expect(JSON.stringify(after.snapshot)).toContain(ids.get(decider) as string) + // Readers still see it, with the name the site gives for that person id now. + expect(await readAs(accepted!.id, ben)).toMatchObject({ status: 'accepted', authorName: 'Ann Keeper' }) + // It is as locked as before. + await expect(payload.update({ collection: PROPOSALS as never, id: accepted!.id, data: { proposal: draft({ title: 'Later thought' }) } as never })).rejects.toThrow(/can't change/) + await expect(payload.delete({ collection: PROPOSALS as never, id: accepted!.id })).rejects.toThrow(/it is kept/) + } finally { + await raw.updateOne({ _id: accepted!.id }, { $set: { author: String(ann.id), decidedBy: decider } }) + } + }) }) describe('a withdrawn snapshot', () => { diff --git a/packages/react/README.md b/packages/react/README.md index 755c5b9..fa75d30 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,7 +20,8 @@ const view = createView(data, { id: 'turtles', title: 'Turtles', target: { kind: - What's left out is counted and said: in the description and in a note under the drawing ("891 branches are too small to draw at this zoom and are shown as wedges holding 18,218 nodes. 891 names and values are too small or crowded to show. Zoom in to see them."). Replace the wording with `lodNote` and `mergedLabel`. - Clicks are hit-tested (a node's dot, its name or its wedge), so `onNodeClick` and the explorer's select, fold and drag work as on the SVG. `canvasNodeAt` does the same from your own pointer handlers. - In the explorer, PNG download draws the canvas in the light colors with the disclosure underneath. SVG download isn't available for a canvas tree, and it says so. - - The planning is pure and exported (`prepareTree`, `planCanvas`, `hitTest`, `drawPlan`, `describeLod`). Timings in Node are in `docs/benchmarks.md`. It hasn't been timed in a browser yet. + - The planning is pure and exported (`prepareTree`, `planCanvas`, `hitTest`, `drawPlan`, `describeLod`). Timings in Node are in `docs/benchmarks.md`. + - Frame times in a browser are in `docs/benchmarks-browser.md`, from one machine: headless Chrome 157 on an M2 Max Mac, on 2026-11-11, with other work running. Panning, zooming and scrolling a 1000 px square drawing, the canvas kept to the display's 60 frames a second at every size tried, up to 19,999 nodes, and still did with Chrome slowing the main thread 4 times. SVG kept up through 1,999 nodes and fell to about 30 frames a second at 4,999. With the main thread slowed 4 times, SVG kept up at 499 nodes and dropped frames at 999 (58% late while zooming). So the default switch at 1,000 drawn nodes has room to spare on a fast machine and is a little high for a slow one: if your readers are on slow devices, try `canvasThreshold={500}`. Not measured: WebKit (Safari and the Mac app), Firefox, a window on a real screen, a real slow device, and radial layouts. - **On a server (Next.js App Router, React Server Components).** The package can be imported from a Server Component. `TreeView` renders there as SVG with no JavaScript sent for it: ``. Pass it no handlers on a server, since functions can't cross to the browser. `TreeOutline`, `Explorer`, `Legend`, `LessonPlayer`, `TipLinker`, `ComparePanel`, `SupportControl` and `CanvasTreeView` are client components (`'use client'`): render them from a Server Component with plain data as props, and they run in the browser. A tree over the canvas threshold is handed to `CanvasTreeView`, so it's drawn in the browser; that path hasn't been tried from a Server Component yet. Plain functions that live in the client files can only be called in the browser for now: `canvasNodeAt`, `canvasPlanOf`, `canvasPng`, `cameraBox`, `rootedRecord`, `supportSummary`, `tipsUnder`, `proposeTipLinks`, `linkedPhylogeny`, the `PHYLO_LABELS`, `TIP_LINK_LABELS` and `DEFAULT_CANVAS_SIZE` values, and the hooks. Everything else (`layoutFor`, `treeViewBox`, the canvas planning, shapes, export, drag and near-name helpers) works in both places. `apps/payload-example` draws its public pages this way. - **`TreeOutline`**: the WAI-ARIA tree pattern: one tab stop, arrows, Right/Left to open/close, Home/End, type-ahead, Space to select (Shift+Space adds to the selection), Enter to open a branch, `*` to open its siblings, **Alt+Up/Down to reorder** (the keyboard alternative to dragging). Folded descendants are announced ("12 not shown"), and † is read as "extinct". - **`Explorer`**: toolbar (undo, redo, expand/collapse all, layout, living/extinct/unknown filter), drawing, outline and inspector (rank, status, highlights with *clade* or *group* and the hypothesis they were assessed against, flip children, show only this branch). Layouts the data can't support stay disabled, and the reason is written under the toolbar. The toolbar is one Tab stop: Left and Right move between its controls, Home and End jump (in the number field the arrows stay with the field). Everything you can do by dragging has a button: Move up and Move down in the inspector reorder a taxon among its siblings, and the drawing controls pan left, right, up and down. With the drawing focused, arrow keys pan, + and − zoom, 0 fits and Escape clears the selection; the inspector's hint lists the outline keys. Highlights and trait states are drawn in different shapes as well as colors (twelve: circle, square, diamond, four triangles, hexagon, star, plus, cross and hourglass; they repeat after twelve), and the legend shows the same shapes. Nodes in the drawing aren't Tab stops: the outline is the keyboard path to each taxon (ADR 0010). All text is replaceable through `labels`. diff --git a/scripts/bench-browser.mjs b/scripts/bench-browser.mjs new file mode 100644 index 0000000..1120e57 --- /dev/null +++ b/scripts/bench-browser.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node +// Runs the renderer benchmark page (apps/playground/bench.tsx) in headless Chrome and adds a section to +// docs/benchmarks-browser.md. Three runs: pixel density 1, pixel density 2, and pixel density 2 with Chrome's main +// thread slowed four times (its stand-in for a slower device; it does not slow the graphics card). +// +// pnpm -r build && node scripts/bench-browser.mjs # runs (about ten minutes) and writes the section +// node scripts/bench-browser.mjs --print # runs and prints the section, writes nothing +// node scripts/bench-browser.mjs --json > numbers.json # runs and prints the raw numbers +// node scripts/bench-browser.mjs --from numbers.json # no run: the section from saved numbers +// CHROME=/path/to/chrome node scripts/bench-browser.mjs # another Chrome or Chromium +// +// It needs a Chrome or Chromium already on this machine and downloads nothing. The page is opened from the built +// files on disk, Chrome gets a throwaway profile that is deleted afterwards, and its debugging port listens on this +// machine only. Nothing is sent anywhere. +// +// What it measures and what it can't is written at the top of docs/benchmarks-browser.md (HEAD below). +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const page = join(root, 'apps/playground/dist/bench.html') +const chrome = [process.env.CHROME, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => p && existsSync(p)) + +/** One run of the page in a fresh headless Chrome at the given pixel density, with its main thread slowed `slow` times. */ +async function runAt(dpr, slow) { + const profile = mkdtempSync(join(os.tmpdir(), 'onetaxonomy-bench-')) + const child = spawn( + chrome, + [ + '--headless=new', + '--remote-debugging-port=0', + '--remote-debugging-address=127.0.0.1', + `--user-data-dir=${profile}`, + '--window-size=1100,1100', + `--force-device-scale-factor=${dpr}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-extensions', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--metrics-recording-only', + 'about:blank', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + try { + const endpoint = await new Promise((resolve, reject) => { + let text = '' + const timer = setTimeout(() => reject(new Error('Chrome did not say where its debugging port is within 30 seconds')), 30_000) + child.stderr.on('data', (chunk) => { + text += chunk + const m = text.match(/DevTools listening on (ws:\/\/127\.0\.0\.1:\d+\/\S+)/) + if (m) { + clearTimeout(timer) + resolve(m[1]) + } + }) + child.on('exit', (code) => reject(new Error(`Chrome exited (${code}) before it was ready`))) + }) + const ws = new WebSocket(endpoint) + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true }) + }) + let next = 0 + const waiting = new Map() + const events = new Map() + ws.addEventListener('message', (event) => { + const msg = JSON.parse(String(event.data)) + if (msg.method) { + events.get(msg.method)?.(msg.params) + return + } + const w = waiting.get(msg.id) + if (!w) return + waiting.delete(msg.id) + if (msg.error) w.reject(new Error(msg.error.message)) + else w.resolve(msg.result) + }) + const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + const id = ++next + waiting.set(id, { resolve, reject }) + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })) + }) + const version = await send('Browser.getVersion') + // Open a blank tab, then load the page and wait for it, so the script below runs in the page and not in the blank one. + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }) + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }) + await send('Page.enable', {}, sessionId) + if (slow > 1) await send('Emulation.setCPUThrottlingRate', { rate: slow }, sessionId) + const loaded = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('the page did not load within 30 seconds')), 30_000) + events.set('Page.loadEventFired', () => { + clearTimeout(timer) + resolve() + }) + }) + const nav = await send('Page.navigate', { url: pathToFileURL(page).href }, sessionId) + if (nav.errorText) throw new Error(`the page did not load: ${nav.errorText}`) + await loaded + const answer = await send( + 'Runtime.evaluate', + { + expression: `window.runBench ? window.runBench() : Promise.reject(new Error('the page loaded without its script'))`, + awaitPromise: true, + returnByValue: true, + }, + sessionId, + ) + if (answer.exceptionDetails) throw new Error(answer.exceptionDetails.exception?.description ?? answer.exceptionDetails.text) + ws.close() + return { ...answer.result.value, browser: version.product, slow } + } finally { + child.kill() + await new Promise((resolve) => (child.exitCode !== null ? resolve() : child.once('exit', resolve))) + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } +} + +const sh = (cmd, args) => { + try { + return execFileSync(cmd, args, { encoding: 'utf8' }).trim() + } catch { + return 'unknown' + } +} +const MOTIONS = ['pan', 'zoom', 'read'] +const arg = (name) => { + const i = process.argv.indexOf(name) + return i === -1 ? undefined : process.argv[i + 1] +} + +/** Runs the page three ways and gives everything the report needs, as plain data. */ +async function measure() { + if (!existsSync(page) || !existsSync(join(root, 'apps/playground/dist/bench.js'))) { + console.error('apps/playground/dist/bench.html is missing. Run `pnpm -r build` first.') + process.exit(1) + } + if (!chrome) { + console.error('No Chrome or Chromium found. Set CHROME to its path.') + process.exit(1) + } + const load = () => os.loadavg().map((l) => Number(l.toFixed(1))) + const loadBefore = load() + const reports = [] + for (const [dpr, slow] of [[1, 1], [2, 1], [2, 4]]) reports.push(await runAt(dpr, slow)) + return { + date: new Date().toISOString().slice(0, 10), + machine: process.platform === 'darwin' ? `${sh('sysctl', ['-n', 'hw.model'])}, ${sh('sysctl', ['-n', 'machdep.cpu.brand_string'])}` : (os.cpus()[0]?.model ?? 'unknown'), + cores: os.cpus().length, + loadBefore, + loadAfter: load(), + headless: true, + reports, + } +} + +// --from uses numbers saved earlier (with --json, or with the page's "Save the numbers" link) and runs nothing. +const from = arg('--from') +const run = from ? JSON.parse(readFileSync(from, 'utf8')) : await measure() +if (process.argv.includes('--json')) { + console.log(JSON.stringify(run, null, 2)) + process.exit(0) +} + +const f = (ms) => (ms < 10 ? ms.toFixed(1) : ms.toFixed(0)) +const n = (x) => Math.round(x).toLocaleString('en-US') +const first = run.reports[0] +const headless = run.headless !== false +/** On time: under 5% late frames in every motion. */ +const onTime = (x) => MOTIONS.every((m) => x[m].late < 0.05) +const title = (r) => `Pixel density ${r.dpr}${r.slow > 1 ? `, main thread slowed ${r.slow} times` : ''}` + +const lines = [] +lines.push(`## ${first.browser.includes(' ') ? first.browser : first.browser.replace('/', ' ')}${headless ? ', headless' : ''}, ${run.date}`) +lines.push('') +lines.push(`- Machine: ${run.machine ? `${run.machine}, ${run.cores} cores` : `not recorded${run.cores ? ` (${run.cores} cores)` : ''}`}.`) +lines.push(`- Browser: ${first.browser.includes(' ') ? first.browser : first.browser.replace('/', ' ')}${headless ? ', headless (no window on a screen)' : ', in a window'}. Graphics: ${first.gpu}.`) +lines.push(run.loadBefore ? `- Load: the machine was busy with other work. Load average ${run.loadBefore.join(', ')} before and ${run.loadAfter.join(', ')} after (1, 5 and 15 minutes).` : '- Load: not recorded.') +lines.push(`- Drawing: ${first.stage} × ${first.stage} CSS pixels. 180 frames for each motion, after 20 warm-up frames. One run of each; not repeated.`) +lines.push('') +lines.push('What the numbers say, counting a size as on time when under 5% of its frames were late in every motion:') +lines.push('') +for (const r of run.reports) { + const say = (renderer, name) => { + const rows = r.results.filter((x) => x.renderer === renderer) + if (!rows.length) return `${name} wasn't run` + const miss = rows.find((x) => !onTime(x)) + const ok = rows.filter((x) => onTime(x) && (!miss || x.nodes < miss.nodes)).at(-1) + if (!miss) return `${name} was on time at every size, up to ${n(ok.nodes)} nodes` + // The motion with the most late frames. A median alone can hide them: 44% late still has a median of one frame. + const worst = MOTIONS.reduce((w, m) => (miss[m].late > miss[w].late ? m : w)) + const doing = { pan: 'panning', zoom: 'zooming', read: 'scrolling at reading size' }[worst] + const at = `${name} first fell behind at ${n(miss.nodes)} nodes (${(miss[worst].late * 100).toFixed(0)}% of frames late while ${doing}, median frame ${f(miss[worst].interval.median)} ms, ${(1000 / miss[worst].interval.median).toFixed(0)} frames a second)` + return ok ? `${name} was on time up to ${n(ok.nodes)} nodes; ${at.replace(`${name} first`, 'it first')}` : `${at}, the smallest size run` + } + lines.push(`- **${title(r)}:** ${say('svg', 'SVG')}. ${say('canvas', 'The canvas')}.`) +} +// `TreeView` draws as SVG up to CANVAS_THRESHOLD drawn nodes (packages/react/src/canvasPlan.ts) and on the canvas above. +const SWITCH = 1000 +const atSwitch = run.reports.map((r) => ({ r, row: r.results.filter((x) => x.renderer === 'svg' && x.nodes <= SWITCH).at(-1) })).filter((x) => x.row) +if (atSwitch.length) { + const lateIn = (row) => { + const worst = MOTIONS.reduce((w, m) => (row[m].late > row[w].late ? m : w)) + return `${(row[worst].late * 100).toFixed(0)}% of frames late while ${{ pan: 'panning', zoom: 'zooming', read: 'scrolling at reading size' }[worst]}` + } + const each = atSwitch.map(({ r, row }) => `${title(r).replace(/^P/, 'p')}: ${n(row.nodes)} nodes, ${onTime(row) ? 'on time' : lateIn(row)}`) + lines.push('') + lines.push(`\`TreeView\` draws as SVG up to ${n(SWITCH)} drawn nodes and on the canvas above that. The largest SVG tree measured at or under the switch: ${each.join('; ')}.`) +} +lines.push('') +for (const r of run.reports) { + lines.push(`### ${title(r)}`) + lines.push('') + lines.push(`The display frame here, with nothing to draw: ${r.refresh.toFixed(1)} ms (${(1000 / r.refresh).toFixed(0)} frames a second).`) + lines.push('') + lines.push('| Renderer | Tips | Nodes | Pan: frame, late | Zoom: frame, late | Read: frame, late | Script (pan / zoom / read) | First render, script |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for (const x of r.results) { + const frame = (d) => `${f(d.interval.median)} / ${f(d.interval.p95)} / ${f(d.interval.worst)}, ${(d.late * 100).toFixed(0)}%` + lines.push(`| ${x.renderer} | ${n(x.tips)} | ${n(x.nodes)} | ${MOTIONS.map((m) => frame(x[m])).join(' | ')} | ${MOTIONS.map((m) => f(x[m].script.median)).join(' / ')} | ${f(x.mountScript)} |`) + } + lines.push('') +} +lines.push('### What was on screen') +lines.push('') +lines.push('The SVG holds every element whatever the view. The canvas plans each frame: it leaves out what is out of view and merges subtrees too small to tell apart. Median for each frame, with the most in any frame in brackets.') +lines.push('') +lines.push('| Tips | Nodes | SVG elements | Canvas, pan: nodes drawn | Canvas, zoom: nodes drawn, names | Canvas, read: nodes drawn, names |') +lines.push('| ---: | ---: | ---: | ---: | ---: | ---: |') +for (const tips of [...new Set(first.results.map((x) => x.tips))]) { + const svg = first.results.find((x) => x.tips === tips && x.renderer === 'svg') + const canvas = first.results.find((x) => x.tips === tips && x.renderer === 'canvas') + const drawn = (d, names) => `${n(d.drawn.median)} (${n(d.drawn.worst)})${names ? `, ${n(d.labels.median)} (${n(d.labels.worst)})` : ''}` + lines.push(`| ${n(tips)} | ${n((svg ?? canvas).nodes)} | ${svg ? n(svg.elements) : 'not run'} | ${canvas ? drawn(canvas.pan, false) : 'not run'} | ${canvas ? drawn(canvas.zoom, true) : 'not run'} | ${canvas ? drawn(canvas.read, true) : 'not run'} |`) +} +const section = lines.join('\n') + +const HEAD = `# Browser benchmarks + +How long a frame takes while a made-up tree is panned, zoomed and scrolled, drawn as SVG and on the canvas. Each tip is "Taxon n" and about a third of the tips go left at every split, the same trees as [benchmarks.md](benchmarks.md), which times the pure steps in Node. Written by \`node scripts/bench-browser.mjs\` after \`pnpm -r build\`. Rerun it to add another machine or browser; each run adds a section above the older ones. + +**What this measures.** The page (\`apps/playground/bench.tsx\`) sets a new view box on every animation frame, as dragging and zooming do in the explorer, and renders at once. It records the time between animation frames and the script time of each render (React, and for the canvas the plan and the draw calls). Three motions: **pan** slides the whole tree with every node in view; **zoom** goes from the whole tree in to reading size (40 tip rows on the drawing, every name drawn) and back; **read** scrolls the tips at reading size, at up to 20 pixels a frame. In the tables, a frame is given in milliseconds as median / 95th percentile / worst, **late** is the share of frames that took more than one and a half display frames, and script is the median. + +**What it doesn't.** + +- Other browser engines. Only the browsers with a section below were measured. WebKit, which the Mac app and Safari use, and Firefox may differ. To measure one, open \`apps/playground/dist/bench.html\` in it, press Run, save the numbers and run \`node scripts/bench-browser.mjs --from onetaxonomy-bench.json\`. +- A window on a real screen. A headless run draws with the graphics card but shows nothing. +- All of painting. Raster and compositing happen off the main thread. They show in the frame time only once they fall behind and the browser holds the next frame back, so a steady frame time means the page kept up, not that painting cost nothing. +- A slow device. "Main thread slowed 4 times" is Chrome's own throttle. It slows script, style and layout on the main thread and leaves the graphics card and the other threads at full speed, so it is a rough stand-in, not a phone or an old laptop. +- Radial layouts, highlights, traits, support values and folded branches. The trees are plain rectangular ones with names on the tips. +- The outline (\`TreeOutline\`), which isn't virtualized. +` + +if (process.argv.includes('--print')) console.log(section) +else { + const file = join(root, 'docs/benchmarks-browser.md') + const old = existsSync(file) ? readFileSync(file, 'utf8') : '' + const at = old.indexOf('\n## ') + writeFileSync(file, `${HEAD}\n${section}\n${at === -1 ? '' : old.slice(at)}`) + console.log(`Wrote ${file}`) +} diff --git a/scripts/bench-canvas.mjs b/scripts/bench-canvas.mjs index 812fe45..20fdddf 100644 --- a/scripts/bench-canvas.mjs +++ b/scripts/bench-canvas.mjs @@ -145,7 +145,7 @@ Made-up trees of 1,000, 10,000 and 50,000 tips (each tip "Taxon n", about a thir **What this measures:** CPU time in Node for the pure steps: projecting the view (with \`projectView\`'s 10,000-node limit lifted, so every node is drawn), laying it out, and the canvas renderer's level-of-detail plan (\`prepareTree\`, \`planCanvas\`) and hit-testing. Each figure is the median of ${rows[0].proj.runs} runs (5 for 50,000 tips) after two warm-up runs, in milliseconds, with the fastest run in brackets. -**What it doesn't:** painting. This repo has no canvas for Node (no \`node-canvas\` dependency), so the draw was run against a context that only counts calls, to show how much would be drawn. Paint time and frame rates while panning or zooming need a real browser (WebKit in the Mac app, Safari, Chrome, Firefox) and have **not** been measured. Nothing here says the canvas is smooth at 1–2k visible nodes yet: the plan for one frame is cheap enough to allow it, and a browser run has to confirm it. +**What it doesn't:** painting. This repo has no canvas for Node (no \`node-canvas\` dependency), so the draw was run against a context that only counts calls, to show how much would be drawn. Frame times while panning, zooming and scrolling in a browser are in [benchmarks-browser.md](benchmarks-browser.md), which also says which browsers were measured and which weren't. ## ${machine.model}, ${machine.date} @@ -172,10 +172,9 @@ ${rows.map((r) => `| ${r.tips.toLocaleString('en-US')} | ${(r.calls.moveTo ?? 0) Edges are batched into at most four paths (plain, weak, and each while dragging), so the stroke count stays small however many edges there are. -## Not measured yet +## Not measured here -- Paint time and frame rate in a browser, at 1–2k visible nodes, at 1× and 2× pixel density. Needs a person with the Mac app or a browser open, or a headless browser run. -- The SVG renderer at the threshold (1,000 drawn nodes), to check the threshold is in the right place. It was chosen by judgment: the explorer re-renders every SVG node on each pan step, and at 1,000 nodes that is 1,000 React elements per pointer move. +- Anything in a browser. [benchmarks-browser.md](benchmarks-browser.md) has frame times for both renderers in headless Chrome, and lists what is still unmeasured there: WebKit (the Mac app and Safari), a window on a real screen, and a real slow device. - The outline (\`TreeOutline\`) with 10,000+ rows: it isn't virtualized, and on large trees it may be slower than the drawing. `