| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .. | ||
| src | ||
| Cargo.toml | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| NOTICE | ||
| PROVENANCE.md | ||
| README.md | ||
dpb-plot — the GPU plot engine of the AuraSense instruments
Vendored into flowRS from AuraSense's
aurasense-dpb-training,crates/dpb-plotat commit9202cd83fe2aa284957bf6a6d8aa1284eb1541e6(branchfeat/dpb-plot), under its Apache-2.0 OR MIT licence. The text below is upstream's. Thewasmandbenchfeatures 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 inPROVENANCE.mdbeside 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)
- Renderer: measured, then chosen
- Budgets
- Colour
- How it works
- Tests and gates
- Limits
Wiring it (for engine-web)
In engine/Cargo.toml:
dpb-plot = { path = "../crates/dpb-plot", default-features = false, features = ["web"] }
Once
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
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
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
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
roleand 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()andresume(scope);set_end(t)for a scrub bar overhist.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, atchrome.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
TimeLinkbetween views on one time axis and callsynceach frame. The view a person moved publishes its cursor, frozen state and window; the others take them. -
Scope. Set the trigger and persistence:
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) orSeriesKind::Bounds { hi }(from this channel tohi; 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 })andann.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:
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)andframe(&[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::ValuePathwithRampKind::SequentialAltwhen 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:
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. -
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
#f0efeclight and#383835dark. -
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 #ffffffOn #1a2120good 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:
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 throughjs_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:
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=1adds the renderer comparison.REPS=3repeats the eight-plot runs;node summarize-multi.mjstakes 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.