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
+184
-85
@@ -31,41 +31,69 @@ const Axis& default_axis() noexcept {
|
||||
return axis;
|
||||
}
|
||||
|
||||
/// An axis coordinate in the space the live follow arithmetic runs in: log10 when
|
||||
/// the axis is logarithmic, the value itself otherwise -- the same space the
|
||||
/// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset`
|
||||
/// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades
|
||||
/// and holds decades under follow: a linear span on a log axis would rescale the
|
||||
/// view on every frame (the defect this fixes).
|
||||
f64 to_axis_space(f64 v, bool log) noexcept {
|
||||
return log ? Transform::log10_positive(v) : v;
|
||||
}
|
||||
f64 from_axis_space(f64 v, bool log) noexcept {
|
||||
return log ? std::pow(10.0, v) : v;
|
||||
}
|
||||
|
||||
/// The sample range a data interval covers: `[begin, end)` over the samples whose
|
||||
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
|
||||
/// statistics are over. The nearest-sample snap seeds the walk and the two
|
||||
/// corrections after it move by the one or two samples a rounded index can be off
|
||||
/// by, so which side of a bound a sample falls on is decided by the same doubles
|
||||
/// the series carries rather than by a division that is exact only on a grid that
|
||||
/// is. Still O(1): the seed is never more than half a sample from the answer.
|
||||
/// statistics are over. The brackets come from `Series::neighbours()`, which names
|
||||
/// the samples at or either side of a bound *by the series' own x coordinates*
|
||||
/// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series
|
||||
/// -- is covered exactly where its samples actually are, not where a `dx()` grid
|
||||
/// would have put them. Still O(1): two neighbour lookups.
|
||||
///
|
||||
/// False when the interval holds no sample at all, which is a real case: an
|
||||
/// interval between two adjacent samples on a sparse series, one entirely before or
|
||||
/// after the window, a non-finite bound, and a series with no usable `dx()`.
|
||||
/// after the window, and a non-finite bound.
|
||||
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
|
||||
const u64 n = series.size();
|
||||
const f64 x0 = series.x0();
|
||||
const f64 dx = series.dx();
|
||||
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) {
|
||||
return false;
|
||||
if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false;
|
||||
|
||||
// The first sample at or after `lo`: the right neighbour of the bound, unless the
|
||||
// bound lands on a sample -- which is then itself the first one inside. A bound
|
||||
// outside the window covers everything on its side: before the oldest sample the
|
||||
// coverage starts at 0, after the newest there is nothing inside.
|
||||
u64 i0 = 0;
|
||||
u64 i1 = 0;
|
||||
if (!series.neighbours(lo, i0, i1)) {
|
||||
if (series.x_at(0u) > lo) {
|
||||
i1 = 0u;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (series.x_at(i0) >= lo) {
|
||||
i1 = i0;
|
||||
}
|
||||
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
|
||||
const u64 b = i1;
|
||||
|
||||
// The first sample at or after `lo`: walk left while the one before is also
|
||||
// inside, then right while this one is not yet.
|
||||
u64 b = series.pyramid().snap(lo, x0, dx);
|
||||
while (b > 0u && x_of(b - 1u) >= lo) --b;
|
||||
while (b < n && x_of(b) < lo) ++b;
|
||||
// One past the last sample at or before `hi`, from the same lookup: the left
|
||||
// neighbour of the bound, or the sample the bound landed on.
|
||||
u64 j0 = 0;
|
||||
u64 j1 = 0;
|
||||
if (!series.neighbours(hi, j0, j1)) {
|
||||
if (series.x_at(n - 1u) < hi) {
|
||||
j0 = n - 1u;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (series.x_at(j1) <= hi) {
|
||||
j0 = j1;
|
||||
}
|
||||
const u64 last = j0;
|
||||
|
||||
// One past the last sample at or before `hi`, from the same seed.
|
||||
const u64 seed = series.pyramid().snap(hi, x0, dx);
|
||||
u64 e = seed == n - 1u ? n : seed + 1u;
|
||||
while (e < n && x_of(e) <= hi) ++e;
|
||||
while (e > 0u && x_of(e - 1u) > hi) --e;
|
||||
|
||||
if (e <= b) return false;
|
||||
if (last < b) return false; // the interval falls between two adjacent samples
|
||||
begin = b;
|
||||
end = e;
|
||||
end = last + 1u;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -228,6 +256,12 @@ u32 Plot::add_trace(const TraceDesc& desc) {
|
||||
trace.axis_x = desc.axis_x;
|
||||
trace.axis_y = desc.axis_y;
|
||||
trace.name = desc.name;
|
||||
trace.mode = desc.mode;
|
||||
trace.dash_on = desc.dash_on;
|
||||
trace.dash_off = desc.dash_off;
|
||||
trace.marker = desc.marker;
|
||||
trace.marker_size_px = desc.marker_size_px;
|
||||
trace.marker_every = desc.marker_every;
|
||||
traces_.push_back(trace);
|
||||
mark_measures_dirty();
|
||||
return static_cast<u32>(traces_.size() - 1);
|
||||
@@ -363,15 +397,27 @@ void Plot::set_x_range(u32 axis, Range r) {
|
||||
// normalised; a span that is not a span is ignored outright, leaving the span
|
||||
// and offset the axis holds. `auto_fit_x()` is how a window is framed either
|
||||
// way.
|
||||
//
|
||||
// The offset is taken in **axis space** (log10 when the axis is logarithmic):
|
||||
// "how far behind the live edge" is a distance the axis' own transform measures,
|
||||
// so a log axis states it in decades and the window that follows from it holds
|
||||
// decades, not a decaying linear width. The span itself is read back from the
|
||||
// stored range by place_live_x_window(), in the same space.
|
||||
const f64 lo = std::min(r.min, r.max);
|
||||
const f64 hi = std::max(r.min, r.max);
|
||||
if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return;
|
||||
a.range = Range{lo, hi};
|
||||
a.offset = live.max - hi;
|
||||
a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log);
|
||||
place_live_x_window(axis);
|
||||
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
|
||||
return;
|
||||
}
|
||||
// A bound that is not a number is not a range: storing one would hand the
|
||||
// transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one
|
||||
// way back -- so the call is refused outright and the axis keeps the range *and*
|
||||
// the auto-scale state it had. The same rule `set_region()` applies to a region's
|
||||
// bounds, and for the same reason.
|
||||
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
|
||||
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
|
||||
// which is what every caller of this setter got before follow existed.
|
||||
a.range = r;
|
||||
@@ -380,6 +426,9 @@ void Plot::set_x_range(u32 axis, Range r) {
|
||||
|
||||
void Plot::set_y_range(u32 axis, Range r) {
|
||||
if (axis >= y_axes_.size()) return;
|
||||
// The x setter's refusal, on the y side: a non-finite bound would leave the axis
|
||||
// with no range to draw and no auto-scale to recover with.
|
||||
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
|
||||
y_axes_[axis].range = r;
|
||||
y_axes_[axis].auto_scale = false;
|
||||
}
|
||||
@@ -421,7 +470,10 @@ XFollow Plot::x_follow(u32 axis) const {
|
||||
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
// No such offset: a NaN would place a window that is not a window, and the last
|
||||
// usable one is a better answer than that for a value no gesture can undo.
|
||||
// usable one is a better answer than that for a value no gesture can undo. The
|
||||
// units are **axis space** ones -- data units on a linear axis, decades on a log
|
||||
// one -- matching how `set_x_range()` derives the offset and how
|
||||
// `place_live_x_window()` spends it.
|
||||
if (!std::isfinite(units_behind_live)) return;
|
||||
x_axes_[axis].offset = std::max(0.0, units_behind_live);
|
||||
// The upper end of the clamp is what the traces still retain, which only they
|
||||
@@ -489,19 +541,30 @@ bool Plot::place_live_x_window(u32 axis) {
|
||||
if (a.follow != XFollow::Live) return false;
|
||||
Range live{};
|
||||
if (!live_x_bounds(axis, live)) return false;
|
||||
// The span is the axis' own -- what the user zoomed to, or what the last fit
|
||||
// produced -- and the follow window only moves it: the span is what makes this a
|
||||
// scroll rather than a rescale. A span that is not a span is nothing to place a
|
||||
// window from, so the range is left exactly as it is.
|
||||
const f64 span = a.range.span();
|
||||
// The span and the offset are distances in **axis space** -- log10 when the axis
|
||||
// is logarithmic -- the space the controller's gestures are computed in. The span
|
||||
// is the axis' own -- what the user zoomed to, or what the last fit produced --
|
||||
// and the follow window only moves it: the span is what makes this a scroll
|
||||
// rather than a rescale. On a log axis a *linear* span would rescale the view
|
||||
// once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9
|
||||
// it is nothing -- while the log-space span holds the visible decades constant
|
||||
// whatever the edge does. A span that is not a span is nothing to place a window
|
||||
// from, so the range is left exactly as it is.
|
||||
const f64 hi_space = to_axis_space(a.range.max, a.log);
|
||||
const f64 lo_space = to_axis_space(a.range.min, a.log);
|
||||
const f64 span = hi_space - lo_space;
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return false;
|
||||
// The offset can outlive the window it names: a ring that dropped samples moves
|
||||
// the oldest x forward under it, so it is clamped against what the traces hold
|
||||
// *now* -- an offset past that would put the window's right edge behind the
|
||||
// oldest sample left, i.e. over data that is gone.
|
||||
const f64 retained = std::max(0.0, live.max - live.min);
|
||||
// *now* -- an offset past that would put the window's right edge behind the oldest
|
||||
// sample left, i.e. over data that is gone. In axis space, like the span: the
|
||||
// retained window's length is measured by the same ruler that measures the
|
||||
// offset, or a log axis could hold an offset larger than everything it retains.
|
||||
const f64 newest_space = to_axis_space(live.max, a.log);
|
||||
const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log));
|
||||
a.offset = std::clamp(a.offset, 0.0, retained);
|
||||
a.range = Range{live.max - a.offset - span, live.max - a.offset};
|
||||
const f64 right = newest_space - a.offset;
|
||||
a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)};
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -669,11 +732,26 @@ const Measurement& Plot::measurement(u32 id) const {
|
||||
slot.result = Measurement{};
|
||||
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
|
||||
slot.cursor_b < cursors_.size()) {
|
||||
const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx());
|
||||
const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx());
|
||||
// snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever
|
||||
// the cursor pair is not inverted. An inverted pair is not an interval.
|
||||
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
|
||||
const Cursor& ca = cursors_[slot.cursor_a];
|
||||
const Cursor& cb = cursors_[slot.cursor_b];
|
||||
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler
|
||||
// has no x -- it is the dead field of its orientation (see set_cursor_x()).
|
||||
// Evaluating the pair anyway would silently measure from x=0 and report
|
||||
// numbers about nothing, so the result stays invalid instead; the id the
|
||||
// caller holds is real, and `valid == false` is its answer.
|
||||
if (!ca.is_horizontal() && !cb.is_horizontal()) {
|
||||
// The cursors land on samples through the series' own lookups
|
||||
// (`snap_index` clamps like the old pyramid snap did, and `x_at()` is
|
||||
// where the sample really is), so an XY series measures between the
|
||||
// samples its own x array names rather than between indices a uniform
|
||||
// `dx()` guesses.
|
||||
const u64 a = series->snap_index(ca.x);
|
||||
const u64 b = series->snap_index(cb.x);
|
||||
// snap_index() clamps to [0, size-1], so [a, b+1) is inside the series
|
||||
// whenever the cursor pair is not inverted. An inverted pair is not an
|
||||
// interval.
|
||||
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
|
||||
}
|
||||
}
|
||||
slot.last_series = series;
|
||||
slot.last_size = samples;
|
||||
@@ -821,10 +899,22 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
||||
if (id >= measures_.size()) return MeasureSpan{};
|
||||
const MeasureSlot& slot = measures_[id];
|
||||
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
|
||||
// A band is drawn between two *x* coordinates, and a horizontal ruler has none
|
||||
// (the same dead field `measurement()` refuses under R8): the span says invalid
|
||||
// rather than naming a band one edge of which sits at x=0.
|
||||
if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) {
|
||||
return MeasureSpan{};
|
||||
}
|
||||
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
|
||||
}
|
||||
|
||||
const PlotLayout& Plot::layout() {
|
||||
// Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every
|
||||
// `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome
|
||||
// is clamped against are read non-negative here too -- layout() is on the path of
|
||||
// every host, every frame, and must be undefined nowhere.
|
||||
const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f;
|
||||
const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f;
|
||||
// Live x windows are placed first, so everything below that derives from a range
|
||||
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
|
||||
// range a caller reads back -- describes the live edge of *this* frame rather than
|
||||
@@ -883,10 +973,10 @@ const PlotLayout& Plot::layout() {
|
||||
// left of the allowance -- which is what keeps axes.w and axes.h positive
|
||||
// however long the labels and titles are. `viewport_` is what a huge label set
|
||||
// is clamped to first, so neither strip can push the rect outside it.
|
||||
y_width = std::clamp(y_width, 0.0f, viewport_.w);
|
||||
x_height = std::clamp(x_height, 0.0f, viewport_.h);
|
||||
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
|
||||
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
|
||||
y_width = std::clamp(y_width, 0.0f, vw);
|
||||
x_height = std::clamp(x_height, 0.0f, vh);
|
||||
const f32 y_budget = (1.0f - kMinAxesShare) * vw;
|
||||
const f32 x_budget = (1.0f - kMinAxesShare) * vh;
|
||||
// The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w`
|
||||
// so every row's axes starts at the same x. It is served by the *label* strip -- the
|
||||
// title column is one text line per titled axis and cannot be widened to fill a gutter
|
||||
@@ -903,8 +993,8 @@ const PlotLayout& Plot::layout() {
|
||||
layout_.y_title_width = y_title_width;
|
||||
layout_.x_title_height = x_title_height;
|
||||
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
|
||||
viewport_.w - (y_width + y_title_width),
|
||||
viewport_.h - (x_height + x_title_height)};
|
||||
vw - (y_width + y_title_width),
|
||||
vh - (x_height + x_title_height)};
|
||||
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
|
||||
layout_.y_label_area =
|
||||
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h};
|
||||
@@ -917,11 +1007,11 @@ const PlotLayout& Plot::layout() {
|
||||
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
||||
// The same caps as above, so this compares like with like and the loop still
|
||||
// reaches its fixed point on the second pass.
|
||||
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
|
||||
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
||||
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
||||
// point has to be zero for a row that gave its strip away.
|
||||
const f32 need_x =
|
||||
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
|
||||
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget)
|
||||
: 0.0f;
|
||||
// What the strip *wants* is the label's own width or the figure's floor, whichever is
|
||||
// greater -- the floor is a maximum over sources, settled by one `max` and not by
|
||||
@@ -987,8 +1077,10 @@ const data::Series* Plot::snap_series(u32 axis_x) const {
|
||||
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
|
||||
const data::Series* s = snap_series(axis_x);
|
||||
if (s == nullptr) return x;
|
||||
const u64 i = s->pyramid().snap(x, s->x0(), s->dx());
|
||||
return s->x0() + static_cast<f64>(i) * s->dx();
|
||||
// Through the series' own index arithmetic (`snap_index`/`x_at`), so a series
|
||||
// whose samples are not on a uniform grid snaps to the sample that is really
|
||||
// nearest, not to where a `dx()` grid would have put one.
|
||||
return s->x_at(s->snap_index(x));
|
||||
}
|
||||
|
||||
f64 Plot::snapped_x(const Cursor& c) const {
|
||||
@@ -1211,52 +1303,59 @@ void Plot::build_regions() {
|
||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
||||
CrosshairSpan span;
|
||||
const u64 n = series.size();
|
||||
const f64 x0 = series.x0();
|
||||
const f64 dx = series.dx();
|
||||
// No samples, no grid, no finite origin: nothing to interpolate between, and `index`
|
||||
// below would be a division by zero or a NaN -- whose floor is undefined -- rather than a
|
||||
// sample index. `!(dx > 0.0)` is also the NaN test for `dx`.
|
||||
if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span;
|
||||
// No samples and no finite query: nothing to interpolate between.
|
||||
if (n == 0u || !std::isfinite(x)) return span;
|
||||
|
||||
// The window is closed at both ends -- the oldest and the newest sample are samples too --
|
||||
// and a query outside it has no neighbours to interpolate between. Answering with the
|
||||
// nearest sample's value instead is the clamped lie this refuses: the readout would report
|
||||
// a number as if it were under the pointer when the series does not reach that far.
|
||||
const f64 newest = x0 + static_cast<f64>(n - 1u) * dx;
|
||||
if (x < x0 || x > newest) return span;
|
||||
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
||||
// the window is closed at both ends -- the oldest and the newest sample are
|
||||
// samples too -- and a query outside it has no neighbours to interpolate
|
||||
// between. Answering with the nearest sample's value instead is the clamped lie
|
||||
// this refuses: the readout would report a number as if it were under the
|
||||
// pointer when the series does not reach that far. The series owns where its
|
||||
// samples are, so an XY series is bracketed by its own x array exactly, where
|
||||
// the old uniform-grid index arithmetic would have read samples that do not
|
||||
// exist at the positions it assumed.
|
||||
u64 i0 = 0;
|
||||
u64 i1 = 0;
|
||||
if (!series.neighbours(x, i0, i1)) return span;
|
||||
|
||||
// Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is
|
||||
// `floor((x - x0) / dx)` and there is nothing to look up. The two bounds are checked
|
||||
// *before* the cast, which is also what keeps it defined: `index` is a quotient of two
|
||||
// caller-supplied doubles and can overflow to infinity for an extreme origin and spacing,
|
||||
// and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well.
|
||||
const f64 index = (x - x0) / dx;
|
||||
if (!(index > 0.0)) {
|
||||
span.begin = span.end = 0u; // the oldest sample, or a rounding step below it
|
||||
// A query that landed **on** a sample is a span of one, whichever side the
|
||||
// series' bracket reported: a sample at the query is the answer, and the two
|
||||
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
||||
// on sample 0 would read as an interpolation with no width. Decided on the
|
||||
// samples' own coordinates, so it holds for any `neighbours()` implementation.
|
||||
if (series.x_at(i0) == x) {
|
||||
span.begin = span.end = i0;
|
||||
span.t = 0.0;
|
||||
span.valid = true;
|
||||
return span;
|
||||
}
|
||||
if (index >= static_cast<f64>(n - 1u)) {
|
||||
span.begin = span.end = n - 1u; // the newest sample, or a step past it
|
||||
if (series.x_at(i1) == x) {
|
||||
span.begin = span.end = i1;
|
||||
span.t = 0.0;
|
||||
span.valid = true;
|
||||
return span;
|
||||
}
|
||||
// `index` is finite and inside `(0, n-1)`, so truncating it is a floor on a value a `u64`
|
||||
// holds. Two rounding cases keep the span honest at a sample: a `t` that lands on 0 is the
|
||||
// query sitting *on* sample `i`, and one that rounds up to 1.0 is it sitting on the sample
|
||||
// after it -- neither is an interpolation with no width.
|
||||
const u64 i = static_cast<u64>(index);
|
||||
const f64 t = index - static_cast<f64>(i);
|
||||
if (!(t > 0.0)) {
|
||||
span.begin = span.end = i;
|
||||
} else if (t >= 1.0) {
|
||||
span.begin = span.end = i + 1u;
|
||||
|
||||
if (i0 == i1) {
|
||||
// The series bracketed a single sample for a query that is not on it: nothing
|
||||
// to interpolate with, and the sample is the honest answer.
|
||||
span.begin = span.end = i0;
|
||||
span.t = 0.0;
|
||||
} else {
|
||||
span.begin = i;
|
||||
span.end = i + 1u;
|
||||
span.t = t;
|
||||
span.begin = i0;
|
||||
span.end = i1;
|
||||
// `t` from the samples' own x coordinates, so the interpolation is exact on
|
||||
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
||||
// interpolates nothing and reads the earlier sample.
|
||||
const f64 xa = series.x_at(i0);
|
||||
const f64 xb = series.x_at(i1);
|
||||
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
||||
if (!(span.t > 0.0)) {
|
||||
span.t = 0.0; // a rounding step below the earlier sample is the sample
|
||||
} else if (span.t > 1.0) {
|
||||
span.t = 1.0;
|
||||
}
|
||||
}
|
||||
span.valid = true;
|
||||
return span;
|
||||
|
||||
Reference in new issue
Block a user