diff --git a/README.md b/README.md index 0300b25..f0348ad 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ run touched a GPU, so read it as an upper bound and a record of the software pat - cursors in both orientations — vertical markers and horizontal rulers; - draggable regions and measurement bands with exact interval statistics; - a signal legend anchored to an axes corner; -- a hover crosshair with a per-signal, interpolated readout; +- a hover crosshair with a per-signal readout of the nearest sample's value; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis; - live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index 04d4113..0c8402b 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -166,10 +166,10 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt which path each trace took. Below the threshold the raw samples are drawn instead; both sides go through the same instanced anti-aliased quad pipeline. -## Exact versus interpolated +## One sample or a statistic of many -The library deliberately holds two conventions next to each other, and they do **not** -agree: +Every number the library reports is one the samples actually hold. What differs between +these two is *how many* samples answer the question: - **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is @@ -179,15 +179,18 @@ agree: and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these, never on the `*_buckets` forms, which would silently report wrong extremes for an unaligned interval. -- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)` - returns the two samples `x` lies between (`begin`, `end`, `t`), and - `crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what - is the signal *at* this instant", which is generally between two samples — a different - question from an interval statistic. The lookup is O(1) (one division and a floor) and - allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a - host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue` - (an em dash, `"—"`), and an x outside the series window yields no value rather than the - nearest sample's. +- **The crosshair readout is a single sample — the nearest one.** `crosshair_span(series, + x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and the readout takes + whichever of the two is nearer: `t <= 0.5` the earlier, otherwise the later. It answers + "which datum is under the pointer", not "what is the signal *at* this instant" — a + linearly interpolated y is a number the series does not contain, and on a source whose + samples are already averaged or decimated at ingest it is a claim about a signal nobody + measured. The lookup is O(1) and allocation-free, and it reads level 0, so the value is a + stored sample and never an LOD bucket's aggregate; `Plot::crosshair_value(trace, x, + out_y)` exposes the same number to a host, and `CrosshairRow::marker` is placed from that + same y, so the dot and the number cannot disagree. A row whose trace has no sample at the + crosshair's x shows `kCrosshairNoValue` (an em dash, `"—"`), and an x outside the series + window yields no value rather than clamping to the first or last sample. `Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**, `sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That diff --git a/docs/interaction.md b/docs/interaction.md index 8385c16..c9aad73 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -113,9 +113,10 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so a figure-wide horizontal line would be a value that means something different in each row. -Readout values are **interpolated**, not interval statistics (see -[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the -crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value. +Readout values are the **nearest sample's**, neither interpolated between two samples nor an +interval statistic (see [data-and-performance.md](data-and-performance.md)); a row whose trace +has no sample at the crosshair's x is listed with the em dash `"—"` and no marker, rather than a +fabricated value. ## The gesture table (exactly as implemented) diff --git a/include/plot/data/series.hpp b/include/plot/data/series.hpp index 22ec9f2..19fd050 100644 --- a/include/plot/data/series.hpp +++ b/include/plot/data/series.hpp @@ -97,7 +97,7 @@ class Series { /// lands exactly on a sample). False when x lies outside the series' x /// bounds; `i0`/`i1` are then untouched. The crosshair and measurement /// paths read coordinates only through this and `x_at()`, so a series with - /// its own x array interpolates exactly. + /// its own x array brackets exactly. virtual bool neighbours(f64 x, u64& i0, u64& i1) const; /// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`; diff --git a/include/plot/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp index e5b6f4d..24afbb4 100644 --- a/include/plot/plot/crosshair.hpp +++ b/include/plot/plot/crosshair.hpp @@ -44,10 +44,11 @@ struct CrosshairStyle { /// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only, /// and a `Figure` is always vertical-only because its rows share x and not y. /// -/// **The readout's values are interpolated, and deliberately so.** The y a row reports for -/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample -/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and -/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`. +/// **The readout's values are samples.** The y a row reports for a trace is the y of +/// whichever of the two samples adjacent to `x` is **nearer** to it -- not a value +/// interpolated between them, and not an interval statistic. See `crosshair_span()` for the +/// rule stated in full, and for how it differs from +/// `Series::reduce()`/`sum()`/`sumsq()`. struct Crosshair { /// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by /// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the @@ -58,7 +59,7 @@ struct Crosshair { /// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only: /// a crosshair placed at one coordinate has no second value to mark a row with. bool horizontal = true; - /// List the visible traces and their interpolated values in a panel beside the pointer. + /// List the visible traces and their values in a panel beside the pointer. /// On by default, and free to leave on: a plot with no visible trace has no rows to /// measure and the panel is then not placed at all. bool show_readout = true; @@ -84,14 +85,19 @@ struct Crosshair { /// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer /// move, so that is a requirement rather than a nicety. /// -/// **Interpolated, not exact.** The value a caller builds from this span by -/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is -/// generally between two samples -- the opposite convention from `Series::reduce()`, -/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit -/// next to each other in this library on purpose and they do not agree: `reduce()` answers -/// "what are the samples' extremes over this interval", this answers "what is the signal at -/// this instant". A readout that pretended to be exact would be quoting an extreme or a -/// mean of a window as if it were the value under the pointer. +/// **`t` chooses between the two samples; it does not blend them.** The value a caller +/// builds from this span is the y of whichever end `t` is nearer to (`Plot::crosshair_value()` +/// takes `t <= 0.5` as `begin`), so a readout quotes a sample the series actually holds. It +/// used to be a linear interpolation between the two, which reads as "the signal at this +/// instant" -- a number that is *between* the data and therefore in no row of it. On a +/// decimated or averaged source that is a value nothing measured, and at a zoom past the +/// sample spacing it is visibly not the datum the pointer is over. +/// +/// So `t` is a *selector*, and this remains a different convention from +/// `Series::reduce()`, `sum()` and `sumsq()`, which are **exact over a sample range**: those +/// answer "what are the samples' extremes/total over this interval", this answers "which +/// sample is under the pointer, and what is its value". Both now quote the data; what differs +/// is whether the answer is one sample or a statistic of many. /// /// `valid` is false -- and every field left at its default -- for an **empty series**, a /// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window @@ -104,13 +110,14 @@ struct CrosshairSpan { /// Index of the first sample whose x is at or after the query: `begin` when the query /// lands on a sample, `begin + 1` between two of them. u64 end = 0; - /// The query's position between the two samples, in `[0, 1]`. + /// The query's position between the two samples, in `[0, 1]`. A *selector*, not a blend + /// factor -- see the struct comment. f64 t = 0.0; bool valid = false; }; /// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the -/// interpolation convention it is half of, and for the cases that yield `valid == false`. +/// convention it is half of, and for the cases that yield `valid == false`. /// /// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample /// fetch is the caller's -- `Plot::crosshair_value()` reads the span through @@ -118,13 +125,6 @@ struct CrosshairSpan { /// allocates. CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; -/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at -/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and -/// its documentation cannot drift apart. -inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { - return y_begin + (y_end - y_begin) * t; -} - /// One trace's line of the readout: which trace it is about, the value under the pointer /// and the boxes the renderer stages. /// @@ -150,7 +150,7 @@ struct CrosshairRow { /// entries -- so a row that is not listed is still a listed *trace*, with a marker, just /// not a line in a panel that has no room for it. bool listed = false; - /// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only + /// The y of the trace's sample nearest the crosshair's x, on the trace's own y axis. Only /// meaningful when `valid`. f64 y = 0.0; /// The trace's name, or the positional placeholder of an unnamed one. @@ -161,9 +161,12 @@ struct CrosshairRow { /// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as /// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it. std::string value; - /// The trace's intersection with the crosshair, in framebuffer pixels: the pen the - /// renderer draws the marker at. Placed through the *trace's own* axis pair, so the - /// marker is where that trace is actually drawn. Meaningless when `!valid`. + /// Where the renderer draws the marker, in framebuffer pixels: the pen for `y`, placed + /// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the + /// scale it is drawn against. It is the nearest **sample**, so at a zoom past the sample + /// spacing the marker sits beside the polyline rather than on it -- deliberately: it marks + /// the datum the row reports, and one y feeding both keeps the dot and the number from ever + /// disagreeing. Meaningless when `!valid`. Vec2 marker{}; /// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty /// when `!listed`. diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index b581fd2..2276821 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -101,8 +101,8 @@ struct PlotLayout { /// where a pointer can grab it cannot drift apart. std::vector regions; /// The crosshair's geometry, when one is active: the two lines' stroke boxes, the - /// readout panel and one row per visible trace with the interpolated value the pointer - /// is over. Placed here rather than derived by the renderer, exactly as the legend and + /// readout panel and one row per visible trace with the value of the sample nearest the + /// pointer. Placed here rather than derived by the renderer, exactly as the legend and /// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and /// clamped against the rect it was measured for, so nothing of the readout can be drawn /// over a label strip or into a figure's neighbouring row. @@ -693,10 +693,13 @@ class Plot { /// coordinates it holds and the geometry `layout()` places from them cannot disagree. const Crosshair& crosshair() const noexcept { return crosshair_; } - /// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()` - /// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup - /// the readout's rows are built from, exposed for a host that wants the number for - /// itself (a status line, a tooltip of its own). + /// The value of `trace`'s **nearest sample** to a data `x`: `crosshair_span()` brackets + /// `x`, and the nearer of the two samples is the answer (`t <= 0.5` takes the earlier) -- + /// the same lookup the readout's rows are built from, exposed for a host that wants the + /// number for itself (a status line, a tooltip of its own). + /// + /// Never a value interpolated between two samples: see `CrosshairSpan` for why a readout + /// quotes the data rather than the signal it implies. /// /// False, leaving `out_y` untouched, for an id that names no trace, a trace with no /// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid` @@ -816,7 +819,7 @@ class Plot { /// never names a trace. void build_legend(); /// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row - /// per visible trace with its interpolated value and marker, and the readout panel -- + /// per visible trace with its nearest sample's value and marker, and the readout panel -- /// placed beside the crosshair, flipped where it would cross an edge and clamped, so /// every rect it produces is inside `axes`. A plot with no visible trace still gets its /// lines: the rows are the readout's, not the crosshair's. diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 5169b40..a35f679 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -1082,7 +1082,7 @@ const PlotLayout& Plot::layout() { build_regions(); build_legend(); // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and - // its rows are the interpolated values at a data x -- so it wants the transforms the + // its rows are the sample values nearest a data x -- so it wants the transforms the // settled layout pins, exactly as the legend and the regions do. build_crosshair(); return layout_; @@ -1352,13 +1352,13 @@ void Plot::build_regions() { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan span; const u64 n = series.size(); - // No samples and no finite query: nothing to interpolate between. + // No samples and no finite query: nothing to bracket. if (n == 0u || !std::isfinite(x)) 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 + // samples too -- and a query outside it has no neighbours at all. 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 @@ -1371,8 +1371,8 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { // 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. + // on sample 0 would read as a span with a width it does not have. Decided on + // the samples' own coordinates, so it holds for any `neighbours()` impl. if (series.x_at(i0) == x) { span.begin = span.end = i0; span.t = 0.0; @@ -1387,16 +1387,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { } 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. + // The series bracketed a single sample for a query that is not on it: there + // is no second sample to choose between, and this one is the honest answer. span.begin = span.end = i0; span.t = 0.0; } else { span.begin = i0; span.end = i1; - // `t` from the samples' own x coordinates, so the interpolation is exact on + // `t` from the samples' own x coordinates, so "which is nearer" is exact on // any grid, uniform or not. A degenerate pair (two samples sharing an x) - // interpolates nothing and reads the earlier sample. + // 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; @@ -1455,7 +1455,12 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const { if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing const f64 y_begin = static_cast(crosshair_scratch_[1u]); const f64 y_end = static_cast(crosshair_scratch_[2u * pairs - 1u]); - out_y = crosshair_interpolate(y_begin, y_end, span.t); + // **The nearer sample, never a value between two of them.** A readout quotes the data: a + // number the series does not contain is a claim about a signal between its samples, which + // is the caller's to make and not a plot's to put in a panel. `span.t` is measured on the + // samples' own x, 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. + out_y = span.t <= 0.5 ? y_begin : y_end; return true; } @@ -1530,7 +1535,7 @@ void Plot::build_crosshair() { row.label_box = Rect{}; row.value_box = Rect{}; row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); - // The value: the interpolated number where the trace has a sample at this x, and the em dash + // The value: the nearest sample's number where the trace has one at this x, and the em dash // where it does not. The row is listed either way, so the absence has to be *said* -- a blank // value cell reads as a value that has not arrived -- and the dash is measured into the panel's // width like any other value, so the row's boxes are placed by one rule. diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index c3b8e14..251fbbe 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -877,8 +877,8 @@ namespace { /// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on /// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that -/// sample at different rates are the point -- one x, three different interpolations -- and the -/// last row is the case a readout must not invent a value for. +/// sample at different rates are the point -- one x, and each row reporting a sample off its own +/// grid -- and the last row is the case a readout must not invent a value for. plot::Figure crosshair_figure() { plot::Figure figure(4u); figure.set_viewport(whole_figure()); @@ -906,7 +906,7 @@ plot::Figure crosshair_figure() { /// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one /// hover is one instant and N readouts rather than N independent ones. Rows at different sample -/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and +/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and /// each row's series is read on its own grid -- while a row whose series does not reach that x /// reports no value at all instead of a fabricated one. PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { @@ -930,14 +930,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { PLOT_CHECK(first.rows[0].label == "fast"); PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9); - // Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2 - // (y = 200), so it reports 150 -- a different number for the same x, which is what makes this - // case about the rows rather than about one series read four times. + // Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and + // sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different + // number for the same x, which is what makes this case about the rows rather than about one + // series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3. const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair; PLOT_CHECK(second.rows.size() == 1u); PLOT_CHECK(second.rows[0].valid); PLOT_CHECK(second.rows[0].label == "slow"); - PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9); + PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9); PLOT_CHECK(second.rows[0].value != first.rows[0].value); // Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer. @@ -972,7 +973,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12); } PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9); - PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5 + // Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100), + // and a readout quotes one of them. + PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9); PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid); // On *every* row at once, and only the rows free it. diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index d35b118..81e803b 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series { } // namespace -/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples -/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the -/// implementation's own arithmetic. The four corners of the rule are all asserted: a query -/// between two samples, the two exact-sample ends of the window (where the span collapses onto -/// one sample), and a one-sample series, whose window is that sample alone. -PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { +/// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the +/// crosshair's x**, never a value between them. Every y asserted here is therefore one of the +/// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what +/// this refuses, and what it used to return. The corners of the rule are all covered: a query +/// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of +/// the window (where the span collapses onto one sample), and a one-sample series. +PLOT_TEST(crosshair_value_is_the_nearest_sample) { CrosshairScene scene; const u32 trace = scene.plot.add_trace( {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); // The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2 - // (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and - // not only the value is what pins the convention -- a nearest-sample lookup would report the - // span's `t` as 0 or 1, and an average of a *range* would not report a span at all. + // (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports + // both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the + // bracketing is unchanged and only what is built from it differs. const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5); PLOT_CHECK(half.valid); PLOT_CHECK(half.begin == 1u); @@ -1986,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { PLOT_CHECK_NEAR(half.t, 0.5, 1e-12); f64 y = 0.0; + // Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and + // 20 are `tens` values, where an interpolation would have answered 11 and 19. + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y)); + PLOT_CHECK_NEAR(y, 10.0, 1e-9); + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y)); + PLOT_CHECK_NEAR(y, 20.0, 1e-9); + // Exactly halfway is the tie, and it reads the *earlier* sample -- the bias + // `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer. PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y)); - PLOT_CHECK_NEAR(y, 15.0, 1e-9); - // A quarter of the way through another pair: 20 + (30 - 20) * 0.25. + PLOT_CHECK_NEAR(y, 10.0, 1e-9); + // A quarter of the way through another pair: sample 2, not 22.5. PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y)); - PLOT_CHECK_NEAR(y, 22.5, 1e-9); + PLOT_CHECK_NEAR(y, 20.0, 1e-9); + // Just short of the next sample is that sample, so the readout steps rather than ramps. + PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y)); + PLOT_CHECK_NEAR(y, 30.0, 1e-9); // The ends of the window are samples too, so a query exactly on one reads that sample -- - // and reads it *as* a span of one, with nothing to its left or right to interpolate with. + // and reads it *as* a span of one, with no neighbour on the far side. const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0); PLOT_CHECK(first.valid); PLOT_CHECK(first.begin == 0u); @@ -2008,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y)); PLOT_CHECK_NEAR(y, 30.0, 1e-9); - // Outside the window there is no value at all -- not the nearest sample's, not the end of - // the window's, which are the clamped lies the header refuses -- and a series with no - // samples has no span at any x. + // Outside the window there is no value at all. The nearest-sample rule is a rule for an x the + // series *brackets*; extending it past the ends would answer with the first or last sample for + // any x whatever, which is the clamped lie the header refuses. A series with no samples has no + // span at any x. PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid); PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid); PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y)); @@ -2023,7 +2036,7 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing // A one-sample series: the sample itself is the only query it answers, and it answers it - // without a neighbour to interpolate against. + // with no second sample to be nearer to. plot::data::ArraySeries single(std::vector{7.0f}, 5.0, 1.0); plot::Plot one; one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); @@ -2039,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y)); // The readout's row is the same lookup: the number a user reads off the panel is the value - // this test computed by hand, not a second derivation of it. + // this test computed by hand, not a second derivation of it. x = 1.5 is the tie, so the row + // reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed + // `"15"`, a number the series does not contain. scene.plot.set_crosshair(1.5, true); const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair; PLOT_CHECK(crosshair.active); @@ -2049,8 +2064,8 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { // the figure's case pins). PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows[0].valid); - PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9); - PLOT_CHECK(crosshair.rows[0].value == "15"); + PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9); + PLOT_CHECK(crosshair.rows[0].value == "10"); } /// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is @@ -2080,14 +2095,16 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) { PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u)); PLOT_CHECK(crosshair.rows[1].label == "#1"); - // Both are listed with the value their own series has at x = 1.5, the swatch, the label's box - // and the value's box -- all placed by the plot, all inside the panel it measured. + // Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's + // box and the value's box -- all placed by the plot, all inside the panel it measured. x = 1.5 + // is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an + // interpolating readout showed. PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f); for (const plot::CrosshairRow& row : crosshair.rows) { PLOT_CHECK(row.valid); PLOT_CHECK(row.listed); - PLOT_CHECK_NEAR(row.y, 15.0, 1e-9); - PLOT_CHECK(row.value == "15"); + PLOT_CHECK_NEAR(row.y, 10.0, 1e-9); + PLOT_CHECK(row.value == "10"); PLOT_CHECK(row.swatch.x >= crosshair.panel.x); PLOT_CHECK(row.swatch.right() <= crosshair.panel.right()); PLOT_CHECK(row.swatch.y >= crosshair.panel.y); @@ -2148,7 +2165,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) { PLOT_CHECK(on.rows[0].listed); PLOT_CHECK(on.rows[1].listed); PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f); - PLOT_CHECK(on.rows[0].value == "15"); + PLOT_CHECK(on.rows[0].value == "10"); } /// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise @@ -2253,11 +2270,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) { p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u}); - // A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them. + // A query between two samples: 1234 % 100 = 34, the next is 35, and 1234.5 is the tie, which + // reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are + // still read: the bound below is on how much data the lookup touches, not on which end it + // then reports. f64 y = 0.0; const u64 before_reads = series.samples_read(); PLOT_CHECK(p.crosshair_value(trace, 1234.5, y)); - PLOT_CHECK_NEAR(y, 34.5, 1e-9); + PLOT_CHECK_NEAR(y, 34.0, 1e-9); PLOT_CHECK(series.samples_read() - before_reads <= 2u); PLOT_CHECK(series.gather_calls() <= 1u); @@ -2288,7 +2308,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) { const plot::CrosshairLayout& crosshair = p.layout().crosshair; PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows[0].valid); - PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9); + PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.0, 1e-9); PLOT_CHECK(series.samples_read() - reads <= 2u); PLOT_CHECK(series.gather_calls() - calls <= 1u); // And the readout is not vacuous: the series it read two samples of really does hold a @@ -2511,9 +2531,12 @@ PLOT_TEST(scrolled_back_live_window_keeps_its_decades) { } /// The XY routing, crosshair half: the span brackets the samples the *array* -/// names, and the interpolation fraction is measured on the samples' own positions -/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read -/// samples that do not exist at the positions it assumed. +/// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the +/// wide gap, where a uniform-grid guess would have read samples that do not exist at +/// the positions it assumed. The gap is what makes the value worth asserting too: +/// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a +/// real choice to get right, and it only has that choice because `t` came from the +/// array. PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) { XyScene scene; const u32 trace = scene.plot.add_trace( @@ -2526,7 +2549,11 @@ PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) { PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12); f64 y = 0.0; PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y)); - PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9); + PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap + // And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a + // fixed preference for one end. + PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y)); + PLOT_CHECK_NEAR(y, 20.0, 1e-9); // On a sample, at both ends of the array's window, and outside it: the same // closed-window rule a uniform series follows, on the array's own coordinates. diff --git a/tests/test_xy_series.cpp b/tests/test_xy_series.cpp index 8681dc1..58cca35 100644 --- a/tests/test_xy_series.cpp +++ b/tests/test_xy_series.cpp @@ -72,9 +72,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) { } PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) { - // The crosshair asks neighbours() at an arbitrary x and interpolates between - // the two samples it gets back. On a non-uniform grid that needs real - // searches: a uniform-grid formula would land megabytes off here. + // The crosshair asks neighbours() at an arbitrary x and reports whichever of + // the two samples it gets back is nearer. On a non-uniform grid that needs + // real searches: a uniform-grid formula would land megabytes off here. auto x = spread_x(1000); const std::vector xs = x; plot::data::XYSeries s(std::move(x), saw_y(1000));