fix(plot): a crosshair readout quotes a sample, never a value between two

The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.

`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.

`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 13:42:57 +02:00
1 parent 26a7c82a80
commit c028f48fa0
10 files changed
+150 -105

No files matched your search

+1 -1
View File
@@ -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; - cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics; - draggable regions and measurement bands with exact interval statistics;
- a signal legend anchored to an axes corner; - 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; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - 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 - XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
+15 -12
View File
@@ -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 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. 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** Every number the library reports is one the samples actually hold. What differs between
agree: these two is *how many* samples answer the question:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what - **`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 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, 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 never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval. unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)` - **The crosshair readout is a single sample — the nearest one.** `crosshair_span(series,
returns the two samples `x` lies between (`begin`, `end`, `t`), and x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and the readout takes
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what whichever of the two is nearer: `t <= 0.5` the earlier, otherwise the later. It answers
is the signal *at* this instant", which is generally between two samples — a different "which datum is under the pointer", not "what is the signal *at* this instant" — a
question from an interval statistic. The lookup is O(1) (one division and a floor) and linearly interpolated y is a number the series does not contain, and on a source whose
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a samples are already averaged or decimated at ingest it is a claim about a signal nobody
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue` measured. The lookup is O(1) and allocation-free, and it reads level 0, so the value is a
(an em dash, `"—"`), and an x outside the series window yields no value rather than the stored sample and never an LOD bucket's aggregate; `Plot::crosshair_value(trace, x,
nearest sample's. 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**, `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 `sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
+4 -3
View File
@@ -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 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. a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see Readout values are the **nearest sample's**, neither interpolated between two samples nor an
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the interval statistic (see [data-and-performance.md](data-and-performance.md)); a row whose trace
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value. 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) ## The gesture table (exactly as implemented)
+1 -1
View File
@@ -97,7 +97,7 @@ class Series {
/// lands exactly on a sample). False when x lies outside the series' x /// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement /// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with /// 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; virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`; /// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
+29 -26
View File
@@ -44,10 +44,11 @@ struct CrosshairStyle {
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only, /// 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. /// 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 /// **The readout's values are samples.** The y a row reports for a trace is the y of
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample /// whichever of the two samples adjacent to `x` is **nearer** to it -- not a value
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and /// interpolated between them, and not an interval statistic. See `crosshair_span()` for the
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`. /// rule stated in full, and for how it differs from
/// `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair { struct Crosshair {
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by /// 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 /// `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: /// 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. /// a crosshair placed at one coordinate has no second value to mark a row with.
bool horizontal = true; 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 /// 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. /// measure and the panel is then not placed at all.
bool show_readout = true; 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 /// 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. /// move, so that is a requirement rather than a nicety.
/// ///
/// **Interpolated, not exact.** The value a caller builds from this span by /// **`t` chooses between the two samples; it does not blend them.** The value a caller
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is /// builds from this span is the y of whichever end `t` is nearer to (`Plot::crosshair_value()`
/// generally between two samples -- the opposite convention from `Series::reduce()`, /// takes `t <= 0.5` as `begin`), so a readout quotes a sample the series actually holds. It
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit /// used to be a linear interpolation between the two, which reads as "the signal at this
/// next to each other in this library on purpose and they do not agree: `reduce()` answers /// instant" -- a number that is *between* the data and therefore in no row of it. On a
/// "what are the samples' extremes over this interval", this answers "what is the signal at /// decimated or averaged source that is a value nothing measured, and at a zoom past the
/// this instant". A readout that pretended to be exact would be quoting an extreme or a /// sample spacing it is visibly not the datum the pointer is over.
/// mean of a window as if it were the value under the pointer. ///
/// 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 /// `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 /// 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 /// 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. /// lands on a sample, `begin + 1` between two of them.
u64 end = 0; 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; f64 t = 0.0;
bool valid = false; bool valid = false;
}; };
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the /// 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 /// 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 /// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,13 +125,6 @@ struct CrosshairSpan {
/// allocates. /// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; 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 /// One trace's line of the readout: which trace it is about, the value under the pointer
/// and the boxes the renderer stages. /// 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 /// 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. /// not a line in a panel that has no room for it.
bool listed = false; 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`. /// meaningful when `valid`.
f64 y = 0.0; f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one. /// 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 /// `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. /// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
std::string value; std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the /// Where the renderer draws the marker, in framebuffer pixels: the pen for `y`, placed
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the /// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
/// marker is where that trace is actually drawn. Meaningless when `!valid`. /// 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{}; Vec2 marker{};
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty /// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
/// when `!listed`. /// when `!listed`.
+10 -7
View File
@@ -101,8 +101,8 @@ struct PlotLayout {
/// where a pointer can grab it cannot drift apart. /// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions; std::vector<RegionLayout> regions;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// 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 /// readout panel and one row per visible trace with the value of the sample nearest the
/// is over. Placed here rather than derived by the renderer, exactly as the legend and /// 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 /// 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 /// 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. /// 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. /// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; } const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()` /// The value of `trace`'s **nearest sample** to a data `x`: `crosshair_span()` brackets
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup /// `x`, and the nearer of the two samples is the answer (`t <= 0.5` takes the earlier) --
/// the readout's rows are built from, exposed for a host that wants the number for /// the same lookup the readout's rows are built from, exposed for a host that wants the
/// itself (a status line, a tooltip of its own). /// 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 /// 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` /// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
@@ -816,7 +819,7 @@ class Plot {
/// never names a trace. /// never names a trace.
void build_legend(); void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row /// 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 /// 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 /// 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. /// lines: the rows are the readout's, not the crosshair's.
+17 -12
View File
@@ -1082,7 +1082,7 @@ const PlotLayout& Plot::layout() {
build_regions(); build_regions();
build_legend(); build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // 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. // settled layout pins, exactly as the legend and the regions do.
build_crosshair(); build_crosshair();
return layout_; return layout_;
@@ -1352,13 +1352,13 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span; CrosshairSpan span;
const u64 n = series.size(); 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; if (n == 0u || !std::isfinite(x)) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`): // The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are // 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 // samples too -- and a query outside it has no neighbours at all. Answering
// between. Answering with the nearest sample's value instead is the clamped lie // 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 // 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 // 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 // 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 // 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 // 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 // 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 // on sample 0 would read as a span with a width it does not have. Decided on
// samples' own coordinates, so it holds for any `neighbours()` implementation. // the samples' own coordinates, so it holds for any `neighbours()` impl.
if (series.x_at(i0) == x) { if (series.x_at(i0) == x) {
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
@@ -1387,16 +1387,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
} }
if (i0 == i1) { if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: nothing // The series bracketed a single sample for a query that is not on it: there
// to interpolate with, and the sample is the honest answer. // is no second sample to choose between, and this one is the honest answer.
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
} else { } else {
span.begin = i0; span.begin = i0;
span.end = i1; 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) // 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 xa = series.x_at(i0);
const f64 xb = series.x_at(i1); const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0; 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 if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]); const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]); const f64 y_end = static_cast<f64>(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; return true;
} }
@@ -1530,7 +1535,7 @@ void Plot::build_crosshair() {
row.label_box = Rect{}; row.label_box = Rect{};
row.value_box = Rect{}; row.value_box = Rect{};
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); 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 // 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 // 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. // width like any other value, so the row's boxes are placed by one rule.
+11 -8
View File
@@ -877,8 +877,8 @@ namespace {
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on /// 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 /// 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 /// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// last row is the case a readout must not invent a value for. /// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() { plot::Figure crosshair_figure() {
plot::Figure figure(4u); plot::Figure figure(4u);
figure.set_viewport(whole_figure()); 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 /// 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 /// 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 /// 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. /// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { 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(first.rows[0].label == "fast");
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9); 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 // Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this // sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// case about the rows rather than about one series read four times. // 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; const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
PLOT_CHECK(second.rows.size() == 1u); PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid); PLOT_CHECK(second.rows[0].valid);
PLOT_CHECK(second.rows[0].label == "slow"); 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); 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. // 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(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(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); PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
// On *every* row at once, and only the rows free it. // On *every* row at once, and only the rows free it.
+59 -32
View File
@@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
} // namespace } // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples /// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the /// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query /// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
/// between two samples, the two exact-sample ends of the window (where the span collapses onto /// this refuses, and what it used to return. The corners of the rule are all covered: a query
/// one sample), and a one-sample series, whose window is that sample alone. /// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { /// the window (where the span collapses onto one sample), and a one-sample series.
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
CrosshairScene scene; CrosshairScene scene;
const u32 trace = scene.plot.add_trace( const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); {&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 // 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 // (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
// not only the value is what pins the convention -- a nearest-sample lookup would report the // both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all. // bracketing is unchanged and only what is built from it differs.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5); const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid); PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u); 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); PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
f64 y = 0.0; 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(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9); PLOT_CHECK_NEAR(y, 10.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25. // 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(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 -- // 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); const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
PLOT_CHECK(first.valid); PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u); 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(scene.plot.crosshair_value(trace, 3.0, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9); 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 // Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
// the window's, which are the clamped lies the header refuses -- and a series with no // series *brackets*; extending it past the ends would answer with the first or last sample for
// samples has no span at any x. // 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, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 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)); 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 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 // 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<f32>{7.0f}, 5.0, 1.0); plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
plot::Plot one; plot::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); 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)); 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 // 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); scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair; const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active); PLOT_CHECK(crosshair.active);
@@ -2049,8 +2064,8 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
// the figure's case pins). // the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9); PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15"); PLOT_CHECK(crosshair.rows[0].value == "10");
} }
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is /// 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 == plot::crosshair_unnamed_label(1u));
PLOT_CHECK(crosshair.rows[1].label == "#1"); 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 // Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
// and the value's box -- all placed by the plot, all inside the panel it measured. // 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); PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
for (const plot::CrosshairRow& row : crosshair.rows) { for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid); PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed); PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9); PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
PLOT_CHECK(row.value == "15"); PLOT_CHECK(row.value == "10");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x); PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right()); PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y); 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[0].listed);
PLOT_CHECK(on.rows[1].listed); PLOT_CHECK(on.rows[1].listed);
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f); 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 /// 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}); 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}); 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; f64 y = 0.0;
const u64 before_reads = series.samples_read(); const u64 before_reads = series.samples_read();
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y)); 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.samples_read() - before_reads <= 2u);
PLOT_CHECK(series.gather_calls() <= 1u); PLOT_CHECK(series.gather_calls() <= 1u);
@@ -2288,7 +2308,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
const plot::CrosshairLayout& crosshair = p.layout().crosshair; const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); 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.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u); PLOT_CHECK(series.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a // 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* /// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions /// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read /// wide gap, where a uniform-grid guess would have read samples that do not exist at
/// samples that do not exist at the positions it assumed. /// 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) { PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene; XyScene scene;
const u32 trace = scene.plot.add_trace( 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); PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0; f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y)); 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 // 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. // closed-window rule a uniform series follows, on the array's own coordinates.
+3 -3
View File
@@ -72,9 +72,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
} }
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) { PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and interpolates between // The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back. On a non-uniform grid that needs real // the two samples it gets back is nearer. On a non-uniform grid that needs
// searches: a uniform-grid formula would land megabytes off here. // real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000); auto x = spread_x(1000);
const std::vector<f64> xs = x; const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000)); plot::data::XYSeries s(std::move(x), saw_y(1000));