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:
1 parent
c028f48fa0
commit
c960decaea
9 files changed
+372
-78
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-4
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
|
||||
+41
-12
@@ -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<unsigned>(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<f64>(crosshair_scratch_[1u]);
|
||||
const f64 y_end = static_cast<f64>(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<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)),
|
||||
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(row.x)),
|
||||
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
|
||||
} else {
|
||||
row.value.assign(kCrosshairNoValue);
|
||||
|
||||
+22
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32>(scene.plot.x_transform(scene.ax).to_screen_x(2.0));
|
||||
const f32 pointer_col = static_cast<f32>(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<f32>(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<f32>(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
|
||||
|
||||
Reference in new issue
Block a user