Compare commits

...
9 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 bee318870f feat(plot): a figure can keep an empty band to the right of its data
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
`layout_.axes` reached the viewport's right edge, so the rightmost x tick
label -- centred on its tick -- had half of itself outside the plot, and
the newest sample's ink landed on the frame. `Plot::set_right_gutter()`
reserves a band there, out of the same `1 - kMinAxesShare` allowance the
label and title strips are served from, so it can no more squeeze `axes`
below the half-viewport floor than they can. `Figure` sets one value on
every row, the left gutter's reason: the rows' axes have to end on a
common column as well as start on one.

Nothing is drawn in the band, so unlike the strips it is a want and never
a measurement: no measuring pass, and it cannot feed the layout's fixed
point.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 22:24:27 +02:00
Martino Ferrari 58473db384 fix: a time label's mantissa cannot reach its own radix
`format_time()` picked its sub-second rung from the raw value, so three
significant figures of a value just under a rung's top rounded to 1000 and
`%.3g` wrote that as an exponent: a one-second tick computed as 0.9999999 s
read `1e+03ms`. The rung is now chosen by the rounded mantissa, which is the
same 999.5 check `format_si()` already made for the SI prefixes, and a value
that rounds to a whole second leaves the sub-second branch entirely.
2026-10-05 21:31:43 +02:00
Martino Ferrari c2c8c596f3 fix(data): the gather origin is the caller's, so a deep zoom draws on the sample
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
`Series::gather()` stages x as f32 relative to `x0()` -- the start of the
*retained window* -- so the value's ULP was a fraction of data the view need
not even hold: 1.9 us over a 20 s ring. A readout quotes `x_at()` in exact
f64, so at a 10 us view across 700 px that put more than a hundred pixels
between where a sample is drawn and the x reported for it.

The origin is now a parameter, placed before `out_xy` so every call site is a
type error rather than a silent change of behaviour. `Renderer::draw_traces`
passes the visible range's minimum, which makes the error a fixed 2^-24 of the
span on screen -- 4e-5 of a pixel over a 700 px axis -- at every zoom.

`stage_xy()` loses its second origin as a consequence: the gather origin and
the draw origin are now always the same value, so the linear case hands the
gathered buffer straight to `add_polyline_xy()` with no per-point copy.

`Plot::crosshair_point()` names the query itself, the one origin that cannot
lose precision for a caller that might.

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00
Martino FerrariandClaude Opus 4.6 59c2e45af7 feat(plot): an x axis caption drawn as chrome, not as an annotation
A host that re-anchors its x labels on a grid has to name the anchor somewhere, and
an `Annotation` cannot serve: its anchor is a data coordinate, so the caption crawls
across the plot under a pan and drifts under a zoom.

`Plot::set_x_note()` takes the host's own string -- the host is the only one that
knows how much precision the sum of a label and its caption needs -- and
`Renderer::draw_x_note()` places it in the axes' bottom-left corner in pixels, on the
legend's own backplate. It is measured in the renderer rather than in `Plot::layout()`
because it reserves no space and nothing can be picked on it, and a caption explaining
the x labels must not be able to move the layout those labels were measured into.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:16 +02:00
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
Martino Ferrari 26a7c82a80 feat(input): Ctrl restricts keyboard zoom to x, Alt to y
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
2026-10-04 12:03:01 +02:00
Martino Ferrari 0ee6e10c81 fix(render): a clamped ring front does not draw a segment across the panel
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
A ring-backed series answers a *clamped* stamp for the oldest slots of its window -- the ones the
producer has already overwritten -- because the published window is pinned while both rings slide
under it, and the alternative (a sentinel) collapsed the whole index mapping. The consequence for a
polyline is that the stamps do not increase at the *front*: the first points carry the newest stamp
and everything after them is older. Drawn as they come, that is a segment from the newest sample
back to the oldest -- a line across the whole panel, which a saturated ring shows as a flicker.

The gathered points are now reduced to their longest strictly increasing suffix before anything is
staged, so the unreadable front is left out and the real samples are untouched. A forward pass
cannot express this: the bad points are at the front and everything behind them is legitimately
smaller, so "drop anything that does not advance" would delete the trace -- walking backwards keeps
each point strictly older than the one kept after it and stops at the first unreadable slot. One
pass over the visible points, which the staging then walks anyway.

Test: a_clamped_front_does_not_draw_a_segment_across_the_panel -- a series whose first two samples
carry the newest stamp at -1 while the rest walk 0..50 at +1, the shape a saturated ChannelBuffer
produces. Flat at +1 except for those two, so any ink below the middle of the axes is the bad
segment: with the filter there is none (0 below, 1032 above in the columns the trace covers), and
without it the diagonal crosses the panel.
2026-10-03 17:51:37 +02:00
29 changed files with 1055 additions and 230 deletions

No files matched your search

