Compare commits

..
3 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 03c41adbec fix(render): three warnings a stricter build reports, none of them behavioural
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
The point-index loop in `draw_traces` named its variable `i`, shadowing the trace
index that the whole block is nested inside; it is `p` now, which also leaves the
comment free to say which one is which. The marker loop's two screen coordinates
were narrowed implicitly. And `same_cell()`'s only caller is an `assert`, so a
release build had an unused function; it is guarded the way its caller is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:12:39 +02:00
Martino Ferrari c960decaea 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.
2026-10-05 14:36:47 +02:00
Martino FerrariandClaude Opus 4.6 c028f48fa0 fix(plot): a crosshair readout quotes a sample, never a value between two
The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.

`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.

`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 13:42:57 +02:00
13 changed files with 469 additions and 125 deletions

No files matched your search

+3 -1
View File
@@ -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, interpolated readout;
- 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
+20 -12
View File
@@ -166,10 +166,10 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
## Exact versus interpolated
## One sample or a statistic of many
The library deliberately holds two conventions next to each other, and they do **not**
agree:
Every number the library reports is one the samples actually hold. What differs between
these two is *how many* samples answer the question:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
@@ -179,15 +179,23 @@ agree:
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 interpolated, and says so.** `crosshair_span(series, x)`
returns the two samples `x` lies between (`begin`, `end`, `t`), and
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
is the signal *at* this instant", which is generally between two samples — a different
question from an interval statistic. The lookup is O(1) (one division and a floor) and
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
host. 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 the
nearest sample's.
- **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 -3
View File
@@ -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,9 +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 **interpolated**, not interval statistics (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)
+1 -1
View File
@@ -97,7 +97,7 @@ class Series {
/// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with
/// its own x array interpolates exactly.
/// its own x array brackets exactly.
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
+87 -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,10 +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 interpolated, and deliberately so.** The y a row reports for
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
/// **The readout's values are samples 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
@@ -58,7 +87,7 @@ struct Crosshair {
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
/// a crosshair placed at one coordinate has no second value to mark a row with.
bool horizontal = true;
/// List the visible traces and their interpolated values in a panel beside the pointer.
/// List the visible traces and their values in a panel beside the pointer.
/// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all.
bool show_readout = true;
@@ -67,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()`.
@@ -84,14 +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.
///
/// **Interpolated, not exact.** The value a caller builds from this span by
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
/// generally between two samples -- the opposite convention from `Series::reduce()`,
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
/// "what are the samples' extremes over this interval", this answers "what is the signal at
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
/// mean of a window as if it were the value under the pointer.
/// **`t` 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.
///
/// 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
@@ -104,13 +140,14 @@ struct CrosshairSpan {
/// Index of the first sample whose x is at or after the query: `begin` when the query
/// lands on a sample, `begin + 1` between two of them.
u64 end = 0;
/// The query's position between the two samples, in `[0, 1]`.
/// The query's position between the two samples, in `[0, 1]`. 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
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
/// 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
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,12 +155,14 @@ struct CrosshairSpan {
/// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
/// its documentation cannot drift apart.
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
return y_begin + (y_end - y_begin) * t;
}
/// 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 trace's interpolated y at 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,9 +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;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(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 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`.
+31 -9
View File
@@ -101,8 +101,8 @@ struct PlotLayout {
/// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the interpolated value the pointer
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
/// readout panel and one row per visible trace with the value of the sample nearest the
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row.
@@ -688,20 +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 **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
/// the readout's rows are built from, exposed for a host that wants the number for
/// itself (a status line, a tooltip of its own).
/// The value 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
@@ -709,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
@@ -816,7 +838,7 @@ class Plot {
/// never names a trace.
void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
/// per visible trace with its interpolated value and marker, and the readout panel --
/// per visible trace with its nearest sample's value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's.
+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 ----------------------------------------------------------
+50 -16
View File
@@ -1082,7 +1082,7 @@ const PlotLayout& Plot::layout() {
build_regions();
build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
// its rows are the interpolated values at a data x -- so it wants the transforms the
// its rows are the sample values nearest a data x -- so it wants the transforms the
// settled layout pins, exactly as the legend and the regions do.
build_crosshair();
return layout_;
@@ -1352,13 +1352,13 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span;
const u64 n = series.size();
// No samples and no finite query: nothing to interpolate between.
// No samples and no finite query: nothing to bracket.
if (n == 0u || !std::isfinite(x)) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are
// samples too -- and a query outside it has no neighbours to interpolate
// between. Answering with the nearest sample's value instead is the clamped lie
// samples too -- and a query outside it has no neighbours at all. Answering
// with the nearest sample's value instead is the clamped lie
// this refuses: the readout would report a number as if it were under the
// pointer when the series does not reach that far. The series owns where its
// samples are, so an XY series is bracketed by its own x array exactly, where
@@ -1371,8 +1371,8 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
// A query that landed **on** a sample is a span of one, whichever side the
// series' bracket reported: a sample at the query is the answer, and the two
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
// on sample 0 would read as an interpolation with no width. Decided on the
// samples' own coordinates, so it holds for any `neighbours()` implementation.
// on sample 0 would read as a span with a width it does not have. Decided on
// the samples' own coordinates, so it holds for any `neighbours()` impl.
if (series.x_at(i0) == x) {
span.begin = span.end = i0;
span.t = 0.0;
@@ -1387,16 +1387,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
}
if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: nothing
// to interpolate with, and the sample is the honest answer.
// The series bracketed a single sample for a query that is not on it: there
// is no second sample to choose between, and this one is the honest answer.
span.begin = span.end = i0;
span.t = 0.0;
} else {
span.begin = i0;
span.end = i1;
// `t` from the samples' own x coordinates, so the interpolation is exact on
// `t` from the samples' own x coordinates, so "which is nearer" is exact on
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
// interpolates nothing and reads the earlier sample.
// reads the earlier sample.
const f64 xa = series.x_at(i0);
const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
@@ -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,9 +1467,25 @@ 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]);
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;
}
void Plot::build_crosshair() {
CrosshairLayout& crosshair = layout_.crosshair;
@@ -1524,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 interpolated number where the trace has a sample 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
@@ -1543,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);
+5
View File
@@ -9,12 +9,17 @@
namespace plot::render {
namespace {
#ifndef NDEBUG
/// Two declared cells agree when neither size differs by more than the atlas'
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
///
/// Guarded because its one caller is an `assert` (see `TextBatch::set_cell`), so a
/// release build has no use for it and would warn that it is unused.
bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y);
}
#endif
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
+22 -13
View File
@@ -1043,8 +1043,8 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]);
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
const f32 sx = xt.to_screen_x(x_abs);
const f32 sy = yt.to_screen_y(y_abs);
const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(yt.to_screen_y(y_abs));
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
sy <= layout.axes.bottom())) {
continue;
@@ -1085,12 +1085,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
usize kept = 0;
f64 next = std::numeric_limits<f64>::infinity();
for (usize k = count; k > 0; --k) {
const usize i = k - 1u;
const f64 x = static_cast<f64>(gather_[2u * i]) + series.x0();
const usize p = k - 1u; // the point index; `i` above is the trace's
const f64 x = static_cast<f64>(gather_[2u * p]) + series.x0();
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
next = x;
gather_[2u * (count - 1u - kept)] = gather_[2u * i];
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * i + 1u];
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
++kept;
}
if (kept != count) {
@@ -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);
// 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, style_.device(1.0f));
flush_lines(pixel_mapping(), color, mark_stroke);
}
}
}
+11 -8
View File
@@ -877,8 +877,8 @@ namespace {
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
/// sample at different rates are the point -- one x, three different interpolations -- and the
/// last row is the case a readout must not invent a value for.
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() {
plot::Figure figure(4u);
figure.set_viewport(whole_figure());
@@ -906,7 +906,7 @@ plot::Figure crosshair_figure() {
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and
/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
/// each row's series is read on its own grid -- while a row whose series does not reach that x
/// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
@@ -930,14 +930,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK(first.rows[0].label == "fast");
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
// case about the rows rather than about one series read four times.
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// number for the same x, which is what makes this case about the rows rather than about one
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid);
PLOT_CHECK(second.rows[0].label == "slow");
PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9);
PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
@@ -972,7 +973,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
}
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5
// Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
// and a readout quotes one of them.
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
// On *every* row at once, and only the rows free it.
+187 -32
View File
@@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
} // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
/// one sample), and a one-sample series, whose window is that sample alone.
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
/// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
/// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
/// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
/// this refuses, and what it used to return. The corners of the rule are all covered: a query
/// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
/// the window (where the span collapses onto one sample), and a one-sample series.
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
CrosshairScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
// not only the value is what pins the convention -- a nearest-sample lookup would report the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
// (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
// both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
// bracketing is unchanged and only what is built from it differs.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u);
@@ -1986,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
f64 y = 0.0;
// Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and
// 20 are `tens` values, where an interpolation would have answered 11 and 19.
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// Exactly halfway is the tie, and it reads the *earlier* sample -- the bias
// `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer.
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
// A quarter of the way through another pair: sample 2, not 22.5.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
PLOT_CHECK_NEAR(y, 22.5, 1e-9);
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// Just short of the next sample is that sample, so the readout steps rather than ramps.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
// The ends of the window are samples too, so a query exactly on one reads that sample --
// and reads it *as* a span of one, with nothing to its left or right to interpolate with.
// and reads it *as* a span of one, with no neighbour on the far side.
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u);
@@ -2008,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
// Outside the window there is no value at all -- not the nearest sample's, not the end of
// the window's, which are the clamped lies the header refuses -- and a series with no
// samples has no span at any x.
// Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
// series *brackets*; extending it past the ends would answer with the first or last sample for
// any x whatever, which is the clamped lie the header refuses. A series with no samples has no
// span at any x.
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
@@ -2023,7 +2036,7 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
// A one-sample series: the sample itself is the only query it answers, and it answers it
// without a neighbour to interpolate against.
// with no second sample to be nearer to.
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
plot::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
@@ -2039,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
// The readout's row is the same lookup: the number a user reads off the panel is the value
// this test computed by hand, not a second derivation of it.
// this test computed by hand, not a second derivation of it. x = 1.5 is the tie, so the row
// reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed
// `"15"`, a number the series does not contain.
scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active);
@@ -2049,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
// the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15");
PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
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
@@ -2080,14 +2223,16 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
PLOT_CHECK(crosshair.rows[1].label == "#1");
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
// and the value's box -- all placed by the plot, all inside the panel it measured.
// Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
// box and the value's box -- all placed by the plot, all inside the panel it measured. x = 1.5
// is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an
// interpolating readout showed.
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
PLOT_CHECK(row.value == "15");
PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
PLOT_CHECK(row.value == "10");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
@@ -2148,7 +2293,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(on.rows[0].listed);
PLOT_CHECK(on.rows[1].listed);
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
PLOT_CHECK(on.rows[0].value == "15");
PLOT_CHECK(on.rows[0].value == "10");
}
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
@@ -2253,11 +2398,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them.
// A query between two samples: 1234 % 100 = 34, the next is 35, and 1234.5 is the tie, which
// reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are
// still read: the bound below is on how much data the lookup touches, not on which end it
// then reports.
f64 y = 0.0;
const u64 before_reads = series.samples_read();
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
PLOT_CHECK_NEAR(y, 34.5, 1e-9);
PLOT_CHECK_NEAR(y, 34.0, 1e-9);
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
PLOT_CHECK(series.gather_calls() <= 1u);
@@ -2288,7 +2436,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.0, 1e-9);
PLOT_CHECK(series.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a
@@ -2511,9 +2659,12 @@ PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
}
/// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
/// samples that do not exist at the positions it assumed.
/// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
/// wide gap, where a uniform-grid guess would have read samples that do not exist at
/// the positions it assumed. The gap is what makes the value worth asserting too:
/// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a
/// real choice to get right, and it only has that choice because `t` came from the
/// array.
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene;
const u32 trace = scene.plot.add_trace(
@@ -2526,7 +2677,11 @@ PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap
// And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a
// fixed preference for one end.
PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// On a sample, at both ends of the array's window, and outside it: the same
// closed-window rule a uniform series follows, on the array's own coordinates.
+3 -3
View File
@@ -72,9 +72,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
}
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and interpolates between
// the two samples it gets back. On a non-uniform grid that needs real
// searches: a uniform-grid formula would land megabytes off here.
// The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back is nearer. On a non-uniform grid that needs
// real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));