Files
Martino Ferrari c2c8c596f3
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
fix(data): the gather origin is the caller's, so a deep zoom draws on the sample
`Series::gather()` stages x as f32 relative to `x0()` -- the start of the
*retained window* -- so the value's ULP was a fraction of data the view need
not even hold: 1.9 us over a 20 s ring. A readout quotes `x_at()` in exact
f64, so at a 10 us view across 700 px that put more than a hundred pixels
between where a sample is drawn and the x reported for it.

The origin is now a parameter, placed before `out_xy` so every call site is a
type error rather than a silent change of behaviour. `Renderer::draw_traces`
passes the visible range's minimum, which makes the error a fixed 2^-24 of the
span on screen -- 4e-5 of a pixel over a 700 px axis -- at every zoom.

`stage_xy()` loses its second origin as a consequence: the gather origin and
the draw origin are now always the same value, so the linear case hands the
gathered buffer straight to `add_polyline_xy()` with no per-point copy.

`Plot::crosshair_point()` names the query itself, the one origin that cannot
lose precision for a caller that might.

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00

158 lines
8.2 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
namespace plot::data {
/// What geometry a gather level produces for its buckets. The LOD *level* is
/// chosen exactly as before; the mode decides what each bucket contributes.
enum class GatherMode {
/// One (min, max) envelope pair per bucket: the default, and what a dense
/// time-series trace wants -- the eye reads the extremes.
Envelope,
/// One raw sample per bucket (the pyramid's `snap` pick), x at the sample:
/// a thinner geometry for traces whose *shape* matters more than extremes.
Nearest,
/// One point per bucket, y = the bucket mean (`sum` / count), x at the
/// bucket centre: a smoothed trace for noisy data.
Mean,
};
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
///
/// Every concrete series answers range queries exactly, and never by scanning the
/// whole range: the bucket-aligned interior comes from the pyramid (O(log n)
/// bucket aggregates) and at most two partial edge buckets are read through
/// copy_raw() and scanned. Cost is independent of series length and no query
/// allocates.
///
/// Indexing is over the series' own window: index 0 is the oldest sample the
/// series currently holds (for a ring-backed series that is the oldest retained
/// one), so reduce(start, end) and gather() are in the same coordinates as
/// size(), x0() and pyramid().
class Series {
public:
virtual ~Series() = default;
virtual u64 size() const = 0;
/// x of sample 0, i.e. of the oldest sample currently held.
virtual f64 x0() const = 0;
/// Uniform x spacing between consecutive samples.
virtual f64 dx() const = 0;
virtual Range x_bounds() const = 0;
virtual Range y_bounds() const = 0;
virtual const MinMaxPyramid& pyramid() const = 0;
/// True when the retained window slides as samples are produced, so that
/// `x_bounds()` reports the window the series currently holds rather than an
/// extent that only changes when the caller replaces the data. Only such a
/// series has a *live edge* for `XFollow::Live` to follow -- the newest x it
/// holds, which moves forward on its own -- so the follow path asks this rather
/// than guessing from a type: `StreamSeries` says true, and every other series
/// keeps the default. Virtual rather than a flag a caller could disagree with,
/// so the series that owns the sliding window is the one that answers.
virtual bool streaming() const noexcept { return false; }
/// Appends interleaved (x, y) pairs for level `level`.
///
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// 1.7e9). y is unchanged.
///
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained
/// window, bounded by the two partial buckets at the edges of the requested
/// range -- the bucket the window starts inside and the bucket the newest
/// samples are in -- which it emits as raw samples through copy_raw(). A
/// bucket that is only partly retained holds aggregates of samples the
/// window no longer has and a partial trailing bucket is not stored at all,
/// so neither may be drawn from aggregates. `mode` picks what a bucket
/// contributes (see `GatherMode`); the edge raw samples are the same for
/// every mode. A bucket whose samples are all non-finite is empty, not
/// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing
/// for it, Nearest draws the raw centre sample. `level` is clamped to
/// `kMaxPyramidLevel`.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at
/// most two buckets of extra raw work (about two pixels at the level
/// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared.
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
/// Clamping is the caller's business: out-of-range `i` is the caller's bug.
virtual f64 x_at(u64 i) const;
/// The samples bracketing data-space `x`: sets `i0` to the last index with
/// `x_at(i0) <= x` and `i1` to the first with `x_at(i1) >= x` (equal when x
/// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with
/// its own x array brackets exactly.
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
/// 0 for an empty series.
virtual u64 snap_index(f64 x) const;
/// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
/// zero-span Range and zero sums. Implemented once, non-virtually, over
/// copy_raw() in src/data/series.cpp.
Range reduce(u64 begin, u64 end) const;
f64 sum(u64 begin, u64 end) const;
f64 sumsq(u64 begin, u64 end) const;
protected:
/// Copies raw y samples for [begin, end) into `out`, which the caller sized to
/// (end - begin). Handles ring wraparound. Only ever called with the small edge
/// spans of the queries above, never on a per-sample path. Out-of-range spans
/// are clamped and the unwritten tail is zero-filled.
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const;
private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
/// exactly, plus the at most two partial edge spans it cannot.
struct QueryParts {
u64 interior_begin = 0, interior_end = 0; // whole level-0 buckets, may be empty
u64 left_begin = 0, left_end = 0; // partial head bucket
u64 right_begin = 0, right_end = 0; // partial tail bucket
};
/// Needs the pyramid's window base: the bucket grid it aligns to is absolute,
/// while begin/end are window coordinates.
QueryParts split_query(u64 begin, u64 end) const;
/// Reads both edge spans through copy_raw() into `out` (which must hold at
/// least 2*kBucket samples) and returns how many were written.
u64 copy_edges(const QueryParts& parts, f32* out) const;
/// Appends the raw samples of [begin, end) as (x, y) pairs, in copy_raw()'s
/// bounded chunks. The one path that reads samples rather than aggregates: level
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
};
} // namespace plot::data