656 lines
29 KiB
Markdown
656 lines
29 KiB
Markdown
# 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.
|