feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
@@ -2,20 +2,27 @@
|
||||
|
||||
## The sample stores
|
||||
|
||||
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||||
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
|
||||
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.
|
||||
|
||||
```cpp
|
||||
class Series {
|
||||
public:
|
||||
virtual u64 size() const;
|
||||
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||||
virtual f64 dx() const;
|
||||
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 void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
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)
|
||||
@@ -29,11 +36,65 @@ Indices are the series' **own window**: index 0 is the oldest sample it currentl
|
||||
**`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) or `commit()`,
|
||||
which rebuilds the pyramid and cached bounds from the current `y`.
|
||||
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)`.
|
||||
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 pick
|
||||
`pyramid().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 exact
|
||||
`bucket_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
|
||||
|
||||
@@ -82,11 +143,12 @@ the visible pixels rather than on the sample count.
|
||||
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 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||||
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
|
||||
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
|
||||
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
|
||||
mean/RMS/integral from whole buckets instead of raw samples.
|
||||
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||||
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
|
||||
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
|
||||
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.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
|
||||
|
||||
Reference in new issue
Block a user