feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice

The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
This commit is contained in:
Martino Ferrari committed 2026-10-05 14:36:47 +02:00
1 parent c028f48fa0
commit c960decaea
9 files changed
+372 -78

No files matched your search

+84 -27
View File
@@ -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`.
+28 -9
View File
@@ -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
+15
View File
@@ -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 ----------------------------------------------------------