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
@@ -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]`;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -101,8 +101,8 @@ struct PlotLayout {
|
||||
/// where a pointer can grab it cannot drift apart.
|
||||
std::vector<RegionLayout> 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.
|
||||
|
||||
Reference in new issue
Block a user