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:
1 parent
26a7c82a80
commit
c028f48fa0
10 files changed
+150
-105
No files matched your search
+11
-8
@@ -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.
|
||||
|
||||
+59
-32
@@ -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<f32>{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.
|
||||
|
||||
@@ -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<f64> xs = x;
|
||||
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
||||
|
||||
Reference in new issue
Block a user