# dpb-plot — the GPU plot engine of the AuraSense instruments > **Vendored into flowRS** from AuraSense's `aurasense-dpb-training`, `crates/dpb-plot` > at commit `9202cd83fe2aa284957bf6a6d8aa1284eb1541e6` (branch `feat/dpb-plot`), under its > Apache-2.0 OR MIT licence. The text below is upstream's. The `wasm` and `bench` features > and their pages are not vendored; flowRS's changes (the std level of the pyramid, the > column helpers the Waveform panel draws with) are listed in `PROVENANCE.md` beside > this file. The engine lab (engine.aurasense.ai/app/) draws its live signals with this crate, and the research workbench and the twin can too. It plots every channel at its full rate, many lanes at the display's refresh. It has real axes, a cursor, zoom, history, a trigger and persistence. It measures movement and timing; it reads no feelings and names no condition. - **Core** (`no_std` + `alloc`): history, decimation, ticks, scales, view and gestures, trigger, chrome, palette, scene. Pure Rust, unit-tested natively. - **`web`**: the renderer on a canvas. engine-web calls it as Rust. - **`wasm`**: bindings for pages without Rust (`build-wasm.sh`). - **`bench`**: the budget page's harness. Never in a shipped page. ## Contents - [Wiring it (for engine-web)](#wiring-it-for-engine-web) - [Renderer: measured, then chosen](#renderer-measured-then-chosen) - [Budgets](#budgets) - [Colour](#colour) - [How it works](#how-it-works) - [Tests and gates](#tests-and-gates) - [Limits](#limits) ## Wiring it (for engine-web) In `engine/Cargo.toml`: ```toml dpb-plot = { path = "../crates/dpb-plot", default-features = false, features = ["web"] } ``` ### Once ```rust use dpb_plot::{ChannelSpec, History, LaneSpec, PlotOptions, Scene, SeriesSpec, TimePlot, Annotations}; use dpb_plot::gpu::{Renderer, RendererOptions}; let mut hist = History::new(); let ax = hist.add_channel(ChannelSpec::new(250.0)); // 250 Hz, 5 min kept let ay = hist.add_channel(ChannelSpec::new(250.0)); let lanes = vec![ LaneSpec::new("Acceleration", "m/s²") // one lane, one unit .with(SeriesSpec::line("x", ax, 0)) // slot: the source's fixed colour .with(SeriesSpec::line("y", ay, 1)), ]; let mut plot = TimePlot::new(lanes, PlotOptions { width: 10.0, ..PlotOptions::default() }); let mut renderer = Renderer::new(canvas.clone(), RendererOptions::default()).await; // WebGPU → WebGL2 → canvas 2D, the page's one device let mut scene = Scene::new(); let mut ann = Annotations::default(); ``` ### On resize, theme and motion changes ```rust plot.set_size(css_w, css_h, device_pixel_ratio); plot.set_theme(Theme::Dark, colour::SURFACE_DARK); plot.set_reduced_motion(reduce); ``` `set_theme` takes `colour::SURFACE_LIGHT` or `colour::SURFACE_DARK`. `reduce` is the `prefers-reduced-motion` match; it turns persistence off. ### As samples arrive ```rust hist.push(ax, t_first, &samples); ``` `t_first` is the page time of `samples[0]`, in seconds. A fixed-rate channel anchors at its first push. A late batch (later than the channel's `gap`) leaves a NaN break; an early one by more than the gap moves the anchor and counts a clock step. Resample irregular sources to an even grid (dpb-hf) first. ### Each animation frame ```rust for p in &mut plots { p.sync(&mut link); } // views on one time axis let chrome = plot.frame(&hist, &ann, now_s, &mut scene); // no allocation once warm renderer.render(&scene, &hist); // then update the DOM from `chrome` (below) ``` ### Inputs Positions are CSS px from the plot area's top-left corner. When `r.consumed`, call `preventDefault`. | DOM event | `Input` | |---|---| | `pointerdown` (then `setPointerCapture`) | `PointerDown { id, x, y }` | | `pointermove` | `PointerMove { id, x, y, down: buttons != 0 }` | | `pointerup` | `PointerUp { id, x, y }` | | `pointercancel` | `PointerCancel { id }` | | `pointerleave` | `PointerLeave` | | `wheel` | `Wheel { x, dx, dy, ctrl: ctrlKey \|\| metaKey, focused }`. Scale `deltaMode` 1 by 16 px and `deltaMode` 2 by the plot's height. | | `dblclick` | `DoubleClick { x }` | | `keydown` | `Key { key: Key::from_dom(&ev.key())?, shift }` | Give the canvas these attributes: - `tabindex="0"`, so the keys reach it; - `touch-action: none`, so the plot gets pinch and drag; - a `role` and a label. The gestures are listed in `src/view.rs`: - hover, tap or click puts the cursor (snapped to the focused lane's nearest sample); - drag pans (a live view freezes and keeps its history); - pinch zooms; - Ctrl/⌘ wheel or a trackpad pinch zooms, and a plain wheel zooms only when the plot has focus; - a sideways wheel pans; - double-click fits; - ← → move the cursor (Shift: a tenth of the window), ↑ ↓ change the focused lane; - \+ and − zoom; - Home and End go to the start of the history and the newest sample; - Page Up and Page Down move a window; - Space freezes and goes live again; - Esc hides the cursor; - 0 or F fits. The top bar calls `plot.view_mut()`: - `set_width(secs)` for the timebase; - `freeze()` and `resume(scope)`; - `set_end(t)` for a scrub bar over `hist.span()`. ### The chrome, drawn in the DOM `TimePlot::frame` returns `&Chrome`. Positions are CSS px from the plot area. Every list keeps its capacity. Label text wears the page's ink tokens; a series' colour only marks its key (a short line). | Field | What it holds | |---|---| | `x: Axis` | Time ticks: `pos`, `label`. The unit is `"s"`, `"ms"`, `"m:ss"` or `"h:mm:ss"`. Live views count before now (−30 … 0), the scope counts from the trigger, and a frozen view uses a clock from `set_origin` (default: the first sample). Vertical gridlines go at the same `pos`. `labels_changed` says whether text changed or only positions did. | | `lanes[i]` | `rect`; `y: Axis` (one SI prefix per axis, unit `"mV"` style); `lo`, `hi`; `zero` (a baseline when 0 is in range). | | `readout` | `visible`, `x`, `t`, `time` and `time_unit`, `focus_lane`. Then `rows`: per series, the value first (`value`, `unit`, `v`), with `y` for a dot on the trace. When the pixel column holds several samples, `col_min` and `col_max` too. | | `markers`, `spans` | `x0`, `x1`, your `id`, and the `tint`. Put your labels and reasons here; a status tint always gets its icon and label. | | `ends` | Each series' newest visible y, for direct labels (up to 4 series in a lane). | | `trigger_x`, `trigger_y` | Where the scope's trigger is, and its level in its lane. | | `mode`, `window`, `frame` | The state, for the top bar and the Table view. | The legend is `plot.legend()`, read on a configuration change. It gives each series' name, slot, colour (for its key only), key shape (line, band, dot), and two flags: - `needed`: the lane has two or more series, so it needs a legend; - `direct`: the lane has two to four series, so label them directly too, at `chrome.ends`. The Table view reads the same readout. Over the visible window, read `hist.channel(id)` with `range_min_max`, `nearest` and `get`. ### More - **Linking.** Share one `TimeLink` between views on one time axis and call `sync` each frame. The view a person moved publishes its cursor, frozen state and window; the others take them. - **Scope.** Set the trigger and persistence: ```rust plot.set_trigger(Some(TriggerSpec { channel, condition: Condition::Edge(Edge::Rising), level, hysteresis, holdoff, pre: 0.25, sweep: Sweep::Auto, })); plot.set_persistence(8); ``` `rearm()` re-arms a single sweep. - **Bands.** Use `SeriesKind::Envelope` (the decimated range) or `SeriesKind::Bounds { hi }` (from this channel to `hi`; e.g. the relay clock's ± bound). - **Points in a time lane.** Use `SeriesKind::Points { radius }`. - **Markers and spans.** `ann.add_marker(Marker { t, tint, id })` and `ann.add_span(Span { t0, t1, tint, id })`. An open span ends at +∞; `close_span(id, t1)` closes it. - **Spectrogram lane.** Make the ring, push columns, then add the lane: ```rust let s = hist.add_heat(HeatSpec { rows, hop, columns, y0, y1 }); hist.heat_mut(s).unwrap().push_column(t, &db); LaneSpec { heat: Some(HeatLane { heat: s, ramp: RampKind::Sequential, lo: -80.0, hi: 0.0 }), ..LaneSpec::new("Spectrum", "Hz") } ``` A pitch track can overlay it as a line series in Hz. - **x–y plots.** Use `XyPlot::new(x_axis, y_axis, series)` and `frame(&[XyData], &hist, &mut scene)`. It covers: - scatter (pointing time against ID): at most 3 series, validated all-pairs; - a phase portrait; - the pen path, coloured by speed (`XyKind::ValuePath` with `RampKind::SequentialAlt` when a heat grid is under it) and with pressure as width; - QUEST+'s posterior: `set_heat(XyHeat { … })` over a heat ring. `chrome()` gives the ticks and the point nearest the pointer, within 24 px. - **Orientation.** Set up the view and give its mesh to the renderer: ```rust let mut v = OrientationView::new([w, d, h]); let mesh = renderer.add_mesh(&v.mesh()); // again on a theme change v.frame(q, Some(gravity_world), mesh, v.mesh().len(), &mut scene); ``` The camera fits a sphere around the body and its axes (a third beyond the body's longest half-side) to 70 % of the canvas's shorter side, at any aspect ratio, so the body fills its frame and the view does not zoom as it turns. The chrome gives the label places for X, Y, Z and g. - **Colour in the DOM.** `colour::categorical(slot, theme).css(&mut label)` gives a legend swatch. The tokens are in [Colour](#colour). - **Memory.** `hist.memory_bytes()` is the history's size, to show beside the history length. ### One GPU per page, and recovery Every renderer on a page borrows the page's one GPU objects. B11 changes nothing: `RendererOptions::default()` shares. - **WebGPU:** one adapter and device, with the pipelines, the layouts, one set of upload buffers and the offset table. A renderer keeps only its canvas context, heat textures and meshes. The renderers draw one after another, and the queue runs each one's uploads before its draws. - **WebGL2:** WebGL contexts share nothing, so the page has one context on a hidden canvas, with the programs and buffers. Each renderer draws its scene into that canvas's top-left corner, then copies the region to its own canvas through a 2D context. - **Its own:** `RendererOptions { own_context: true, .. }` gives a renderer its own device or context, the way it was before (for isolation, and for the measurements below). **Recovery.** When the device or the shared context is lost, every renderer moves to a new one at its next frames. The page makes one request and the other renderers wait for it. Each renderer uploads its meshes again (they are kept by `add_mesh`) and keeps its `data-renderer`. | Call | What it gives | |---|---| | `renderer.recoveries()` | How many times it moved to a new device or context. | | `renderer.lost()` | True only if no new device could be had; put a new canvas in its place. | | `gpu::page_bytes()` | Bytes the page's shared objects hold on the GPU. | | `renderer.gpu_bytes()` | Bytes the renderer's own objects hold on the GPU. | | `gpu::lose_page_context()` | Loses the device or context on purpose (the test hook). | A canvas that started on WebGPU cannot switch to WebGL2 or 2D, because a canvas keeps its first context type. Recovery therefore stays on the same backend. Pages without Rust use `DpbPlot` (`src/wasm.rs`): numbers and typed arrays in, the chrome out as `chromeNumbers()` (f64) and `chromeText()` (UTF-8 lines), whose layouts are documented there. `bench/gallery.mjs` decodes them and draws the DOM chrome as a working reference. ## Renderer: measured, then chosen ### The two options The plan asked for a choice between two options, made by measurement: - **(a) `wgpu`**: version 30.0.1, the latest stable, with its WebGPU and WebGL2 backends. - **(b) WebGPU directly, plus a canvas-2D fallback**: the existing plotter's approach. ### How they were measured Both draw the same scene through one harness (`measure/wgpu-proto`): | Feature | What it draws through | |---|---| | `render` | wgpu | | `raw` | this crate's renderer | | neither | nothing (the baseline) | The scene is 16 lanes at 1 kHz with a 30-second window. The canvas is 1200 × 800 CSS px at 2×, so 2400 device px wide. A frame pushes the samples due, builds the scene and renders it. Each run measures 600 animation frames after 120 of warm-up. It records: - **CPU time** per frame, with `performance.now()` in a cross-origin-isolated page (5 µs resolution in Chromium, 20 µs in WebKit); - **Rust allocations** per frame, from a counting global allocator; - **Frame intervals**. The browsers are Playwright 1.64.1's Chromium (full browser, new headless, GPU) and WebKit, on the build host (M3 Ultra). The size column is the brotli-compressed WASM after `wasm-opt -O3`. ### Results | Option | Backend | WASM (brotli) | Chromium p50 / p95 | WebKit p50 / p95 | Rust allocations per frame | |---|---|---|---|---|---| | baseline (nothing drawn) | — | 42.2 KB | 0.45 / 0.93 ms | 0.46 / 0.52 ms | 0 | | **(b) this crate** | WebGPU | **67.3 KB (+25.1 KB)** | **1.13 / 1.98 ms** | **0.74 / 0.96 ms** | **0** | | **(b) this crate** | WebGL2 | (same module) | **0.71 / 1.20 ms** | **0.76 / 0.92 ms** | **0** | | (b) canvas-2D fallback | canvas 2D | (same module) | 5.18 / 5.52 ms* | 9.74 / 15.16 ms*, 10 late frames | 0 | | (a) wgpu 30.0.1 | WebGPU | 726.8 KB (+684.7 KB) | 1.17 / 1.97 ms | 0.80 / 1.00 ms | 4 | | (a) wgpu 30.0.1 | WebGL2 | (same module) | 1.19 / 1.79 ms | 1.60 / 2.00 ms | 112 | The two options differ in what they draw: - **(b)** carries every mark: trace, band, segments, points, rects, heat and mesh, in WGSL and GLSL, plus canvas 2D. - **(a)** carries only the trace and rect pipelines. So the size gap is a lower bound. \* Canvas 2D was measured on the crate's own budget page, with the same scene. The runs were made on 2026-10-18, starting at a 1-minute load of 37. They used Chromium 156.0.8078.4 and WebKit 27.2 (Safari 27.2), both at 60 Hz headless. The CPU times are a frame's whole cost in the page: pushing the samples due, building the frame and rendering it. ### How each browser behaved - **WebGPU starts in both.** Playwright's WebKit 27 exposes `navigator.gpu`, and the crate's WGSL compiles there unchanged. - **WebGL2** was forced in both, and runs. - **wgpu's WebGL2 backend** allocates 112 times a frame inside wgpu. Its WebGPU backend allocates 4 times. - **Canvas 2D misses the budget in both browsers at 2×.** It draws one rectangle per pixel column per lane, about 60 000 calls a frame: p95 5.52 ms in Chromium, 15.16 ms in WebKit. ### The decision The decision is **(b), WebGPU bound directly, with a hand-written WebGL2 path between it and canvas 2D.** - **wgpu costs 27 times the WASM.** It adds 684.7 KB brotli against 25.1 KB, and loads before the first frame on every iPad. - **wgpu allocates on every frame.** It allocates 4 times a frame on WebGPU and 112 on WebGL2, against the zero the budget requires. - **wgpu is no faster.** The GPU work is the same. Its CPU time is equal on WebGPU and up to twice ours on WebGL2 (WebKit p95 2.00 ms against 0.92 ms). - **Plain (b) as planned fails the budget.** Its only fallback is canvas 2D, which misses the 4 ms budget: p95 5.52 ms in Chromium, and 15.16 ms in WebKit with 10 late frames. Safari before 26, older iPads and Firefox on Linux would hit it. - **A WebGL2 path fixes that.** It draws the same instanced marks from the same 256-byte runs, passes the budget (p95 0.88–1.41 ms), and costs a few KB inside the 25.1 KB. Canvas 2D stays as the last resort, for a blocklisted GPU. It is complete, and is fast enough at small sizes. WebGPU sits behind `--cfg web_sys_unstable_apis` in web-sys, so the crate binds the calls it makes itself (`src/gpu/js.rs`). No consumer needs a cfg flag. ## Budgets The budgets come from `bench/budget.spec.mjs` on the budget page (`bench/index.html`). The spec asserts them in Chromium and WebKit at 2×: - **The workload:** 16 lanes × 1 kHz × a 30-second window, at the display's refresh. Every lane draws one column per device pixel or finer, so at least 2400 columns a lane. At most 1 % of animation frames may be late (an interval over 1.5× the median). - **CPU time:** a frame's CPU time in the page stays **under 4 ms** at p95. That covers pushing the samples due, building the frame and rendering it. - **Allocations:** **zero** Rust allocations in every measured frame, counted. - **Size:** the WASM size is recorded, and the shipped bindings' brotli size is capped at 160 KB. It runs on the build host only when the 1-minute load is under 40. Measured on the build host on 2026-10-18, starting at a 1-minute load of 37. Each run is 600 frames after 120 of warm-up, at 1200 × 800 CSS px, 2× (2400 device px, so 60 032 columns a frame). The headless browsers run at 60 Hz: | Browser | Renderer | Frame CPU p50 / p95 / max | p95 push / frame / render | Late frames | Rust allocations | |---|---|---|---|---|---| | Chromium 156 | WebGPU | 1.38 / **1.74** / 5.71 ms | 0.05 / 0.88 / 0.82 ms | 0 of 600 | **0** | | Chromium 156 | WebGL2 | 0.91 / **1.41** / 3.44 ms | 0.06 / 1.13 / 0.28 ms | 0 of 600 | **0** | | WebKit 27.2 | WebGPU | 0.74 / **1.26** / 1.96 ms | 0.04 / 0.82 / 0.44 ms | 0 of 600 | **0** | | WebKit 27.2 | WebGL2 | 0.72 / **0.88** / 1.48 ms | 0.04 / 0.46 / 0.42 ms | 0 of 600 | **0** | | Chromium 156 | canvas 2D (last resort; not held to the budget) | 5.18 / 5.52 / 8.54 ms | 0.03 / 0.45 / 5.05 ms | 0 of 600 | 0 | | WebKit 27.2 | canvas 2D (last resort; not held to the budget) | 9.74 / 15.16 / 17.08 ms | 0.02 / 0.28 / 14.92 ms | 10 of 600 | 0 | At 120 Hz (ProMotion) a frame lasts 8.3 ms. The CPU budget is 4 ms whatever the refresh, and the GPU paths use 1.74 ms at p95, at most. The WASM sizes (after `wasm-opt -O3`): | Module | Raw | gzip | brotli | |---|---|---|---| | **`dpb_plot` with feature `wasm`**: what a page without Rust ships (`build-wasm.sh`) | 206 037 B | 82 403 B | **68 859 B** (cap: 160 000) | | `dpb_plot` with feature `bench`: the budget page (with the gallery) | 268 065 B | 106 478 B | 87 555 B | | Comparison baseline: the harness, nothing drawn | 113 479 B | 49 435 B | 42 164 B | | Comparison (b): the harness and this crate's renderer | 196 504 B | 79 870 B | 67 258 B | | Comparison (a): the harness and wgpu 30.0.1 | 2 871 003 B | 988 145 B | 726 838 B | engine-web links the crate as Rust (feature `web`), so what it adds there is the renderer and the core. Comparison (b) minus the baseline is about 25 KB brotli for the renderer, plus the plot core inside the baseline. The history for the budget's 16 channels × 5 minutes at 1 kHz takes **39.8 MB**: 19.2 MB of samples and the pyramid beside them. `History::memory_bytes` reports it. `tests/no_alloc.rs` asserts **zero allocations per frame natively** too, in these states: - live with the readout; - paused while scrubbing; - as a scope with a trigger and four persistent sweeps; - with a spectrogram lane, bands, points, spans and markers; - the x–y plot and the orientation view. ## Colour Colour follows the dataviz method: form first, then colour, then validation. Every value lives in `src/colour.rs`. - **Categorical: 8 slots in a fixed order.** The colour follows the entity: a source keeps its slot in every view. A ninth series folds to grey (`OTHER`) or facets; a hue is never generated. Line marks use the adjacent pairlist. Scatter and small multiples take at most the first three slots, which are validated all-pairs. - **Sequential: one hue (blue 100–700).** It runs light to dark on light surfaces, and the anchor flips in dark mode, so low values recede into the surface. A second magnitude in the same view takes orange at the same OKLab lightness steps (`RampKind::SequentialAlt`). Lightness is monotone in both (unit tests). Heatmaps get 16 stops resampled for the shader. - **Diverging: blue and red arms around a grey midpoint.** The arms have equal lightness steps (red derived at each blue step's OKLab L). The midpoint is `#f0efec` light and `#383835` dark. - **Status: reserved, never a series.** Good `#0ca30c`, warning `#fab219`, serious `#ec835a`, critical `#d03b3b`, the same in both modes, always shown with an icon and a label. | Status | On `#ffffff` | On `#1a2120` | |---|---|---| | good | 3.35:1 | 4.88:1 | | warning | 1.83:1 | 8.93:1 | | serious | 2.64:1 | 6.21:1 | | critical | 4.80:1 | 3.41:1 | - **Washes.** Bands are drawn at 16 % opacity, spans at 14 % and marker hairlines at 70 %. | Slot | Light | Dark | |---|---|---| | 1 blue | `#2a78d6` | `#3987e5` | | 2 orange | `#e96632` | `#d95926` | | 3 aqua | `#0ba370` | `#199e70` | | 4 yellow | `#bd7f03` | `#c98500` | | 5 magenta | `#d76c95` | `#d55181` | | 6 green | `#008300` | `#008300` | | 7 violet | `#6250d6` | `#9085e9` | | 8 red | `#e34948` | `#e66767` | **What was fixed.** The reference palette's light slots for aqua, yellow and magenta failed the contrast check against the lab's light surface. On `#ffffff` the validator gave aqua 2.82, yellow 2.17 and magenta 2.69: a contrast WARN, so relief would have been required. Snap-to-passing stepped those three darker. Each kept its OKLCH hue, and only its lightness moved. The search kept the order and maximised the worst adjacent CVD and normal-vision ΔE. Now every slot clears 3:1 on both light surfaces, and no check WARNs or FAILs. The dark slots are the reference dark steps unchanged; they pass on both dark surfaces. The validator's report against the lab's surfaces (engine-web `style/app.css`): ``` $ node validate_palette.js "#2a78d6,#e96632,#0ba370,#bd7f03,#d76c95,#008300,#6250d6,#e34948" --mode light --surface #ffffff Palette (light, surface #ffffff, categorical): 8 slots [PASS] Lightness band all 8 inside L 0.43–0.77 [PASS] Chroma floor all 8 >= 0.1 [PASS] CVD separation worst adjacent #bd7f03↔#0ba370 ΔE 9.4 (protan) · tritan 5.1 [PASS] Normal-vision floor worst adjacent #d76c95↔#bd7f03 ΔE 17.4 (normal) [PASS] Contrast vs surface all 8 >= 3:1 $ node validate_palette.js "#2a78d6,#e96632,#0ba370,#bd7f03,#d76c95,#008300,#6250d6,#e34948" --mode light --surface #f6f7f5 Palette (light, surface #f6f7f5, categorical): 8 slots [PASS] Lightness band all 8 inside L 0.43–0.77 [PASS] Chroma floor all 8 >= 0.1 [PASS] CVD separation worst adjacent #bd7f03↔#0ba370 ΔE 9.4 (protan) · tritan 5.1 [PASS] Normal-vision floor worst adjacent #d76c95↔#bd7f03 ΔE 17.4 (normal) [PASS] Contrast vs surface all 8 >= 3:1 $ node validate_palette.js "#2a78d6,#e96632,#0ba370" --mode light --surface #ffffff --pairs all Palette (light, surface #ffffff, categorical): 3 slots [PASS] Lightness band all 3 inside L 0.43–0.77 [PASS] Chroma floor all 3 >= 0.1 [PASS] CVD separation worst all-pairs #0ba370↔#e96632 ΔE 9.8 (protan) · tritan 6.6 [PASS] Normal-vision floor worst all-pairs #0ba370↔#2a78d6 ΔE 22.7 (normal) [PASS] Contrast vs surface all 3 >= 3:1 $ node validate_palette.js "#3987e5,#d95926,#199e70,#c98500,#d55181,#008300,#9085e9,#e66767" --mode dark --surface #1a2120 Palette (dark, surface #1a2120, categorical): 8 slots [PASS] Lightness band all 8 inside L 0.48–0.67 [PASS] Chroma floor all 8 >= 0.1 [PASS] CVD separation worst adjacent #c98500↔#199e70 ΔE 8.4 (protan) · tritan 8.7 [PASS] Normal-vision floor worst adjacent #d55181↔#c98500 ΔE 19.3 (normal) [PASS] Contrast vs surface all 8 >= 3:1 $ node validate_palette.js "#3987e5,#d95926,#199e70,#c98500,#d55181,#008300,#9085e9,#e66767" --mode dark --surface #111615 Palette (dark, surface #111615, categorical): 8 slots [PASS] Lightness band all 8 inside L 0.48–0.67 [PASS] Chroma floor all 8 >= 0.1 [PASS] CVD separation worst adjacent #c98500↔#199e70 ΔE 8.4 (protan) · tritan 8.7 [PASS] Normal-vision floor worst adjacent #d55181↔#c98500 ΔE 19.3 (normal) [PASS] Contrast vs surface all 8 >= 3:1 $ node validate_palette.js "#3987e5,#d95926,#199e70" --mode dark --surface #1a2120 --pairs all Palette (dark, surface #1a2120, categorical): 3 slots [PASS] Lightness band all 3 inside L 0.48–0.67 [PASS] Chroma floor all 3 >= 0.1 [PASS] CVD separation worst all-pairs #199e70↔#d95926 ΔE 9.4 (deutan) · tritan 4.0 [PASS] Normal-vision floor worst all-pairs #199e70↔#3987e5 ΔE 20.9 (normal) [PASS] Contrast vs surface all 3 >= 3:1 ``` ## How it works ### History Each channel is a ring at full rate. Beside it sit min/max levels for blocks of 4, 8, 16 … samples, up to about a sixteenth of the ring. The levels are aligned with the ring, so a sample pushed costs O(1) amortised. A window is read at the coarsest level whose block is at most one device pixel wide. That means between W and 2W blocks for W device px, whatever the window's length. A column's first and last samples come from the ring itself. The blocks are aligned to sample indices, not to pixels, so a scrolling trace does not shimmer. The newest partial block comes from a range query over the levels. ### Marks - **Trace.** One instance per column: `min, max, first, last`. Six vertices draw the column's min–max stroke and six the join to the next column's first sample, each a capsule quad whose fragment shader measures its distance to the segment. So edges are anti-aliased at any width. A spike one sample wide survives any zoom. - **The other marks:** bands, segments, discs with a surface ring, rects with a least width, heat textures (r32float, a 16-stop ramp) and a lit mesh. ### Frames Each draw's parameters are a 256-byte run in one uniform buffer. WebGPU picks a run by a dynamic offset from a persistent offset table; WebGL2 binds a buffer range. Nothing is allocated per frame on the Rust side. A WebGPU frame creates only the JS objects the API requires: a texture, a view, an encoder, a pass, a command buffer and the typed views for the uploads. ### Chrome as data Ticks are 1, 2 or 5 × 10ᵏ, with 10 s, 15 s, 30 s, minutes and hours on long time axes. Each tick is an integer times an exact power of ten, so 0.3 is 3/10. An axis has one SI prefix. Labels are written into inline strings. Scales snap to nice ticks. They hold while the data moves inside them, widen at once, and narrow only below half the range. ### Looking at it `bench/gallery.html` draws every mark on deterministic data, with the chrome in the DOM from the crate's chrome data: - a frozen time plot (lines, an envelope, ± bounds, points, a spectrogram lane, a warning span, a marker, the readout); - a phase portrait coloured by speed over a posterior grid, with a scatter; - the orientation body. `bench/gallery.spec.mjs` screenshots it in WebGPU, WebGL2 and canvas 2D, light and dark, in Chromium and WebKit. All twelve start the backend asked for, with no page error. The screenshots were looked at: the three backends draw the same picture. ## Tests and gates All of these run on the build host, never on the dev Mac. `gates.sh` runs every gate in one job: ```bash rb -- 'export WASM_BINDGEN=~/Library/Caches/dev.trunkrs.trunk/wasm-bindgen-0.2.129/wasm-bindgen; bash crates/dpb-plot/gates.sh' ``` It runs: - clippy `-D warnings`: native with all targets, no_std, and wasm32 with each feature; - clippy and the build for wasm32 with `--cfg=web_sys_unstable_apis`. engine-web builds that way (WebHID needs it), and web-sys then types some calls differently: `putImageData`'s position is i32, so canvas 2D calls it through `js_sys::Reflect`; - the 65 unit tests and `tests/no_alloc.rs`; - the comparison harness's three configurations; - the bindings' WASM. The pages and their specs run on a quiet host only, with a free E2E_PORT and one worker: ```bash rb -- 'export WASM_BINDGEN=…; bash crates/dpb-plot/bench/build.sh && cd crates/dpb-plot/bench && npm ci && E2E_PORT=4790 npx playwright test' ``` - `MEASURE=1` adds the renderer comparison. - `REPS=3` repeats the eight-plot runs; `node summarize-multi.mjs` takes their medians. The unit tests cover: - **ticks:** nice steps, exact values, SI prefixes, relative and clock time; - **decimation:** a spike survives, brute-force equality; - **history:** range queries and buckets at every level across wraps, the fast column path against the block-by-block reference, gaps, clock steps, heat rings, memory; - **scales;** - **the view:** hover, drag, tap, pinch, wheel, keys, fit, limits, linking; - **triggers:** edges with interpolated crossings, hysteresis, holdoff, levels, scanning in pieces, sweeps, single, persistence; - **annotations;** - **the time plot:** columns no wider than a pixel, capacity held, spikes, the readout, spans under traces, a scope standing still; - **the x–y plot, the palette;** - **the body:** its winding and projection, and its fit: 48 rotations on square, wide, tall and very wide canvases, everything inside a 10 % margin, centred, the widest extent 60–80 % of the shorter side, the labels on the canvas. ## Limits - A channel has a fixed rate. Irregular sources are resampled before the history (dpb-hf). Batch times move the anchor or leave gaps, as described under "As samples arrive". - Values are stored as f32: 24-bit mantissas, ample for sensors. Times stay in f64, and the scene holds them relative to the window. - A heat ring keeps at most 8192 columns (WebGPU's default texture limit). That is 2.7 minutes at a 20 ms hop. - WebGL2 needs `UNIFORM_BUFFER_OFFSET_ALIGNMENT` ≤ 256 (every implementation known). Otherwise the renderer falls back to canvas 2D. - The x–y plot has no pan or zoom. It fits its data, and double-click fits again.