// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include 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& 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& 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& out_xy) const; }; } // namespace plot::data