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.
150 lines
7.4 KiB
C++
150 lines
7.4 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 `x0()`**: the emitted value is `x_at(i) - x0()` as
|
|
/// f32, never the absolute coordinate. A consumer reconstructs the absolute
|
|
/// x in f64 (`x0() + 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), while the relative
|
|
/// value `i*dx` is small and exact. y is unchanged.
|
|
///
|
|
/// 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, 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 interpolates 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, 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, std::vector<f32>& out_xy) const;
|
|
};
|
|
|
|
} // namespace plot::data
|