diff --git a/README.md b/README.md index f0348ad..a10b9de 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ 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 readout of the nearest sample's value; +- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it + (`CrosshairValue::NearestSample`, the default) or interpolating between two + (`CrosshairValue::Interpolated`); - 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 0c8402b..601405c 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -179,18 +179,23 @@ these two is *how many* samples answer the question: 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 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. +- **The crosshair readout is a single sample — the nearest one — by default.** + `crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`), + and `CrosshairValue::NearestSample` 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. That is why it is the default; a host whose x is a + quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets + `crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is + O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample + (or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x, + out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone; + `CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in + `NearestSample` the mark snaps onto the quoted sample and 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 c9aad73..0af7b22 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -106,6 +106,7 @@ one per plot. - `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and dash. +- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below. In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only @@ -113,10 +114,39 @@ 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 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. +### What a readout is: the two value modes + +`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and +which one a caller is asking decides the mode: + +| mode | the value | the marker | answers | +|---|---|---|---| +| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* | +| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* | + +The default is the sample, because a readout that quotes a number the data does not contain is +a second estimate laid over the first — and once the view is zoomed past the sample spacing the +polyline between two samples is a line the *renderer* drew, so reading a value off it reports +the drawing rather than the signal. A host whose x is a continuous quantity it is willing to +interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the +blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of +them, never an LOD bucket's aggregate (see +[data-and-performance.md](data-and-performance.md)). + +`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to — +the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker` +is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same +lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x +discarded. + +The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at +`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots** +(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it +would read as one more sample instead of as the one the panel is quoting. `0` across draws no +mark. + +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/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp index 24afbb4..203a450 100644 --- a/include/plot/plot/crosshair.hpp +++ b/include/plot/plot/crosshair.hpp @@ -30,6 +30,35 @@ struct CrosshairStyle { ///< (once resolved) means solid. }; +/// What a crosshair readout's number *is*: one of the series' own samples, or a value +/// computed between two of them. +/// +/// The two answer different questions and the right one is a property of the data, which the +/// plot cannot know — so it is the caller's to state. +/// +/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of +/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and +/// the marker is placed at **that sample's own x**, so the mark says *which* sample the +/// number came from. This is the default, and it is the honest mode for a source whose +/// samples are already averaged or decimated before they arrive: a number between two of +/// those is a second estimate of a signal that nothing measured, and at a zoom past the +/// sample spacing the polyline between two samples is a line the renderer drew, so a +/// reading off it reports the drawing. +/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation +/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the +/// crosshair's own x, on the polyline, because that is where the number is. Right for a +/// continuous quantity sampled finely enough that the line between two samples is a model +/// the caller believes — a smooth analytical curve, an interpolating instrument — and +/// wrong wherever that line is an artefact of plotting. +/// +/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers, +/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside +/// the pointer can never disagree about what the value means. +enum class CrosshairValue : u8 { + NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default. + Interpolated, ///< A linear blend of the two, marked at the crosshair's own x. +}; + /// The crosshair's state: where it is, which of its two lines it draws, and whether the /// per-signal readout is wanted. /// @@ -44,11 +73,10 @@ 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 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()`. +/// **The readout's values are samples by default** -- the y a row reports is the nearer of +/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what +/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and +/// `crosshair_span()` for how both differ 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 @@ -68,6 +96,10 @@ struct Crosshair { /// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only /// crosshair, which draws no row and anchors its readout at the top of the axes. f64 y = 0.0; + /// Whether a row's value is one of the series' samples or a blend of two -- see + /// `CrosshairValue`, which also says where each mode puts the marker. Set through + /// `Plot::set_crosshair_value_mode()`. + CrosshairValue value_mode = CrosshairValue::NearestSample; /// This plot's own stroke and dash, when it wants to differ from the global style; a /// default-constructed `CrosshairStyle` defers every field to `render::Style` (see /// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`. @@ -85,19 +117,17 @@ 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. /// -/// **`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. +/// **`t` is a position, not yet an answer.** It says where the query sits between the two +/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In +/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout +/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through +/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is +/// about the data and not about the drawing; `CrosshairValue` is where that is argued. /// -/// 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. +/// Either way this is 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", a crosshair answers "what is the signal at this one +/// x". What differs is a statistic of many samples against a reading at one place. /// /// `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 @@ -110,13 +140,13 @@ 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]`. A *selector*, not a blend - /// factor -- see the struct comment. + /// The query's position between the two samples, in `[0, 1]`. Whether it selects one of + /// them or blends both is `CrosshairValue`'s -- 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 +/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the /// 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 @@ -125,6 +155,15 @@ struct CrosshairSpan { /// allocates. CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; +/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`, +/// which is what `CrosshairValue::Interpolated` reports. +/// +/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin` +/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a +/// query that landed on a sample must read that sample's value back unchanged, whichever mode +/// is in force. `NearestSample` does not call it at all. +f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept; + /// One trace's line of the readout: which trace it is about, the value under the pointer /// and the boxes the renderer stages. /// @@ -150,8 +189,14 @@ 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 y of the trace's sample nearest the crosshair's x, on the trace's own y axis. Only - /// meaningful when `valid`. + /// The data x the row's value belongs to, on the trace's own x axis: in + /// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the + /// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an + /// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when + /// `valid`. + f64 x = 0.0; + /// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the + /// two -- see `CrosshairValue`. Only meaningful when `valid`. f64 y = 0.0; /// The trace's name, or the positional placeholder of an unnamed one. std::string label; @@ -161,12 +206,24 @@ 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; - /// Where the renderer draws the marker, in framebuffer pixels: the pen for `y`, placed + /// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, 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`. + /// scale it is drawn against. + /// + /// It marks **the point the row reports**, which is the whole job: in + /// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum + /// rather than beside it on the crosshair's column, so the mark says which of the samples + /// under the pointer the number came from. (Placing it at the pointer's x with the sample's y + /// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In + /// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where + /// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them + /// from ever disagreeing. + /// + /// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample + /// markers** on purpose, since a snapped mark lands among them and has to read as "this one" + /// rather than as one more sample. A mark for a sample outside the visible range is staged + /// where that sample is and left to the axes clip, rather than being dropped by a range test + /// of the drawing layer's own. 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 2276821..f8ec0f0 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -688,23 +688,31 @@ class Plot { /// dash, which live on `CrosshairLayout` because they depend on `ui_scale`. const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; } + /// Chooses what a readout's numbers *are*: one of the series' own samples, or a value + /// computed between two of them. `CrosshairValue` argues the two cases and says where each + /// puts the marker; the default is `NearestSample`. + /// + /// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style + /// override: it is a statement about the data, so a pointer move must not be able to change + /// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately. + void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; } + /// The readout's value mode, as set -- see `set_crosshair_value_mode()`. + CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; } + /// The crosshair's state: where it is, which lines it draws and whether the readout is /// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the /// coordinates it holds and the geometry `layout()` places from them cannot disagree. const Crosshair& crosshair() const noexcept { return crosshair_; } - /// 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. + /// The value this plot's readout would report for `trace` at a data `x`: the nearer + /// bracketing sample's y, or the blend of the two, according to + /// `crosshair_value_mode()` -- 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). /// /// 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` - /// documents, plus the trace itself. Never a clamped or nearest-sample answer: see - /// `crosshair_span()` for why an out-of-window x is not a value. + /// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why + /// an out-of-window x is not a value. /// /// O(1) and allocation-free once the plot has looked a value up before: the span is /// index arithmetic and the samples are read through one `Series::gather()` of at most @@ -712,6 +720,17 @@ class Plot { /// on every pointer move -- once per visible trace, so nothing here may scan a series. bool crosshair_value(u32 trace, f64 x, f64& out_y) const; + /// The whole **point** the readout reports, x as well as y: `crosshair_value()`'s number + /// together with the data x it belongs to. In `CrosshairValue::NearestSample` that x is the + /// quoted sample's own, which is generally not the `x` asked for; in `Interpolated` it is + /// `x` itself. + /// + /// This is what places `CrosshairRow::marker`, and it is public because a host that draws + /// instruments of its own -- a cursor pinned to the value it reads, an annotation naming the + /// sample -- needs the same snap, and recomputing it would be a second rule that can drift + /// from this one. Same failure cases, same cost, same scratch buffer as `crosshair_value()`. + bool crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const; + private: /// One measure plus its cache. The dirty flag is what keeps measurement() from /// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 2387e0e..a4ceefa 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -86,6 +86,21 @@ struct Style { f32 crosshair_width = 0.75f; f32 crosshair_dash_on = 4.0f; f32 crosshair_dash_off = 4.0f; + /// The full extent, in logical pixels, of the little cross drawn at each trace's readout + /// point (`CrosshairRow::marker`), in that trace's own colour. + /// + /// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted + /// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is + /// *snapped to a sample*, so it lands among those dots -- at the same size it would read as + /// one more sample instead of as "this is the one the panel is quoting". The lines are chrome + /// and recede; this mark is the answer to the question the hover asks, and it is the one + /// crosshair element that should not. + /// + /// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0` + /// across draws no mark at all, which is how a caller that wants the panel without the marks + /// says so. + f32 crosshair_marker_px = 9.0f; + f32 crosshair_marker_width = 1.5f; // --- the scale ---------------------------------------------------------- diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index a35f679..858c649 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -1410,6 +1410,13 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { return span; } +f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { + // This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is + // `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever + // mode is in force. The symmetric form rounds at both ends. + return y_begin + t * (y_end - y_begin); +} + std::string crosshair_unnamed_label(u32 trace) { char buf[16]; std::snprintf(buf, sizeof buf, "#%u", static_cast(trace)); @@ -1439,6 +1446,11 @@ void Plot::set_crosshair(f64 x, f64 y, bool active) { } bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const { + f64 ignored = 0.0; + return crosshair_point(trace, x, ignored, out_y); +} + +bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const { if (trace >= traces_.size()) return false; const data::Series* series = traces_[trace].series; if (series == nullptr) return false; @@ -1455,12 +1467,23 @@ 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]); - // **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; + if (crosshair_.value_mode == CrosshairValue::Interpolated) { + // The signal the samples imply *at* this x, so the point is the query's own x: that is + // where an interpolated number lives, and the marker on the polyline there is honest + // about it. + out_x = x; + out_y = crosshair_interpolate(y_begin, y_end, span.t); + return true; + } + // `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on + // the samples' own coordinates, 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. The x + // comes back through `x_at()` rather than from the gathered pairs, which are relative to + // `x0()` by the `Series::gather()` contract -- adding it back here would be a second + // reconstruction of a coordinate the series will hand over directly. + const bool take_begin = span.t <= 0.5; + out_x = series->x_at(take_begin ? span.begin : span.end); + out_y = take_begin ? y_begin : y_end; return true; } @@ -1529,17 +1552,23 @@ void Plot::build_crosshair() { row.trace = i; row.valid = false; row.listed = false; + row.x = 0.0; row.y = 0.0; row.marker = Vec2{}; row.swatch = Rect{}; row.label_box = Rect{}; row.value_box = Rect{}; row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); - // 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. - if (crosshair_value(i, crosshair_.x, row.y)) { + // The value 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. + // + // The **point**, not just the number: `crosshair_point()` reports the x the value belongs to + // as well, which in the default `NearestSample` mode is the quoted sample's own and not the + // pointer's. Both coordinates come from that one call, so the mark and the text cannot + // disagree about which datum is being read (see `CrosshairRow::marker`). + if (crosshair_point(i, crosshair_.x, row.x, row.y)) { row.valid = true; char value[32]; // The trace's *own* y axis formats the number, so a readout reads exactly like the @@ -1548,7 +1577,7 @@ void Plot::build_crosshair() { row.value.assign(value); // Placed through the trace's own axis pair, so the marker sits where that trace is // actually drawn however its axes are scaled. - row.marker = Vec2{static_cast(x_transform(trace.axis_x).to_screen_x(crosshair_.x)), + row.marker = Vec2{static_cast(x_transform(trace.axis_x).to_screen_x(row.x)), static_cast(y_transform(trace.axis_y).to_screen_y(row.y))}; } else { row.value.assign(kCrosshairNoValue); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 7967c2b..0df2d00 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1522,19 +1522,28 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) flush_lines(data, style_.crosshair, stroke, crosshair.dash); } - // A small cross at each trace's intersection, in the trace's own colour -- opaque, like a - // legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm - // length is a logical size like every other marker's, and the position is the layout's: a - // marker for a sample far outside the visible y range is staged where the trace is and the - // axes clip keeps it inside, rather than being skipped by a range test of this layer's own. - const f32 arm = style_.device(3.0f); - for (const CrosshairRow& row : crosshair.rows) { - if (!row.valid) continue; - Color color = plot.trace(row.trace).color; - color.a = 1.0f; - lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y}); - lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm}); - flush_lines(pixel_mapping(), color, style_.device(1.0f)); + // A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's + // swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the + // layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample* + // rather than the pointer's column: a mark for a sample outside the visible range is staged + // where that sample is and the axes clip keeps it inside, rather than being skipped by a range + // test of this layer's own. + // + // `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a + // trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read + // as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel + // and the lines without the marks. + const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px); + const f32 mark_stroke = style_.device(style_.crosshair_marker_width); + if (arm > 0.0f) { + for (const CrosshairRow& row : crosshair.rows) { + if (!row.valid) continue; + Color color = plot.trace(row.trace).color; + color.a = 1.0f; + lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y}); + lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm}); + flush_lines(pixel_mapping(), color, mark_stroke); + } } } diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 81e803b..57cf8a1 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -2068,6 +2068,134 @@ PLOT_TEST(crosshair_value_is_the_nearest_sample) { PLOT_CHECK(crosshair.rows[0].value == "10"); } +/// The mark is **snapped to the sample the row quotes**, which is what makes it say *which* +/// value is being read. Placing it at the pointer's x with the sample's y put it on neither the +/// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on +/// nothing. +/// +/// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong +/// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query, +/// and that `row.marker` is that position mapped through the trace's own axes. The pixel column +/// is computed from the transform the plot already exposes, so this checks the two agree rather +/// than re-deriving the mapping. +PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) { + CrosshairScene scene; + const u32 trace = scene.plot.add_trace( + {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"}); + + // A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as + // y = 20 -- so the mark lands on the datum and not a fifth of a division to its left. + f64 px = 0.0; + f64 py = 0.0; + PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py)); + PLOT_CHECK_NEAR(px, 2.0, 1e-12); + PLOT_CHECK_NEAR(py, 20.0, 1e-9); + // The tie takes the earlier sample, and takes *its* x with it: the same bias in both + // coordinates, so the mark and the number can never name different samples. + PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py)); + PLOT_CHECK_NEAR(px, 1.0, 1e-12); + PLOT_CHECK_NEAR(py, 10.0, 1e-9); + // `crosshair_value()` is the same call with the x dropped, so the two cannot disagree. + f64 y_only = 0.0; + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only)); + PLOT_CHECK_NEAR(y_only, py, 1e-12); + // A failing lookup leaves both outputs alone, as the value form does. + PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py)); + PLOT_CHECK_NEAR(px, 1.0, 1e-12); + + // And through the layout: the row carries the snapped x, and the marker is that x through the + // trace's own transform -- not the crosshair's column, which is where it used to be. + scene.plot.set_crosshair(1.8, true); + scene.plot.layout(); + const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0]; + PLOT_CHECK(row.valid); + PLOT_CHECK_NEAR(row.x, 2.0, 1e-12); + PLOT_CHECK_NEAR(row.y, 20.0, 1e-9); + const f32 sample_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(2.0)); + const f32 pointer_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(1.8)); + PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3); + PLOT_CHECK_NEAR(row.marker.y, + static_cast(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3); + // The two columns are far apart at this scale, so the check above is not satisfied by both + // being nearly the same number. + PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f); + // The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved. + PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x + + 0.5f * scene.plot.layout().crosshair.vertical.w, + pointer_col, 1e-3); +} + +/// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the +/// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a +/// continuous quantity sampled finely enough that the line between two samples is a model it +/// believes. `NearestSample` is the default because that line is an artefact of plotting for most +/// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers +/// both rather than picking for the caller. +/// +/// Every number here is one the *other* mode refuses, so the two are pinned apart rather than +/// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value, +/// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the +/// polyline, and nowhere else. +PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) { + CrosshairScene scene; + const u32 trace = scene.plot.add_trace( + {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"}); + PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample); + scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated); + PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated); + + f64 y = 0.0; + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y)); + PLOT_CHECK_NEAR(y, 15.0, 1e-9); + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y)); + PLOT_CHECK_NEAR(y, 11.0, 1e-9); + PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y)); + PLOT_CHECK_NEAR(y, 19.0, 1e-9); + // A query *on* a sample reads that sample back exactly in this mode too: the span collapses to + // one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded. + PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y)); + PLOT_CHECK(y == 20.0); + PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0); + PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0); + PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12); + // Out of the window is still no value: interpolating needs two samples to be between. + PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y)); + + // The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the + // number is -- the opposite placement from `NearestSample`, and the mode's visible half. + f64 px = 0.0; + f64 py = 0.0; + PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py)); + PLOT_CHECK_NEAR(px, 1.8, 1e-12); + PLOT_CHECK_NEAR(py, 18.0, 1e-9); + scene.plot.set_crosshair(1.8, true); + scene.plot.layout(); + const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0]; + PLOT_CHECK_NEAR(row.x, 1.8, 1e-12); + PLOT_CHECK(row.value == "18"); + const f32 pointer_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(1.8)); + PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3); + + // Switching back is a plain state change, with no second placement to keep in step: the next + // layout reports the sample again. + scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample); + scene.plot.layout(); + PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12); + PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9); +} + +/// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is +/// snapped onto one of them: at the same size it reads as one more sample rather than as the one +/// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is +/// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is +/// the regression, and nothing else in the library would notice. +PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) { + const plot::render::Style style; + PLOT_CHECK(style.crosshair_marker_px > style.marker_px); + // And heavier than the crosshair's own lines, which are chrome and recede on purpose. + PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width); +} + /// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is /// named** -- the readout is about what is under the pointer, so the legend's rule of skipping /// unnamed traces does not apply -- while a trace with no series or no samples is not under the