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
@@ -22,6 +22,10 @@ class ArraySeries final : public Series {
|
||||
void reset(std::vector<f32> y, f64 x0, f64 dx);
|
||||
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
|
||||
void commit();
|
||||
/// Appends one sample incrementally: the pyramid folds it (`append`, amortised
|
||||
/// O(1)) and the cached bounds extend in O(1), NaN-aware, instead of commit()
|
||||
/// rebuilding the whole store. The wholesale path stays `commit()`.
|
||||
void push(f32 y);
|
||||
|
||||
u64 size() const override;
|
||||
f64 x0() const override;
|
||||
@@ -29,7 +33,8 @@ class ArraySeries final : public Series {
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||
@@ -41,6 +46,11 @@ class ArraySeries final : public Series {
|
||||
MinMaxPyramid pyramid_;
|
||||
Range x_bounds_{};
|
||||
Range y_bounds_{};
|
||||
/// False until a finite sample has been seen: `y_bounds_` is then a
|
||||
/// placeholder (zero-span), not an aggregate, and the next finite sample
|
||||
/// seeds it. This is what makes push()'s incremental extension and commit()'s
|
||||
/// rebuild agree on a window that holds non-finite samples.
|
||||
bool y_has_finite_ = false;
|
||||
};
|
||||
|
||||
} // namespace plot::data
|
||||
@@ -19,6 +19,12 @@ struct MinMax {
|
||||
/// series and the level maths is a shift.
|
||||
inline constexpr u32 kBucket = 64;
|
||||
|
||||
/// Deepest pyramid level. `kBucket << 57` is `2^63`, the widest bucket span a
|
||||
/// u64 sample count can name; past it the shift would overflow (UB), so every
|
||||
/// level computation clamps here and a `gather()` asked for a deeper level
|
||||
/// simply gets this one (whose buckets are already wider than any window).
|
||||
inline constexpr u32 kMaxPyramidLevel = 57;
|
||||
|
||||
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
|
||||
///
|
||||
/// Level `l` bucket `k` covers the absolute samples
|
||||
@@ -37,14 +43,14 @@ inline constexpr u32 kBucket = 64;
|
||||
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
|
||||
/// level discards the buckets whose span starts before `u`, so the memory is
|
||||
/// bounded by the retained window and not by the number of samples ever appended.
|
||||
/// A bucket 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, ~19% of the 4n bytes of raw
|
||||
/// samples. (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.) Every stored entry is the complete aggregate of its own span and
|
||||
/// every stored span lies inside the retained window, so no aggregate is ever
|
||||
/// recomputed or invalidated.
|
||||
/// A bucket 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, ~25% of the 4n bytes of raw samples. (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.) Every stored entry is
|
||||
/// the complete aggregate of its own span and every stored span lies inside the
|
||||
/// retained window, so no aggregate is ever recomputed or invalidated.
|
||||
///
|
||||
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
|
||||
/// `size()` is the retained sample count, and `window_base()` is the absolute
|
||||
@@ -64,6 +70,11 @@ class MinMaxPyramid {
|
||||
/// appends never grows the level vector. The bucket deques themselves grow in
|
||||
/// chunks: a sliding window cannot know their final length up front.
|
||||
void reserve(u64 n);
|
||||
/// Folds one sample into the pyramid. A non-finite sample advances the
|
||||
/// absolute sample count (bucket boundaries are positional) but is excluded
|
||||
/// from the aggregates: min/max/sum/sumsq/count never see it, and a bucket
|
||||
/// whose samples were all non-finite is stored as an empty aggregate
|
||||
/// (count 0, zero sums) that every reader treats as absent.
|
||||
void append(f32 v);
|
||||
/// Slides the retained window's leading edge to absolute sample index
|
||||
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
||||
@@ -100,6 +111,15 @@ class MinMaxPyramid {
|
||||
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
|
||||
/// level_base(l) <= i < level_base(l) + level_size(l).
|
||||
MinMax bucket(u32 l, u64 i) const;
|
||||
/// The bucket's exact `sum`/`sumsq` folds -- what `GatherMode::Mean` needs to
|
||||
/// average a bucket and what an RMS readout folds without touching raw
|
||||
/// samples. Same precondition as `bucket()`.
|
||||
f64 bucket_sum(u32 l, u64 i) const;
|
||||
f64 bucket_sumsq(u32 l, u64 i) const;
|
||||
/// How many *finite* samples the bucket aggregated: the divisor that makes
|
||||
/// `bucket_sum()` a mean under the non-finite-exclusion policy, and 0 exactly
|
||||
/// when the bucket holds no usable sample. Same precondition as `bucket()`.
|
||||
u32 bucket_count(u32 l, u64 i) const;
|
||||
|
||||
/// Min/max over the whole level-0 buckets fully contained in the window range
|
||||
/// [begin, end). Partial edge buckets are excluded, never approximated; an
|
||||
@@ -119,13 +139,18 @@ class MinMaxPyramid {
|
||||
u64 snap(f64 x, f64 x0, f64 dx) const;
|
||||
|
||||
private:
|
||||
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
|
||||
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
|
||||
/// integral come from whole buckets instead of raw samples.
|
||||
/// One bucket: its extremes plus its own exact sum and sum of squares, and
|
||||
/// how many finite samples produced them. The extra words take a bucket from
|
||||
/// 8 bytes to 32, which is what lets mean/RMS/integral come from whole
|
||||
/// buckets instead of raw samples -- and the count is what keeps a bucket of
|
||||
/// nothing (all its samples non-finite) empty rather than zero-valued: an
|
||||
/// entry with count 0 has zero sums and a meaningless mm, and every reader
|
||||
/// skips it.
|
||||
struct Entry {
|
||||
MinMax mm;
|
||||
f64 sum = 0.0;
|
||||
f64 sumsq = 0.0;
|
||||
u32 count = 0; ///< finite samples folded in; 0 = an empty bucket
|
||||
};
|
||||
/// One level's complete buckets, in absolute bucket-index order.
|
||||
struct Level {
|
||||
|
||||
@@ -12,8 +12,13 @@ namespace plot::data {
|
||||
|
||||
/// Single-producer / single-consumer ring of at most `capacity` elements.
|
||||
///
|
||||
/// The capacity is rounded up to a power of two so slot addressing is a mask
|
||||
/// instead of a modulo. The producer writes a slot and then publishes the new
|
||||
/// The capacity is rounded up to a power of two -- `capacity()` reports the
|
||||
/// *rounded* value, not the requested one, and `size()` saturates there -- so
|
||||
/// slot addressing is a mask instead of a modulo. Callers that need the
|
||||
/// retained window's exact bound must read `capacity()`, never their own
|
||||
/// constructor argument (see `StreamSeries`, which reserves the rounded value).
|
||||
///
|
||||
/// The producer writes a slot and then publishes the new
|
||||
/// sample count with a release store; a consumer that observes that count with an
|
||||
/// acquire load is guaranteed to see the slot contents written before it.
|
||||
///
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
|
||||
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
|
||||
@@ -43,21 +57,52 @@ class Series {
|
||||
/// 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 in data coordinates for level `level`.
|
||||
/// Appends interleaved (x, y) pairs for level `level`.
|
||||
///
|
||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
|
||||
/// envelope pair 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 as an envelope.
|
||||
/// **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.
|
||||
///
|
||||
/// 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. Pairs are appended to
|
||||
/// out_xy, which is never cleared.
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
||||
/// 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
|
||||
@@ -75,8 +120,9 @@ class Series {
|
||||
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 min/max envelope above it.
|
||||
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
/// 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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
@@ -57,6 +58,11 @@ namespace plot::data {
|
||||
/// class specifies.
|
||||
class StreamSeries final : public Series {
|
||||
public:
|
||||
/// `capacity` is the sample count to retain; it is rounded up to a power of
|
||||
/// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded
|
||||
/// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The
|
||||
/// pyramid's level table is reserved for the rounded capacity up front, so a
|
||||
/// saturated stream never grows it.
|
||||
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
|
||||
|
||||
/// Producer thread only.
|
||||
@@ -68,6 +74,17 @@ class StreamSeries final : public Series {
|
||||
/// Consumer thread only: snapshots the produced count and brings the pyramid
|
||||
/// and bounds up to it.
|
||||
void acquire();
|
||||
/// Consumer thread only: copies the window published by the *last acquire()*
|
||||
/// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen
|
||||
/// series never changes again, no matter what the producer pushes afterwards
|
||||
/// -- it answers from its own copy, not from the ring -- which is what a
|
||||
/// consumer that needs a stable window for longer than one read wants. (The
|
||||
/// ring itself keeps overwriting; only the copy is frozen, so this costs one
|
||||
/// window's worth of samples. It reads through `copy_raw()`, so like every
|
||||
/// read it is a snapshot of *which* absolute indices to walk, not a guarantee
|
||||
/// about slots a producer is overwriting -- call it on the consumer thread,
|
||||
/// interleaved with `acquire()`, per the class note.)
|
||||
ArraySeries freeze() const;
|
||||
|
||||
u64 size() const override;
|
||||
f64 x0() const override;
|
||||
@@ -75,7 +92,8 @@ class StreamSeries final : public Series {
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
/// Always true: this series' `x_bounds()` is the retained ring window, which
|
||||
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
|
||||
bool streaming() const noexcept override { return true; }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
|
||||
namespace plot::data {
|
||||
|
||||
/// Series over its own x array: sample i lives at `x()[i]`, and the samples are
|
||||
/// sorted by ascending x. This is the store for data whose x is *not* on a
|
||||
/// uniform grid -- scattered measurements, event times, a log-swept frequency
|
||||
/// axis -- where `ArraySeries`' fixed `dx` cannot hold.
|
||||
///
|
||||
/// **Precondition: `x` is sorted ascending.** The O(log n) crosshair and
|
||||
/// nearest-sample lookups are binary searches, which are only correct on sorted
|
||||
/// input; the constructor and `reset()` assert sortedness in debug builds and
|
||||
/// the behaviour with unsorted input is undefined in release ones. Callers with
|
||||
/// a possibly-unsorted source sort it first (sorting the *pairs*, not the
|
||||
/// arrays independently, or the samples get scrambled).
|
||||
///
|
||||
/// Everything the base `Series` contracts to holds, with the sample index as
|
||||
/// the grid: the pyramid buckets samples by *index* (`kBucket` samples per
|
||||
/// level-0 bucket, exactly as for a uniform series) and `gather()` emits x
|
||||
/// relative to `x0()` -- `f32(x_at(i) - x0())`, never the absolute
|
||||
/// coordinate -- so an epoch-scale x array narrows to f32 the same safe,
|
||||
/// O(visible pixels) way a uniform one does. The queries that need a *uniform*
|
||||
/// grid are overridden with the binary-search answer instead: `x_at()` reads
|
||||
/// the array, `neighbours()` brackets with `upper_bound`/`lower_bound`, and
|
||||
/// `snap_index()` compares the two candidates the search leaves. `dx()` is
|
||||
/// meaningless here and returns 0: nothing that routes through
|
||||
/// `x_at`/`neighbours`/`snap_index` consults it.
|
||||
class XYSeries final : public Series {
|
||||
public:
|
||||
/// Takes both arrays by value. `x.size() == y.size()` and `x` sorted
|
||||
/// ascending (asserted in debug).
|
||||
XYSeries(std::vector<f64> x, std::vector<f32> y);
|
||||
/// Replaces both arrays, then rebuilds derived state (== commit()). Same
|
||||
/// preconditions as the constructor.
|
||||
void reset(std::vector<f64> x, std::vector<f32> y);
|
||||
/// Rebuilds the pyramid and the cached bounds from the arrays as they stand.
|
||||
void commit();
|
||||
|
||||
u64 size() const override;
|
||||
/// x of sample 0: the first (smallest) x in the array.
|
||||
f64 x0() const override;
|
||||
/// Always 0: the grid is the x array, not a uniform step.
|
||||
f64 dx() const override;
|
||||
/// From the first and last x; a zero span for an empty series.
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
/// Same contract as `Series::gather`, with x emitted relative to `x0()`.
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
|
||||
/// The sample's own x.
|
||||
f64 x_at(u64 i) const override;
|
||||
/// Binary-search bracket of data-space `x`: false outside
|
||||
/// `[x_at(0), x_at(size() - 1)]`, else the last sample at or left of `x` and
|
||||
/// the first at or right of it (equal on an exact hit).
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||
/// Binary-search nearest sample, clamped to `[0, size() - 1]`; ties go to the
|
||||
/// lower index, and an empty series returns 0.
|
||||
u64 snap_index(f64 x) const override;
|
||||
|
||||
/// The x array itself (sorted ascending).
|
||||
const std::vector<f64>& x() const noexcept { return x_; }
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||
|
||||
private:
|
||||
std::vector<f64> x_;
|
||||
std::vector<f32> y_;
|
||||
MinMaxPyramid pyramid_;
|
||||
Range x_bounds_{};
|
||||
Range y_bounds_{};
|
||||
/// False until a finite sample has been seen (see `ArraySeries`): the cached
|
||||
/// y bounds are then a placeholder, and the first finite sample seeds them.
|
||||
bool y_has_finite_ = false;
|
||||
};
|
||||
|
||||
} // namespace plot::data
|
||||
Reference in new issue
Block a user