Compare commits
6
Commits
c028f48fa0
...
0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee318870f | ||
|
|
58473db384 | ||
|
|
c2c8c596f3 | ||
|
|
59c2e45af7 | ||
|
|
03c41adbec | ||
|
|
c960decaea |
No files matched your search
@@ -47,7 +47,9 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
- cursors in both orientations — vertical markers and horizontal rulers;
|
- 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 readout of the nearest sample's value;
|
- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
|
||||||
|
(`CrosshairValue::NearestSample`, the default) or interpolating between two
|
||||||
|
(`CrosshairValue::Interpolated`);
|
||||||
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
- 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
|
||||||
|
|||||||
@@ -179,18 +179,23 @@ these two is *how many* samples answer the question:
|
|||||||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
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 a single sample — the nearest one.** `crosshair_span(series,
|
- **The crosshair readout is a single sample — the nearest one — by default.**
|
||||||
x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and the readout takes
|
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||||||
whichever of the two is nearer: `t <= 0.5` the earlier, otherwise the later. It answers
|
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||||||
"which datum is under the pointer", not "what is the signal *at* this instant" — a
|
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||||||
linearly interpolated y is a number the series does not contain, and on a source whose
|
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||||||
samples are already averaged or decimated at ingest it is a claim about a signal nobody
|
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||||||
measured. The lookup is O(1) and allocation-free, and it reads level 0, so the value is a
|
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||||||
stored sample and never an LOD bucket's aggregate; `Plot::crosshair_value(trace, x,
|
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||||||
out_y)` exposes the same number to a host, and `CrosshairRow::marker` is placed from that
|
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||||||
same y, so the dot and the number cannot disagree. A row whose trace has no sample at the
|
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||||||
crosshair's x shows `kCrosshairNoValue` (an em dash, `"—"`), and an x outside the series
|
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||||||
window yields no value rather than clamping to the first or last sample.
|
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
-4
@@ -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,10 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
|
|||||||
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
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 the **nearest sample's**, neither interpolated between two samples nor an
|
### What a readout is: the two value modes
|
||||||
interval statistic (see [data-and-performance.md](data-and-performance.md)); a row whose trace
|
|
||||||
has no sample at the crosshair's x is listed with the em dash `"—"` and no marker, rather than a
|
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
|
||||||
fabricated value.
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,11 +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 samples.** The y a row reports for a trace is the y of
|
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
|
||||||
/// whichever of the two samples adjacent to `x` is **nearer** to it -- not a value
|
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
|
||||||
/// interpolated between them, and not an interval statistic. See `crosshair_span()` for the
|
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
|
||||||
/// rule stated in full, and for how it differs from
|
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
|
||||||
/// `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
|
||||||
@@ -68,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()`.
|
||||||
@@ -85,19 +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.
|
||||||
///
|
///
|
||||||
/// **`t` chooses between the two samples; it does not blend them.** The value a caller
|
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
|
||||||
/// builds from this span is the y of whichever end `t` is nearer to (`Plot::crosshair_value()`
|
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
|
||||||
/// takes `t <= 0.5` as `begin`), so a readout quotes a sample the series actually holds. It
|
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
|
||||||
/// used to be a linear interpolation between the two, which reads as "the signal at this
|
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
|
||||||
/// instant" -- a number that is *between* the data and therefore in no row of it. On a
|
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
|
||||||
/// decimated or averaged source that is a value nothing measured, and at a zoom past the
|
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
|
||||||
/// sample spacing it is visibly not the datum the pointer is over.
|
|
||||||
///
|
///
|
||||||
/// So `t` is a *selector*, and this remains a different convention from
|
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
|
||||||
/// `Series::reduce()`, `sum()` and `sumsq()`, which are **exact over a sample range**: those
|
/// which are **exact over a sample range**: those answer "what are the samples'
|
||||||
/// answer "what are the samples' extremes/total over this interval", this answers "which
|
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
|
||||||
/// sample is under the pointer, and what is its value". Both now quote the data; what differs
|
/// x". What differs is a statistic of many samples against a reading at one place.
|
||||||
/// is whether the answer is one sample or a statistic of many.
|
|
||||||
///
|
///
|
||||||
/// `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
|
||||||
@@ -110,13 +140,13 @@ 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]`. A *selector*, not a blend
|
/// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
|
||||||
/// factor -- see the struct comment.
|
/// 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
|
||||||
/// 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
|
||||||
@@ -125,6 +155,15 @@ 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 linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
|
||||||
|
/// which is what `CrosshairValue::Interpolated` reports.
|
||||||
|
///
|
||||||
|
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
|
||||||
|
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
|
||||||
|
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
|
||||||
|
/// is in force. `NearestSample` does not call it at all.
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
|
||||||
|
|
||||||
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
/// 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 y of the trace's sample nearest 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,12 +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;
|
||||||
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `y`, placed
|
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
|
||||||
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
|
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
|
||||||
/// scale it is drawn against. It is the nearest **sample**, so at a zoom past the sample
|
/// scale it is drawn against.
|
||||||
/// spacing the marker sits beside the polyline rather than on it -- deliberately: it marks
|
///
|
||||||
/// the datum the row reports, and one y feeding both keeps the dot and the number from ever
|
/// It marks **the point the row reports**, which is the whole job: in
|
||||||
/// disagreeing. Meaningless when `!valid`.
|
/// `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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,23 +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 value of `trace`'s **nearest sample** to a data `x`: `crosshair_span()` brackets
|
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
|
||||||
/// `x`, and the nearer of the two samples is the answer (`t <= 0.5` takes the earlier) --
|
/// bracketing sample's y, or the blend of the two, according to
|
||||||
/// the same lookup the readout's rows are built from, exposed for a host that wants the
|
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
|
||||||
/// number for 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).
|
||||||
///
|
|
||||||
/// Never a value interpolated between two samples: see `CrosshairSpan` for why a readout
|
|
||||||
/// quotes the data rather than the signal it implies.
|
|
||||||
///
|
///
|
||||||
/// 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
|
||||||
@@ -712,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
|
||||||
@@ -829,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_{};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ----------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+57
-14
@@ -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 =
|
||||||
@@ -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,17 +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]);
|
||||||
// **The nearer sample, never a value between two of them.** A readout quotes the data: a
|
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
|
||||||
// number the series does not contain is a claim about a signal between its samples, which
|
// The signal the samples imply *at* this x, so the point is the query's own x: that is
|
||||||
// is the caller's to make and not a plot's to put in a panel. `span.t` is measured on the
|
// where an interpolated number lives, and the marker on the polyline there is honest
|
||||||
// samples' own x, so "nearer" is exact on any grid; a tie reads the earlier sample, which
|
// about it.
|
||||||
// is the bias `crosshair_span()` already takes for a degenerate pair.
|
out_x = x;
|
||||||
out_y = span.t <= 0.5 ? y_begin : y_end;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,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 nearest sample's number where the trace has one 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
|
||||||
@@ -1548,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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+65
-36
@@ -533,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.
|
||||||
@@ -735,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1025,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;
|
||||||
@@ -1040,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;
|
||||||
@@ -1058,11 +1056,11 @@ 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);
|
||||||
@@ -1085,12 +1083,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
usize kept = 0;
|
usize kept = 0;
|
||||||
f64 next = std::numeric_limits<f64>::infinity();
|
f64 next = std::numeric_limits<f64>::infinity();
|
||||||
for (usize k = count; k > 0; --k) {
|
for (usize k = count; k > 0; --k) {
|
||||||
const usize i = k - 1u;
|
const usize p = k - 1u; // the point index; `i` above is the trace's
|
||||||
const f64 x = static_cast<f64>(gather_[2u * i]) + series.x0();
|
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
||||||
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
|
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
|
||||||
next = x;
|
next = x;
|
||||||
gather_[2u * (count - 1u - kept)] = gather_[2u * i];
|
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
|
||||||
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * i + 1u];
|
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
|
||||||
++kept;
|
++kept;
|
||||||
}
|
}
|
||||||
if (kept != count) {
|
if (kept != count) {
|
||||||
@@ -1113,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,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};
|
||||||
@@ -1403,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
|
||||||
@@ -1522,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.
|
||||||
|
//
|
||||||
|
// `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
|
||||||
|
// trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
|
||||||
|
// as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
|
||||||
|
// and the lines without the marks.
|
||||||
|
const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
|
||||||
|
const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
|
||||||
|
if (arm > 0.0f) {
|
||||||
for (const CrosshairRow& row : crosshair.rows) {
|
for (const CrosshairRow& row : crosshair.rows) {
|
||||||
if (!row.valid) continue;
|
if (!row.valid) continue;
|
||||||
Color color = plot.trace(row.trace).color;
|
Color color = plot.trace(row.trace).color;
|
||||||
color.a = 1.0f;
|
color.a = 1.0f;
|
||||||
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
|
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
|
||||||
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
|
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
|
||||||
flush_lines(pixel_mapping(), color, style_.device(1.0f));
|
flush_lines(pixel_mapping(), color, mark_stroke);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+132
-4
@@ -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_; }
|
||||||
@@ -2068,6 +2068,134 @@ PLOT_TEST(crosshair_value_is_the_nearest_sample) {
|
|||||||
PLOT_CHECK(crosshair.rows[0].value == "10");
|
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
|
||||||
/// named** -- the readout is about what is under the pointer, so the legend's rule of skipping
|
/// named** -- the readout is about what is under the pointer, so the legend's rule of skipping
|
||||||
/// unnamed traces does not apply -- while a trace with no series or no samples is not under the
|
/// unnamed traces does not apply -- while a trace with no series or no samples is not under the
|
||||||
@@ -2343,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)]; }
|
||||||
|
|||||||
+110
-4
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1512,9 +1512,10 @@ PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
|
|||||||
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
|
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void gather(u32, u64 begin, u64 end, std::vector<f32>& out, plot::data::GatherMode) const override {
|
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) {
|
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(i < 2u ? -1.0f : 1.0f);
|
out.push_back(i < 2u ? -1.0f : 1.0f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3171,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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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).
|
||||||
|
|||||||
Reference in new issue
Block a user