Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
13 KiB
Data and performance
The sample stores
All series derive from plot::data::Series (include/plot/data/series.hpp), which
describes the sample-indexing contract; a uniform-grid series additionally has
sample i at x = x0() + i * dx() (ArraySeries, StreamSeries), while
XYSeries carries its own sorted, ascending x array for data that is not on a
uniform grid.
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
virtual u64 snap_index(f64 x) const; // nearest sample
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
};
Indices are the series' own window: index 0 is the oldest sample it currently holds, so
size(), x0(), reduce(), gather() and pyramid() are all in the same coordinates.
ArraySeries (array_series.hpp) owns a std::vector<f32> of y samples plus an
x0/dx grid. Construct with ArraySeries(std::vector<f32> y, f64 x0, f64 dx). The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store
changes only through reset(y, x0, dx) (a whole new vector, then a rebuild), commit(),
which rebuilds the pyramid and cached bounds from the current y, or push(y), which
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
extend in place, NaN-aware, instead of rebuilding.
StreamSeries (stream_series.hpp) is the single-producer / single-consumer streaming
store: StreamSeries(u64 capacity, f64 x0, f64 dx). capacity is rounded up to a power of
two by the ring, size() saturates at that rounded capacity() (a StreamSeries(100)
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
freeze() copies the window published by the last acquire() into a fresh ArraySeries
(consumer thread only): a stable snapshot that no later production can move.
Non-finite samples
The library-wide NaN policy: a non-finite sample is excluded from every aggregate —
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
cached bounds. A window with no finite samples at all aggregates to a zero-span
Range / zero sums, never to NaN. The policy is applied everywhere an aggregate is
built: MinMaxPyramid::append (a NaN occupies its absolute position, so bucket
boundaries stay positional, but folds nothing), the edge folds in Series::reduce,
sum and sumsq, ArraySeries::commit and push bounds, and
StreamSeries::acquire bounds. A bucket whose samples were all non-finite is stored as
an empty aggregate (count 0, zero sums) that every reader skips — it is absent, not
zero-valued. gather() follows suit: Envelope and Mean emit nothing for such a bucket,
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
them with NaN samples and will get exactly that: excluded from statistics, present in
raw geometry.
Gather x is relative, and what each mode emits
Series::gather emits x relative to x0() — exactly i * dx for a uniform
series, x_at(i) - x0() for XYSeries — never the absolute coordinate. y is
unchanged. The renderer reconstructs absolute data x in f64 (x0() + emitted) before
transforming, so no precision is lost at the f32 narrowing: the staged value is
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
relative value is small and exact.) The emitted geometry always spans
[begin, end) exactly and never reaches outside it: at level ≥ 1 the bucket edges are
selected ceil/floor (first = first bucket starting at or after begin, last = one
past the last bucket ending at or before end) and the two partial edge spans are
drawn from raw samples, which is what keeps an unaligned window envelope-exact
(previously the buckets straddling the range leaked samples from outside it into the
geometry). level is clamped to kMaxPyramidLevel (57 — the deepest level whose
bucket span fits a u64), so any u32 level is safe.
The GatherMode decides what each bucket contributes, at the same level the renderer
already picked:
Envelope(the default): one (min, max) pair per bucket, min drawn at the bucket's first sample and max at its last. The dense, extreme-reading geometry a time-series trace wants.Nearest: one raw sample per bucket — the centre sample, the pickpyramid().snap()would make — drawn at its own x. Thinner geometry for traces whose shape matters more than their extremes.Mean: one point per bucket, y = the bucket's finite samples' mean (the exactbucket_sum()the pyramid stores divided by its finite count), x at the bucket centre. A smoothed trace for noisy data.
Level 0 is the raw samples for every mode.
The SPSC contract
One producer thread, one consumer thread, and the division is strict
(stream_series.hpp):
- The producer may only call
push(f32 y),push(const f32* ys, u64 n)andproduced(). Those touch the ring buffer and nothing else: no lock, no pyramid, no GL. The acquisition thread can therefore never stall the renderer. - The consumer calls
acquire(), which snapshots the produced count and brings the pyramid and bounds up to it, and then queries the published window.size(),reduce(),sum(),sumsq(),gather()andpyramid()all use the window published by the lastacquire().
acquire() costs O(samples produced since the last acquire), not O(capacity): the pyramid
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
The one exception is a producer that laps the consumer (more than capacity() samples
between two acquires), where the missing samples cannot be represented and the pyramid
restarts at the new base, re-reading the whole window.
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
data — so it is not a frozen window. Those slots are relaxed atomics, so this is not a
data race and no value tears; but if the producer pushes k samples while a read is in
flight, the first k positions of that read can hold one of those k newer samples rather
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
producer to stop before unread data (drop-on-full) or a claim protocol — a different
contract from this one.
streaming() returns true for StreamSeries and false for everything else. Only a
streaming series has a live edge (the newest x it holds, which moves on its own), and it
is that property XFollow::Live follows (see interaction.md).
The min/max LOD pyramid
data::MinMaxPyramid (minmax_pyramid.hpp) is the index that makes per-frame cost depend on
the visible pixels rather than on the sample count.
- Samples are grouped into buckets of
kBucket = 64(kBucketis a fixed constant, so all series share one LOD geometry). Levellbucketkcovers the absolute samples[k * (kBucket << l), (k + 1) * (kBucket << l)), and bucket boundaries are fixed for the pyramid's life — appending never re-buckets existing aggregates, and a window can slide over the same buckets. - Each level holds its complete buckets in a
std::deque(O(1)push_backandpop_front) plus abase.append()folds a sample into the in-progress level-0 bucket and, when it completes, pushes it and walks up, stopping at the first level that gains no entry: an append costs amortised O(1).drop_before(u)slides the window's leading edge, bounding the memory by the retained window rather than by everything ever appended. - An entry is 32 bytes (
MinMaxplus the exactsum/sumsqthe exact range queries fold over and the finite-samplecountthe mean folds by), so level 0 costs0.5nbytes and the geometric tail brings the whole pyramid to about1nbytes — roughly 25% of the4nbytes of raw samples (f32). A min/max-only index would be0.25n(~6%); the extra words are the price of answering mean/RMS/integral from whole buckets instead of raw samples.
Queries on the pyramid are bucket-grained and named *_buckets (reduce_buckets,
sum_buckets, sumsq_buckets): a pyramid stores aggregates, so it can only answer exactly
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
range queries live on Series, which owns the raw samples.
level_for(u64 n, f64 samples_per_pixel) returns the pyramid level whose buckets hold about
samples_per_pixel samples for a window of n samples (clamped to levels that exist, to
levels with more than one bucket, and to levels that give the window at least two buckets).
The renderer uses this: a trace's x range is mapped onto sample indices, and Series::gather
is asked for that window at pyramid().level_for(n, samples_per_pixel) whenever a pixel
covers Style::dense_threshold_spp samples or more (default 1.5). A 100-million-sample
series therefore stages O(visible pixels) vertices, not O(samples), and FrameStats records
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
Exact versus interpolated
The library deliberately holds two conventions next to each other, and they do not agree:
Series::reduce/sum/sumsqare exact over a sample range. They answer "what are the samples' extremes / sum / sum of squares over[begin, end)". Each is implemented once overcopy_raw(): the bucket-aligned interior comes from the pyramid (O(log n) bucket aggregates) and at most two partial edge buckets are read raw and scanned, so the cost is O(kBucket) worst case and independent of the series length, and no query allocates.Measure::evaluateandPlot::region_statsare built on these, never on the*_bucketsforms, which would silently report wrong extremes for an unaligned interval.- The crosshair readout is interpolated, and says so.
crosshair_span(series, x)returns the two samplesxlies between (begin,end,t), andcrosshair_interpolate(y_begin, y_end, t)does a linear interpolation: it answers "what is the signal at this instant", which is generally between two samples — a different question from an interval statistic. The lookup is O(1) (one division and a floor) and allocation-free;Plot::crosshair_value(trace, x, out_y)exposes the same number to a host. A row whose trace has no sample at the crosshair's x showskCrosshairNoValue(an em dash,"—"), and an x outside the series window yields no value rather than the nearest sample's.
Measurement (from Plot::measurement()) documents integral as a left-Riemann sum,
sum(begin, end) * dx — the samples taken as the value at the left edge of each step. That
is part of the result's contract, not an implementation detail.
Measured frame times
These are the demos' own frame summaries, taken from a linux-release build, all of them
llvmpipe (Mesa software rasteriser) under Xvfb — no run touched a GPU. Treat them as
upper bounds and as a record of the software path, not as hardware numbers.
| demo | window | mean ms/frame | work |
|---|---|---|---|
GLFW (plot_glfw_demo) |
1280×720 | 0.42 | one 1 MS/s StreamSeries |
ImGui (plot_imgui_demo) |
1280×720 | 0.37 | the same stream, embedded |
multiplot (plot_multiplot_demo) |
1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
Qt6 (plot_qt_demo) |
820×588 at exit | 3.23 | one stream, QOpenGLWidget FBO |
GTK4 (plot_gtk_demo) |
810×564 at exit | 2.85 | one stream, GtkGLArea FBO |
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the library.