`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()`.
88 lines
3.8 KiB
C++
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
|