Files
plotter/include/plot/data/xy_series.hpp
T
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

88 lines
3.8 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>
#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 `x_origin`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, 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