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; - cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics; - draggable regions and measurement bands with exact interval statistics;
- a signal legend anchored to an axes corner; - 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; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - 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 - 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 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. 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** Every number the library reports is one the samples actually hold. What differs between
agree: these two is *how many* samples answer the question:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what - **`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 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, 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 never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval. unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)` - **The crosshair readout is a single sample — the nearest one — by default.**
returns the two samples `x` lies between (`begin`, `end`, `t`), and `crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
is the signal *at* this instant", which is generally between two samples — a different earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
question from an interval statistic. The lookup is O(1) (one division and a floor) and the signal *at* this instant" — a linearly interpolated y is a number the series does not
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a contain, and on a source whose samples are already averaged or decimated at ingest it is a
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue` claim about a signal nobody measured. That is why it is the default; a host whose x is a
(an em dash, `"—"`), and an x outside the series window yields no value rather than the quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
nearest sample's. `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**, `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 `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 - `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 crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash. 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 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 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 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. a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see ### What a readout is: the two value modes
[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. `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) ## 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 /// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement /// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with /// 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; virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`; /// 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. ///< (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 /// The crosshair's state: where it is, which of its two lines it draws, and whether the
/// per-signal readout is wanted. /// per-signal readout is wanted.
/// ///
@@ -44,10 +73,10 @@ struct CrosshairStyle {
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only, /// 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. /// 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 /// **The readout's values are samples by default** -- the y a row reports is the nearer of
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample /// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and /// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`. /// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair { struct Crosshair {
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by /// 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 /// `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: /// 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. /// a crosshair placed at one coordinate has no second value to mark a row with.
bool horizontal = true; 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 /// 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. /// measure and the panel is then not placed at all.
bool show_readout = true; 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 /// 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. /// crosshair, which draws no row and anchors its readout at the top of the axes.
f64 y = 0.0; 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 /// 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 /// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`. /// `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 /// 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. /// move, so that is a requirement rather than a nicety.
/// ///
/// **Interpolated, not exact.** The value a caller builds from this span by /// **`t` is a position, not yet an answer.** It says where the query sits between the two
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is /// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
/// generally between two samples -- the opposite convention from `Series::reduce()`, /// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit /// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
/// next to each other in this library on purpose and they do not agree: `reduce()` answers /// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
/// "what are the samples' extremes over this interval", this answers "what is the signal at /// about the data and not about the drawing; `CrosshairValue` is where that is argued.
/// 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. /// 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 /// `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 /// 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 /// 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. /// lands on a sample, `begin + 1` between two of them.
u64 end = 0; 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; f64 t = 0.0;
bool valid = false; 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
/// interpolation convention it is half of, and for the cases that yield `valid == false`. /// convention it is half of, and for the cases that yield `valid == false`.
/// ///
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample /// 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 /// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,12 +155,14 @@ struct CrosshairSpan {
/// allocates. /// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; 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 /// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and /// which is what `CrosshairValue::Interpolated` reports.
/// its documentation cannot drift apart. ///
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { /// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
return y_begin + (y_end - y_begin) * t; /// 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 /// One trace's line of the readout: which trace it is about, the value under the pointer
/// and the boxes the renderer stages. /// 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 /// 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. /// not a line in a panel that has no room for it.
bool listed = false; bool listed = false;
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only /// The data x the row's value belongs to, on the trace's own x axis: in
/// meaningful when `valid`. /// `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; f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one. /// The trace's name, or the positional placeholder of an unnamed one.
std::string label; std::string label;
@@ -161,9 +206,24 @@ struct CrosshairRow {
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as /// `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. /// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
std::string value; std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the /// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the /// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
/// marker is where that trace is actually drawn. 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{}; Vec2 marker{};
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty /// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
/// when `!listed`. /// when `!listed`.
+31 -9
View File
@@ -101,8 +101,8 @@ struct PlotLayout {
/// where a pointer can grab it cannot drift apart. /// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions; std::vector<RegionLayout> regions;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// 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 /// readout panel and one row per visible trace with the value of the sample nearest the
/// is over. Placed here rather than derived by the renderer, exactly as the legend and /// 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 /// 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 /// 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. /// 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`. /// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; } 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 /// 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 /// 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. /// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; } const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()` /// The value this plot's readout would report for `trace` at a data `x`: the nearer
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup /// bracketing sample's y, or the blend of the two, according to
/// the readout's rows are built from, exposed for a host that wants the number for /// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
/// itself (a status line, a tooltip of its own). /// 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 /// 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` /// 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 /// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
/// `crosshair_span()` for why an out-of-window x is not a value. /// 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 /// 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 /// 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. /// 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; 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: private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from /// 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 /// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
@@ -816,7 +838,7 @@ class Plot {
/// never names a trace. /// never names a trace.
void build_legend(); void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row /// 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 /// 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 /// 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. /// 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_width = 0.75f;
f32 crosshair_dash_on = 4.0f; f32 crosshair_dash_on = 4.0f;
f32 crosshair_dash_off = 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 ---------------------------------------------------------- // --- the scale ----------------------------------------------------------
+50 -16
View File
@@ -1082,7 +1082,7 @@ const PlotLayout& Plot::layout() {
build_regions(); build_regions();
build_legend(); build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // 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. // settled layout pins, exactly as the legend and the regions do.
build_crosshair(); build_crosshair();
return layout_; return layout_;
@@ -1352,13 +1352,13 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span; CrosshairSpan span;
const u64 n = series.size(); 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; if (n == 0u || !std::isfinite(x)) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`): // The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are // 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 // samples too -- and a query outside it has no neighbours at all. Answering
// between. Answering with the nearest sample's value instead is the clamped lie // 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 // 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 // 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 // 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 // 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 // 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 // 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 // on sample 0 would read as a span with a width it does not have. Decided on
// samples' own coordinates, so it holds for any `neighbours()` implementation. // the samples' own coordinates, so it holds for any `neighbours()` impl.
if (series.x_at(i0) == x) { if (series.x_at(i0) == x) {
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
@@ -1387,16 +1387,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
} }
if (i0 == i1) { if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: nothing // The series bracketed a single sample for a query that is not on it: there
// to interpolate with, and the sample is the honest answer. // is no second sample to choose between, and this one is the honest answer.
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
} else { } else {
span.begin = i0; span.begin = i0;
span.end = i1; 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) // 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 xa = series.x_at(i0);
const f64 xb = series.x_at(i1); const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0; 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; 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) { std::string crosshair_unnamed_label(u32 trace) {
char buf[16]; char buf[16];
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace)); 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 { 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; if (trace >= traces_.size()) return false;
const data::Series* series = traces_[trace].series; const data::Series* series = traces_[trace].series;
if (series == nullptr) return false; 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 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_begin = static_cast<f64>(crosshair_scratch_[1u]);
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 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); out_y = crosshair_interpolate(y_begin, y_end, span.t);
return true; 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() { void Plot::build_crosshair() {
CrosshairLayout& crosshair = layout_.crosshair; CrosshairLayout& crosshair = layout_.crosshair;
@@ -1524,17 +1552,23 @@ void Plot::build_crosshair() {
row.trace = i; row.trace = i;
row.valid = false; row.valid = false;
row.listed = false; row.listed = false;
row.x = 0.0;
row.y = 0.0; row.y = 0.0;
row.marker = Vec2{}; row.marker = Vec2{};
row.swatch = Rect{}; row.swatch = Rect{};
row.label_box = Rect{}; row.label_box = Rect{};
row.value_box = Rect{}; row.value_box = Rect{};
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); 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 // The value where the trace has one at this x, and the em dash where it does not. The row is
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank // listed either way, so the absence has to be *said* -- a blank value cell reads as a value
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's // that has not arrived -- and the dash is measured into the panel's width like any other
// width like any other value, so the row's boxes are placed by one rule. // value, so the row's boxes are placed by one rule.
if (crosshair_value(i, crosshair_.x, row.y)) { //
// 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; row.valid = true;
char value[32]; char value[32];
// The trace's *own* y axis formats the number, so a readout reads exactly like the // 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); row.value.assign(value);
// Placed through the trace's own axis pair, so the marker sits where that trace is // Placed through the trace's own axis pair, so the marker sits where that trace is
// actually drawn however its axes are scaled. // 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))}; static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
} else { } else {
row.value.assign(kCrosshairNoValue); row.value.assign(kCrosshairNoValue);
+5
View File
@@ -9,12 +9,17 @@
namespace plot::render { namespace plot::render {
namespace { namespace {
#ifndef NDEBUG
/// Two declared cells agree when neither size differs by more than the atlas' /// 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. /// 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 { bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; }; const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y); 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 /// `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 /// 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 x_abs = series.x0() + static_cast<f64>(gather_[k]);
const f64 y_abs = static_cast<f64>(gather_[k + 1]); const f64 y_abs = static_cast<f64>(gather_[k + 1]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue; if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
const f32 sx = xt.to_screen_x(x_abs); const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
const f32 sy = yt.to_screen_y(y_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 && if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
sy <= layout.axes.bottom())) { sy <= layout.axes.bottom())) {
continue; continue;
@@ -1085,12 +1085,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
usize kept = 0; usize kept = 0;
f64 next = std::numeric_limits<f64>::infinity(); f64 next = std::numeric_limits<f64>::infinity();
for (usize k = count; k > 0; --k) { for (usize k = count; k > 0; --k) {
const usize i = k - 1u; const usize p = k - 1u; // the point index; `i` above is the trace's
const f64 x = static_cast<f64>(gather_[2u * i]) + series.x0(); 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 if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
next = x; next = x;
gather_[2u * (count - 1u - kept)] = gather_[2u * i]; gather_[2u * (count - 1u - kept)] = gather_[2u * p];
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * i + 1u]; gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
++kept; ++kept;
} }
if (kept != count) { 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); 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 // A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm // swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
// length is a logical size like every other marker's, and the position is the layout's: a // layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
// marker for a sample far outside the visible y range is staged where the trace is and the // rather than the pointer's column: a mark for a sample outside the visible range is staged
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own. // where that sample is and the axes clip keeps it inside, rather than being skipped by a range
const f32 arm = style_.device(3.0f); // 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) { for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue; if (!row.valid) continue;
Color color = plot.trace(row.trace).color; Color color = plot.trace(row.trace).color;
color.a = 1.0f; 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 - 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}); 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 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 /// 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 /// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// last row is the case a readout must not invent a value for. /// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() { plot::Figure crosshair_figure() {
plot::Figure figure(4u); plot::Figure figure(4u);
figure.set_viewport(whole_figure()); 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 /// 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 /// 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 /// 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. /// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { 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(first.rows[0].label == "fast");
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9); 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 // Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this // sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// case about the rows rather than about one series read four times. // 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; const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
PLOT_CHECK(second.rows.size() == 1u); PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid); PLOT_CHECK(second.rows[0].valid);
PLOT_CHECK(second.rows[0].label == "slow"); 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); 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. // 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(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(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); PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
// On *every* row at once, and only the rows free it. // 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 } // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples /// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the /// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query /// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
/// between two samples, the two exact-sample ends of the window (where the span collapses onto /// this refuses, and what it used to return. The corners of the rule are all covered: a query
/// one sample), and a one-sample series, whose window is that sample alone. /// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { /// the window (where the span collapses onto one sample), and a one-sample series.
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
CrosshairScene scene; CrosshairScene scene;
const u32 trace = scene.plot.add_trace( const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); {&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 // 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 // (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
// not only the value is what pins the convention -- a nearest-sample lookup would report the // both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all. // bracketing is unchanged and only what is built from it differs.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5); const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid); PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u); 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); PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
f64 y = 0.0; 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(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9); PLOT_CHECK_NEAR(y, 10.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25. // 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(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 -- // 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); const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
PLOT_CHECK(first.valid); PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u); 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(scene.plot.crosshair_value(trace, 3.0, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9); 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 // Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
// the window's, which are the clamped lies the header refuses -- and a series with no // series *brackets*; extending it past the ends would answer with the first or last sample for
// samples has no span at any x. // 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, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 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)); 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 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 // 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::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
plot::Plot one; plot::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); 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)); 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 // 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); scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair; const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active); PLOT_CHECK(crosshair.active);
@@ -2049,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
// the figure's case pins). // the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9); PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15"); 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 /// 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 == plot::crosshair_unnamed_label(1u));
PLOT_CHECK(crosshair.rows[1].label == "#1"); 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 // Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
// and the value's box -- all placed by the plot, all inside the panel it measured. // 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); PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
for (const plot::CrosshairRow& row : crosshair.rows) { for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid); PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed); PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9); PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
PLOT_CHECK(row.value == "15"); PLOT_CHECK(row.value == "10");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x); PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right()); PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y); 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[0].listed);
PLOT_CHECK(on.rows[1].listed); PLOT_CHECK(on.rows[1].listed);
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f); 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 /// 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}); 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}); 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; f64 y = 0.0;
const u64 before_reads = series.samples_read(); const u64 before_reads = series.samples_read();
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y)); 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.samples_read() - before_reads <= 2u);
PLOT_CHECK(series.gather_calls() <= 1u); PLOT_CHECK(series.gather_calls() <= 1u);
@@ -2288,7 +2436,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
const plot::CrosshairLayout& crosshair = p.layout().crosshair; const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); 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.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u); PLOT_CHECK(series.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a // 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* /// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions /// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read /// wide gap, where a uniform-grid guess would have read samples that do not exist at
/// samples that do not exist at the positions it assumed. /// 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) { PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene; XyScene scene;
const u32 trace = scene.plot.add_trace( 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); PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0; f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y)); 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 // 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. // 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) { PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and interpolates between // The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back. On a non-uniform grid that needs real // the two samples it gets back is nearer. On a non-uniform grid that needs
// searches: a uniform-grid formula would land megabytes off here. // real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000); auto x = spread_x(1000);
const std::vector<f64> xs = x; const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000)); plot::data::XYSeries s(std::move(x), saw_y(1000));