+7 -1
View File
@@ -29,6 +29,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## Features ## Features
- a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published
window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable
front cannot draw a segment from the newest sample back to the oldest (a line across the whole
panel);
- a following group's window stays on its members' data: while the members retain at least a - a following group's window stays on its members' data: while the members retain at least a
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
edge cannot leave the window's own span over data the scope never had (a window *wider* than the edge cannot leave the window's own span over data the scope never had (a window *wider* than the
@@ -43,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
@@ -33,7 +33,7 @@ class ArraySeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
protected: protected:
+18 -10
View File
@@ -59,12 +59,20 @@ class Series {
/// Appends interleaved (x, y) pairs for level `level`. /// Appends interleaved (x, y) pairs for level `level`.
/// ///
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as /// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// f32, never the absolute coordinate. A consumer reconstructs the absolute /// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an /// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// absolute epoch-time x as f32 would quantise it to a grid coarser than /// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative /// 1.7e9). y is unchanged.
/// value `i*dx` is small and exact. y is unchanged. ///
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
/// ///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained /// geometry element per *complete* level bucket wholly inside the retained
@@ -85,7 +93,7 @@ class Series {
/// level_for() picks), and never reaches outside the retained window -- nor /// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never /// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared. /// cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0; GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series). /// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
@@ -97,7 +105,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]`;
@@ -121,7 +129,7 @@ class Series {
/// The shared body of the concrete gather() overrides: raw samples at level 0, /// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket geometry above it (per `mode`). /// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const; GatherMode mode) const;
private: private:
@@ -143,7 +151,7 @@ class Series {
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge /// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition: /// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size(). /// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const; void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
}; };
} // namespace plot::data } // namespace plot::data
+1 -1
View File
@@ -92,7 +92,7 @@ class StreamSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which /// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows. /// slides with every `acquire()` -- the property `XFollow::Live` follows.
+2 -2
View File
@@ -53,8 +53,8 @@ class XYSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x0()`. /// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x. /// The sample's own x.
+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`.
+11
View File
@@ -138,6 +138,16 @@ class Figure {
} }
f32 min_y_gutter() const noexcept { return min_y_gutter_; } f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip /// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row /// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra. /// draws its own labels and the figure draws nothing extra.
@@ -326,6 +336,7 @@ class Figure {
FigureLayout layout_{}; FigureLayout layout_{};
f32 row_gap_ = 0.0f; f32 row_gap_ = 0.0f;
f32 min_y_gutter_ = 0.0f; f32 min_y_gutter_ = 0.0f;
f32 right_gutter_ = 0.0f;
bool labels_bottom_only_ = true; bool labels_bottom_only_ = true;
}; };
+67 -9
View File
@@ -88,6 +88,11 @@ struct PlotLayout {
/// no text backend there is no title to lay out at all and both stay 0.0f. /// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f; f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f; f32 x_title_height = 0.0f;
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
/// clamped by the same cap as the strips above, so what was asked for and what was
/// reserved are not always the same number.
f32 right_gutter = 0.0f;
/// The measured legend: inside `axes`, never reserving any part of it, and empty /// The measured legend: inside `axes`, never reserving any part of it, and empty
/// when there is nothing to draw. It is measured with the same text backend the /// when there is nothing to draw. It is measured with the same text backend the
/// strips above are, and against the same `axes` rect the caller reads back, so /// strips above are, and against the same `axes` rect the caller reads back, so
@@ -101,8 +106,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.
@@ -508,6 +513,18 @@ class Plot {
void set_x_axis_origin_from_range(u32 axis, bool on); void set_x_axis_origin_from_range(u32 axis, bool on);
void set_y_axis_origin_from_range(u32 axis, bool on); void set_y_axis_origin_from_range(u32 axis, bool on);
/// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the
/// axes with the legend's own backplate behind it. It exists for the thing a shifted origin
/// leaves unsaid: labels counted from a window's left edge are only readable beside the value
/// that edge has, and the reader adds the two. An `Annotation` cannot serve -- its anchor is a
/// data coordinate, so the caption crawls across the plot under a pan and drifts under a zoom,
/// which is the one thing a legend-like panel must not do. The text is the host's, because only
/// the host knows how much precision the sum needs: the axis' own label form carries three
/// significant figures, which is exactly what made the shifted origin necessary.
/// Empty (the default) draws nothing.
void set_x_note(std::string text);
const std::string& x_note() const noexcept { return x_note_; }
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different /// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same /// series, a longer one, or a sliding window whose sample count stayed the same
@@ -628,6 +645,20 @@ class Plot {
} }
f32 min_y_gutter() const noexcept { return min_y_gutter_; } f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// An empty band reserved between `axes`' right edge and the viewport's, in device
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
/// value is the caller's to choose. Reserved inside the same cap as the label and
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
/// they can. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space. /// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; } const Grid& grid() const noexcept { return grid_; }
@@ -688,20 +719,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 +751,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 +869,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.
@@ -826,11 +879,16 @@ class Plot {
render::Style style_{}; render::Style style_{};
const render::TextBackend* text_ = nullptr; const render::TextBackend* text_ = nullptr;
Legend legend_{}; Legend legend_{};
/// The x axis' corner caption; see set_x_note(). Empty draws nothing.
std::string x_note_;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode(). /// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both; XStrip x_strip_ = XStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means /// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps. /// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f; f32 min_y_gutter_ = 0.0f;
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
/// which is what the layout did before the setter existed.
f32 right_gutter_ = 0.0f;
/// Which handles a pointer is on, published by the input layer (see HotHandles). /// Which handles a pointer is on, published by the input layer (see HotHandles).
HotHandles hot_{}; HotHandles hot_{};
+11 -4
View File
@@ -445,11 +445,12 @@ class Renderer {
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a /// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision. /// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see /// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The /// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as /// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch). /// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y); void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into /// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the /// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run. /// per-instance `a_colour`). Returns the pen past the run.
@@ -534,6 +535,12 @@ class Renderer {
/// them again, so the panel a test asserts and the panel this draws are the same /// them again, so the panel a test asserts and the panel this draws are the same
/// numbers. /// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout); void draw_legend(const Plot& plot, const PlotLayout& layout);
/// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is
/// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no
/// space and nothing can be picked on it, so there is no measurement a host or a test needs to
/// read back, and a caption that explains the x labels must not be able to move the layout the
/// labels were measured into.
void draw_x_note(const Plot& plot, const PlotLayout& layout);
/// The backplate the legend and the crosshair's readout share: the frame's background at /// The backplate the legend and the crosshair's readout share: the frame's background at
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the /// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured /// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
+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 ----------------------------------------------------------
+2 -2
View File
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; } const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const { void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+15 -11
View File
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
return static_cast<u64>(k); return static_cast<u64>(k);
} }
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const { void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
const f64 step = dx(); const f64 step = dx();
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
const f64 shift = x0() - x_origin;
f32 raw[kGatherChunk]; f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) { for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i); const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw); copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) { for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather() out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
out_xy.push_back(raw[k]); out_xy.push_back(raw[k]);
} }
i += take; i += take;
} }
} }
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
@@ -198,7 +201,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (b >= e) return; if (b >= e) return;
if (level == 0) { if (level == 0) {
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
// Public levels clamp to the deepest representable one (R3): past // Public levels clamp to the deepest representable one (R3): past
@@ -226,6 +229,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
// picks, so the envelope path stays O(buckets) and the geometry still spans // picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode. // the requested range exactly, for every mode.
const f64 step = dx(); const f64 step = dx();
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base(); const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level); const u64 lbase = pyramid().level_base(level);
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (first >= last) { if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed // No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is. // into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
const u64 lead = first * span - origin; // window index the first emitted bucket starts at const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy); if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
switch (mode) { switch (mode) {
case GatherMode::Envelope: { case GatherMode::Envelope: {
@@ -252,9 +256,9 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const MinMax m = pyramid().bucket(level, bi); const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin; const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
out_xy.push_back(m.min); out_xy.push_back(m.min);
out_xy.push_back(f32(f64(last_sample) * step)); out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max); out_xy.push_back(m.max);
} }
break; break;
@@ -269,7 +273,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f; f32 v = 0.0f;
copy_raw(centre, centre + 1, &v); copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(v); out_xy.push_back(v);
} }
break; break;
@@ -283,7 +287,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u32 count = pyramid().bucket_count(level, bi); const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue; if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin; const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count))); out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
} }
break; break;
@@ -291,7 +295,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
} }
const u64 trail = last * span - origin; // one past the last emitted bucket's span const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy); if (trail < e) gather_raw(trail, e, x_origin, out_xy);
} }
} // namespace plot::data } // namespace plot::data
+2 -2
View File
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; } const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const { void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+4 -4
View File
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
: hi; // ties go to the lower index : hi; // ties go to the lower index
} }
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n); const u64 e = std::min(std::max(end, b), n);
if (b >= e) return; if (b >= e) return;
// x emitted relative to x0(): see Series::gather(). The x array is f64, so // x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
// the subtraction happens wide and only the small, exact offset narrows. // the subtraction happens wide and only the small, exact offset narrows.
const f64 origin = x0(); const f64 origin = x_origin;
if (level == 0) { if (level == 0) {
for (u64 i = b; i < e; ++i) { for (u64 i = b; i < e; ++i) {
@@ -127,7 +127,7 @@ void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
// `begin`, `last` one past the last bucket wholly before `end`, and the two // `begin`, `last` one past the last bucket wholly before `end`, and the two
// edge spans around them are read from the raw arrays -- so no emitted // edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the // geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to x0(). // array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level); const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend); const u64 first = std::min(ceil_div(b, span), lend);
+20 -10
View File
@@ -323,17 +323,27 @@ void Controller::on_key(const KeyEvent& e) {
if (zooms) { if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
const f64 factor = zoom_factor(notches); const f64 factor = zoom_factor(notches);
for (u32 i = 0; i < plot_->x_axis_count(); ++i) { // Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
const Axis& a = plot_->x_axis(i); // used: '+' is shift+'=' on common layouts, so key_from() folds them together and
const f64 lo = to_space(a.range.min, a.log); // a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
const f64 hi = to_space(a.range.max, a.log); // which is harmless and needs no special case.
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor); const bool only_x = has_mod(e.mods, Mod::Ctrl);
const bool only_y = has_mod(e.mods, Mod::Alt);
if (!only_y) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
} }
for (u32 i = 0; i < plot_->y_axis_count(); ++i) { if (!only_x) {
const Axis& a = plot_->y_axis(i); for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const f64 lo = to_space(a.range.min, a.log); const Axis& a = plot_->y_axis(i);
const f64 hi = to_space(a.range.max, a.log); const f64 lo = to_space(a.range.min, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
} }
} else if (fits) { } else if (fits) {
plot_->auto_fit_x(); plot_->auto_fit_x();
+4
View File
@@ -451,6 +451,10 @@ const FigureLayout& Figure::layout() {
// not drawn once per row. A figure told to label every row keeps them everywhere, // not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own. // and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
// Before either pass below, and the same value for every row: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
// on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
} }
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and // After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves // its transforms describe this frame's rects; and before the rows lay themselves
+67 -19
View File
@@ -342,6 +342,8 @@ void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
y_axes_[axis].origin_follows_range = on; y_axes_[axis].origin_follows_range = on;
} }
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
f64 Plot::x_axis_origin(u32 axis) const { f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
} }
@@ -1037,12 +1039,21 @@ const PlotLayout& Plot::layout() {
x_height = std::min(x_height, x_budget); x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
const f32 x_title_height = std::min(x_title_h, x_budget - x_height); const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
// The right-hand band, out of what is left of the same allowance the two left
// strips were served from -- so the three together cannot take more than
// `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn
// in it, so unlike the strips above it is a want and never a measurement, and it
// cannot feed the fixed point below: the y labels are chosen from the row's range
// and height, so a narrower `axes` does not change what they need.
const f32 right_gutter =
std::min(right_gutter_, y_budget - (y_width + y_title_width));
layout_.y_axis_width = y_width; layout_.y_axis_width = y_width;
layout_.x_axis_height = x_height; layout_.x_axis_height = x_height;
layout_.y_title_width = y_title_width; layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height; layout_.x_title_height = x_title_height;
layout_.right_gutter = right_gutter;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
vw - (y_width + y_title_width), vw - (y_width + y_title_width + right_gutter),
vh - (x_height + x_title_height)}; vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area = layout_.y_label_area =
@@ -1082,7 +1093,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 +1363,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 +1382,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 +1398,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 +1421,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 +1457,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;
@@ -1450,12 +1473,31 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
// when it fell between them. The scratch is cleared and refilled rather than reallocated, // when it fell between them. The scratch is cleared and refilled rather than reallocated,
// and sized for two pairs from construction, so the pointer path allocates nothing. // and sized for two pairs from construction, so the pointer path allocates nothing.
crosshair_scratch_.clear(); crosshair_scratch_.clear();
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_); // The origin is the query itself: only y is read out of the pairs below -- the x comes back
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
// that might.
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
const usize pairs = static_cast<usize>(span.end - span.begin + 1u); const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
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]);
out_y = crosshair_interpolate(y_begin, y_end, span.t); 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 a
// caller-named origin 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; return true;
} }
@@ -1524,17 +1566,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 +1591,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);
+9 -2
View File
@@ -98,7 +98,10 @@ void format_time(f64 seconds, char* buf, usize n) {
const char* const sign = seconds < 0.0 ? "-" : ""; const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds); const f64 t = std::fabs(seconds);
if (t < 1.0) { // `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
// The same spill one rung down is handled inside the ladder.
if (t < 0.9995) {
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI // Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed // formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and // millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
@@ -119,8 +122,12 @@ void format_time(f64 seconds, char* buf, usize n) {
{1.0e-9, "ns"}, {1.0e-9, "ns"},
{1.0e-12, "ps"}, {1.0e-12, "ps"},
}; };
// The rung is picked by the *rounded* mantissa, which is what `format_si`'s own 999.5
// check buys there: 0.9999 ms is `999.9µs` before rounding and `1e+03µs` after, so it is
// the millisecond rung's value and reads `1ms`. Testing the raw `t < u.scale` let the
// mantissa reach 1000 at every rung boundary.
for (const auto& u : kUnits) { for (const auto& u : kUnits) {
if (t < u.scale) continue; if (t < u.scale * 0.9995) continue;
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit); std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
return; return;
} }
+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
+101 -38
View File
@@ -9,6 +9,7 @@
#include <plot/render/detail/text_scale.hpp> #include <plot/render/detail/text_scale.hpp>
#include <algorithm> #include <algorithm>
#include <cstring>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
@@ -532,6 +533,7 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout); draw_measures(plot, layout);
draw_annotations(plot); draw_annotations(plot);
draw_legend(plot, layout); draw_legend(plot, layout);
draw_x_note(plot, layout);
// The under text batch goes out here, so the readout's panel below it can // The under text batch goes out here, so the readout's panel below it can
// occlude what it covers: legend names, cursor labels, measure labels and // occlude what it covers: legend names, cursor labels, measure labels and
// annotation captions are all chrome the panel is allowed to sit on. // annotation captions are all chrome the panel is allowed to sit on.
@@ -734,24 +736,16 @@ void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool
static_cast<f32>(space(y1, log_y))}); static_cast<f32>(space(y1, log_y))});
} }
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
bool log_y) {
if (!log_x && !log_y) { if (!log_x && !log_y) {
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` -- // The common case costs nothing at all: the gather emitted x relative to this very origin
// the gather contract's own reconstruction), stage it relative to the // (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
// draw's origin, and the f32 is O(visible pixels), not O(epoch). // needs no mapping, so the gathered buffer *is* the staged one.
staged_.clear(); lines_.add_polyline_xy(xy, n);
staged_.reserve(usize(n) * 2u);
const f64 origin_s = space(origin_x, log_x);
for (u32 i = 0; i < n; ++i) {
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
staged_.push_back(xy[2u * i + 1u]);
}
lines_.add_polyline_xy(staged_.data(), n);
return; return;
} }
for (u32 i = 0; i + 1u < n; ++i) { for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0, stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
xy[2u * i + 3u], log_x, log_y); xy[2u * i + 3u], log_x, log_y);
} }
} }
@@ -1024,7 +1018,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u; : 0u;
gather_.clear(); gather_.clear();
series.gather(level, first, last, gather_, trace.mode); // The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
// quotes for it. See `Series::gather()`.
series.gather(level, first, last, x_range.min, gather_, trace.mode);
if (gather_.size() < 4u) continue; // fewer than two points is no segment if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible; stats_.visible_samples += visible;
@@ -1039,11 +1038,11 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const auto& yt = plot.y_transform(trace.axis_y); const auto& yt = plot.y_transform(trace.axis_y);
const f32 half = 0.5f * style_.device(style_.marker_px); const f32 half = 0.5f * style_.device(style_.marker_px);
for (u32 k = 0; k + 1 < gather_.size(); k += 2) { for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]); const f64 x_abs = x_range.min + 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;
@@ -1057,14 +1056,47 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
++stats_.lod_traces; ++stats_.lod_traces;
} }
// Gathered x is relative to `series.x0()` (the gather contract); the staging // Gathered x is relative to `x_range.min` (the gather contract, with the origin this
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) - // function asked for); the staging reconstructs the absolute data x in f64 and emits
// to_space(origin)` -- the visible x range's minimum is the natural origin // `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The // pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
// mapping folds the origin back into the offset uniform, in f64. // in f64.
const bool log_x = plot.x_axis(trace.axis_x).log; const bool log_x = plot.x_axis(trace.axis_x).log;
const bool log_y = plot.y_axis(trace.axis_y).log; const bool log_y = plot.y_axis(trace.axis_y).log;
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min); const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
// Keep the longest **increasing suffix** of the gathered points, and drop what precedes it.
//
// A ring-backed series may answer a *clamped* stamp for the oldest slots of its window -- the ones
// the producer has already overwritten. The clamp is deliberate (the alternative was a sentinel
// that collapsed the whole index mapping), but it makes the stamps *non-increasing at the front*:
// the first few points carry the newest stamp, and everything after them is older. A polyline
// through that draws a segment from the newest sample back to the oldest -- a line across the
// whole panel, which is what a saturated ring shows as a flicker.
//
// A forward filter cannot express this: the bad points are at the *front* and everything behind
// them is legitimately smaller, so dropping "anything that does not advance" would delete the
// trace. Walking backwards keeps each point that is strictly older than the one kept after it,
// which stops at the first unreadable slot and leaves the real samples alone. The cost is one
// pass over the visible points, which the staging below is about to walk anyway.
{
const usize count = gather_.size() / 2u;
usize kept = 0;
f64 next = std::numeric_limits<f64>::infinity();
for (usize k = count; k > 0; --k) {
const usize p = k - 1u; // the point index; `i` above is the trace's
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
next = x;
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
++kept;
}
if (kept != count) {
// The kept points were written back-to-front into the tail; move them down to the front.
std::memmove(gather_.data(), gather_.data() + 2u * (count - kept), 2u * kept * sizeof(f32));
gather_.resize(2u * kept);
}
}
const u32 points = static_cast<u32>(gather_.size() / 2u); const u32 points = static_cast<u32>(gather_.size() / 2u);
// A trace's dash rides on the same per-draw uniform the crosshair uses, in // A trace's dash rides on the same per-draw uniform the crosshair uses, in
@@ -1079,7 +1111,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace. // markers below are the whole trace.
if (trace.width > 0.0f) { if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y); stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash); flush_lines(map, trace.color, style_.device(trace.width), dash);
} }
@@ -1097,7 +1129,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const f64 origin_s = space(x_range.min, log_x); const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0; u32 staged = 0;
for (u32 p = 0; p < points; p += stride) { for (u32 p = 0; p < points; p += stride) {
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0(); const f64 x_abs = static_cast<f64>(gather_[2u * p]) + x_range.min;
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s); const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y)); const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y}; const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
@@ -1369,6 +1401,28 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
} }
} }
void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
const std::string& text = plot.x_note();
if (text.empty()) return;
const Rect& axes = layout.axes;
const f32 line_h = font_backend_.height();
const f32 width = font_backend_.measure(text.c_str());
if (!(width > 0.0f) || !(line_h > 0.0f)) return;
// The pad is the legend's, so the two panels read as the same kind of chrome.
const f32 pad = style_.device(4.0f);
const Rect panel{axes.x + pad, axes.bottom() - pad - line_h - 2.0f * pad, width + 2.0f * pad,
line_h + 2.0f * pad};
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
if (panel.w > axes.w || panel.h > axes.h) return;
draw_readout_panel(panel, style_.axis_line);
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
// it: text staged into the over batch would be drawn before its own backplate.
stage_text(glyphs_under_, {panel.x + pad, panel.y + pad}, text.c_str(), style_.axis_text);
}
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) { void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
// Marker shapes as line segments, in viewport pixels: the trace's flush maps // Marker shapes as line segments, in viewport pixels: the trace's flush maps
// them with `pixel_mapping()`, so the size is device pixels whatever the data // them with `pixel_mapping()`, so the size is device pixels whatever the data
@@ -1488,19 +1542,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.
for (const CrosshairRow& row : crosshair.rows) { //
if (!row.valid) continue; // `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
Color color = plot.trace(row.trace).color; // trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
color.a = 1.0f; // as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y}); // and the lines without the marks.
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm}); const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
flush_lines(pixel_mapping(), color, style_.device(1.0f)); const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
if (arm > 0.0f) {
for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue;
Color color = plot.trace(row.trace).color;
color.a = 1.0f;
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, mark_stroke);
}
} }
} }
+21
View File
@@ -263,6 +263,27 @@ PLOT_TEST(key_minus_zooms_out) {
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0); PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
} }
PLOT_TEST(ctrl_plus_zooms_only_x) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
}
PLOT_TEST(alt_plus_zooms_only_y) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports /// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out /// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis /// of a degenerate mapping of its own. The pan arithmetic runs through the axis
+57 -8
View File
@@ -425,6 +425,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
} }
} }
/// The other side of the same band. `axes` reached the viewport's right edge, which left
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
/// ask for an empty band there. One value for every row, like the left gutter, because
/// the rows' axes have to end on a common column as well as start on one.
PLOT_TEST(figure_rows_share_one_right_gutter) {
plot::Figure figure = different_magnitude_figure();
figure.layout();
const plot::f32 flush = figure.plot(0u).layout().axes.right();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
// The default is the layout as it always was: nothing between the data and the frame.
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
figure.set_right_gutter(30.0f);
figure.layout();
const plot::Rect first = figure.plot(0u).layout().axes;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& rl = figure.plot(row).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
1e-4);
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
// so a host can balance the two sides without the one moving when it sets the other.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
// ... and the x strip follows the data area rather than the viewport, or the shared
// axis would be labelled over a band that holds no data.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
1e-4);
}
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
// squeeze `axes` below the floor `Plot::layout()` guarantees.
figure.set_right_gutter(10.0f * kW);
figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
}
// Negative means "none", like the gutter and the row gap, and the data area goes back
// to the viewport's edge.
figure.set_right_gutter(-5.0f);
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
figure.layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
static_cast<plot::f64>(flush), 1e-4);
}
/// The degenerate neighbour: a figure of one row imposes exactly what that row /// The degenerate neighbour: a figure of one row imposes exactly what that row
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a /// measured, so it lays out as the plot would on its own -- the gutter cannot move a
/// row that has no sibling to line up with. /// row that has no sibling to line up with.
@@ -877,8 +923,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 +952,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 +976,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 +1019,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.
+191 -36
View File
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 99.0}; } plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_; ++gather_calls_;
samples_read_ += end - begin; samples_read_ += end - begin;
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
u64 gather_calls() const noexcept { return gather_calls_; } u64 gather_calls() const noexcept { return gather_calls_; }
@@ -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
@@ -2323,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 20.0}; } plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; } f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
@@ -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.
+204 -2
View File
@@ -723,11 +723,11 @@ class LyingGridSeries final : public plot::data::Series {
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u; if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true; return true;
} }
void gather(u32, u64 begin, u64 end, std::vector<f32>& out, void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override { plot::data::GatherMode) const override {
++gather_calls_; ++gather_calls_;
for (u64 i = begin; i < end; ++i) { for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0())); out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(kY); out.push_back(kY);
} }
} }
@@ -1470,6 +1470,103 @@ PLOT_TEST(a_sparse_window_marks_its_samples) {
ctx.destroy(); ctx.destroy();
} }
/// A ring-backed series whose oldest slots read a *clamped* stamp gets no segment across the panel.
///
/// `ChannelBuffer`'s documented bargain: the published window is pinned while both rings slide under
/// it, so the oldest few slots of a saturated window point at time slots the producer has
/// overwritten and have to be clamped to the oldest *readable* one -- the alternative, a sentinel,
/// collapsed the whole index mapping. The effect on the polyline is that the stamps do not increase
/// at the *front*: the first points carry the newest stamp and everything behind them is older. Drawn
/// as they come, that is a segment from the newest sample back to the oldest: a line across the whole
/// panel, which is what "it connects the newest point with the oldest" looks like.
///
/// The trace here is flat at +1 except for those two unreadable points, which carry -1. Any ink in the
/// lower half of the axes is therefore the bad segment, and nothing else in the frame is that colour.
PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
/// Eight samples: indices 0 and 1 are the clamped front (newest stamp, low value), 2..7 walk 0..50
/// at +1.
struct ClampedFront final : plot::data::Series {
ClampedFront() {
pyramid_.reserve(8u);
for (u64 i = 0; i < 8u; ++i) pyramid_.append(i < 2u ? -1.0f : 1.0f);
}
u64 size() const override { return 8u; }
f64 x0() const override { return 0.0; }
f64 dx() const override { return 1.0; }
plot::Range x_bounds() const override { return {0.0, 60.0}; }
plot::Range y_bounds() const override { return {-1.0, 1.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
f64 x_at(u64 i) const override { return i < 2u ? 60.0 : 10.0 * static_cast<f64>(i - 2u); }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x < 0.0 || x > 60.0) return false;
const u64 k = std::min<u64>(2u + static_cast<u64>(x / 10.0), 7u);
i0 = k;
i1 = k;
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override {
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(i < 2u ? -1.0f : 1.0f);
}
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end; ++i) out[i - begin] = i < 2u ? -1.0f : 1.0f;
}
private:
plot::data::MinMaxPyramid pyramid_;
} series;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {-5.0, 55.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
plot::u32 below = 0;
plot::u32 on_the_line = 0;
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) + 2u;
row + 2u < static_cast<plot::u32>(a.h); ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (green_pixel(px)) ++below;
}
}
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) - 2u;
row > 1u; --row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (green_pixel(px)) ++on_the_line;
}
}
std::printf("[clamped-front] ink below the middle: %u, above: %u\n", below, on_the_line);
PLOT_CHECK(below == 0u); // the two clamped points' segments are nowhere
PLOT_CHECK(on_the_line > 100u); // and the real trace is still drawn
ctx.destroy();
}
PLOT_TEST(grid_hairlines_land_on_whole_pixels) { PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH); plottest::HeadlessContext ctx(kW, kH);
plot::Status st; plot::Status st;
@@ -3075,6 +3172,111 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
ctx.destroy(); ctx.destroy();
} }
/// `Plot::set_x_note()`'s caption is *chrome*: it sits in the bottom-left corner of the
/// axes on the legend's own backplate, and panning or zooming the x range does not move
/// it by a pixel. That is the whole reason it exists rather than an `Annotation`, whose
/// anchor is a data coordinate and so crawls under a pan and drifts under a zoom.
///
/// The caption's rect is deliberately not readable back -- it reserves no space and
/// nothing can be picked on it -- so the test measures it the only way a reader can see
/// it: the pixels a frame *with* the note differs from the same frame without one. The
/// bounding box of that difference is the caption's footprint, and the assertion is that
/// two completely different x ranges produce the *same* box.
PLOT_TEST(the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
// The footprint of the note at one x range: the bounding box of the pixels that the
// caption, and only the caption, is responsible for.
const auto footprint = [&](const plot::Range& view, const char* dump) {
plot::Rect box{0.0f, 0.0f, 0.0f, 0.0f};
p.set_x_range(ax, view);
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, dump);
const std::vector<plot::u8> with = read_frame(ctx);
p.set_x_note("");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
for (plot::u32 y = 0; y < kH; ++y) {
for (plot::u32 x = 0; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
continue;
}
++differing;
lo_x = std::min(lo_x, x);
lo_y = std::min(lo_y, y);
hi_x = std::max(hi_x, x);
hi_y = std::max(hi_y, y);
}
}
// A comparison of two frames is only evidence if the caption was in one of them.
PLOT_CHECK(differing > 0u);
box = plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
static_cast<plot::f32>(hi_x - lo_x + 1u),
static_cast<plot::f32>(hi_y - lo_y + 1u)};
return box;
};
const plot::Rect wide = footprint({0.0, 640.0}, "x_note.ppm");
// The corner: inside the axes, held off the left and bottom edges by a pad of a few
// pixels, and nowhere near the right-hand side or the top.
const plot::Rect axes = p.layout().axes;
PLOT_CHECK(wide.x > axes.x && wide.x < axes.x + 16.0f);
PLOT_CHECK(wide.bottom() <= axes.bottom() && wide.bottom() > axes.bottom() - 16.0f);
PLOT_CHECK(wide.right() < axes.x + 0.5f * axes.w); // a caption, not a banner
PLOT_CHECK(wide.h >= 8.0f && wide.h < 0.5f * axes.h);
// Real glyph ink on the backplate, so the box is a caption and not an empty panel. The
// frame has to be re-drawn *with* the note first: the measurement above left the
// note-less frame in the buffer, and `scan_rect_ink` reads the live one.
p.set_x_range(ax, {0.0, 640.0});
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(scan_rect_ink(ctx, wide, r.style().axis_text).pixels >= 16u);
// The same caption under a pan to a different decade and a 64x zoom: an annotation
// anchored in data would have left the frame entirely, and the one here has not moved
// a pixel. The text is fixed across the three, since this case is about the position.
const plot::Rect panned = footprint({320.0, 960.0}, "x_note_panned.ppm");
const plot::Rect zoomed = footprint({300.0, 310.0}, "x_note_zoomed.ppm");
PLOT_CHECK_NEAR(panned.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(panned.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(panned.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(panned.h, wide.h, 1e-4);
PLOT_CHECK_NEAR(zoomed.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(zoomed.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(zoomed.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(zoomed.h, wide.h, 1e-4);
std::printf("[x note] footprint %.0f,%.0f %.0fx%.0f in axes %.0f,%.0f %.0fx%.0f; panned "
"%.0f,%.0f %.0fx%.0f, zoomed %.0f,%.0f %.0fx%.0f\n",
wide.x, wide.y, wide.w, wide.h, axes.x, axes.y, axes.w, axes.h, panned.x, panned.y,
panned.w, panned.h, zoomed.x, zoomed.y, zoomed.w, zoomed.h);
ctx.destroy();
}
// --- one physical size at any DPI -------------------------------------------- // --- one physical size at any DPI --------------------------------------------
namespace { namespace {
+60 -20
View File
@@ -24,7 +24,7 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9); PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, 4, out); s.gather(0, 0, 4, s.x0(), out);
PLOT_CHECK(out.size() == 8); // interleaved x,y PLOT_CHECK(out.size() == 8); // interleaved x,y
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract). // x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6); PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
@@ -104,7 +104,7 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1); PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples s.gather(0, 0, n, s.x0(), out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n); PLOT_CHECK(out.size() == 2 * n);
// x is relative to x0(): the oldest retained sample emits 0, the newest // x is relative to x0(): the oldest retained sample emits 0, the newest
// (n - 1) * dx, never the absolute data coordinates (relative-x contract). // (n - 1) * dx, never the absolute data coordinates (relative-x contract).
@@ -157,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
(void)s.sum(0, n); (void)s.sum(0, n);
(void)s.sumsq(0, n); (void)s.sumsq(0, n);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
if (out.size() != 2 * n) sizes_ok = false; if (out.size() != 2 * n) sizes_ok = false;
} }
++reads; ++reads;
@@ -258,7 +258,7 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
// wholly at or after window index 0, `last` one past the last wholly inside // wholly at or after window index 0, `last` one past the last wholly inside
// the window -- so every emitted bucket's samples lie inside the window. // the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out; std::vector<f32> out;
s.gather(1, 0, n, out); s.gather(1, 0, n, s.x0(), out);
const u64 first = const u64 first =
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
const u64 last = std::min((base + n) / span, lbase + lsize); const u64 last = std::min((base + n) / span, lbase + lsize);
@@ -374,7 +374,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) { const auto check_ends = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, n, out); s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the // Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later). x is relative to // first whole bucket (which starts `lead` samples later). x is relative to
@@ -403,7 +403,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2 PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
plot::data::ArraySeries a(y, 0.0, 1e-6); plot::data::ArraySeries a(y, 0.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
a.gather(2, 0, ny, out); a.gather(2, 0, ny, a.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9); PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
@@ -469,7 +469,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const auto check_range = [&](u32 level) { const auto check_range = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
@@ -499,7 +499,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const u64 buckets = last - first; const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span))); PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0; plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
@@ -544,7 +544,7 @@ PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
const plot::Range exact = s.reduce(b, e); const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0); PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out; std::vector<f32> out;
s.gather(1, b, e, out); s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
@@ -614,11 +614,11 @@ PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f; for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0); plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
std::vector<f32> deepest; std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest); s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
PLOT_CHECK(!deepest.empty()); PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) { for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, s.size(), out); s.gather(level, 0, s.size(), s.x0(), out);
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
} }
// The cap is where the span shift still fits: level 57 spans exactly 2^63. // The cap is where the span shift still fits: level 57 spans exactly 2^63.
@@ -657,9 +657,9 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
PLOT_CHECK(buckets == 16); PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean; std::vector<f32> env, near, mean;
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope); s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest); s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean); s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at // Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and // its last (this sawtooth's per-bucket min is its first sample's value and
@@ -692,7 +692,7 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
// Level 0 and sub-bucket ranges are the raw samples for every mode. // Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw; std::vector<f32> raw;
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean); s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5)); PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9); PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
} }
@@ -712,17 +712,17 @@ PLOT_TEST(gather_skips_all_nan_buckets) {
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans. // first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near; std::vector<f32> env, mean, near;
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope); s.gather(1, 0, 256, s.x0(), env, plot::data::GatherMode::Envelope);
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9); PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9); PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9); PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9); PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean); s.gather(1, 0, 256, s.x0(), mean, plot::data::GatherMode::Mean);
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9); PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9); PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest); s.gather(1, 0, 256, s.x0(), near, plot::data::GatherMode::Nearest);
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9); PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9); PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
@@ -739,7 +739,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
const u64 n = s.size(); const u64 n = s.size();
const plot::Range bounds = s.y_bounds(); const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered; std::vector<f32> gathered;
s.gather(1, 0, n, gathered); s.gather(1, 0, n, s.x0(), gathered);
const plot::Range reduced = s.reduce(0, n); const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window. // Freeze now, while the ring still holds exactly the published window.
@@ -754,7 +754,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6); PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3); PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered; std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen_gathered); frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest // The producer now laps the ring (700 more pushes overwrite the oldest
@@ -769,6 +769,46 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max); PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
} }
// --- The gather origin: f32 precision against the view, not the window -------
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
// decides is the span that f32 has to resolve: the whole retained window (which
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
// readout quotes `x_at()` in exact f64, so the difference between the two is the
// distance between where a sample is *drawn* and the x reported for it.
constexpr u64 kN = 20001;
std::vector<f32> y(static_cast<plot::usize>(kN));
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
const auto worst_error_px = [&](f64 origin) {
std::vector<f32> out;
s.gather(0, kN - 3u, kN, origin, out);
PLOT_CHECK(out.size() == 6u);
f64 worst = 0.0;
for (u64 k = 0; k < 3u; ++k) {
// Exactly the renderer's reconstruction: the origin added back in f64.
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
}
return worst;
};
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
// the error is a fixed fraction of a pixel at every zoom.
PLOT_CHECK(worst_error_px(view.min) < 0.05);
// The start of the retained window -- what every caller used to get -- resolves
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
}
// --- Incremental append: ArraySeries::push() ---------------------------------- // --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) { PLOT_TEST(array_series_push_matches_commit) {
+12
View File
@@ -263,6 +263,18 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d"); plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d"); plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
// A mantissa may not reach its own radix either: three significant figures of a value a
// hair under a rung's top round up to 1000, and `%.3g` writes that `1e+03`. A one-second
// tick computed as 0.9999999 s read `1e+03ms` on a live axis, which is where this was seen.
plot::format_time(0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9995, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9994, b, sizeof b); PLOT_CHECK(std::string(b) == "999ms");
plot::format_time(0.9999999e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "1ms");
plot::format_time(0.9994e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "999\u00b5s");
plot::format_time(0.9999999e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "1\u00b5s");
plot::format_time(0.9999999e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "1ns");
plot::format_time(-0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "-1s");
// Sweep the boundary neighbourhoods and check every emitted field. // Sweep the boundary neighbourhoods and check every emitted field.
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4, const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5, 3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
+7 -6
View File
@@ -52,7 +52,8 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and // Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
// gather() emits x relative to x0() -- small numbers, not quantised epochs. // gather() emits x relative to the origin the caller names -- x0() here, so
// small numbers, not quantised epochs.
std::vector<f64> epoch = spread_x(500); std::vector<f64> epoch = spread_x(500);
const std::vector<f64> es = epoch; // un-moved copy for the expectations const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9; for (auto& v : epoch) v += 1.7e9;
@@ -60,7 +61,7 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3); PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3); PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out; std::vector<f32> out;
e.gather(0, 0, 500, out); e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000); PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small // x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from // values even though the absolute epoch is 1.7e9. The expectations come from
@@ -72,9 +73,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));
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out, plot::data::GatherMode::Envelope); s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches // Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them). // both the range's extremes (the lead/trail raw spans carry them).