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()`.
This commit is contained in:
1 parent
59c2e45af7
commit
c2c8c596f3
15 files changed
+150
-95
No files matched your search
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
|
||||
|
||||
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
|
||||
|
||||
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
GatherMode mode) const {
|
||||
gather_impl(level, begin, end, out_xy, mode);
|
||||
gather_impl(level, begin, end, x_origin, out_xy, mode);
|
||||
}
|
||||
|
||||
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
|
||||
+15
-11
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
|
||||
return static_cast<u64>(k);
|
||||
}
|
||||
|
||||
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
|
||||
void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
|
||||
const f64 step = dx();
|
||||
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
|
||||
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
|
||||
const f64 shift = x0() - x_origin;
|
||||
f32 raw[kGatherChunk];
|
||||
for (u64 i = begin; i < end;) {
|
||||
const u64 take = std::min<u64>(kGatherChunk, end - i);
|
||||
copy_raw(i, i + take, raw);
|
||||
for (u64 k = 0; k < take; ++k) {
|
||||
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather()
|
||||
out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
|
||||
out_xy.push_back(raw[k]);
|
||||
}
|
||||
i += take;
|
||||
}
|
||||
}
|
||||
|
||||
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
GatherMode mode) const {
|
||||
const u64 n = size();
|
||||
const u64 b = std::min(begin, n);
|
||||
@@ -198,7 +201,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
if (b >= e) return;
|
||||
|
||||
if (level == 0) {
|
||||
gather_raw(b, e, out_xy);
|
||||
gather_raw(b, e, x_origin, out_xy);
|
||||
return;
|
||||
}
|
||||
// Public levels clamp to the deepest representable one (R3): past
|
||||
@@ -226,6 +229,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
// picks, so the envelope path stays O(buckets) and the geometry still spans
|
||||
// the requested range exactly, for every mode.
|
||||
const f64 step = dx();
|
||||
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 origin = pyramid().window_base();
|
||||
const u64 lbase = pyramid().level_base(level);
|
||||
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
if (first >= last) {
|
||||
// No complete bucket of this level is inside the range -- a window squeezed
|
||||
// into one bucket -- so the raw samples are the only exact geometry there is.
|
||||
gather_raw(b, e, out_xy);
|
||||
gather_raw(b, e, x_origin, out_xy);
|
||||
return;
|
||||
}
|
||||
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
|
||||
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
|
||||
if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
|
||||
|
||||
switch (mode) {
|
||||
case GatherMode::Envelope: {
|
||||
@@ -252,9 +256,9 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
const MinMax m = pyramid().bucket(level, bi);
|
||||
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
|
||||
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
||||
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0()
|
||||
out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
|
||||
out_xy.push_back(m.min);
|
||||
out_xy.push_back(f32(f64(last_sample) * step));
|
||||
out_xy.push_back(f32(shift + f64(last_sample) * step));
|
||||
out_xy.push_back(m.max);
|
||||
}
|
||||
break;
|
||||
@@ -269,7 +273,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
|
||||
f32 v = 0.0f;
|
||||
copy_raw(centre, centre + 1, &v);
|
||||
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
|
||||
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
|
||||
out_xy.push_back(v);
|
||||
}
|
||||
break;
|
||||
@@ -283,7 +287,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
const u32 count = pyramid().bucket_count(level, bi);
|
||||
if (count == 0) continue;
|
||||
const u64 centre = bi * span + span / 2 - origin;
|
||||
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
|
||||
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
|
||||
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
|
||||
}
|
||||
break;
|
||||
@@ -291,7 +295,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
}
|
||||
|
||||
const u64 trail = last * span - origin; // one past the last emitted bucket's span
|
||||
if (trail < e) gather_raw(trail, e, out_xy);
|
||||
if (trail < e) gather_raw(trail, e, x_origin, out_xy);
|
||||
}
|
||||
|
||||
} // namespace plot::data
|
||||
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
|
||||
|
||||
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
|
||||
|
||||
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
GatherMode mode) const {
|
||||
gather_impl(level, begin, end, out_xy, mode);
|
||||
gather_impl(level, begin, end, x_origin, out_xy, mode);
|
||||
}
|
||||
|
||||
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
|
||||
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
|
||||
: hi; // ties go to the lower index
|
||||
}
|
||||
|
||||
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
GatherMode mode) const {
|
||||
const u64 n = size();
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
|
||||
// x emitted relative to x0(): see Series::gather(). The x array is f64, so
|
||||
// x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
|
||||
// the subtraction happens wide and only the small, exact offset narrows.
|
||||
const f64 origin = x0();
|
||||
const f64 origin = x_origin;
|
||||
|
||||
if (level == 0) {
|
||||
for (u64 i = b; i < e; ++i) {
|
||||
@@ -127,7 +127,7 @@ void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
// `begin`, `last` one past the last bucket wholly before `end`, and the two
|
||||
// edge spans around them are read from the raw arrays -- so no emitted
|
||||
// geometry comes from outside [begin, end), and the emitted x values are the
|
||||
// array's own coordinates relative to x0().
|
||||
// array's own coordinates relative to the caller's origin.
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 lend = pyramid().level_size(level);
|
||||
const u64 first = std::min(ceil_div(b, span), lend);
|
||||
|
||||
+7
-4
@@ -1464,7 +1464,10 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
|
||||
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
|
||||
// and sized for two pairs from construction, so the pointer path allocates nothing.
|
||||
crosshair_scratch_.clear();
|
||||
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_);
|
||||
// The origin is the query itself: only y is read out of the pairs below -- the x comes back
|
||||
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
|
||||
// that might.
|
||||
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
|
||||
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
|
||||
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
|
||||
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
|
||||
@@ -1480,9 +1483,9 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
|
||||
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
|
||||
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
|
||||
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
|
||||
// comes back through `x_at()` rather than from the gathered pairs, which are relative to
|
||||
// `x0()` by the `Series::gather()` contract -- adding it back here would be a second
|
||||
// reconstruction of a coordinate the series will hand over directly.
|
||||
// comes back through `x_at()` rather than from the gathered pairs, which are relative to a
|
||||
// caller-named origin by the `Series::gather()` contract -- adding it back here would be a
|
||||
// second reconstruction of a coordinate the series will hand over directly.
|
||||
const bool take_begin = span.t <= 0.5;
|
||||
out_x = series->x_at(take_begin ? span.begin : span.end);
|
||||
out_y = take_begin ? y_begin : y_end;
|
||||
|
||||
+21
-24
@@ -736,24 +736,16 @@ void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool
|
||||
static_cast<f32>(space(y1, log_y))});
|
||||
}
|
||||
|
||||
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x,
|
||||
bool log_y) {
|
||||
void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
|
||||
if (!log_x && !log_y) {
|
||||
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
|
||||
// the gather contract's own reconstruction), stage it relative to the
|
||||
// draw's origin, and the f32 is O(visible pixels), not O(epoch).
|
||||
staged_.clear();
|
||||
staged_.reserve(usize(n) * 2u);
|
||||
const f64 origin_s = space(origin_x, log_x);
|
||||
for (u32 i = 0; i < n; ++i) {
|
||||
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
|
||||
staged_.push_back(xy[2u * i + 1u]);
|
||||
}
|
||||
lines_.add_polyline_xy(staged_.data(), n);
|
||||
// The common case costs nothing at all: the gather emitted x relative to this very origin
|
||||
// (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
|
||||
// needs no mapping, so the gathered buffer *is* the staged one.
|
||||
lines_.add_polyline_xy(xy, n);
|
||||
return;
|
||||
}
|
||||
for (u32 i = 0; i + 1u < n; ++i) {
|
||||
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0,
|
||||
stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
|
||||
xy[2u * i + 3u], log_x, log_y);
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1018,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
: 0u;
|
||||
|
||||
gather_.clear();
|
||||
series.gather(level, first, last, gather_, trace.mode);
|
||||
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
|
||||
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
|
||||
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
|
||||
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
|
||||
// quotes for it. See `Series::gather()`.
|
||||
series.gather(level, first, last, x_range.min, gather_, trace.mode);
|
||||
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
||||
|
||||
stats_.visible_samples += visible;
|
||||
@@ -1041,7 +1038,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
const auto& yt = plot.y_transform(trace.axis_y);
|
||||
const f32 half = 0.5f * style_.device(style_.marker_px);
|
||||
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
|
||||
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]);
|
||||
const f64 x_abs = x_range.min + static_cast<f64>(gather_[k]);
|
||||
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
|
||||
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
|
||||
const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
|
||||
@@ -1059,11 +1056,11 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
++stats_.lod_traces;
|
||||
}
|
||||
|
||||
// Gathered x is relative to `series.x0()` (the gather contract); the staging
|
||||
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) -
|
||||
// to_space(origin)` -- the visible x range's minimum is the natural origin
|
||||
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The
|
||||
// mapping folds the origin back into the offset uniform, in f64.
|
||||
// Gathered x is relative to `x_range.min` (the gather contract, with the origin this
|
||||
// function asked for); the staging reconstructs the absolute data x in f64 and emits
|
||||
// `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
|
||||
// pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
|
||||
// in f64.
|
||||
const bool log_x = plot.x_axis(trace.axis_x).log;
|
||||
const bool log_y = plot.y_axis(trace.axis_y).log;
|
||||
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
|
||||
@@ -1087,7 +1084,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
f64 next = std::numeric_limits<f64>::infinity();
|
||||
for (usize k = count; k > 0; --k) {
|
||||
const usize p = k - 1u; // the point index; `i` above is the trace's
|
||||
const f64 x = static_cast<f64>(gather_[2u * p]) + series.x0();
|
||||
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
||||
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
|
||||
next = x;
|
||||
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
|
||||
@@ -1114,7 +1111,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
||||
// markers below are the whole trace.
|
||||
if (trace.width > 0.0f) {
|
||||
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y);
|
||||
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
|
||||
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
||||
}
|
||||
|
||||
@@ -1132,7 +1129,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
const f64 origin_s = space(x_range.min, log_x);
|
||||
u32 staged = 0;
|
||||
for (u32 p = 0; p < points; p += stride) {
|
||||
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0();
|
||||
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
||||
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
|
||||
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
|
||||
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
|
||||
|
||||
Reference in new issue
Block a user