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
+17
-12
@@ -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<f64>(crosshair_scratch_[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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user