flowrs-engine/crates/dpb-plot/README.md
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

656 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <worktree> -- '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 <worktree> -- '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.