feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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

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:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+47 -15
View File
@@ -22,6 +22,22 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f;
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so
/// a log group scrolls decades and holds decades; a *linear* span there would
/// rescale every row's view as the live edge moved. The bottom row rules the
/// space because it owns the shared axis; a group whose members disagree about
/// `log` still gets one data-space range pushed into every member, computed in
/// that row's space.
f64 to_link_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_link_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {}
@@ -151,9 +167,11 @@ Status Figure::set_x_link_range(u32 group, Range r) {
// Under a following group the argument is not a pair of positions: it states the
// span the window should have and, through its maximum, where the window sits
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
// refused rather than stored, because a following group has nothing to place a
// window from otherwise.
// on a `Live` axis, applied to the group's own edge. Both the span and the
// offset are taken in the shared axis' **axis space** (log10 decades on a log
// group), which is what makes the window hold decades instead of a decaying
// linear width. A span that is not a span is refused rather than stored,
// because a following group has nothing to place a window from otherwise.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) {
@@ -161,8 +179,9 @@ Status Figure::set_x_link_range(u32 group, Range r) {
"a following group's range states the window's span, so its bounds "
"must differ"};
}
g.span = hi - lo;
g.offset = live.max - hi;
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
g.span = to_link_space(hi, log) - to_link_space(lo, log);
g.offset = to_link_space(live.max, log) - to_link_space(hi, log);
g.has_range = true;
place_live_window(g);
push_link(g);
@@ -249,18 +268,26 @@ bool Figure::place_live_window(XLink& group) {
if (!live_edge(group, live)) return false;
// The span is the group's own -- what the user zoomed to, or what the last range
// it was given stated -- 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
// makes this a scroll rather than a rescale. Both it and the offset are in the
// shared axis' **axis space** (see `to_link_space`), so a log group holds its
// visible decades whatever the edge does. A span that is not a span is nothing
// to place a window from, so the group's range is left exactly as it is.
if (!std::isfinite(group.span) || !(group.span > 0.0)) return false;
if (!std::isfinite(group.offset)) group.offset = 0.0;
// 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 members 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.
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
// 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 members hold
// *now* -- measured in the same space, or a log group could hold an offset larger
// than everything it retains. An offset past that would put the window's right
// edge behind the oldest sample left, i.e. over data that is gone.
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log);
const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log));
group.offset = std::clamp(group.offset, 0.0, retained);
group.live_newest = live.max;
group.has_live = true;
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
const f64 right = newest_space - group.offset;
group.range = Range{from_link_space(right - group.span, log), from_link_space(right, log)};
return true;
}
@@ -300,15 +327,20 @@ void Figure::sync_x_links() {
// A group that was changed from outside restates its scroll state the way a
// single `Live` axis does -- the new range's span, and the offset its maximum
// implies -- and against the group's own edge, not the edge of the row the
// change arrived on. Then the window is recomputed from the live edge, which is
// change arrived on. Both are read in the shared axis' **axis space** (the
// same space `set_x_link_range()` and `place_live_window()` state them in),
// so a member's pan or zoom on a log group states decades like everything
// else on it. Then the window is recomputed from the live edge, which is
// the whole of what following is.
if (adopted) {
Range live{};
if (live_edge(group, live)) {
const f64 span = std::max(0.0, group.range.max - group.range.min);
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 span = std::max(0.0, to_link_space(group.range.max, log) -
to_link_space(group.range.min, log));
if (span > 0.0) {
group.span = span;
group.offset = live.max - group.range.max;
group.offset = to_link_space(live.max, log) - to_link_space(group.range.max, log);
}
}
}
+184 -85
View File
@@ -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;
+40 -4
View File
@@ -167,15 +167,51 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return;
const f64 stride = include_minors ? minor_step : step;
// Minors subdivide the major step an exact integer number of times, so the
// major/minor split is an *integer-index* test: a tick is a major when its
// minor index, counted from `first_major`, is a multiple of the ratio. Both
// operands of the index are exact for the windows axes actually show --
// `v - first_major` is the difference of two near doubles (Sterbenz-exact), and
// rounding the small quotient lands on the index the grid defines -- so no float
// tolerance is needed and none is applied. The old `|v - round(v/step)*step| <=
// step*1e-9` test fought a ULP-sized residue that an epoch origin produces
// whenever the step is not a power of two (v/step carries a rounding error of a
// few ULP of 1.75e9, i.e. far more than step*1e-9) and misclassified its majors;
// the index here has no residue to fight.
const auto minor_index_is_major = [&](f64 v) {
const f64 index = std::round((v - first_major) / minor_step);
return std::fmod(index, minor_ratio) == 0.0;
};
// Mutable: once the minors are capped the walk steps the major stride (see the
// cap branch below).
f64 stride = include_minors ? minor_step : step;
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return;
char label[kLabelBuf];
usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break;
const bool major = !include_minors ||
std::fabs(v - std::round(v / step) * step) <= step * 1e-9;
const bool major = !include_minors || minor_index_is_major(v);
if (!major && minors >= kMaxTicks) {
// The cap counts minors only, and the majors are never truncated: an axis
// dense enough to want more subdivisions than fit loses its *subdivisions*,
// not its labels, so the far end of the axis keeps its numbers. From here on
// the walk steps the major stride; the next major at or after `v` is
// re-seated onto the major grid explicitly, because continuing from an
// off-grid minor with the new stride would visit only off-grid points and
// silently drop every remaining label. `minors >= kMaxTicks` is also the
// guard that keeps a hostile request (px towering over min_px) from stepping
// a minor stride across the whole range after the cap; the majors-only walk
// is as long as the caller's own pixel count asked it to be.
stride = step;
const f64 major_index = std::ceil((v - first_major) / step - 1e-9);
f64 reseat = first_major + major_index * step;
if (reseat < v) reseat = first_major + (major_index + 1.0) * step;
v = reseat;
continue;
}
if (!major) ++minors;
Tick tick;
tick.value = v;
tick.major = major;