Compare commits
30
Commits
26a7c82a80
...
1d5b286611
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5b286611 | ||
|
|
2cb31cbb91 | ||
|
|
69f0083fc0 | ||
|
|
2b4b316ad8 | ||
|
|
25c3381551 | ||
|
|
73f610b136 | ||
|
|
e5b7faadec | ||
|
|
7d8c453232 | ||
|
|
f79a97be9b | ||
|
|
1b8b55d3ee | ||
|
|
738205293e | ||
|
|
6a6915e64e | ||
|
|
3ad02e5120 | ||
|
|
9790becb33 | ||
|
|
2f7cdccdde | ||
|
|
be1f31a04f | ||
|
|
1fc97d818f | ||
|
|
ebc67bd318 | ||
|
|
1ae72d4903 | ||
|
|
2ebf2be571 | ||
|
|
37e414bcc5 | ||
|
|
cc908efa36 | ||
|
|
e81e84645f | ||
|
|
bee318870f | ||
|
|
58473db384 | ||
|
|
c2c8c596f3 | ||
|
|
59c2e45af7 | ||
|
|
03c41adbec | ||
|
|
c960decaea | ||
|
|
c028f48fa0 |
No files matched your search
+12
@@ -3,3 +3,15 @@ build/
|
|||||||
# Not this project's and not generated by it: a Solarus game-engine message that
|
# Not this project's and not generated by it: a Solarus game-engine message that
|
||||||
# happens to sit in the repo root.
|
# happens to sit in the repo root.
|
||||||
error.txt
|
error.txt
|
||||||
|
|
||||||
|
# An *in-tree* configure writes these beside the sources. `build/` above only covers the
|
||||||
|
# directory the presets use, and `cmake -S . -B .` leaves the whole of CMake's output in the
|
||||||
|
# repository root -- which then reads as an untracked submodule to whoever has it checked out.
|
||||||
|
CMakeFiles/
|
||||||
|
CMakeCache.txt
|
||||||
|
cmake_install.cmake
|
||||||
|
Makefile
|
||||||
|
CTestTestfile.cmake
|
||||||
|
CPackConfig.cmake
|
||||||
|
# Configured from `include/plot/version.hpp.in`, so it is output and not a source file.
|
||||||
|
include/plot/version.hpp
|
||||||
@@ -14,6 +14,12 @@ built on top of it.
|
|||||||
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
||||||
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
||||||
|
|
||||||
|
The GTK4 adapter exists for, and is exercised by, a real consumer: **udpscope**, an oscilloscope-style viewer
|
||||||
|
for a UDPStreamer source, at <https://git.ferrarinas.it/martino/udpscope>. It embeds the core through
|
||||||
|
`plot::gtk4`, and the library's interaction layer — cursors, measurement marks, the shading of a span a host
|
||||||
|
has declared invalid — has been shaped by what that host needed. The notes in `src/`, `docs/` and `tests/`
|
||||||
|
that say "in udpscope's case" are those places.
|
||||||
|
|
||||||
The visible x range is mapped onto sample indices through the series' own x
|
The visible x range is mapped onto sample indices through the series' own x
|
||||||
(`Series::neighbours()`, a binary search over the real stamps), never through the
|
(`Series::neighbours()`, a binary search over the real stamps), never through the
|
||||||
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
|
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
|
||||||
@@ -37,6 +43,35 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||||
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||||
data keeps the older placement -- right edge on the newest sample);
|
data keeps the older placement -- right edge on the newest sample);
|
||||||
|
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
|
||||||
|
one snapped vertical segment per integer column, lit from that column's own extremes --
|
||||||
|
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
|
||||||
|
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
|
||||||
|
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
|
||||||
|
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
|
||||||
|
- and such a column can be drawn **graded by dwell** instead of as one flat band
|
||||||
|
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
|
||||||
|
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
|
||||||
|
what puts back the information the reduction throws away — a column's min and max are the same
|
||||||
|
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
|
||||||
|
finer than the pixel grid and the distribution read from those sub-buckets. Below the density at
|
||||||
|
which columns are reduced the same switch grades the **polyline** by each segment's slope
|
||||||
|
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
|
||||||
|
threshold;
|
||||||
|
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
|
||||||
|
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
|
||||||
|
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
|
||||||
|
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
|
||||||
|
often it passed. It fades either by a **time constant** in stream seconds or by a
|
||||||
|
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
|
||||||
|
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
|
||||||
|
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
|
||||||
|
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
|
||||||
|
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
|
||||||
|
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
|
||||||
|
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
|
||||||
|
columns rather than the samples, and a frame whose grids did not move costs one draw call per
|
||||||
|
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
|
||||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||||
@@ -47,7 +82,9 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
- cursors in both orientations — vertical markers and horizontal rulers;
|
- cursors in both orientations — vertical markers and horizontal rulers;
|
||||||
- draggable regions and measurement bands with exact interval statistics;
|
- draggable regions and measurement bands with exact interval statistics;
|
||||||
- a signal legend anchored to an axes corner;
|
- a signal legend anchored to an axes corner;
|
||||||
- a hover crosshair with a per-signal, interpolated readout;
|
- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
|
||||||
|
(`CrosshairValue::NearestSample`, the default) or interpolating between two
|
||||||
|
(`CrosshairValue::Interpolated`);
|
||||||
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
||||||
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
||||||
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
||||||
@@ -64,7 +101,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
|
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
|
||||||
controller paths as mouse and wheel (touch parity), with keyboard panning
|
controller paths as mouse and wheel (touch parity), with keyboard panning
|
||||||
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
|
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
|
||||||
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
|
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being
|
||||||
|
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
|
||||||
|
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
|
||||||
|
sliver a straight horizontal drag sweeps;
|
||||||
- DPI-consistent sizing: metrics are logical units converted once through
|
- DPI-consistent sizing: metrics are logical units converted once through
|
||||||
`Style::ui_scale`;
|
`Style::ui_scale`;
|
||||||
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
|
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
|
||||||
@@ -170,12 +210,12 @@ documentation.
|
|||||||
```sh
|
```sh
|
||||||
cmake --preset linux-debug
|
cmake --preset linux-debug
|
||||||
cmake --build build/debug
|
cmake --build build/debug
|
||||||
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
|
ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
|
||||||
|
|
||||||
tools/coverage.sh # configure+build+run+capture+floor check
|
tools/coverage.sh # configure+build+run+capture+floor check
|
||||||
```
|
```
|
||||||
|
|
||||||
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
|
The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** of
|
||||||
them, because its adapters are off and 17 cases need an adapter target (11 in
|
them, because its adapters are off and 17 cases need an adapter target (11 in
|
||||||
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
||||||
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
||||||
|
|||||||
+27
-22
@@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept {
|
|||||||
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u32 rows = figure_->row_count();
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
row_controllers_.reserve(rows);
|
// flattened plot index the figure uses.
|
||||||
for (u32 i = 0; i < rows; ++i) {
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++i) {
|
||||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||||
}
|
}
|
||||||
// The new figure's rects go into the cache now, not at the next frame: an event
|
// The new figure's rects go into the cache now, not at the next frame: an event
|
||||||
@@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept {
|
|||||||
|
|
||||||
void Host::refresh_row_rects() noexcept {
|
void Host::refresh_row_rects() noexcept {
|
||||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||||
// instead of paying this again, which is the whole point of the cache (R7).
|
// instead of paying this again, which is the whole point of the cache (R7). The rects
|
||||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
// are the figure's **cells** (one per plot, flattened), which for a figure with one
|
||||||
|
// cell per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller* Host::row_controller(u32 row) noexcept {
|
input::Controller* Host::row_controller(u32 row) noexcept {
|
||||||
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
|||||||
// frame stale, which is the documented worst case.
|
// frame stale, which is the documented worst case.
|
||||||
const std::vector<Rect>& rows = row_rects_;
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
const u32 row = row_in(rows, x, y);
|
||||||
const u32 row = row_in(rows, y);
|
|
||||||
last_row_ = row;
|
last_row_ = row;
|
||||||
input::Controller& controller = *row_controllers_[row];
|
input::Controller& controller = *row_controllers_[row];
|
||||||
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
||||||
@@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
|||||||
|
|
||||||
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
bool released) noexcept {
|
bool released) noexcept {
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
|
||||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
const std::vector<Rect>& rows = row_rects_;
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||||
@@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
|
|||||||
// drag, and its press must not steal the capture to the row it landed in.
|
// drag, and its press must not steal the capture to the row it landed in.
|
||||||
if (released && button == captured_button_) captured_ = false;
|
if (released && button == captured_button_) captured_ = false;
|
||||||
} else {
|
} else {
|
||||||
row = row_in(rows, y);
|
row = row_in(rows, x, y);
|
||||||
if (pressed) {
|
if (pressed) {
|
||||||
captured_ = true;
|
captured_ = true;
|
||||||
captured_row_ = row;
|
captured_row_ = row;
|
||||||
@@ -541,27 +543,30 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
|
|||||||
|
|
||||||
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
||||||
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
||||||
return row_in(rows, last_pointer_y_);
|
return row_in(rows, last_pointer_x_, last_pointer_y_);
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
||||||
if (rows.empty()) return 0u;
|
if (rows.empty()) return 0u;
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
|
||||||
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
|
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
// row whose rect is nearest. Selection by distance rather than by "the first that
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
|
// pixel to the rect). Selection by distance rather than by "the first that matches" is
|
||||||
// tile the figure at the default gap of 0, so this is unreachable until a host asks
|
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
|
||||||
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
|
// visible gap must not be routed to plot 0 by default. For a figure with one cell per
|
||||||
|
// row every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
u32 row = 0u;
|
u32 row = 0u;
|
||||||
f32 best = std::numeric_limits<f32>::infinity();
|
double best = std::numeric_limits<double>::infinity();
|
||||||
for (u32 i = 0; i < rows.size(); ++i) {
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
const f32 top = rows[i].y;
|
const Rect& r = rows[i];
|
||||||
const f32 bottom = rows[i].y + rows[i].h;
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
if (distance < best) {
|
if (distance < best) {
|
||||||
best = distance;
|
best = distance;
|
||||||
row = i;
|
row = i;
|
||||||
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
if (distance == 0.0) break; // inside this cell: nothing can be nearer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
+19
-15
@@ -121,18 +121,21 @@ class Host {
|
|||||||
/// `plot_` is then untouched by any of this.
|
/// `plot_` is then untouched by any of this.
|
||||||
void set_figure(Figure* figure) noexcept;
|
void set_figure(Figure* figure) noexcept;
|
||||||
Figure* figure() noexcept { return figure_; }
|
Figure* figure() noexcept { return figure_; }
|
||||||
/// One figure row's controller -- the one a gesture on that row reaches -- or
|
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||||
/// null while no figure is set or the row does not exist. A host reads a drag
|
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
|
||||||
|
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
|
||||||
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
||||||
/// not the one figure gestures go to).
|
/// not the one figure gestures go to).
|
||||||
input::Controller* row_controller(u32 row) noexcept;
|
input::Controller* row_controller(u32 row) noexcept;
|
||||||
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
||||||
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
||||||
/// row that pixel lands in otherwise. The row rects come from the cache
|
/// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
|
||||||
|
/// several cells side by side. The cell rects come from the cache
|
||||||
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
||||||
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
||||||
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
/// inside no cell -- the row or column gap a host asked for with
|
||||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
/// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell rather than
|
||||||
|
/// dropped or defaulted, so a drag
|
||||||
/// that leaves the data area still ends where it should.
|
/// that leaves the data area still ends where it should.
|
||||||
///
|
///
|
||||||
/// It also hands that row its own viewport, which is what keeps the controller's
|
/// It also hands that row its own viewport, which is what keeps the controller's
|
||||||
@@ -217,11 +220,12 @@ class Host {
|
|||||||
void on_scroll(f64 dx, f64 dy) noexcept;
|
void on_scroll(f64 dx, f64 dy) noexcept;
|
||||||
void on_key(int key, int action, int mods) noexcept;
|
void on_key(int key, int action, int mods) noexcept;
|
||||||
|
|
||||||
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
|
/// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
|
||||||
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
|
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
|
||||||
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
|
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
|
||||||
/// that has just laid the figure out does not make it lay out again for this.
|
/// -- and taking the rects rather than reading them, so a caller that has just laid the figure
|
||||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
/// out does not make it lay out again for this.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
|
||||||
|
|
||||||
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
||||||
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
||||||
@@ -231,10 +235,10 @@ class Host {
|
|||||||
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
bool released) noexcept;
|
bool released) noexcept;
|
||||||
|
|
||||||
/// The row the pointer's events currently go to: the captured row while a drag owns
|
/// The plot (flattened index) the pointer's events currently go to: the captured one
|
||||||
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
|
||||||
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
/// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
|
||||||
/// hover or a grab it last published.
|
/// drawing a hover or a grab it last published.
|
||||||
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
||||||
|
|
||||||
/// Spreads the hover over a whole `Figure` (spec §15).
|
/// Spreads the hover over a whole `Figure` (spec §15).
|
||||||
@@ -292,7 +296,7 @@ class Host {
|
|||||||
u32 captured_row_ = 0u;
|
u32 captured_row_ = 0u;
|
||||||
input::Button captured_button_ = input::Button::Left;
|
input::Button captured_button_ = input::Button::Left;
|
||||||
|
|
||||||
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
|
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
|
||||||
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
||||||
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
||||||
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
||||||
|
|||||||
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
|
|||||||
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u32 rows = figure_->row_count();
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
row_controllers_.reserve(rows);
|
// flattened plot index the figure uses.
|
||||||
for (u32 i = 0; i < rows; ++i) {
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++i) {
|
||||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||||
}
|
}
|
||||||
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
||||||
@@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept {
|
|||||||
|
|
||||||
void PlotArea::refresh_row_rects() noexcept {
|
void PlotArea::refresh_row_rects() noexcept {
|
||||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||||
// instead of paying this again, which is the whole point of the cache.
|
// instead of paying this again, which is the whole point of the cache. The rects are
|
||||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
|
||||||
|
// per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
||||||
@@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
|||||||
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
||||||
// pointer motion over a multi-row figure would re-lay every row out per move.
|
// pointer motion over a multi-row figure would re-lay every row out per move.
|
||||||
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
const u32 row = row_in(row_rects_, x, y);
|
||||||
const u32 row = row_in(row_rects_, y);
|
|
||||||
input::Controller& controller = *row_controllers_[row];
|
input::Controller& controller = *row_controllers_[row];
|
||||||
controller.set_viewport(row_rects_[row]);
|
controller.set_viewport(row_rects_[row]);
|
||||||
return controller;
|
return controller;
|
||||||
@@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
|||||||
|
|
||||||
u32 PlotArea::row_at(double x, double y) const noexcept {
|
u32 PlotArea::row_at(double x, double y) const noexcept {
|
||||||
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
||||||
(void)x;
|
const Vec2 p = to_device(x, y);
|
||||||
return row_in(row_rects_, to_device(x, y).y);
|
return row_in(row_rects_, p.x, p.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
|
||||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
// The capture: while a press is open the gesture belongs to the row that press
|
// The capture: while a press is open the gesture belongs to the row that press
|
||||||
@@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
|||||||
// the release to the row the pointer is over *now* would leave the first row's drag
|
// the release to the row the pointer is over *now* would leave the first row's drag
|
||||||
// open for ever -- its controller never sees the release that ends it -- and let a
|
// open for ever -- its controller never sees the release that ends it -- and let a
|
||||||
// later gesture resume it from the stale anchor.
|
// later gesture resume it from the stale anchor.
|
||||||
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y);
|
u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
|
||||||
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
||||||
// guard keeps a stale index from reaching past the controller list.
|
// guard keeps a stale index from reaching past the controller list.
|
||||||
if (row >= row_controllers_.size()) row = 0;
|
if (row >= row_controllers_.size()) row = 0;
|
||||||
@@ -314,23 +317,26 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
||||||
if (rows.empty()) return 0;
|
if (rows.empty()) return 0;
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
// The cell the pixel is in, or -- when it is in none of them, which is the row or
|
||||||
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
|
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
// whose rect is nearest. By distance rather than by "the first that matches": the
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
// rows tile the figure at the default gap of 0, so this is unreachable until a host
|
// pixel to the rect). By distance rather than by "the first that matches": a gesture
|
||||||
// asks for a gap, and a gesture in a visible gap must not default to row 0.
|
// in a visible gap must not default to plot 0. For a figure with one cell per row
|
||||||
|
// every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
u32 row = 0;
|
u32 row = 0;
|
||||||
f32 best = std::numeric_limits<f32>::infinity();
|
double best = std::numeric_limits<double>::infinity();
|
||||||
for (u32 i = 0; i < rows.size(); ++i) {
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
const f32 top = rows[i].y;
|
const Rect& r = rows[i];
|
||||||
const f32 bottom = rows[i].y + rows[i].h;
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
if (distance < best) {
|
if (distance < best) {
|
||||||
best = distance;
|
best = distance;
|
||||||
row = i;
|
row = i;
|
||||||
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
if (distance == 0.0) break; // inside this cell: nothing can be nearer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -341,7 +347,22 @@ u32 PlotArea::pointer_row() const noexcept {
|
|||||||
// The captured row while a press owns the gesture, so an in-flight drag keeps its
|
// The captured row while a press owns the gesture, so an in-flight drag keeps its
|
||||||
// highlight even after the pointer has left the row it began in.
|
// highlight even after the pointer has left the row it began in.
|
||||||
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
|
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
|
||||||
return row_in(row_rects_, pointer_.y);
|
return row_in(row_rects_, pointer_.x, pointer_.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlotArea::sync_selection_frames() noexcept {
|
||||||
|
const auto frame_for = [](input::Controller& c) -> Rect {
|
||||||
|
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
|
||||||
|
};
|
||||||
|
if (figure_ != nullptr) {
|
||||||
|
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
||||||
|
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
|
||||||
|
// with, and a store of the same value is what keeps this path free of anything else.
|
||||||
|
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plot_->set_selection_rect(frame_for(controller_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlotArea::sync_figure_crosshair() noexcept {
|
void PlotArea::sync_figure_crosshair() noexcept {
|
||||||
@@ -534,6 +555,9 @@ void PlotArea::render() noexcept {
|
|||||||
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
|
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
|
||||||
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
|
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
|
||||||
renderer_->style().ui_scale = mapping_.factor;
|
renderer_->style().ui_scale = mapping_.factor;
|
||||||
|
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
|
||||||
|
// with the previous frame's scale is not the one being drawn.
|
||||||
|
sync_selection_frames();
|
||||||
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
|
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
|
||||||
if (figure_ != nullptr) {
|
if (figure_ != nullptr) {
|
||||||
renderer_->draw(*figure_);
|
renderer_->draw(*figure_);
|
||||||
@@ -598,7 +622,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
|
|||||||
// The figure row this gesture belongs to for its whole life, claimed from the press's
|
// The figure row this gesture belongs to for its whole life, claimed from the press's
|
||||||
// own position *before* the event is dispatched (see `target_controller()`). Harmless
|
// own position *before* the event is dispatched (see `target_controller()`). Harmless
|
||||||
// and unread while no figure is set.
|
// and unread while no figure is set.
|
||||||
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0;
|
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0;
|
||||||
press_open_ = true;
|
press_open_ = true;
|
||||||
press_button_ = button;
|
press_button_ = button;
|
||||||
press_mods_ = mods;
|
press_mods_ = mods;
|
||||||
@@ -616,7 +640,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
|
|||||||
e.down = true;
|
e.down = true;
|
||||||
e.pressed = true;
|
e.pressed = true;
|
||||||
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
|
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
|
||||||
target_controller(p.y).on_pointer(e);
|
target_controller(p.x, p.y).on_pointer(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +649,7 @@ void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
|
|||||||
// Resolved while the press is still open, so the release reaches the row the press
|
// Resolved while the press is still open, so the release reaches the row the press
|
||||||
// claimed rather than the row the pointer ended over -- the controller that owns the
|
// claimed rather than the row the pointer ended over -- the controller that owns the
|
||||||
// drag is the only one that can close it.
|
// drag is the only one that can close it.
|
||||||
input::Controller& target = target_controller(position.y);
|
input::Controller& target = target_controller(position.x, position.y);
|
||||||
press_open_ = false;
|
press_open_ = false;
|
||||||
|
|
||||||
input::PointerEvent e;
|
input::PointerEvent e;
|
||||||
@@ -730,7 +754,7 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
|
|||||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||||
// The press's row, not the row the pointer has reached: `press_open_` is true here,
|
// The press's row, not the row the pointer has reached: `press_open_` is true here,
|
||||||
// so a drag that crosses a row boundary stays with the controller that began it.
|
// so a drag that crosses a row boundary stays with the controller that began it.
|
||||||
area->target_controller(sample.position.y).on_pointer(e);
|
area->target_controller(sample.position.x, sample.position.y).on_pointer(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +801,7 @@ void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
|
|||||||
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
|
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
|
||||||
// The folded event carries the focal point, which is the position this pinch belongs
|
// The folded event carries the focal point, which is the position this pinch belongs
|
||||||
// to; while a press is open the capture overrides it, as it does for a drag.
|
// to; while a press is open the capture overrides it, as it does for a drag.
|
||||||
area->target_controller(g.y).on_gesture(g);
|
area->target_controller(g.x, g.y).on_gesture(g);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +840,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
|||||||
e.dx = static_cast<f32>(scroll_notches(dx, unit));
|
e.dx = static_cast<f32>(scroll_notches(dx, unit));
|
||||||
e.dy = static_cast<f32>(notches);
|
e.dy = static_cast<f32>(notches);
|
||||||
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
|
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
|
||||||
area->target_controller(at.y).on_wheel(e);
|
area->target_controller(at.x, at.y).on_wheel(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||||
return TRUE; // consumed: the plot zoomed
|
return TRUE; // consumed: the plot zoomed
|
||||||
}
|
}
|
||||||
@@ -840,7 +864,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
|
|||||||
e.down = false;
|
e.down = false;
|
||||||
e.time_s = PlotArea::pointer_time_s(
|
e.time_s = PlotArea::pointer_time_s(
|
||||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
|
||||||
area->target_controller(p.y).on_pointer(e);
|
area->target_controller(p.x, p.y).on_pointer(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- keyboard -------------------------------------------------------------
|
// --- keyboard -------------------------------------------------------------
|
||||||
|
|||||||
@@ -260,15 +260,17 @@ class PlotArea {
|
|||||||
Figure* figure() noexcept { return figure_; }
|
Figure* figure() noexcept { return figure_; }
|
||||||
const Figure* figure() const noexcept { return figure_; }
|
const Figure* figure() const noexcept { return figure_; }
|
||||||
|
|
||||||
/// One figure row's controller -- the one a gesture on that row reaches -- or null
|
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||||
/// while no figure is set or the row does not exist. A host reads a drag mode or a
|
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
|
||||||
|
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag mode or a
|
||||||
/// hover out of it.
|
/// hover out of it.
|
||||||
input::Controller* row_controller(u32 row) noexcept;
|
input::Controller* row_controller(u32 row) noexcept;
|
||||||
|
|
||||||
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
|
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
|
||||||
/// while no figure is set, and otherwise the controller of the figure row that
|
/// while no figure is set, and otherwise the controller of the figure plot (cell)
|
||||||
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
|
/// that pixel lands in -- x and y both pick it, as a row may hold several cells side
|
||||||
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data
|
/// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
|
||||||
|
/// margin) is the *nearest* cell rather than dropped, so a drag that leaves the data
|
||||||
/// area still ends where it should. It also hands that row its own viewport, which
|
/// area still ends where it should. It also hands that row its own viewport, which
|
||||||
/// is what keeps the controller's hit-test geometry in step with the layout between
|
/// is what keeps the controller's hit-test geometry in step with the layout between
|
||||||
/// two frames.
|
/// two frames.
|
||||||
@@ -277,10 +279,10 @@ class PlotArea {
|
|||||||
/// press capture, so a host may call it freely.
|
/// press capture, so a host may call it freely.
|
||||||
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
||||||
|
|
||||||
/// The figure row a point in the widget's **logical** coordinates lands in -- the
|
/// The flattened plot index a point in the widget's **logical** coordinates lands in -- the
|
||||||
/// coordinates every GTK event controller reports, so a host's own drop target or
|
/// coordinates every GTK event controller reports, so a host's own drop target or
|
||||||
/// context menu can name the row a pointer is over without converting anything. The
|
/// context menu can name the plot a pointer is over without converting anything. The
|
||||||
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
|
/// same nearest-cell rule (x and y) as `controller_at()`, and 0 while no figure is set.
|
||||||
u32 row_at(double x, double y) const noexcept;
|
u32 row_at(double x, double y) const noexcept;
|
||||||
|
|
||||||
/// Re-reads the figure's row rects into the cache the event routing answers from --
|
/// Re-reads the figure's row rects into the cache the event routing answers from --
|
||||||
@@ -376,20 +378,21 @@ class PlotArea {
|
|||||||
/// into device pixels. The one place that mapping exists.
|
/// into device pixels. The one place that mapping exists.
|
||||||
Vec2 to_device(double x, double y) const noexcept;
|
Vec2 to_device(double x, double y) const noexcept;
|
||||||
|
|
||||||
/// The controller one event of this area's goes to, given the event's device y.
|
/// The controller one event of this area's goes to, given the event's device (x, y).
|
||||||
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
|
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
|
||||||
/// to: the row the open press claimed while a press is open, and the row under `y`
|
/// to: the row the open press claimed while a press is open, and the row under `y`
|
||||||
/// between gestures. That is the capture `controller_at()` deliberately omits, and
|
/// between gestures (x and y pick the cell). That is the capture `controller_at()` deliberately omits, and
|
||||||
/// it is why this -- not `controller_at()` -- is what every handler below calls.
|
/// it is why this -- not `controller_at()` -- is what every handler below calls.
|
||||||
///
|
///
|
||||||
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
|
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
|
||||||
/// geometry the last frame drew.
|
/// geometry the last frame drew.
|
||||||
input::Controller& target_controller(f32 y) noexcept;
|
input::Controller& target_controller(f32 x, f32 y) noexcept;
|
||||||
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
|
/// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
|
||||||
/// one when it is in none (a row gap, a margin outside the figure). Selection by
|
/// to: the cell it is in, or the nearest one when it is in none (a row or column gap,
|
||||||
/// distance rather than "the first that matches" is what makes a pixel in a visible
|
/// a margin outside the figure). Selection by distance rather than "the first that
|
||||||
/// gap belong to the row it is *near* instead of to row 0.
|
/// matches" is what makes a pixel in a visible gap belong to the cell it is *near*
|
||||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
/// instead of to plot 0.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
|
||||||
/// The row this area's events currently go to: the press's row while one is open,
|
/// The row this area's events currently go to: the press's row while one is open,
|
||||||
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
|
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
|
||||||
/// position otherwise. 0 with no rows.
|
/// position otherwise. 0 with no rows.
|
||||||
@@ -397,6 +400,17 @@ class PlotArea {
|
|||||||
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
|
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
|
||||||
/// per frame, from the render callback, after this frame's row rects are cached.
|
/// per frame, from the render callback, after this frame's row rects are cached.
|
||||||
void sync_figure_crosshair() noexcept;
|
void sync_figure_crosshair() noexcept;
|
||||||
|
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
|
||||||
|
/// the ones whose gesture is over. Run once per frame from the render callback, like
|
||||||
|
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
|
||||||
|
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
|
||||||
|
/// per-event hook would have to be repeated at every one of them, which is how a frame
|
||||||
|
/// ends up stuck on screen after a cancelled zoom.
|
||||||
|
///
|
||||||
|
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
|
||||||
|
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
|
||||||
|
/// otherwise.
|
||||||
|
void sync_selection_frames() noexcept;
|
||||||
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
|
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
|
||||||
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
|
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
|
||||||
/// `render()`, which must not drift apart.
|
/// `render()`, which must not drift apart.
|
||||||
@@ -446,7 +460,8 @@ class PlotArea {
|
|||||||
/// `set_figure()` is called, and the single-plot path reads neither.
|
/// `set_figure()` is called, and the single-plot path reads neither.
|
||||||
Figure* figure_ = nullptr;
|
Figure* figure_ = nullptr;
|
||||||
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
|
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
|
||||||
/// The figure's row rects as of the last `refresh_row_rects()`: the cache the event
|
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split)
|
||||||
|
/// as of the last `refresh_row_rects()`: the cache the event
|
||||||
/// routing reads instead of laying the figure out per event. Empty while no figure
|
/// routing reads instead of laying the figure out per event. Empty while no figure
|
||||||
/// is set, which is the state the routing falls back to `controller_` on.
|
/// is set, which is the state the routing falls back to `controller_` on.
|
||||||
std::vector<Rect> row_rects_;
|
std::vector<Rect> row_rects_;
|
||||||
|
|||||||
@@ -41,11 +41,18 @@ ctest --test-dir build/debug --output-on-failure
|
|||||||
|
|
||||||
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
||||||
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
||||||
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug`
|
`plot_umbrella_check`. The `plot_tests` binary itself runs **396 cases** in the `linux-debug`
|
||||||
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
|
preset; its harness prints one line per case and a final `396 cases, 0 failed` summary line.
|
||||||
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
|
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
|
||||||
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
|
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
|
||||||
|
|
||||||
|
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
|
||||||
|
is for working *on* a case rather than for checking the library: most of the render cases
|
||||||
|
create their own EGL context and read pixels back, so the whole binary is far slower than
|
||||||
|
the one case being edited and its one line of measured output scrolls past 376 others. The
|
||||||
|
summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
|
||||||
|
having run one — it is never evidence that the suite passes. Unset, every case runs.
|
||||||
|
|
||||||
## Install layout
|
## Install layout
|
||||||
|
|
||||||
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
|
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
|
||||||
@@ -119,7 +126,7 @@ per-file table and the floor verdict live in [docs/coverage.md](coverage.md), wh
|
|||||||
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
|
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
|
||||||
live numbers.
|
live numbers.
|
||||||
|
|
||||||
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17
|
**One thing to know about the coverage preset:** it builds **379 of the 396 cases**. The 17
|
||||||
that are missing are the adapter cases that need an adapter target to exist — 11 in
|
that are missing are the adapter cases that need an adapter target to exist — 11 in
|
||||||
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
|
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
|
||||||
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
|
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
|
||||||
|
|||||||
+291
-12
@@ -166,10 +166,281 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
|
|||||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||||
sides go through the same instanced anti-aliased quad pipeline.
|
sides go through the same instanced anti-aliased quad pipeline.
|
||||||
|
|
||||||
## Exact versus interpolated
|
## Dense traces are reduced onto the pixel grid
|
||||||
|
|
||||||
The library deliberately holds two conventions next to each other, and they do **not**
|
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||||||
agree:
|
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||||||
|
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||||||
|
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||||||
|
|
||||||
|
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||||||
|
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||||||
|
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||||||
|
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||||||
|
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||||||
|
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||||||
|
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||||||
|
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||||||
|
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||||||
|
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||||||
|
and the snap then puts that one phase on the grid.
|
||||||
|
|
||||||
|
Four properties of the reduction are load-bearing:
|
||||||
|
|
||||||
|
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||||||
|
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||||||
|
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||||||
|
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||||||
|
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||||||
|
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||||||
|
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||||||
|
would draw a vertical line over x the series says nothing about.
|
||||||
|
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||||||
|
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||||||
|
trace, which is what the polyline's connector prevented. The later column is extended to
|
||||||
|
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||||||
|
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||||||
|
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||||||
|
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||||||
|
the two bounds are equal, nothing at all.
|
||||||
|
|
||||||
|
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||||||
|
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||||||
|
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||||||
|
are untouched by the snap.
|
||||||
|
|
||||||
|
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||||||
|
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||||||
|
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||||||
|
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||||||
|
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||||||
|
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||||||
|
|
||||||
|
## A reduced column can be graded by dwell
|
||||||
|
|
||||||
|
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
|
||||||
|
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
|
||||||
|
scope reads. It is off by default — a change of look, not a correction of one — and
|
||||||
|
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
|
||||||
|
grading is a way of drawing the reduction and not a third path.
|
||||||
|
|
||||||
|
What it puts back is the information the reduction throws away: a column's min and max are the
|
||||||
|
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
|
||||||
|
are one band. The distribution cannot be recovered from the band, which is why the gather is
|
||||||
|
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
|
||||||
|
and the dwell read from those sub-buckets.
|
||||||
|
|
||||||
|
Five things carry it, and each was arrived at by being wrong first:
|
||||||
|
|
||||||
|
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
|
||||||
|
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
|
||||||
|
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
|
||||||
|
time.
|
||||||
|
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
|
||||||
|
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
|
||||||
|
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
|
||||||
|
row and the negation one past its last, so it costs two writes whatever its height, and the
|
||||||
|
running sum over the column is the per-row dwell.
|
||||||
|
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
|
||||||
|
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
|
||||||
|
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
|
||||||
|
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
|
||||||
|
the peak.
|
||||||
|
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
|
||||||
|
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
|
||||||
|
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
|
||||||
|
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
|
||||||
|
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
|
||||||
|
existence — and `intensity_gamma` lifts the faint end. The floor only holds if the riser's rows
|
||||||
|
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
|
||||||
|
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
|
||||||
|
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
|
||||||
|
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
|
||||||
|
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
|
||||||
|
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
|
||||||
|
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
|
||||||
|
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
|
||||||
|
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
|
||||||
|
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
|
||||||
|
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). A connector between two disjoint columns
|
||||||
|
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
|
||||||
|
measured to dwell.
|
||||||
|
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
|
||||||
|
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
|
||||||
|
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
|
||||||
|
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
|
||||||
|
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
|
||||||
|
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
|
||||||
|
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
|
||||||
|
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
|
||||||
|
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
|
||||||
|
limit, not the number.
|
||||||
|
|
||||||
|
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
|
||||||
|
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
|
||||||
|
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
|
||||||
|
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
|
||||||
|
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
|
||||||
|
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
|
||||||
|
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
|
||||||
|
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
|
||||||
|
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
|
||||||
|
and the floor is never reached.
|
||||||
|
|
||||||
|
## A sparse polyline is graded by slope
|
||||||
|
|
||||||
|
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
|
||||||
|
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
|
||||||
|
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
|
||||||
|
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
|
||||||
|
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
|
||||||
|
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
|
||||||
|
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
|
||||||
|
the moment you look at it closely. The thing a reader most needs told apart from a real signal
|
||||||
|
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
|
||||||
|
|
||||||
|
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
|
||||||
|
it must:
|
||||||
|
|
||||||
|
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
|
||||||
|
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
|
||||||
|
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
|
||||||
|
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
|
||||||
|
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
|
||||||
|
panning does not reshade the trace.
|
||||||
|
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
|
||||||
|
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
|
||||||
|
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
|
||||||
|
only `intensity_grading` and `intensity_steps > 1` are.
|
||||||
|
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
|
||||||
|
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
|
||||||
|
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
|
||||||
|
reaches too.
|
||||||
|
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
|
||||||
|
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
|
||||||
|
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
|
||||||
|
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
|
||||||
|
threshold over the same transition at the same place. The level reads **191 either way** (a flat
|
||||||
|
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
|
||||||
|
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
|
||||||
|
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
|
||||||
|
left to see.
|
||||||
|
|
||||||
|
## A phosphor density is the trace, one grid per trace
|
||||||
|
|
||||||
|
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
|
||||||
|
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
|
||||||
|
time constant or by an acquisition count. It is the library's because the library is the side that
|
||||||
|
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
|
||||||
|
renderer has already staged.
|
||||||
|
|
||||||
|
The split from the grading above is what it costs: grading measures *one* frame's dwell and
|
||||||
|
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
|
||||||
|
also why it cannot be a per-frame accumulator.
|
||||||
|
|
||||||
|
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
|
||||||
|
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
|
||||||
|
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
|
||||||
|
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
|
||||||
|
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
|
||||||
|
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
|
||||||
|
of these for **each trace it draws**, so the display's memory is that figure times the traces on
|
||||||
|
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
|
||||||
|
The colour is the reason for the per-trace split: a density that had one hue for the plot could
|
||||||
|
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
|
||||||
|
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
|
||||||
|
unbound slot) holds no grid at all.
|
||||||
|
|
||||||
|
The cap is what keeps the resolution from following the window, and the window is not a fixed
|
||||||
|
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
|
||||||
|
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
|
||||||
|
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
|
||||||
|
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
|
||||||
|
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
|
||||||
|
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
|
||||||
|
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
|
||||||
|
quantity — and a fragment between two of them is between their two ink levels, which is what a
|
||||||
|
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
|
||||||
|
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
|
||||||
|
what any magnification of an image does.
|
||||||
|
- **A cell's value is a number of sweeps**, and a trace covering it during one
|
||||||
|
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
|
||||||
|
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
|
||||||
|
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
|
||||||
|
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
|
||||||
|
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
|
||||||
|
still count twice and the two segments that share a column boundary do not.
|
||||||
|
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
|
||||||
|
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
|
||||||
|
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
|
||||||
|
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
|
||||||
|
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
|
||||||
|
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
|
||||||
|
accumulation affordable at the trigger's own rate.
|
||||||
|
- **The newest acquisition is deposited like every other, and no trace is drawn over the image.**
|
||||||
|
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
|
||||||
|
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
|
||||||
|
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
|
||||||
|
image + last acquisition" a user reported, where the newest acquisition never faded.
|
||||||
|
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
|
||||||
|
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
|
||||||
|
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
|
||||||
|
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
|
||||||
|
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
|
||||||
|
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
|
||||||
|
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
|
||||||
|
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
|
||||||
|
sweep and the image behind it. The case that reads either one off the *screen* rather than the
|
||||||
|
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
|
||||||
|
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
|
||||||
|
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
|
||||||
|
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
|
||||||
|
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
|
||||||
|
numbers.
|
||||||
|
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
|
||||||
|
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
|
||||||
|
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
|
||||||
|
grids did not move skips the conversion and the uploads entirely and pays the quads alone
|
||||||
|
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
|
||||||
|
frame under `TimeConstant`, and what makes them stand still between acquisitions under
|
||||||
|
`PerAcquisition`.
|
||||||
|
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
|
||||||
|
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
|
||||||
|
considered and is a **larger** change than this bound and was deliberately not bundled with it:
|
||||||
|
the image would have to live in a ping-pong target the decay multiplies in place, while the
|
||||||
|
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
|
||||||
|
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
|
||||||
|
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
|
||||||
|
|
||||||
|
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
|
||||||
|
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
|
||||||
|
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
|
||||||
|
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
|
||||||
|
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
|
||||||
|
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
|
||||||
|
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
|
||||||
|
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
|
||||||
|
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
|
||||||
|
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
|
||||||
|
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
|
||||||
|
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
|
||||||
|
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
|
||||||
|
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
|
||||||
|
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
|
||||||
|
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
|
||||||
|
ink 578/578**, in two different rows), and
|
||||||
|
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
|
||||||
|
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
|
||||||
|
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
|
||||||
|
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
|
||||||
|
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
|
||||||
|
background's.
|
||||||
|
|
||||||
|
## One sample or a statistic of many
|
||||||
|
|
||||||
|
Every number the library reports is one the samples actually hold. What differs between
|
||||||
|
these two is *how many* samples answer the question:
|
||||||
|
|
||||||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||||||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||||||
@@ -179,15 +450,23 @@ agree:
|
|||||||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||||||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||||||
unaligned interval.
|
unaligned interval.
|
||||||
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
|
- **The crosshair readout is a single sample — the nearest one — by default.**
|
||||||
returns the two samples `x` lies between (`begin`, `end`, `t`), and
|
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||||||
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
|
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||||||
is the signal *at* this instant", which is generally between two samples — a different
|
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||||||
question from an interval statistic. The lookup is O(1) (one division and a floor) and
|
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||||||
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
|
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||||||
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||||||
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
|
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||||||
nearest sample's.
|
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||||||
|
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||||||
|
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||||||
|
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
|
||||||
|
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
|
||||||
|
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
|
||||||
|
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||||||
|
(an em dash, `"—"`), and an x outside the series window yields no value rather than
|
||||||
|
clamping to the first or last sample.
|
||||||
|
|
||||||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||||||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||||||
|
|||||||
+44
-3
@@ -106,6 +106,7 @@ one per plot.
|
|||||||
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
||||||
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
||||||
dash.
|
dash.
|
||||||
|
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
|
||||||
|
|
||||||
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
||||||
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
||||||
@@ -113,9 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
|
|||||||
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
||||||
a figure-wide horizontal line would be a value that means something different in each row.
|
a figure-wide horizontal line would be a value that means something different in each row.
|
||||||
|
|
||||||
Readout values are **interpolated**, not interval statistics (see
|
### What a readout is: the two value modes
|
||||||
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
|
|
||||||
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
|
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
|
||||||
|
which one a caller is asking decides the mode:
|
||||||
|
|
||||||
|
| mode | the value | the marker | answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
|
||||||
|
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
|
||||||
|
|
||||||
|
The default is the sample, because a readout that quotes a number the data does not contain is
|
||||||
|
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
|
||||||
|
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
|
||||||
|
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
|
||||||
|
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
|
||||||
|
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
|
||||||
|
them, never an LOD bucket's aggregate (see
|
||||||
|
[data-and-performance.md](data-and-performance.md)).
|
||||||
|
|
||||||
|
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
|
||||||
|
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
|
||||||
|
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
|
||||||
|
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
|
||||||
|
discarded.
|
||||||
|
|
||||||
|
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
|
||||||
|
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
|
||||||
|
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
|
||||||
|
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
|
||||||
|
mark.
|
||||||
|
|
||||||
|
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
|
||||||
|
marker, rather than a fabricated value.
|
||||||
|
|
||||||
## The gesture table (exactly as implemented)
|
## The gesture table (exactly as implemented)
|
||||||
|
|
||||||
@@ -161,6 +192,16 @@ without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hove
|
|||||||
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
|
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
|
||||||
tooltip, a status line).
|
tooltip, a status line).
|
||||||
|
|
||||||
|
The frame an open box zoom is drawing is published the same way and for the same reason
|
||||||
|
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
|
||||||
|
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
|
||||||
|
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
|
||||||
|
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
|
||||||
|
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
|
||||||
|
rect is what the *release* will apply, which is the one worth showing while it is still only a
|
||||||
|
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
|
||||||
|
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
|
||||||
|
|
||||||
## Live follow
|
## Live follow
|
||||||
|
|
||||||
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
|
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Colours (neither logical nor device — nothing to scale):
|
|||||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||||
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
||||||
|
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
|
||||||
|
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
|
||||||
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
||||||
|
|
||||||
The scale:
|
The scale:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ class MinMaxPyramid {
|
|||||||
/// whose samples were all non-finite is stored as an empty aggregate
|
/// whose samples were all non-finite is stored as an empty aggregate
|
||||||
/// (count 0, zero sums) that every reader treats as absent.
|
/// (count 0, zero sums) that every reader treats as absent.
|
||||||
void append(f32 v);
|
void append(f32 v);
|
||||||
|
/// Folds a backfilled sample into every aggregate that covers absolute index
|
||||||
|
/// `absolute_sample`.
|
||||||
|
///
|
||||||
|
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
|
||||||
|
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
|
||||||
|
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
|
||||||
|
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
|
||||||
|
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
|
||||||
|
/// this is a one-way NaN-to-value operation and not a general write.
|
||||||
|
///
|
||||||
|
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
|
||||||
|
/// consumer thread, so a caller cannot prove the slot is still held before asking.
|
||||||
|
void amend(u64 absolute_sample, f32 v);
|
||||||
/// Slides the retained window's leading edge to absolute sample index
|
/// Slides the retained window's leading edge to absolute sample index
|
||||||
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
||||||
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ class RingBuffer {
|
|||||||
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
|
||||||
|
/// Returns false when that index is not retained -- already overwritten, or not yet
|
||||||
|
/// produced -- in which case nothing is written.
|
||||||
|
///
|
||||||
|
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
|
||||||
|
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
|
||||||
|
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
|
||||||
|
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
|
||||||
|
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
|
||||||
|
/// changes a value inside that window rather than extending it.
|
||||||
|
bool amend(u64 absolute, T v) {
|
||||||
|
const u64 p = produced_.load(std::memory_order_relaxed);
|
||||||
|
if (absolute >= p) return false;
|
||||||
|
if (p > cap_ && absolute < p - cap_) return false;
|
||||||
|
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Consumer: retained element count, saturating at capacity().
|
/// Consumer: retained element count, saturating at capacity().
|
||||||
u64 size() const noexcept {
|
u64 size() const noexcept {
|
||||||
const u64 p = produced_.load(std::memory_order_acquire);
|
const u64 p = produced_.load(std::memory_order_acquire);
|
||||||
@@ -109,6 +127,18 @@ class RingBuffer {
|
|||||||
/// pushing).
|
/// pushing).
|
||||||
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
|
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Consumer thread only: discards every element and resumes the absolute count
|
||||||
|
/// at `absolute`, so the next push() lands there (not safe while a producer is
|
||||||
|
/// pushing).
|
||||||
|
///
|
||||||
|
/// This is for a *recompute*: a ring whose contents are derived from another is
|
||||||
|
/// emptied and refilled from a given absolute origin, and it must come back
|
||||||
|
/// numbered as its source is. clear() would restart it at 0 and leave every
|
||||||
|
/// absolute index -- base(), produced(), copy_from() -- off by that origin.
|
||||||
|
void clear_to(u64 absolute) noexcept {
|
||||||
|
produced_.store(absolute, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
u64 cap_;
|
u64 cap_;
|
||||||
u64 mask_;
|
u64 mask_;
|
||||||
|
|||||||
@@ -59,12 +59,20 @@ class Series {
|
|||||||
|
|
||||||
/// Appends interleaved (x, y) pairs for level `level`.
|
/// Appends interleaved (x, y) pairs for level `level`.
|
||||||
///
|
///
|
||||||
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as
|
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
|
||||||
/// f32, never the absolute coordinate. A consumer reconstructs the absolute
|
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
|
||||||
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an
|
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
|
||||||
/// absolute epoch-time x as f32 would quantise it to a grid coarser than
|
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
|
||||||
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative
|
/// 1.7e9). y is unchanged.
|
||||||
/// value `i*dx` is small and exact. y is unchanged.
|
///
|
||||||
|
/// **The caller names the origin because only the caller knows the span the f32 has to be
|
||||||
|
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
|
||||||
|
/// and a relative value's ULP is then a fraction of the whole window however little of it is
|
||||||
|
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
|
||||||
|
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
|
||||||
|
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
|
||||||
|
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
|
||||||
|
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
|
||||||
///
|
///
|
||||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
||||||
/// geometry element per *complete* level bucket wholly inside the retained
|
/// geometry element per *complete* level bucket wholly inside the retained
|
||||||
@@ -85,7 +93,7 @@ class Series {
|
|||||||
/// level_for() picks), and never reaches outside the retained window -- nor
|
/// level_for() picks), and never reaches outside the retained window -- nor
|
||||||
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
||||||
/// cleared.
|
/// cleared.
|
||||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode = GatherMode::Envelope) const = 0;
|
GatherMode mode = GatherMode::Envelope) const = 0;
|
||||||
|
|
||||||
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
|
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
|
||||||
@@ -97,7 +105,7 @@ class Series {
|
|||||||
/// lands exactly on a sample). False when x lies outside the series' x
|
/// lands exactly on a sample). False when x lies outside the series' x
|
||||||
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
|
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
|
||||||
/// paths read coordinates only through this and `x_at()`, so a series with
|
/// paths read coordinates only through this and `x_at()`, so a series with
|
||||||
/// its own x array interpolates exactly.
|
/// its own x array brackets exactly.
|
||||||
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
|
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
|
||||||
|
|
||||||
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
|
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
|
||||||
@@ -121,7 +129,7 @@ class Series {
|
|||||||
|
|
||||||
/// The shared body of the concrete gather() overrides: raw samples at level 0,
|
/// The shared body of the concrete gather() overrides: raw samples at level 0,
|
||||||
/// bucket geometry above it (per `mode`).
|
/// bucket geometry above it (per `mode`).
|
||||||
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const;
|
GatherMode mode) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -143,7 +151,7 @@ class Series {
|
|||||||
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
||||||
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
||||||
/// begin < end, end <= size().
|
/// begin < end, end <= size().
|
||||||
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace plot::data
|
} // namespace plot::data
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -229,6 +229,18 @@ class Controller {
|
|||||||
DragMode drag_mode() const noexcept { return mode_; }
|
DragMode drag_mode() const noexcept { return mode_; }
|
||||||
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
|
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
|
||||||
Rect selection_rect() const noexcept { return selection_; }
|
Rect selection_rect() const noexcept { return selection_; }
|
||||||
|
|
||||||
|
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
|
||||||
|
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
|
||||||
|
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
|
||||||
|
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
|
||||||
|
/// both, and gets the swept box itself.
|
||||||
|
///
|
||||||
|
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
|
||||||
|
///
|
||||||
|
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
|
||||||
|
/// `Plot` is only forward-declared here.
|
||||||
|
Rect selection_preview() const noexcept;
|
||||||
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
|
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
|
||||||
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
|
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
|
||||||
/// or no cursor is within a few pixels.
|
/// or no cursor is within a few pixels.
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
#include <plot/plot/crosshair.hpp>
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
|
#include <plot/plot/density.hpp>
|
||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
|
|||||||
@@ -102,12 +102,16 @@ struct Axis {
|
|||||||
/// group stops following or the row leaves it.
|
/// group stops following or the row leaves it.
|
||||||
bool live_owner = false;
|
bool live_owner = false;
|
||||||
f64 live_owner_newest = 0.0;
|
f64 live_owner_newest = 0.0;
|
||||||
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
|
/// Set while this axis is a member of a `Figure` link group. On an x axis that is
|
||||||
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
|
/// an x link group (`Figure::link_x()` writes it, `unlink_row()` clears it): the
|
||||||
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
|
/// group owns the shared x window *and* its follow state -- `Figure::link_x()`
|
||||||
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
|
/// refuses a `Live` member joiner -- so `Plot::set_x_follow(Live)` on this axis is
|
||||||
/// than letting a later call put the member's axis back into a state the group
|
/// refused after linking too, rather than letting a later call put the member's
|
||||||
/// cannot drive (see `Plot::set_x_follow()`).
|
/// axis back into a state the group cannot drive (see `Plot::set_x_follow()`).
|
||||||
|
/// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the
|
||||||
|
/// group, not the plot, owns the range *and* the auto scale state, so
|
||||||
|
/// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the
|
||||||
|
/// members' traces instead.
|
||||||
bool link_member = false;
|
bool link_member = false;
|
||||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||||
std::vector<Tick> ticks;
|
std::vector<Tick> ticks;
|
||||||
|
|||||||
+106
-27
@@ -30,6 +30,35 @@ struct CrosshairStyle {
|
|||||||
///< (once resolved) means solid.
|
///< (once resolved) means solid.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them.
|
||||||
|
///
|
||||||
|
/// The two answer different questions and the right one is a property of the data, which the
|
||||||
|
/// plot cannot know — so it is the caller's to state.
|
||||||
|
///
|
||||||
|
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
|
||||||
|
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
|
||||||
|
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
|
||||||
|
/// number came from. This is the default, and it is the honest mode for a source whose
|
||||||
|
/// samples are already averaged or decimated before they arrive: a number between two of
|
||||||
|
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
|
||||||
|
/// sample spacing the polyline between two samples is a line the renderer drew, so a
|
||||||
|
/// reading off it reports the drawing.
|
||||||
|
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
|
||||||
|
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
|
||||||
|
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
|
||||||
|
/// continuous quantity sampled finely enough that the line between two samples is a model
|
||||||
|
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
|
||||||
|
/// wrong wherever that line is an artefact of plotting.
|
||||||
|
///
|
||||||
|
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
|
||||||
|
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
|
||||||
|
/// the pointer can never disagree about what the value means.
|
||||||
|
enum class CrosshairValue : u8 {
|
||||||
|
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
|
||||||
|
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
|
||||||
|
};
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
||||||
/// per-signal readout is wanted.
|
/// per-signal readout is wanted.
|
||||||
///
|
///
|
||||||
@@ -44,10 +73,10 @@ struct CrosshairStyle {
|
|||||||
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
||||||
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
||||||
///
|
///
|
||||||
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
|
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
|
||||||
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
|
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
|
||||||
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
|
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
|
||||||
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
|
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
|
||||||
struct Crosshair {
|
struct Crosshair {
|
||||||
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
||||||
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
||||||
@@ -58,15 +87,27 @@ struct Crosshair {
|
|||||||
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
||||||
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
||||||
bool horizontal = true;
|
bool horizontal = true;
|
||||||
/// List the visible traces and their interpolated values in a panel beside the pointer.
|
/// List the visible traces and their values in a panel beside the pointer.
|
||||||
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
||||||
/// measure and the panel is then not placed at all.
|
/// measure and the panel is then not placed at all.
|
||||||
bool show_readout = true;
|
bool show_readout = true;
|
||||||
|
/// Write the two values the crosshair's lines meet the axes at as small badges in the
|
||||||
|
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
|
||||||
|
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
|
||||||
|
/// where the eye already is, and a host that does not want it turns it off
|
||||||
|
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
|
||||||
|
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
|
||||||
|
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
|
||||||
|
bool axis_labels = true;
|
||||||
/// The data x of the vertical line, on x axis 0.
|
/// The data x of the vertical line, on x axis 0.
|
||||||
f64 x = 0.0;
|
f64 x = 0.0;
|
||||||
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
||||||
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
|
/// Whether a row's value is one of the series' samples or a blend of two -- see
|
||||||
|
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
|
||||||
|
/// `Plot::set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue value_mode = CrosshairValue::NearestSample;
|
||||||
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
||||||
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
||||||
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
||||||
@@ -84,14 +125,17 @@ struct Crosshair {
|
|||||||
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
||||||
/// move, so that is a requirement rather than a nicety.
|
/// move, so that is a requirement rather than a nicety.
|
||||||
///
|
///
|
||||||
/// **Interpolated, not exact.** The value a caller builds from this span by
|
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
|
||||||
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
|
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
|
||||||
/// generally between two samples -- the opposite convention from `Series::reduce()`,
|
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
|
||||||
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
|
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
|
||||||
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
|
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
|
||||||
/// "what are the samples' extremes over this interval", this answers "what is the signal at
|
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
|
||||||
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
|
///
|
||||||
/// mean of a window as if it were the value under the pointer.
|
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
|
||||||
|
/// which are **exact over a sample range**: those answer "what are the samples'
|
||||||
|
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
|
||||||
|
/// x". What differs is a statistic of many samples against a reading at one place.
|
||||||
///
|
///
|
||||||
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
||||||
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
||||||
@@ -104,13 +148,14 @@ struct CrosshairSpan {
|
|||||||
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
||||||
/// lands on a sample, `begin + 1` between two of them.
|
/// lands on a sample, `begin + 1` between two of them.
|
||||||
u64 end = 0;
|
u64 end = 0;
|
||||||
/// The query's position between the two samples, in `[0, 1]`.
|
/// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
|
||||||
|
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
|
||||||
f64 t = 0.0;
|
f64 t = 0.0;
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
|
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
|
||||||
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
|
/// convention it is half of, and for the cases that yield `valid == false`.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
||||||
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
||||||
@@ -118,12 +163,14 @@ struct CrosshairSpan {
|
|||||||
/// allocates.
|
/// allocates.
|
||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
||||||
|
|
||||||
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
|
/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
|
||||||
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
|
/// which is what `CrosshairValue::Interpolated` reports.
|
||||||
/// its documentation cannot drift apart.
|
///
|
||||||
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
|
||||||
return y_begin + (y_end - y_begin) * t;
|
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
|
||||||
}
|
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
|
||||||
|
/// is in force. `NearestSample` does not call it at all.
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
|
||||||
|
|
||||||
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
||||||
/// and the boxes the renderer stages.
|
/// and the boxes the renderer stages.
|
||||||
@@ -150,8 +197,14 @@ struct CrosshairRow {
|
|||||||
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
||||||
/// not a line in a panel that has no room for it.
|
/// not a line in a panel that has no room for it.
|
||||||
bool listed = false;
|
bool listed = false;
|
||||||
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
|
/// The data x the row's value belongs to, on the trace's own x axis: in
|
||||||
/// meaningful when `valid`.
|
/// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
|
||||||
|
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
|
||||||
|
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
|
||||||
|
/// `valid`.
|
||||||
|
f64 x = 0.0;
|
||||||
|
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
|
||||||
|
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
/// The trace's name, or the positional placeholder of an unnamed one.
|
/// The trace's name, or the positional placeholder of an unnamed one.
|
||||||
std::string label;
|
std::string label;
|
||||||
@@ -161,9 +214,24 @@ struct CrosshairRow {
|
|||||||
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
||||||
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
||||||
std::string value;
|
std::string value;
|
||||||
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
|
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
|
||||||
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
|
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
|
||||||
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
|
/// scale it is drawn against.
|
||||||
|
///
|
||||||
|
/// It marks **the point the row reports**, which is the whole job: in
|
||||||
|
/// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum
|
||||||
|
/// rather than beside it on the crosshair's column, so the mark says which of the samples
|
||||||
|
/// under the pointer the number came from. (Placing it at the pointer's x with the sample's y
|
||||||
|
/// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In
|
||||||
|
/// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where
|
||||||
|
/// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them
|
||||||
|
/// from ever disagreeing.
|
||||||
|
///
|
||||||
|
/// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample
|
||||||
|
/// markers** on purpose, since a snapped mark lands among them and has to read as "this one"
|
||||||
|
/// rather than as one more sample. A mark for a sample outside the visible range is staged
|
||||||
|
/// where that sample is and left to the axes clip, rather than being dropped by a range test
|
||||||
|
/// of the drawing layer's own. Meaningless when `!valid`.
|
||||||
Vec2 marker{};
|
Vec2 marker{};
|
||||||
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
||||||
/// when `!listed`.
|
/// when `!listed`.
|
||||||
@@ -217,6 +285,17 @@ struct CrosshairLayout {
|
|||||||
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
|
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
|
||||||
/// axes at all.
|
/// axes at all.
|
||||||
Rect panel{};
|
Rect panel{};
|
||||||
|
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
|
||||||
|
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
|
||||||
|
/// line's height, and the x value in the **x label strip**, centred on the vertical
|
||||||
|
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
|
||||||
|
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
|
||||||
|
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
|
||||||
|
/// whose x strip went to the row below), or there is no text backend to measure them.
|
||||||
|
Rect y_axis_badge{};
|
||||||
|
std::string y_axis_text;
|
||||||
|
Rect x_axis_badge{};
|
||||||
|
std::string x_axis_text;
|
||||||
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
|
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
|
||||||
/// not the panel could list them, so the markers are drawn even in a plot too small to
|
/// not the panel could list them, so the markers are drawn even in a plot too small to
|
||||||
/// hold a panel.
|
/// hold a panel.
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
|
||||||
|
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
|
||||||
|
///
|
||||||
|
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
|
||||||
|
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
|
||||||
|
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
|
||||||
|
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
|
||||||
|
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
|
||||||
|
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
|
||||||
|
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
|
||||||
|
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
|
||||||
|
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
|
||||||
|
/// draw a frame, 4.9 MB uploaded**.
|
||||||
|
///
|
||||||
|
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
|
||||||
|
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
|
||||||
|
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
|
||||||
|
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
|
||||||
|
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
|
||||||
|
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
|
||||||
|
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
|
||||||
|
/// one row of a tall one -- is scaled.
|
||||||
|
inline constexpr usize kDensityCellCap = usize{1} << 18;
|
||||||
|
|
||||||
|
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
|
||||||
|
///
|
||||||
|
/// The two are answers to two different questions, which is why the choice belongs to the
|
||||||
|
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
|
||||||
|
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
|
||||||
|
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
|
||||||
|
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
|
||||||
|
enum class DensityDecay {
|
||||||
|
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
|
||||||
|
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
|
||||||
|
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
|
||||||
|
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
|
||||||
|
/// fading it.
|
||||||
|
TimeConstant,
|
||||||
|
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
|
||||||
|
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
|
||||||
|
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
|
||||||
|
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
|
||||||
|
/// last few sweeps is what the display holds -- which is what a caller wants when the
|
||||||
|
/// interesting comparison is between successive acquisitions and not between seconds.
|
||||||
|
PerAcquisition,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
|
||||||
|
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
|
||||||
|
///
|
||||||
|
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
|
||||||
|
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
|
||||||
|
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
|
||||||
|
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
|
||||||
|
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
|
||||||
|
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
|
||||||
|
/// its trace would have drawn.
|
||||||
|
///
|
||||||
|
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
|
||||||
|
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
|
||||||
|
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
|
||||||
|
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
|
||||||
|
/// the same mapping that could disagree with the first.
|
||||||
|
struct DensityConfig {
|
||||||
|
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
|
||||||
|
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
|
||||||
|
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
|
||||||
|
/// holding a stale image -- see `Plot::set_density()`.
|
||||||
|
bool enabled = false;
|
||||||
|
/// How the grid fades (see `DensityDecay`).
|
||||||
|
DensityDecay decay = DensityDecay::PerAcquisition;
|
||||||
|
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
|
||||||
|
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
|
||||||
|
/// the frame rate. `<= 0` freezes the grid.
|
||||||
|
f32 time_constant_s = 1.0f;
|
||||||
|
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
|
||||||
|
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
|
||||||
|
/// seventh the layer is under 1 %.
|
||||||
|
f32 per_acquisition = 0.5f;
|
||||||
|
/// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
|
||||||
|
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
|
||||||
|
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
|
||||||
|
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
|
||||||
|
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
|
||||||
|
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
|
||||||
|
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
|
||||||
|
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
|
||||||
|
/// colour at full strength and no brighter.
|
||||||
|
///
|
||||||
|
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
|
||||||
|
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
|
||||||
|
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
|
||||||
|
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
|
||||||
|
f32 full_scale = 1.0f;
|
||||||
|
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
|
||||||
|
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
|
||||||
|
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
|
||||||
|
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
|
||||||
|
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
|
||||||
|
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
|
||||||
|
/// caller that wants only the strong agreement visible.
|
||||||
|
f32 gamma = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
|
||||||
|
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
|
||||||
|
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
|
||||||
|
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
|
||||||
|
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
|
||||||
|
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
|
||||||
|
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
|
||||||
|
///
|
||||||
|
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
|
||||||
|
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
|
||||||
|
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
|
||||||
|
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
|
||||||
|
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
|
||||||
|
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
|
||||||
|
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
|
||||||
|
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
|
||||||
|
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
|
||||||
|
///
|
||||||
|
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
|
||||||
|
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
|
||||||
|
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
|
||||||
|
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
|
||||||
|
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
|
||||||
|
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
|
||||||
|
///
|
||||||
|
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
|
||||||
|
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
|
||||||
|
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
|
||||||
|
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
|
||||||
|
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
|
||||||
|
/// whole conversion -- on a frame the grid did not move.
|
||||||
|
class DensityGrid {
|
||||||
|
public:
|
||||||
|
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
|
||||||
|
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
|
||||||
|
/// laid-out plot.
|
||||||
|
u32 columns() const noexcept { return columns_; }
|
||||||
|
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
|
||||||
|
/// `columns()`: `columns() * rows()` cells.
|
||||||
|
u32 rows() const noexcept { return rows_; }
|
||||||
|
usize cell_count() const noexcept {
|
||||||
|
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
|
||||||
|
}
|
||||||
|
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
|
||||||
|
/// for the resolution and what one cell's value means.
|
||||||
|
std::span<const f32> cells() const noexcept { return cells_; }
|
||||||
|
/// One cell, or 0.0 outside the grid.
|
||||||
|
f32 at(u32 column, u32 row) const noexcept {
|
||||||
|
if (column >= columns_ || row >= rows_) return 0.0f;
|
||||||
|
return cells_[static_cast<usize>(row) * columns_ + column];
|
||||||
|
}
|
||||||
|
/// The **total ink**: every cell's value summed. The reading that says how much has been
|
||||||
|
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
|
||||||
|
/// and is 0 for a grid that is off or was just cleared.
|
||||||
|
f64 ink() const noexcept {
|
||||||
|
f64 total = 0.0;
|
||||||
|
for (const f32 v : cells_) total += static_cast<f64>(v);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
|
||||||
|
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
|
||||||
|
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
|
||||||
|
f32 peak() const noexcept {
|
||||||
|
f32 top = 0.0f;
|
||||||
|
for (const f32 v : cells_) top = v > top ? v : top;
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
|
||||||
|
/// The renderer compares it with the revision it last uploaded, so a frame that changed
|
||||||
|
/// nothing uploads nothing.
|
||||||
|
u64 revision() const noexcept { return revision_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class Plot;
|
||||||
|
std::vector<f32> cells_;
|
||||||
|
u32 columns_ = 0;
|
||||||
|
u32 rows_ = 0;
|
||||||
|
u64 revision_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace plot
|
||||||
+140
-30
@@ -27,13 +27,21 @@ struct FigureLayout {
|
|||||||
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
||||||
/// with a y title keeps its column and no two rows can disagree about chrome.
|
/// with a y title keeps its column and no two rows can disagree about chrome.
|
||||||
std::vector<Rect> rows;
|
std::vector<Rect> rows;
|
||||||
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
|
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
|
||||||
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
|
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
|
||||||
/// wide as that row's axes rather than the whole figure. Read back from the row
|
/// rect here; a row of several tiles its rect left to right with `col_gap` between
|
||||||
|
/// cells, the cells' widths summing to the row's less the gaps.
|
||||||
|
std::vector<Rect> cells;
|
||||||
|
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
|
||||||
|
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
|
||||||
|
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
|
||||||
|
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
|
||||||
|
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
|
||||||
|
/// figure. Read back from the row
|
||||||
/// rather than derived here, so a host and the frame cannot disagree about where
|
/// rather than derived here, so a host and the frame cannot disagree about where
|
||||||
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
||||||
/// reserves a label strip -- including the case where every row labels itself, in
|
/// reserves a label strip. A row above whose columns differ from the row below keeps
|
||||||
/// which case it is simply the bottom row's own strip, one of several.
|
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
|
||||||
///
|
///
|
||||||
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
||||||
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
||||||
@@ -44,6 +52,10 @@ struct FigureLayout {
|
|||||||
/// figure edge to edge, which is the default; it is dropped for a figure too
|
/// figure edge to edge, which is the default; it is dropped for a figure too
|
||||||
/// short to hold it, so the rows never lose their height to a gap.
|
/// short to hold it, so the rows never lose their height to a gap.
|
||||||
f32 row_gap = 0.0f;
|
f32 row_gap = 0.0f;
|
||||||
|
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
|
||||||
|
/// value, or 0 when any row holding several cells was too narrow to hold it and
|
||||||
|
/// dropped it, so the cells never lose their width to a gap.
|
||||||
|
f32 col_gap = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Several plots stacked in one column, sharing one x axis.
|
/// Several plots stacked in one column, sharing one x axis.
|
||||||
@@ -59,12 +71,15 @@ struct FigureLayout {
|
|||||||
/// **The shared x axis.** Row heights are an equal division of the figure by
|
/// **The shared x axis.** Row heights are an equal division of the figure by
|
||||||
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
||||||
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
||||||
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
|
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
|
||||||
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
|
/// with the *same columns* (the same number of cells and the same normalised cell
|
||||||
/// strips, so those labels and that title are drawn once, from the bottom row's own
|
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
|
||||||
/// ticks and transform. That places the shared axis *inside* a row, where the
|
/// keeps its own label and title strips, so those labels and that title are drawn once,
|
||||||
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
|
/// from its own ticks and transform. A row whose columns differ from the row below keeps
|
||||||
/// row that no gesture could land in.
|
/// its own strip, drawn under each of its cells, because the axis below is at another
|
||||||
|
/// scale and width. The rule is structural, never a function of what a plot holds. That
|
||||||
|
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
|
||||||
|
/// reach it, instead of in a band outside every row that no gesture could land in.
|
||||||
///
|
///
|
||||||
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
||||||
/// member's x range the same way, so a figure cannot see either of them happen.
|
/// member's x range the same way, so a figure cannot see either of them happen.
|
||||||
@@ -94,13 +109,30 @@ class Figure {
|
|||||||
/// which is the *unlinked* group: rows that share nothing, each with the range
|
/// which is the *unlinked* group: rows that share nothing, each with the range
|
||||||
/// its own plot was given.
|
/// its own plot was given.
|
||||||
explicit Figure(u32 rows);
|
explicit Figure(u32 rows);
|
||||||
|
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
|
||||||
|
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
|
||||||
|
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
|
||||||
|
/// `Figure{3u}` keeps meaning three rows.
|
||||||
|
explicit Figure(std::span<const u32> cells_per_row);
|
||||||
|
|
||||||
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
/// The number of figure rows -- the bands stacked top to bottom -- which equals
|
||||||
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
|
/// `plot_count()` for a figure no row of which is split.
|
||||||
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
|
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
|
||||||
/// -- so a stale row can only ever affect itself.
|
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
|
||||||
Plot& plot(u32 row) noexcept;
|
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
|
||||||
const Plot& plot(u32 row) const noexcept;
|
u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
||||||
|
/// How many cells row `row` holds; 0 for a row that does not exist.
|
||||||
|
u32 cells_in_row(u32 row) const noexcept;
|
||||||
|
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
|
||||||
|
/// row_count()` and for anything beyond.
|
||||||
|
u32 first_plot_of_row(u32 row) const noexcept;
|
||||||
|
/// The row that holds flattened plot `plot` (the last row for an index past the end).
|
||||||
|
u32 row_of_plot(u32 plot) const noexcept;
|
||||||
|
/// One plot, by flattened cell index. A plot that does not exist yields a
|
||||||
|
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
|
||||||
|
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
|
||||||
|
Plot& plot(u32 index) noexcept;
|
||||||
|
const Plot& plot(u32 index) const noexcept;
|
||||||
|
|
||||||
// --- geometry ---------------------------------------------------------
|
// --- geometry ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -119,6 +151,21 @@ class Figure {
|
|||||||
void clear_row_weights() noexcept { row_weights_.clear(); }
|
void clear_row_weights() noexcept { row_weights_.clear(); }
|
||||||
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
||||||
|
|
||||||
|
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
|
||||||
|
/// share of what is left after every cell has its floor width (64 logical pixels,
|
||||||
|
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
|
||||||
|
/// when the row does not exist, the count is not the row's cell count, or a weight is
|
||||||
|
/// not a positive finite number. A row with no weights divides equally.
|
||||||
|
Status set_cell_weights(u32 row, std::span<const f32> weights);
|
||||||
|
/// Back to an equal division of row `row`; a row that does not exist is ignored.
|
||||||
|
void clear_cell_weights(u32 row) noexcept;
|
||||||
|
bool has_cell_weights(u32 row) const noexcept;
|
||||||
|
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
|
||||||
|
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
|
||||||
|
/// dropped for that layout rather than squeezing the cells to nothing.
|
||||||
|
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
||||||
|
f32 col_gap() const noexcept { return col_gap_; }
|
||||||
|
|
||||||
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
||||||
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
||||||
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
||||||
@@ -138,9 +185,22 @@ class Figure {
|
|||||||
}
|
}
|
||||||
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
|
||||||
/// and one shared axis is drawn under the last row (the default), or every row
|
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
|
||||||
/// draws its own labels and the figure draws nothing extra.
|
/// 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.
|
||||||
|
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
|
||||||
|
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
|
||||||
|
/// row is the column gap.
|
||||||
|
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 rows with the same columns as the row below hand it their x axis (the
|
||||||
|
/// default; a row with different columns keeps its own), or every row draws its own
|
||||||
|
/// labels and the figure draws nothing extra.
|
||||||
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
|
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
|
||||||
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
||||||
|
|
||||||
@@ -185,17 +245,21 @@ class Figure {
|
|||||||
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
||||||
void set_crosshair(f64 x, bool active);
|
void set_crosshair(f64 x, bool active);
|
||||||
|
|
||||||
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
|
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
|
||||||
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and
|
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
|
||||||
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that*
|
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
|
||||||
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
|
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
|
||||||
|
/// *that* plot's own scale. **When the row is a split whose cells share one y
|
||||||
|
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
|
||||||
|
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
|
||||||
|
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
|
||||||
/// line at all -- without inventing a figure-wide y, which would mean a different value in
|
/// line at all -- without inventing a figure-wide y, which would mean a different value in
|
||||||
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
|
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
|
||||||
///
|
///
|
||||||
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
|
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
|
||||||
/// two-coordinate placement is; a host reads it from the row's own y transform at the
|
/// two-coordinate placement is; a host reads it from the row's own y transform at the
|
||||||
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under
|
/// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
|
||||||
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
|
/// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
|
||||||
/// state the single-coordinate overload produces.
|
/// state the single-coordinate overload produces.
|
||||||
///
|
///
|
||||||
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
||||||
@@ -216,9 +280,10 @@ class Figure {
|
|||||||
/// How many groups there are, excluding the unlinked group 0.
|
/// How many groups there are, excluding the unlinked group 0.
|
||||||
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
|
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
|
||||||
|
|
||||||
/// Moves one row into a link group and applies that group's range to it at once,
|
/// Moves one plot into a link group and applies that group's range to it at once; `row`
|
||||||
/// so the row is on the group's window from this call rather than from the next
|
/// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
|
||||||
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
|
/// figure with one cell per row. The plot is on the group's window from this call rather
|
||||||
|
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
|
||||||
/// it holds now.
|
/// it holds now.
|
||||||
///
|
///
|
||||||
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
||||||
@@ -232,7 +297,7 @@ class Figure {
|
|||||||
/// per-row window. `XFollow::Off` is the group's to set
|
/// per-row window. `XFollow::Off` is the group's to set
|
||||||
/// (`set_x_link_follow()`), not the axis'.
|
/// (`set_x_link_follow()`), not the axis'.
|
||||||
Status link_x(u32 row, u32 group);
|
Status link_x(u32 row, u32 group);
|
||||||
/// The group a row is in, or 0 when it is in none.
|
/// The group the plot with flattened index `row` is in, or 0 when it is in none.
|
||||||
u32 x_link(u32 row) const noexcept;
|
u32 x_link(u32 row) const noexcept;
|
||||||
|
|
||||||
/// Sets a group's shared range and pushes it to every member.
|
/// Sets a group's shared range and pushes it to every member.
|
||||||
@@ -259,6 +324,22 @@ class Figure {
|
|||||||
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
|
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
|
||||||
XFollow x_link_follow(u32 group) const noexcept;
|
XFollow x_link_follow(u32 group) const noexcept;
|
||||||
|
|
||||||
|
/// Makes the cells of every split row (two or more) share their y axis 0: one range
|
||||||
|
/// and one auto scale state per row, default off. A range set on any member -- a
|
||||||
|
/// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of
|
||||||
|
/// its cells at the next `layout()`; the row is auto or manual as one, and while it is
|
||||||
|
/// auto it fits the union of every cell's visible traces (a cell with none is ignored;
|
||||||
|
/// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group,
|
||||||
|
/// not the plot, owns auto scale: members' own autoscale is suppressed, because each
|
||||||
|
/// would fit only its own traces and the cells would fight over the range. Turning it
|
||||||
|
/// on or off forgets the groups' state; a row of one cell is never a group.
|
||||||
|
void set_share_row_y(bool on);
|
||||||
|
bool share_row_y() const noexcept { return share_row_y_; }
|
||||||
|
/// Whether figure row `row` is auto scaled as a group. True for a row that is not a
|
||||||
|
/// group -- fewer than two cells, sharing off, or a row that does not exist -- so
|
||||||
|
/// "auto" is also the answer when there is no shared state to be manual.
|
||||||
|
bool row_y_auto(u32 row) const noexcept;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// One link group: the range its members share, the scroll state behind that
|
/// One link group: the range its members share, the scroll state behind that
|
||||||
/// range while it follows, the rows in it (ascending) and, in step with them, the
|
/// range while it follows, the rows in it (ascending) and, in step with them, the
|
||||||
@@ -315,7 +396,35 @@ class Figure {
|
|||||||
/// a following group's window, push both to all members.
|
/// a following group's window, push both to all members.
|
||||||
void sync_x_links();
|
void sync_x_links();
|
||||||
|
|
||||||
|
/// A split row's shared y axis: the range last pushed to its cells (the value the
|
||||||
|
/// next frame compares each cell against, so a gesture is told from the group's own
|
||||||
|
/// write -- the same device as `XLink::pushed`), whether the group has been
|
||||||
|
/// initialised from its first cell, and whether it is auto scaled.
|
||||||
|
struct RowY {
|
||||||
|
Range pushed{};
|
||||||
|
bool have = false;
|
||||||
|
bool auto_scale = true;
|
||||||
|
};
|
||||||
|
/// Advances every split row's y group for this frame: adopt what a cell was changed
|
||||||
|
/// to (a range or an auto flag), fit the union of the cells' traces while the group
|
||||||
|
/// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is
|
||||||
|
/// where a plot would otherwise autoscale on its own.
|
||||||
|
void sync_row_y();
|
||||||
|
|
||||||
|
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
|
||||||
|
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
|
||||||
|
/// constructors size from `total_cells()`.
|
||||||
|
void build_cells(std::span<const u32> cells);
|
||||||
|
|
||||||
std::vector<Plot> plots_;
|
std::vector<Plot> plots_;
|
||||||
|
std::vector<u32> cells_per_row_;
|
||||||
|
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
|
||||||
|
/// index of row r's first cell and the last entry the plot count.
|
||||||
|
std::vector<u32> row_first_;
|
||||||
|
std::vector<std::vector<f32>> cell_weights_;
|
||||||
|
bool share_row_y_ = false;
|
||||||
|
std::vector<RowY> row_y_;
|
||||||
|
f32 col_gap_ = 0.0f;
|
||||||
/// What `plot()` yields for a row that does not exist, so a stale row can be used
|
/// What `plot()` yields for a row that does not exist, so a stale row can be used
|
||||||
/// without reading out of bounds. Owned rather than a static, so one figure's
|
/// without reading out of bounds. Owned rather than a static, so one figure's
|
||||||
/// stray row can never write into another's.
|
/// stray row can never write into another's.
|
||||||
@@ -326,6 +435,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+316
-9
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
#include <plot/plot/crosshair.hpp>
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
|
#include <plot/plot/density.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
@@ -38,6 +40,13 @@ namespace plot {
|
|||||||
/// are for a host that wants one without the other.
|
/// are for a host that wants one without the other.
|
||||||
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
|
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
|
||||||
|
|
||||||
|
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
|
||||||
|
/// its own. `None` reserves no label width, no title column and ignores the figure's common
|
||||||
|
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
|
||||||
|
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
|
||||||
|
/// The cell's ticks still refresh and its grid still builds.
|
||||||
|
enum class YStrip { Both, None };
|
||||||
|
|
||||||
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
|
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
|
||||||
/// when it sizes the strip and by the renderer when it decides whether to stage the
|
/// when it sizes the strip and by the renderer when it decides whether to stage the
|
||||||
/// labels -- a row that reserves none would otherwise stage them over its own data.
|
/// labels -- a row that reserves none would otherwise stage them over its own data.
|
||||||
@@ -49,6 +58,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
|
|||||||
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
|
||||||
|
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
|
||||||
|
/// exist and that have not been delivered.
|
||||||
|
///
|
||||||
|
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
|
||||||
|
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
|
||||||
|
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
|
||||||
|
/// that has since been repaired, and from a span that has left the list entirely.
|
||||||
|
struct InvalidSpan {
|
||||||
|
f64 x0 = 0.0;
|
||||||
|
f64 x1 = 0.0;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
|
||||||
|
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
|
||||||
|
/// draws exactly what the layout measured and derives nothing of its own.
|
||||||
|
struct InvalidSpanLayout {
|
||||||
|
Rect fill;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
||||||
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
||||||
/// what the renderer draws with, already converted from the logical metrics in
|
/// what the renderer draws with, already converted from the logical metrics in
|
||||||
@@ -88,6 +119,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
|
||||||
@@ -100,13 +136,29 @@ struct PlotLayout {
|
|||||||
/// controller's hit test reads the very same rects, so where a region is drawn and
|
/// controller's hit test reads the very same rects, so where a region is drawn and
|
||||||
/// where a pointer can grab it cannot drift apart.
|
/// where a pointer can grab it cannot drift apart.
|
||||||
std::vector<RegionLayout> regions;
|
std::vector<RegionLayout> regions;
|
||||||
|
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
|
||||||
|
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
|
||||||
|
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
|
||||||
|
/// measured into. A span entirely outside the window contributes no rect at all, so this is
|
||||||
|
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
|
||||||
|
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
|
||||||
|
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
|
||||||
|
/// at draw time.
|
||||||
|
std::vector<InvalidSpanLayout> invalid_spans;
|
||||||
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
||||||
/// readout panel and one row per visible trace with the interpolated value the pointer
|
/// readout panel and one row per visible trace with the value of the sample nearest the
|
||||||
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
|
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
|
||||||
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
||||||
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
||||||
/// over a label strip or into a figure's neighbouring row.
|
/// over a label strip or into a figure's neighbouring row.
|
||||||
CrosshairLayout crosshair;
|
CrosshairLayout crosshair;
|
||||||
|
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
|
||||||
|
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
|
||||||
|
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
|
||||||
|
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
|
||||||
|
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
|
||||||
|
/// border segments read as "draw nothing".
|
||||||
|
Rect selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
|
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
|
||||||
@@ -193,6 +245,12 @@ class Plot {
|
|||||||
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
||||||
XStrip x_strip_mode() const noexcept { return x_strip_; }
|
XStrip x_strip_mode() const noexcept { return x_strip_; }
|
||||||
|
|
||||||
|
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
|
||||||
|
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
|
||||||
|
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
|
||||||
|
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
|
||||||
|
YStrip y_strip_mode() const noexcept { return y_strip_; }
|
||||||
|
|
||||||
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
||||||
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
||||||
/// is measured: a plot with no renderer lays out with whatever the host sets
|
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||||
@@ -390,6 +448,20 @@ class Plot {
|
|||||||
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
|
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
|
||||||
/// figure never has a linked axis.
|
/// figure never has a linked axis.
|
||||||
void set_x_link_member(u32 axis, bool linked) noexcept;
|
void set_x_link_member(u32 axis, bool linked) noexcept;
|
||||||
|
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
|
||||||
|
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
|
||||||
|
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
|
||||||
|
/// the union of every member's traces -- but a gesture or `set_y_range()` still
|
||||||
|
/// writes it, which is how the group learns of a change. A no-op for an axis that
|
||||||
|
/// does not exist. Not for callers outside the figure.
|
||||||
|
void set_y_link_member(u32 axis, bool linked) noexcept;
|
||||||
|
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
|
||||||
|
/// group collects from each member. False -- and `out` untouched -- when none.
|
||||||
|
bool y_data_bounds(u32 axis, Range& out) const;
|
||||||
|
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
|
||||||
|
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
|
||||||
|
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
|
||||||
|
bool fit_y_bounds(u32 axis, Range bounds);
|
||||||
|
|
||||||
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
||||||
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
||||||
@@ -485,6 +557,14 @@ class Plot {
|
|||||||
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||||
void set_cursor_color(u32 id, Color color);
|
void set_cursor_color(u32 id, Color color);
|
||||||
|
|
||||||
|
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
|
||||||
|
/// ruler writes its own value and needs no caption to be readable. Rendered on a
|
||||||
|
/// backplate, and labels whose backplates meet are merged into one panel with one line per
|
||||||
|
/// label (see the renderer), which is what keeps a marker pair half a division apart
|
||||||
|
/// readable instead of a smear of two texts over one column. An empty string removes the
|
||||||
|
/// caption; an id that names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_label(u32 id, std::string label);
|
||||||
|
|
||||||
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
|
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
|
||||||
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
|
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
|
||||||
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
|
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
|
||||||
@@ -508,6 +588,116 @@ 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_; }
|
||||||
|
|
||||||
|
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
|
||||||
|
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
|
||||||
|
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
|
||||||
|
/// the traces and over the grid.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
|
||||||
|
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
|
||||||
|
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
|
||||||
|
/// marks nobody can usefully drag. This is passive shading and nothing else.
|
||||||
|
///
|
||||||
|
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
|
||||||
|
/// sorted and may overlap; an empty call clears the shading.
|
||||||
|
///
|
||||||
|
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
|
||||||
|
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
|
||||||
|
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
|
||||||
|
void set_invalid_spans(std::span<const InvalidSpan> spans);
|
||||||
|
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
|
||||||
|
|
||||||
|
// --- phosphor density --------------------------------------------------
|
||||||
|
|
||||||
|
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
|
||||||
|
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
|
||||||
|
/// and no frame uploads anything.
|
||||||
|
///
|
||||||
|
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
|
||||||
|
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
|
||||||
|
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
|
||||||
|
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
|
||||||
|
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
|
||||||
|
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
|
||||||
|
/// top of its own density could never fade, which is what a report of "the last acquisition
|
||||||
|
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
|
||||||
|
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
|
||||||
|
/// where retaining the acquisitions would cost a window copy each.
|
||||||
|
///
|
||||||
|
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
|
||||||
|
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
|
||||||
|
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
|
||||||
|
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
|
||||||
|
/// reserves no space either way, so the bound changes its resolution and nothing about the
|
||||||
|
/// layout.
|
||||||
|
///
|
||||||
|
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
|
||||||
|
/// has stopped paying for the display is not left holding a stale image of it, which is the
|
||||||
|
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
|
||||||
|
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
|
||||||
|
/// sized against yet is a screen it has nothing to say about.
|
||||||
|
void set_density(const DensityConfig& config);
|
||||||
|
/// The configuration, as set.
|
||||||
|
const DensityConfig& density() const noexcept { return density_; }
|
||||||
|
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
|
||||||
|
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
|
||||||
|
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
|
||||||
|
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
|
||||||
|
const DensityGrid& density_grid(u32 trace) const noexcept {
|
||||||
|
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
|
||||||
|
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
|
||||||
|
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
|
||||||
|
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
|
||||||
|
/// how much the beam moved (see `DensityGrid`; the dwell question is
|
||||||
|
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
|
||||||
|
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
|
||||||
|
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
|
||||||
|
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
|
||||||
|
/// of what the newest acquisition looks like -- and it fades with the rest.
|
||||||
|
///
|
||||||
|
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
|
||||||
|
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
|
||||||
|
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
|
||||||
|
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
|
||||||
|
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
|
||||||
|
/// deposits nothing.
|
||||||
|
///
|
||||||
|
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
|
||||||
|
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
|
||||||
|
/// held, because the image is of the axes rect and a new rect is a new image. Under
|
||||||
|
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
|
||||||
|
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
|
||||||
|
/// the library-wide policy.
|
||||||
|
void accumulate_density();
|
||||||
|
|
||||||
|
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
|
||||||
|
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
|
||||||
|
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
|
||||||
|
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
|
||||||
|
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
|
||||||
|
/// has not measured a frame yet cannot wipe the grid by accident.
|
||||||
|
void decay_density(f64 dt_s);
|
||||||
|
|
||||||
|
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
|
||||||
|
/// under the image is replaced: the screen-relative image of another stream's sweeps says
|
||||||
|
/// nothing about this one.
|
||||||
|
void clear_density() noexcept;
|
||||||
|
|
||||||
/// 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 +818,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_; }
|
||||||
|
|
||||||
@@ -671,12 +875,35 @@ class Plot {
|
|||||||
/// calls this when the pointer leaves the axes.
|
/// calls this when the pointer leaves the axes.
|
||||||
void clear_crosshair() noexcept { crosshair_.active = false; }
|
void clear_crosshair() noexcept { crosshair_.active = false; }
|
||||||
|
|
||||||
|
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
|
||||||
|
///
|
||||||
|
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
|
||||||
|
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
|
||||||
|
/// the plot has no controller and learns nothing about pointer, button or modifier (see
|
||||||
|
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
|
||||||
|
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
|
||||||
|
/// the axes, so the area being framed stays legible against the traces it covers.
|
||||||
|
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
|
||||||
|
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
|
||||||
|
void clear_selection_rect() noexcept { selection_ = Rect{}; }
|
||||||
|
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
|
||||||
|
/// test reads back without paying a layout.
|
||||||
|
Rect selection_rect() const noexcept { return selection_; }
|
||||||
|
|
||||||
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
|
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
|
||||||
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
|
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
|
||||||
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
|
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
|
||||||
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
|
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
|
||||||
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
|
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
|
||||||
|
|
||||||
|
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
|
||||||
|
/// the y label strip and the x value in the x label strip, at the heights and columns the
|
||||||
|
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
|
||||||
|
/// `set_crosshair()`, for the same reason the readout flag is not.
|
||||||
|
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
|
||||||
|
/// The axis-badge flag, as set.
|
||||||
|
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
|
||||||
|
|
||||||
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
|
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
|
||||||
/// global style every other plot draws with (spec §17): each field left negative in `s`
|
/// global style every other plot draws with (spec §17): each field left negative in `s`
|
||||||
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
|
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
|
||||||
@@ -688,20 +915,31 @@ class Plot {
|
|||||||
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
||||||
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
||||||
|
|
||||||
|
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
|
||||||
|
/// puts the marker; the default is `NearestSample`.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
|
||||||
|
/// override: it is a statement about the data, so a pointer move must not be able to change
|
||||||
|
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
|
||||||
|
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
|
||||||
|
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
||||||
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
||||||
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
||||||
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
||||||
|
|
||||||
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
|
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
|
||||||
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
|
/// bracketing sample's y, or the blend of the two, according to
|
||||||
/// the readout's rows are built from, exposed for a host that wants the number for
|
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
|
||||||
/// itself (a status line, a tooltip of its own).
|
/// for a host that wants the number for itself (a status line, a tooltip of its own).
|
||||||
///
|
///
|
||||||
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
||||||
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
||||||
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
|
/// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
|
||||||
/// `crosshair_span()` for why an out-of-window x is not a value.
|
/// an out-of-window x is not a value.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
||||||
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
||||||
@@ -709,6 +947,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
|
||||||
@@ -799,6 +1048,30 @@ class Plot {
|
|||||||
/// region's pixel geometry is derived -- the controller hit-tests what this
|
/// region's pixel geometry is derived -- the controller hit-tests what this
|
||||||
/// measured instead of mapping the bounds through the transform again.
|
/// measured instead of mapping the bounds through the transform again.
|
||||||
void build_regions();
|
void build_regions();
|
||||||
|
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
|
||||||
|
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
|
||||||
|
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
|
||||||
|
void build_invalid_spans();
|
||||||
|
/// Deposits one trace's visible window into its own density grid: the same index window
|
||||||
|
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
|
||||||
|
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
|
||||||
|
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
|
||||||
|
/// visible window or non-positive `dx()` deposits nothing.
|
||||||
|
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
|
||||||
|
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
|
||||||
|
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
|
||||||
|
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
|
||||||
|
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
|
||||||
|
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
|
||||||
|
/// non-finite coordinate) fills the single column it is in.
|
||||||
|
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
|
||||||
|
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
|
||||||
|
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
|
||||||
|
/// returns whether any grid is usable. The one place the grids are allocated.
|
||||||
|
bool size_density_grids();
|
||||||
|
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
|
||||||
|
/// accessor need not hand back a reference into nothing.
|
||||||
|
static const DensityGrid& empty_density_grid() noexcept;
|
||||||
/// Exact statistics of `region` over `series`, or an invalid result when the
|
/// Exact statistics of `region` over `series`, or an invalid result when the
|
||||||
/// interval covers no sample. The one place the region arithmetic lives.
|
/// interval covers no sample. The one place the region arithmetic lives.
|
||||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||||
@@ -816,7 +1089,7 @@ class Plot {
|
|||||||
/// never names a trace.
|
/// never names a trace.
|
||||||
void build_legend();
|
void build_legend();
|
||||||
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
||||||
/// per visible trace with its interpolated value and marker, and the readout panel --
|
/// per visible trace with its nearest sample's value and marker, and the readout panel --
|
||||||
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
||||||
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
||||||
/// lines: the rows are the readout's, not the crosshair's.
|
/// lines: the rows are the readout's, not the crosshair's.
|
||||||
@@ -826,11 +1099,18 @@ 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;
|
||||||
|
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
|
||||||
|
YStrip y_strip_ = YStrip::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_{};
|
||||||
|
|
||||||
@@ -841,6 +1121,28 @@ class Plot {
|
|||||||
std::vector<MeasureSlot> measures_;
|
std::vector<MeasureSlot> measures_;
|
||||||
std::vector<Annotation> annotations_;
|
std::vector<Annotation> annotations_;
|
||||||
std::vector<Region> regions_;
|
std::vector<Region> regions_;
|
||||||
|
std::vector<InvalidSpan> invalid_spans_;
|
||||||
|
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
|
||||||
|
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
|
||||||
|
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
|
||||||
|
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
|
||||||
|
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
|
||||||
|
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
|
||||||
|
/// traces.
|
||||||
|
DensityConfig density_{};
|
||||||
|
std::vector<DensityGrid> density_grids_;
|
||||||
|
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
|
||||||
|
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
|
||||||
|
/// window, not the series, as `Renderer::gather_` is.
|
||||||
|
std::vector<f32> density_points_;
|
||||||
|
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
|
||||||
|
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
|
||||||
|
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
|
||||||
|
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
|
||||||
|
std::vector<u64> density_stamp_;
|
||||||
|
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
|
||||||
|
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
|
||||||
|
u32 density_trace_ = 0;
|
||||||
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
||||||
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
||||||
/// does. Entries are created for the pairs a caller actually asks about, never
|
/// does. Entries are created for the pairs a caller actually asks about, never
|
||||||
@@ -855,6 +1157,11 @@ class Plot {
|
|||||||
/// The crosshair's state (see Crosshair): at most one per plot, placed by
|
/// The crosshair's state (see Crosshair): at most one per plot, placed by
|
||||||
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
|
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
|
||||||
Crosshair crosshair_{};
|
Crosshair crosshair_{};
|
||||||
|
|
||||||
|
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
|
||||||
|
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
|
||||||
|
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
|
||||||
|
Rect selection_{};
|
||||||
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
|
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
|
||||||
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
|
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
|
||||||
/// most two samples a span covers. Sized to hold them from construction -- four floats is
|
/// most two samples a span covers. Sized to hold them from construction -- four floats is
|
||||||
|
|||||||
@@ -84,4 +84,37 @@ class TextProgram {
|
|||||||
gl::Program program_;
|
gl::Program program_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
|
||||||
|
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
|
||||||
|
/// `GLFailure`.
|
||||||
|
///
|
||||||
|
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
|
||||||
|
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
|
||||||
|
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
|
||||||
|
/// cell -- which is the one thing this pipeline has that the others do not.
|
||||||
|
///
|
||||||
|
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
|
||||||
|
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
|
||||||
|
/// with the caller, who owns its lifetime.
|
||||||
|
class DensityProgram {
|
||||||
|
public:
|
||||||
|
bool create(Status* st = nullptr) noexcept;
|
||||||
|
bool valid() const noexcept { return program_.id() != 0; }
|
||||||
|
void bind() const noexcept { program_.use(); }
|
||||||
|
|
||||||
|
/// `u_viewport`: render target size in pixels.
|
||||||
|
void set_viewport(Vec2 px) const noexcept;
|
||||||
|
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
|
||||||
|
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
|
||||||
|
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
|
||||||
|
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
|
||||||
|
/// the vertex shader builds the quad from anyway.
|
||||||
|
void set_rect(Vec2 origin, Vec2 size) const noexcept;
|
||||||
|
/// `u_color`: the tint the cells' luminances are multiplied into.
|
||||||
|
void set_color(Color color) const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
gl::Program program_;
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
@@ -29,10 +29,37 @@ namespace plot {
|
|||||||
struct FrameStats {
|
struct FrameStats {
|
||||||
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||||
|
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
|
||||||
|
/// per column, lit from that column's own extremes. Counts a trace from either
|
||||||
|
/// path above -- the reduction is about the *density* of the gathered points,
|
||||||
|
/// not about which level produced them.
|
||||||
|
u32 column_traces = 0;
|
||||||
|
/// Traces whose columns were graded by dwell rather than drawn as one flat band
|
||||||
|
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
|
||||||
|
/// drawing the reduction, not a third path.
|
||||||
|
u32 graded_traces = 0;
|
||||||
|
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
|
||||||
|
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
|
||||||
|
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
|
||||||
|
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
|
||||||
|
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
u32 slope_traces = 0;
|
||||||
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||||
u32 draw_calls = 0; ///< draw calls issued, line and text
|
u32 draw_calls = 0; ///< draw calls issued, line and text
|
||||||
|
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
|
||||||
|
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
|
||||||
|
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
|
||||||
|
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
|
||||||
|
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
|
||||||
|
u32 density_cells = 0;
|
||||||
|
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
|
||||||
|
/// since the last frame and 0 when it had not. It is the one field that separates the two
|
||||||
|
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
|
||||||
|
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
|
||||||
|
/// with the display on and off needs to tell apart.
|
||||||
|
u32 density_upload_bytes = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Draws a `Plot` into whatever framebuffer is bound.
|
/// Draws a `Plot` into whatever framebuffer is bound.
|
||||||
@@ -68,6 +95,24 @@ struct FrameStats {
|
|||||||
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
|
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
|
||||||
///
|
///
|
||||||
/// Not thread-safe, and confined to the thread that owns the current context.
|
/// Not thread-safe, and confined to the thread that owns the current context.
|
||||||
|
|
||||||
|
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
|
||||||
|
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
|
||||||
|
/// detail of the cursor labels' backplates, declared at namespace scope only because a
|
||||||
|
/// private nested type cannot be named in an out-of-class member definition's signature.
|
||||||
|
struct CursorLabel {
|
||||||
|
std::string text; ///< the one line the backplate carries
|
||||||
|
Color border{}; ///< the panel's border: the cursor's own colour
|
||||||
|
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
|
||||||
|
f32 w = 0.0f; ///< the measured text width
|
||||||
|
bool horizontal = false;
|
||||||
|
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
|
||||||
|
/// free row when a close neighbour's badge is in the way, and clustered into one panel
|
||||||
|
/// by `draw_clustered_cursor_labels()`.
|
||||||
|
Vec2 pen{};
|
||||||
|
Rect box{};
|
||||||
|
};
|
||||||
|
|
||||||
class Renderer {
|
class Renderer {
|
||||||
public:
|
public:
|
||||||
Renderer() noexcept = default;
|
Renderer() noexcept = default;
|
||||||
@@ -78,8 +123,8 @@ class Renderer {
|
|||||||
Renderer(const Renderer&) = delete;
|
Renderer(const Renderer&) = delete;
|
||||||
Renderer& operator=(const Renderer&) = delete;
|
Renderer& operator=(const Renderer&) = delete;
|
||||||
|
|
||||||
/// Destroys every GL object this renderer created: both programs, both vertex
|
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
|
||||||
/// arrays and their instance buffers, and the glyph atlas texture. The
|
/// their instance buffers, the glyph atlas texture and the density's own texture. The
|
||||||
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
|
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
|
||||||
/// called again on the same object.
|
/// called again on the same object.
|
||||||
///
|
///
|
||||||
@@ -211,12 +256,13 @@ class Renderer {
|
|||||||
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
||||||
/// single-plot hosts.
|
/// single-plot hosts.
|
||||||
|
|
||||||
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
|
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
|
||||||
/// that row's normal path: when the figure says labels belong on the bottom only
|
/// columns), so it is drawn by that row's normal path: when the figure says labels
|
||||||
/// (the default), the rows above reserve no x chrome and the bottom row stages its
|
/// belong on the bottom only (the default), the rows above that match the row below
|
||||||
/// own labels and title once, inside its own strip -- the rect
|
/// reserve no x chrome and the bottom row stages its own labels and title once,
|
||||||
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
|
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
|
||||||
/// its own chrome and each draws its own labels; there is then no shared axis.
|
/// whose columns differ keeps and draws its own; with that switched off, every row
|
||||||
|
/// does and there is no shared axis.
|
||||||
|
|
||||||
/// Styling, the text backend and `ui_scale` are installed on **every** row before
|
/// Styling, the text backend and `ui_scale` are installed on **every** row before
|
||||||
/// the figure computes its layout, so the strips the rows measure and the strokes
|
/// the figure computes its layout, so the strips the rows measure and the strokes
|
||||||
@@ -404,6 +450,13 @@ class Renderer {
|
|||||||
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
|
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
|
||||||
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
|
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
|
||||||
bool snap = false) noexcept;
|
bool snap = false) noexcept;
|
||||||
|
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
|
||||||
|
/// pass stages one batch per alpha step, and a step has to be a draw call of its
|
||||||
|
/// own because the line pipeline takes its colour from a uniform -- so it needs to
|
||||||
|
/// flush a batch it chose rather than the member one. Every other caller flushes
|
||||||
|
/// `lines_` and uses the overload above.
|
||||||
|
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
||||||
|
Vec2 dash = Vec2{}, bool snap = false) noexcept;
|
||||||
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
||||||
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||||
/// once per text batch, after the line geometry it belongs over.
|
/// once per text batch, after the line geometry it belongs over.
|
||||||
@@ -445,11 +498,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.
|
||||||
@@ -490,6 +544,34 @@ class Renderer {
|
|||||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||||
|
|
||||||
void draw_grid(const Plot& plot);
|
void draw_grid(const Plot& plot);
|
||||||
|
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
|
||||||
|
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
|
||||||
|
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
|
||||||
|
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
|
||||||
|
/// and the newest acquisition is inside it.
|
||||||
|
///
|
||||||
|
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
|
||||||
|
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
|
||||||
|
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
|
||||||
|
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
|
||||||
|
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
|
||||||
|
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
|
||||||
|
/// frame.
|
||||||
|
void draw_density(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
|
||||||
|
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
|
||||||
|
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
|
||||||
|
/// image of. The one place the density conversion lives, shared by every trace of
|
||||||
|
/// `draw_density()`.
|
||||||
|
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
|
||||||
|
const PlotLayout& layout);
|
||||||
|
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
|
||||||
|
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
|
||||||
|
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
|
||||||
|
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
|
||||||
|
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
|
||||||
|
/// hole from a trace that has gone quiet.
|
||||||
|
void draw_invalid_spans(const PlotLayout& layout);
|
||||||
void draw_axis_lines(const Plot& plot);
|
void draw_axis_lines(const Plot& plot);
|
||||||
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
||||||
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
||||||
@@ -499,6 +581,53 @@ class Renderer {
|
|||||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||||
/// pins.
|
/// pins.
|
||||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One gathered window reduced onto the pixel grid and staged in framebuffer
|
||||||
|
/// pixels: for each integer column of `layout.axes`, one exactly vertical
|
||||||
|
/// segment from the lowest to the highest screen y any gathered point put in
|
||||||
|
/// it. Returns how many columns were staged (0 stages nothing); the caller
|
||||||
|
/// flushes with `pixel_mapping()` and `snap = true`.
|
||||||
|
///
|
||||||
|
/// `gather_` is the input, read with the same origin `draw_traces` asked the
|
||||||
|
/// series for. See `draw_traces` for when this path is taken and why.
|
||||||
|
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
|
||||||
|
/// each at the alpha step its own share of the column's samples earns, so a flat
|
||||||
|
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
|
||||||
|
///
|
||||||
|
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
|
||||||
|
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
|
||||||
|
/// Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
///
|
||||||
|
/// `gather_` is the input and must have been asked for at the *finer* level
|
||||||
|
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
|
||||||
|
/// from its own min and max (see `Style::intensity_grading`).
|
||||||
|
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The **sparse** window's polyline, graded by the same measure: each segment is
|
||||||
|
/// staged into the step its own *slope* earns, so a near-vertical segment between two
|
||||||
|
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
|
||||||
|
/// anywhere in it -- is faint and a near-flat one is bright.
|
||||||
|
///
|
||||||
|
/// It exists because grading used to be gated on the column reduction, and that
|
||||||
|
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
|
||||||
|
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
|
||||||
|
/// zooming in past it switched the whole phosphor model off and drew an edge as
|
||||||
|
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
|
||||||
|
///
|
||||||
|
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
|
||||||
|
/// rather than per column**: below two points a column there is no column peak to
|
||||||
|
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
|
||||||
|
/// flat segment earns the top step and one crossing the whole axis earns
|
||||||
|
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
|
||||||
|
/// side.
|
||||||
|
///
|
||||||
|
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
|
||||||
|
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
|
||||||
|
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
|
||||||
|
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
|
||||||
|
f64 x_origin, bool log_x, bool log_y);
|
||||||
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
||||||
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
||||||
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
||||||
@@ -510,13 +639,22 @@ class Renderer {
|
|||||||
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
|
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
|
||||||
/// off the plot.
|
/// off the plot.
|
||||||
void draw_cursors(const Plot& plot, const PlotLayout& layout);
|
void draw_cursors(const Plot& plot, const PlotLayout& layout);
|
||||||
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
|
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
|
||||||
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
|
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
|
||||||
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
|
/// ruler exists to be read off -- with the caption first on the line when there is one; a
|
||||||
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
|
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
|
||||||
/// own row, with the caption first on that line when there is one.
|
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
|
||||||
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
|
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
|
||||||
f32 at);
|
/// marks half a division apart readable: two texts over one column is a smear, one panel
|
||||||
|
/// with two lines is a readout.
|
||||||
|
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
|
||||||
|
f32 at, std::vector<CursorLabel>& labels);
|
||||||
|
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
|
||||||
|
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
|
||||||
|
/// readout marks half a division apart readable: two texts over one column is a smear, one
|
||||||
|
/// panel with two lines is a readout.
|
||||||
|
void draw_clustered_cursor_labels(const PlotLayout& layout,
|
||||||
|
const std::vector<CursorLabel>& labels);
|
||||||
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
|
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
|
||||||
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
|
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
|
||||||
/// a region is drawn where it can be grabbed, and neither side derives the other's
|
/// a region is drawn where it can be grabbed, and neither side derives the other's
|
||||||
@@ -534,13 +672,23 @@ 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
|
||||||
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
|
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
|
||||||
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
|
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
|
||||||
/// legend panel and a readout panel do not share.
|
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
|
||||||
void draw_readout_panel(const Rect& panel, Color border);
|
/// background under the border: the legend and the crosshair's readout keep the shared
|
||||||
|
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
|
||||||
|
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
|
||||||
|
/// (ticks, a noisy trace) and is drawn opaque.
|
||||||
|
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
|
||||||
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
|
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
|
||||||
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
|
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
|
||||||
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
|
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
|
||||||
@@ -570,6 +718,17 @@ class Renderer {
|
|||||||
/// Drawn after every other interior pass -- the legend included -- because it is what the
|
/// Drawn after every other interior pass -- the legend included -- because it is what the
|
||||||
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
|
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
|
||||||
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
|
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
|
||||||
|
/// values the two lines meet the axes at, each on the shared readout backplate with its
|
||||||
|
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
|
||||||
|
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
|
||||||
|
/// badge in a strip can sit on the ticks and still be read.
|
||||||
|
void draw_crosshair_axis_badges(const PlotLayout& layout);
|
||||||
|
|
||||||
|
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
|
||||||
|
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
|
||||||
|
/// the area being framed is legible over the traces, the regions and the readout alike.
|
||||||
|
void draw_selection_frame(const PlotLayout& layout);
|
||||||
|
|
||||||
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
|
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
|
||||||
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
|
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
|
||||||
@@ -591,6 +750,27 @@ class Renderer {
|
|||||||
render::TextAtlas atlas_;
|
render::TextAtlas atlas_;
|
||||||
render::LineProgram line_program_;
|
render::LineProgram line_program_;
|
||||||
render::TextProgram text_program_;
|
render::TextProgram text_program_;
|
||||||
|
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
|
||||||
|
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
|
||||||
|
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
|
||||||
|
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
|
||||||
|
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
|
||||||
|
/// held).
|
||||||
|
render::DensityProgram density_program_;
|
||||||
|
gl::VertexArray density_vao_;
|
||||||
|
gl::Texture density_texture_;
|
||||||
|
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
|
||||||
|
/// nothing: one byte per cell.
|
||||||
|
std::vector<u8> density_pixels_;
|
||||||
|
u32 density_w_ = 0;
|
||||||
|
u32 density_h_ = 0;
|
||||||
|
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
|
||||||
|
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
|
||||||
|
/// in a persistence display. The address is part of the key because the texture is shared: two
|
||||||
|
/// traces of one plot have two grids with two revisions, and without the address a skip would
|
||||||
|
/// draw one trace's image under another's colour.
|
||||||
|
const DensityGrid* density_uploaded_grid_ = nullptr;
|
||||||
|
u64 density_uploaded_revision_ = 0;
|
||||||
render::LineVertexArray line_vao_;
|
render::LineVertexArray line_vao_;
|
||||||
render::TextVertexArray text_vao_;
|
render::TextVertexArray text_vao_;
|
||||||
|
|
||||||
@@ -604,7 +784,25 @@ class Renderer {
|
|||||||
render::TextBatch glyphs_under_;
|
render::TextBatch glyphs_under_;
|
||||||
render::TextBatch glyphs_over_;
|
render::TextBatch glyphs_over_;
|
||||||
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||||
std::vector<f32> staged_; ///< scratch for one staged window, re-used
|
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
|
||||||
|
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
|
||||||
|
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
|
||||||
|
/// window.
|
||||||
|
std::vector<f32> columns_;
|
||||||
|
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
|
||||||
|
/// gathered points arrive in x order, so a column can be accumulated, read and
|
||||||
|
/// cleared before the next one starts. A full-axes density image would be
|
||||||
|
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
|
||||||
|
///
|
||||||
|
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
|
||||||
|
/// its last, so accumulating a span costs two writes whatever its height; the
|
||||||
|
/// running sum over the column is then how many sub-buckets cover each row, which
|
||||||
|
/// is the dwell.
|
||||||
|
std::vector<f32> column_rows_;
|
||||||
|
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
|
||||||
|
/// across frames: the line pipeline takes its colour from a uniform, so segments
|
||||||
|
/// at different alphas cannot share a draw call and must be staged apart.
|
||||||
|
std::vector<render::LineBatch> steps_;
|
||||||
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
||||||
|
|
||||||
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
||||||
|
|||||||
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
|
|||||||
extern const char* const kTextVS;
|
extern const char* const kTextVS;
|
||||||
extern const char* const kTextFS;
|
extern const char* const kTextFS;
|
||||||
|
|
||||||
|
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
|
||||||
|
///
|
||||||
|
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
|
||||||
|
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
|
||||||
|
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
|
||||||
|
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
|
||||||
|
///
|
||||||
|
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
|
||||||
|
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
|
||||||
|
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
|
||||||
|
/// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
|
||||||
|
///
|
||||||
|
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
|
||||||
|
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
|
||||||
|
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
|
||||||
|
/// own to get out of step with the numbers a host reads back.
|
||||||
|
///
|
||||||
|
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
|
||||||
|
/// here, as in the line pipeline.
|
||||||
|
extern const char* const kDensityVS;
|
||||||
|
extern const char* const kDensityFS;
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
@@ -55,6 +55,28 @@ struct Style {
|
|||||||
Color axis_text = Color::hex(0xC8D0DC);
|
Color axis_text = Color::hex(0xC8D0DC);
|
||||||
Color cursor = Color::hex(0xFFB020);
|
Color cursor = Color::hex(0xFFB020);
|
||||||
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
||||||
|
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
|
||||||
|
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
|
||||||
|
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
|
||||||
|
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
|
||||||
|
/// low enough that what is under the frame stays readable, because the point is to show what is
|
||||||
|
/// being selected, not to hide it.
|
||||||
|
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
|
||||||
|
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
|
||||||
|
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
|
||||||
|
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
|
||||||
|
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
|
||||||
|
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
|
||||||
|
/// low enough that the grid under it stays legible, which is what makes the band read as a
|
||||||
|
/// region of the plot rather than as a panel laid over it.
|
||||||
|
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
|
||||||
|
/// The same shading for a span whose samples have since arrived and been written in, at well
|
||||||
|
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
|
||||||
|
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
|
||||||
|
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
|
||||||
|
/// different kinds of thing; and still above the threshold where the difference from *no* band
|
||||||
|
/// is visible, which is the floor this alpha is chosen against.
|
||||||
|
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
|
||||||
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
||||||
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
||||||
/// trace and the line says *where*.
|
/// trace and the line says *where*.
|
||||||
@@ -86,6 +108,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 ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -162,6 +199,90 @@ struct Style {
|
|||||||
///< sides draw through the one instanced
|
///< sides draw through the one instanced
|
||||||
///< anti-aliased quad path.
|
///< anti-aliased quad path.
|
||||||
|
|
||||||
|
// --- intensity grading: counts and ratios, never sizes -------------------
|
||||||
|
|
||||||
|
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
|
||||||
|
/// each height**, the way a digital-phosphor scope does, instead of as one flat
|
||||||
|
/// band per column: bright where samples pile up (a flat top, a peak), faint
|
||||||
|
/// where they only pass through (an edge, a noise excursion).
|
||||||
|
///
|
||||||
|
/// Off by default, because it is a change of *look* and not a correction: the
|
||||||
|
/// flat band is the honest answer to "what did this column reach", and a host
|
||||||
|
/// that wants that -- or that draws few enough samples never to take the column
|
||||||
|
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
|
||||||
|
/// host turns it on.
|
||||||
|
///
|
||||||
|
/// It is not derivable from the flat band. A column's min and max are the same
|
||||||
|
/// whether its samples fill the span or sit piled at its two ends, which is
|
||||||
|
/// exactly the difference between a ramp and a pulse -- so grading needs the
|
||||||
|
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
|
||||||
|
/// and reads the distribution from those sub-buckets.
|
||||||
|
///
|
||||||
|
/// The measure is *dwell per row*, and the weighting is the whole of it: each
|
||||||
|
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
|
||||||
|
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
|
||||||
|
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
|
||||||
|
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
|
||||||
|
/// is the pattern inverted.
|
||||||
|
///
|
||||||
|
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
|
||||||
|
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
|
||||||
|
/// against `graded_traces`) -- the same measure for a sparse window, where a
|
||||||
|
/// segment *is* the sub-column. One switch covers both because the alternative was
|
||||||
|
/// a defect: gated on the reduction, the model switched off as a window narrowed
|
||||||
|
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
|
||||||
|
/// all of it in one pixel of zoom.
|
||||||
|
bool intensity_grading = false;
|
||||||
|
|
||||||
|
/// Sub-buckets per pixel column the gather is asked for when grading: the level
|
||||||
|
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
|
||||||
|
/// density is read from this many envelopes instead of from one.
|
||||||
|
///
|
||||||
|
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
|
||||||
|
/// points -- and the points stay bounded by the screen, which is the property
|
||||||
|
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
|
||||||
|
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
|
||||||
|
/// samples apart to separate.
|
||||||
|
///
|
||||||
|
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
|
||||||
|
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
|
||||||
|
/// a column is graded from however many it does hold. Without that floor `level_for`
|
||||||
|
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
|
||||||
|
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
|
||||||
|
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
|
||||||
|
/// bucket size is the limit, not this number.
|
||||||
|
u32 intensity_sub_columns = 8;
|
||||||
|
|
||||||
|
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
|
||||||
|
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
|
||||||
|
/// per-instance one), and the segments are bounded by the columns, so the cost
|
||||||
|
/// of a step is a draw call over a few hundred instances.
|
||||||
|
///
|
||||||
|
/// Four is enough to read a waveform's structure and few enough that a step is a
|
||||||
|
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
|
||||||
|
/// from "passed through", not to measure.
|
||||||
|
u32 intensity_steps = 4;
|
||||||
|
|
||||||
|
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
|
||||||
|
///
|
||||||
|
/// Not 0: a column the signal merely passed through is still a column the signal
|
||||||
|
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
|
||||||
|
/// disconnected lines. This is the floor that keeps the trace continuous.
|
||||||
|
f32 intensity_floor = 0.3f;
|
||||||
|
|
||||||
|
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
|
||||||
|
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
|
||||||
|
/// whose dwell is three orders of magnitude greater.
|
||||||
|
///
|
||||||
|
/// The dwell is normalised **against the column's own peak**, not against a scale
|
||||||
|
/// for the whole frame, so the steps spend their range on the structure *inside*
|
||||||
|
/// each column. One scale for the frame needs the whole `w * h` density image
|
||||||
|
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
|
||||||
|
/// worse: a column the signal flew straight through holds little dwell at any
|
||||||
|
/// height, so it would grade uniformly faint -- a trace that fades out exactly
|
||||||
|
/// where it moves fastest.
|
||||||
|
f32 intensity_gamma = 0.45f;
|
||||||
|
|
||||||
/// `logical` in device pixels: the single conversion point for every logical
|
/// `logical` in device pixels: the single conversion point for every logical
|
||||||
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
||||||
/// ui_scale` scattered over the renderer and the layout.
|
/// ui_scale` scattered over the renderer and the layout.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -179,6 +179,43 @@ void MinMaxPyramid::append(f32 v) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
|
||||||
|
if (!std::isfinite(v)) return;
|
||||||
|
if (absolute_sample < base_ || absolute_sample >= count_) return;
|
||||||
|
|
||||||
|
const f64 vd = f64(v);
|
||||||
|
const auto fold = [&](Entry& e) {
|
||||||
|
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
|
||||||
|
// sample *sets* the extremes instead of widening from a zero that was never a sample.
|
||||||
|
if (e.count == 0) {
|
||||||
|
e.mm.min = v;
|
||||||
|
e.mm.max = v;
|
||||||
|
} else {
|
||||||
|
e.mm.min = std::min(e.mm.min, v);
|
||||||
|
e.mm.max = std::max(e.mm.max, v);
|
||||||
|
}
|
||||||
|
e.sum += vd;
|
||||||
|
e.sumsq += vd * vd;
|
||||||
|
++e.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
|
||||||
|
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
|
||||||
|
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
|
||||||
|
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
|
||||||
|
fold(pending_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
|
||||||
|
// value into a parent gives the same aggregate as re-merging its children would.
|
||||||
|
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
|
||||||
|
const u64 i = absolute_sample / level_span(l);
|
||||||
|
if (i < levels_[l].base) continue;
|
||||||
|
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
|
||||||
|
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
||||||
if (absolute_sample <= base_) return;
|
if (absolute_sample <= base_) return;
|
||||||
if (absolute_sample > count_) {
|
if (absolute_sample > count_) {
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
@@ -751,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect Controller::selection_preview() const noexcept {
|
||||||
|
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
|
||||||
|
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
|
||||||
|
// that finished minutes ago.
|
||||||
|
if (mode_ != DragMode::BoxZoom) return Rect{};
|
||||||
|
// And nothing has been framed until the drag has swept every axis the release will apply: at the
|
||||||
|
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
|
||||||
|
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
|
||||||
|
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
|
||||||
|
// plot, which is exactly what Ctrl is for.
|
||||||
|
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
|
||||||
|
Rect preview = selection_;
|
||||||
|
if (box_zoom_x_ && box_zoom_y_) return preview;
|
||||||
|
const Rect& axes = plot_->layout().axes;
|
||||||
|
if (!box_zoom_x_) {
|
||||||
|
preview.x = axes.x;
|
||||||
|
preview.w = axes.w;
|
||||||
|
}
|
||||||
|
if (!box_zoom_y_) {
|
||||||
|
preview.y = axes.y;
|
||||||
|
preview.h = axes.h;
|
||||||
|
}
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
void Controller::box_zoom_update(const PointerEvent& e) {
|
void Controller::box_zoom_update(const PointerEvent& e) {
|
||||||
const f32 x = std::min(origin_x_, e.x);
|
const f32 x = std::min(origin_x_, e.x);
|
||||||
const f32 y = std::min(origin_y_, e.y);
|
const f32 y = std::min(origin_y_, e.y);
|
||||||
|
|||||||
+338
-34
@@ -23,6 +23,88 @@ namespace {
|
|||||||
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
|
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
|
||||||
constexpr f32 kMinRowHeightLogical = 24.0f;
|
constexpr f32 kMinRowHeightLogical = 24.0f;
|
||||||
|
|
||||||
|
/// The width a cell never drops below, in logical pixels, converted exactly as the row
|
||||||
|
/// height floor is. It yields to the row -- capped at an equal share of the width there
|
||||||
|
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
|
||||||
|
constexpr f32 kMinCellWidthLogical = 64.0f;
|
||||||
|
|
||||||
|
/// The number of plots a per-row cell count asks for, each row holding at least one.
|
||||||
|
u32 total_cells(std::span<const u32> cells) noexcept {
|
||||||
|
u32 total = 0;
|
||||||
|
for (const u32 c : cells) total += std::max<u32>(c, 1u);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
|
||||||
|
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
|
||||||
|
/// gets the floor width and then its weight's share of what is left, and the last cell
|
||||||
|
/// closes the row on its right edge, absorbing the rounding of the ones before it.
|
||||||
|
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
|
||||||
|
f32 ui_scale, Rect* out) {
|
||||||
|
if (n == 1u) {
|
||||||
|
out[0] = r;
|
||||||
|
return col_gap;
|
||||||
|
}
|
||||||
|
f32 gap = col_gap;
|
||||||
|
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
|
||||||
|
if (usable <= 0.0f) {
|
||||||
|
gap = 0.0f;
|
||||||
|
usable = r.w;
|
||||||
|
}
|
||||||
|
usable = std::max(0.0f, usable);
|
||||||
|
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
|
||||||
|
: kMinCellWidthLogical;
|
||||||
|
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
|
||||||
|
const f32 extra = usable - floor_px * static_cast<f32>(n);
|
||||||
|
f32 total = 0.0f;
|
||||||
|
const bool weighted = weights.size() == n;
|
||||||
|
if (weighted) {
|
||||||
|
for (const f32 w : weights) total += w;
|
||||||
|
}
|
||||||
|
f32 x = r.x;
|
||||||
|
f32 consumed = 0.0f;
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
|
||||||
|
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
|
||||||
|
out[i] = Rect{x, r.y, end - consumed, r.h};
|
||||||
|
consumed = end;
|
||||||
|
x = out[i].right() + gap;
|
||||||
|
}
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
|
||||||
|
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
|
||||||
|
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
|
||||||
|
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
|
||||||
|
f32 total = 0.0f;
|
||||||
|
if (weights.size() == n) {
|
||||||
|
for (const f32 w : weights) total += w;
|
||||||
|
}
|
||||||
|
if (total > 0.0f) {
|
||||||
|
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
|
||||||
|
}
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two rows have the **same columns**: the same number of cells and the same
|
||||||
|
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
|
||||||
|
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
|
||||||
|
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
|
||||||
|
/// reads the cell counts and the weights the host set and nothing a plot holds.
|
||||||
|
bool same_columns(const std::vector<u32>& cells_per_row,
|
||||||
|
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
|
||||||
|
const u32 n = cells_per_row[row_a];
|
||||||
|
if (n != cells_per_row[row_b]) return false;
|
||||||
|
if (n == 1u) return true;
|
||||||
|
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
|
||||||
|
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
|
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
|
||||||
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
|
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
|
||||||
/// logarithmic, the value itself otherwise. A following group's span and offset
|
/// logarithmic, the value itself otherwise. A following group's span and offset
|
||||||
@@ -41,18 +123,72 @@ f64 from_link_space(f64 v, bool log) noexcept {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Figure::Figure(u32 rows) : plots_(rows) {}
|
Figure::Figure(u32 rows) : plots_(rows) {
|
||||||
|
const std::vector<u32> ones(rows, 1u);
|
||||||
Plot& Figure::plot(u32 row) noexcept {
|
build_cells(std::span<const u32>(ones));
|
||||||
return row < plots_.size() ? plots_[row] : fallback_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Plot& Figure::plot(u32 row) const noexcept {
|
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
|
||||||
return row < plots_.size() ? plots_[row] : fallback_;
|
build_cells(cells_per_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::build_cells(std::span<const u32> cells) {
|
||||||
|
cells_per_row_.assign(cells.begin(), cells.end());
|
||||||
|
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
|
||||||
|
row_first_.assign(cells_per_row_.size() + 1u, 0u);
|
||||||
|
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
|
||||||
|
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
|
||||||
|
}
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::cells_in_row(u32 row) const noexcept {
|
||||||
|
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::first_plot_of_row(u32 row) const noexcept {
|
||||||
|
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::row_of_plot(u32 plot) const noexcept {
|
||||||
|
u32 r = 0;
|
||||||
|
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plot& Figure::plot(u32 index) noexcept {
|
||||||
|
return index < plots_.size() ? plots_[index] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Plot& Figure::plot(u32 index) const noexcept {
|
||||||
|
return index < plots_.size() ? plots_[index] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
|
||||||
|
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
|
||||||
|
}
|
||||||
|
for (const f32 w : weights) {
|
||||||
|
if (!std::isfinite(w) || !(w > 0.0f)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"cell weights must be positive finite numbers: a weight is a share of "
|
||||||
|
"the row, and a cell with none of it has no rect"};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell_weights_[row].assign(weights.begin(), weights.end());
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::clear_cell_weights(u32 row) noexcept {
|
||||||
|
if (row < cell_weights_.size()) cell_weights_[row].clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::has_cell_weights(u32 row) const noexcept {
|
||||||
|
return row < cell_weights_.size() && !cell_weights_[row].empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
Status Figure::set_row_weights(std::span<const f32> weights) {
|
Status Figure::set_row_weights(std::span<const f32> weights) {
|
||||||
if (weights.size() != plots_.size()) {
|
if (weights.size() != cells_per_row_.size()) {
|
||||||
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
||||||
}
|
}
|
||||||
f32 total = 0.0f;
|
f32 total = 0.0f;
|
||||||
@@ -330,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Figure::set_share_row_y(bool on) {
|
||||||
|
share_row_y_ = on;
|
||||||
|
row_y_.clear();
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
if (cells_per_row_[row] < 2u) continue;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) {
|
||||||
|
plots_[i].set_y_link_member(0u, on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::row_y_auto(u32 row) const noexcept {
|
||||||
|
return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::sync_row_y() {
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
row_y_.resize(cells_per_row_.size());
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (n < 2u) continue;
|
||||||
|
RowY& g = row_y_[row];
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
// Axes added after set_share_row_y() are covered here.
|
||||||
|
for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true);
|
||||||
|
if (!g.have) {
|
||||||
|
g.pushed = plots_[first].y_axis(0u).range;
|
||||||
|
g.auto_scale = plots_[first].y_axis(0u).auto_scale;
|
||||||
|
}
|
||||||
|
// What happened since the last push. A member's auto flag that differs from the group's
|
||||||
|
// is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it);
|
||||||
|
// otherwise a member whose range differs from the pushed one was moved by a gesture.
|
||||||
|
bool want_auto = g.auto_scale;
|
||||||
|
Range target = g.pushed;
|
||||||
|
bool flag_request = false;
|
||||||
|
for (u32 i = first; i < first + n && !flag_request; ++i) {
|
||||||
|
const Axis& a = plots_[i].y_axis(0u);
|
||||||
|
if (a.auto_scale != g.auto_scale) {
|
||||||
|
want_auto = a.auto_scale;
|
||||||
|
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
|
||||||
|
flag_request = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!flag_request && !want_auto) {
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
const Range r = plots_[i].y_axis(0u).range;
|
||||||
|
if (r.min != g.pushed.min || r.max != g.pushed.max) {
|
||||||
|
target = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want_auto) {
|
||||||
|
Range bounds{};
|
||||||
|
bool any = false;
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
Range b{};
|
||||||
|
if (!plots_[i].y_data_bounds(0u, b)) continue;
|
||||||
|
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
// The first cell stands in for the row: all cells are assumed to share one y axis kind/padding (the host builds them identically).
|
||||||
|
if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range;
|
||||||
|
// else: nothing to fit -- keep the range the group had
|
||||||
|
}
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
plots_[i].set_y_range(0u, target); // clears the flag ...
|
||||||
|
plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group
|
||||||
|
}
|
||||||
|
g.pushed = target;
|
||||||
|
g.auto_scale = want_auto;
|
||||||
|
g.have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Figure::sync_x_links() {
|
void Figure::sync_x_links() {
|
||||||
for (XLink& group : links_) {
|
for (XLink& group : links_) {
|
||||||
if (group.rows.empty()) continue;
|
if (group.rows.empty()) continue;
|
||||||
@@ -369,6 +580,8 @@ void Figure::sync_x_links() {
|
|||||||
const FigureLayout& Figure::layout() {
|
const FigureLayout& Figure::layout() {
|
||||||
const u32 rows = row_count();
|
const u32 rows = row_count();
|
||||||
layout_.rows.assign(rows, Rect{});
|
layout_.rows.assign(rows, Rect{});
|
||||||
|
layout_.cells.clear();
|
||||||
|
layout_.col_gap = 0.0f;
|
||||||
layout_.row_gap = row_gap_;
|
layout_.row_gap = row_gap_;
|
||||||
layout_.x_axis_strip = Rect{};
|
layout_.x_axis_strip = Rect{};
|
||||||
if (rows == 0u) {
|
if (rows == 0u) {
|
||||||
@@ -377,12 +590,13 @@ const FigureLayout& Figure::layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const f32 figure_h = std::max(0.0f, viewport_.h);
|
const f32 figure_h = std::max(0.0f, viewport_.h);
|
||||||
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
|
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
|
||||||
// nothing is taken off the figure for it. That is what keeps the axis reachable --
|
// the strip of any row whose columns differ from the row below), so nothing is taken off
|
||||||
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
|
// the figure for it. That is what keeps the axis reachable -- its strip is a real
|
||||||
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
|
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
|
||||||
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
|
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
|
||||||
// leaving a band no one owns.
|
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
|
||||||
|
// band no one owns.
|
||||||
const f32 content_h = figure_h;
|
const f32 content_h = figure_h;
|
||||||
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
|
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
|
||||||
// it: a figure one pixel tall with four rows still gives every row a positive
|
// it: a figure one pixel tall with four rows still gives every row a positive
|
||||||
@@ -441,21 +655,59 @@ const FigureLayout& Figure::layout() {
|
|||||||
// short to hold the one asked for.
|
// short to hold the one asked for.
|
||||||
layout_.row_gap = gap;
|
layout_.row_gap = gap;
|
||||||
|
|
||||||
const u32 bottom_row = rows - 1u;
|
// Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
|
||||||
|
// unsplit figure lays out exactly as it always did.
|
||||||
|
layout_.cells.assign(plots_.size(), Rect{});
|
||||||
|
layout_.col_gap = col_gap_;
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
Plot& p = plots_[row];
|
const u32 n = cells_per_row_[row];
|
||||||
p.set_viewport(layout_.rows[row]);
|
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
|
||||||
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
|
floor_scale, &layout_.cells[row_first_[row]]);
|
||||||
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
|
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
|
||||||
// and every other row gives all of it to that one, so the labels and the title are
|
}
|
||||||
// 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.
|
// Which rows carry their own x strip, decided structurally and before the first
|
||||||
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
// measurement pass so that pass and the final one see the same strips. The bottom row
|
||||||
|
// always has one. A row above hides its strip only when the row directly below has the
|
||||||
|
// same columns (see `same_columns()`), because then the cells of the two rows have the
|
||||||
|
// same x extents and the axis below serves this row too; the rule is transitive, since
|
||||||
|
// that row is itself either labelled or hidden in favour of one that matches it. A row
|
||||||
|
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
|
||||||
|
// drawn under each of its cells, because the axis below is at another scale and width.
|
||||||
|
// A figure told to label every row keeps the strips everywhere.
|
||||||
|
std::vector<XStrip> strips(rows, XStrip::Both);
|
||||||
|
if (labels_bottom_only_) {
|
||||||
|
for (u32 row = rows - 1u; row-- > 0u;) {
|
||||||
|
// By induction the row below is labelled or itself hidden in favour of a matching
|
||||||
|
// row, so hiding this one on a match with it is the transitive rule.
|
||||||
|
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
const XStrip strip = strips[row];
|
||||||
|
// A split row whose cells share one y range also shares one y axis, drawn by its first
|
||||||
|
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
|
||||||
|
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
|
||||||
|
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
|
||||||
|
// row is the column gap and not a gutter more (see the placement block below).
|
||||||
|
const u32 last = row_first_[row + 1u] - 1u;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
|
||||||
|
Plot& p = plots_[i];
|
||||||
|
p.set_viewport(layout_.cells[i]);
|
||||||
|
p.set_x_strip_mode(strip);
|
||||||
|
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
|
||||||
|
// Before either pass below, and the same value for every plot: nothing is measured on
|
||||||
|
// the right, so unlike the left gutter this needs no measurement pass -- the plots
|
||||||
|
// end on a common column because they are all told the one width.
|
||||||
|
p.set_right_gutter(shared_y && i != last ? 0.0f : 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
|
||||||
// out below, so a following group's window is the range they lay out with.
|
// out below, so a following group's window is the range they lay out with.
|
||||||
sync_x_links();
|
sync_x_links();
|
||||||
|
// Before any `Plot::layout()`, where a plot would autoscale its own y axis.
|
||||||
|
sync_row_y();
|
||||||
// Every row is laid out against its own rect, so after this call each row's
|
// Every row is laid out against its own rect, so after this call each row's
|
||||||
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
|
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
|
||||||
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
||||||
@@ -482,26 +734,63 @@ const FigureLayout& Figure::layout() {
|
|||||||
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
|
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
|
||||||
// measurement, so a reserved floor cannot widen what a row reports and then be widened
|
// measurement, so a reserved floor cannot widen what a row reports and then be widened
|
||||||
// by its own reservation on the next frame.
|
// by its own reservation on the next frame.
|
||||||
|
//
|
||||||
|
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
|
||||||
|
// row line up with each other and with the rows above and below.
|
||||||
f32 gutter = min_y_gutter_;
|
f32 gutter = min_y_gutter_;
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
//
|
||||||
plots_[row].set_min_y_gutter(0.0f);
|
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
|
||||||
const PlotLayout& row_layout = plots_[row].layout();
|
// in the maximum: the gutter is the widest of the cells that do draw one.
|
||||||
|
for (Plot& p : plots_) {
|
||||||
|
p.set_min_y_gutter(0.0f);
|
||||||
|
const PlotLayout& row_layout = p.layout();
|
||||||
|
if (p.y_strip_mode() == YStrip::None) continue;
|
||||||
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
||||||
}
|
}
|
||||||
|
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
|
||||||
|
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
|
||||||
|
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
|
||||||
|
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
|
||||||
|
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
|
||||||
|
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
|
||||||
|
// row: the first starts on its left edge and the last still ends on its right edge.
|
||||||
|
//
|
||||||
|
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
|
||||||
|
// other cells were told none above): left to every cell, the visible gap between two
|
||||||
|
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
|
||||||
|
// capped at half the row, and never exceeds the figure's own right gutter.
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (!share_row_y_ || n <= 1u) continue;
|
||||||
|
const Rect& r = layout_.rows[row];
|
||||||
|
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
|
||||||
|
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
|
||||||
|
Rect* cells = &layout_.cells[row_first_[row]];
|
||||||
|
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
|
||||||
|
cell_weights_[row], col_gap_, floor_scale, cells);
|
||||||
|
cells[0].x -= g_eff;
|
||||||
|
cells[0].w += g_eff;
|
||||||
|
cells[n - 1u].w += rg_eff;
|
||||||
|
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
|
||||||
|
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
|
||||||
|
// for more than it was given (the same value whenever the row has room).
|
||||||
|
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
|
||||||
|
}
|
||||||
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
|
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
|
||||||
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
||||||
// also what each would have done alone.
|
// also what each would have done alone.
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
for (Plot& p : plots_) {
|
||||||
plots_[row].set_min_y_gutter(gutter);
|
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
|
||||||
plots_[row].layout();
|
p.layout();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
|
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
|
||||||
// row's own label area -- the rect the controller hit-tests and the renderer stages
|
// controller hit-tests and the renderer stages its labels in, read back here rather than
|
||||||
// its labels in, read back here rather than derived again, so a host and the frame
|
// derived again, so a host and the frame cannot disagree about where it is. Rows above
|
||||||
// cannot disagree about where the shared axis is. It is zero-sized only when there
|
// that carry their own strip (their columns differ from the row below, or every row is
|
||||||
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
|
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
|
||||||
// row's own strip, one of several.
|
// when there are no rows at all.
|
||||||
layout_.x_axis_strip = plots_.back().layout().x_label_area;
|
layout_.x_axis_strip = plots_.back().layout().x_label_area;
|
||||||
return layout_;
|
return layout_;
|
||||||
}
|
}
|
||||||
@@ -530,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
|
|||||||
// than clearing a hover the pointer still has.
|
// than clearing a hover the pointer still has.
|
||||||
if (!active || hovered_row >= plots_.size()) return;
|
if (!active || hovered_row >= plots_.size()) return;
|
||||||
plots_[hovered_row].set_crosshair(x, y_in_row, true);
|
plots_[hovered_row].set_crosshair(x, y_in_row, true);
|
||||||
|
// **The cells of a split row share one y**, so the horizontal line means the same value in
|
||||||
|
// every one of them: it spans the row rather than stopping at the divider between two cells
|
||||||
|
// that read the same scale. Only that row -- rows do not share y with each other, so a
|
||||||
|
// sibling row's line would be a value on a scale it does not have -- and only when the row
|
||||||
|
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
const u32 row = row_of_plot(hovered_row);
|
||||||
|
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
|
||||||
|
const u32 cell = first + k;
|
||||||
|
if (cell != hovered_row && cell < plots_.size()) {
|
||||||
|
plots_[cell].set_crosshair(x, y_in_row, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Figure::clear_crosshair() {
|
void Figure::clear_crosshair() {
|
||||||
|
|||||||
+502
-24
@@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) {
|
|||||||
cursors_[id].color = color;
|
cursors_[id].color = color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_label(u32 id, std::string label) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].label = std::move(label);
|
||||||
|
}
|
||||||
|
|
||||||
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||||
if (id >= cursors_.size()) return;
|
if (id >= cursors_.size()) return;
|
||||||
cursors_[id].width = width;
|
cursors_[id].width = width;
|
||||||
@@ -342,6 +347,313 @@ 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); }
|
||||||
|
|
||||||
|
void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
|
||||||
|
invalid_spans_.assign(spans.begin(), spans.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- phosphor density ---------------------------------------------------------
|
||||||
|
|
||||||
|
void Plot::set_density(const DensityConfig& config) {
|
||||||
|
// Disabling is a caller asking to stop paying for the display, and a trigger that has stopped
|
||||||
|
// firing never reaches another accumulation -- so the image goes now rather than at an event
|
||||||
|
// that may not come. The same rule, for the same reason, as the app's `set_persist_depth()`.
|
||||||
|
if (density_.enabled && !config.enabled) {
|
||||||
|
for (DensityGrid& grid : density_grids_) {
|
||||||
|
grid.cells_.clear();
|
||||||
|
grid.cells_.shrink_to_fit();
|
||||||
|
grid.columns_ = 0u;
|
||||||
|
grid.rows_ = 0u;
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
density_grids_.clear();
|
||||||
|
density_grids_.shrink_to_fit();
|
||||||
|
}
|
||||||
|
// The two fields the renderer turns a cell's count into a luminance with: a change to either is
|
||||||
|
// a change to every cell's *byte*, and a grid's own revision does not see it. A host that states
|
||||||
|
// its configuration every frame (udpscope does) must not force a re-upload per frame, so this
|
||||||
|
// bumps only when one of the two actually moved -- and then a re-upload is owed.
|
||||||
|
const bool rescale = density_.enabled && config.enabled &&
|
||||||
|
(density_.full_scale != config.full_scale || density_.gamma != config.gamma);
|
||||||
|
density_ = config;
|
||||||
|
if (rescale) {
|
||||||
|
for (DensityGrid& grid : density_grids_) ++grid.revision_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::decay_density(f64 dt_s) {
|
||||||
|
if (!density_.enabled || density_.decay != DensityDecay::TimeConstant) return;
|
||||||
|
// A non-positive frame and a non-positive time constant are both "do not fade": the first is a
|
||||||
|
// caller that has not measured a frame yet, and the second is a caller asking for a grid that
|
||||||
|
// is drawn and never fades.
|
||||||
|
if (!(dt_s > 0.0) || !(density_.time_constant_s > 0.0f)) return;
|
||||||
|
const f32 factor =
|
||||||
|
static_cast<f32>(std::exp(-dt_s / static_cast<f64>(density_.time_constant_s)));
|
||||||
|
if (!(factor < 1.0f)) return;
|
||||||
|
for (DensityGrid& grid : density_grids_) {
|
||||||
|
if (grid.cells_.empty()) continue;
|
||||||
|
for (f32& cell : grid.cells_) cell *= factor;
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::clear_density() noexcept {
|
||||||
|
for (DensityGrid& grid : density_grids_) {
|
||||||
|
if (grid.cells_.empty()) continue;
|
||||||
|
std::fill(grid.cells_.begin(), grid.cells_.end(), 0.0f);
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plot::size_density_grids() {
|
||||||
|
const Rect& axes = layout_.axes;
|
||||||
|
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
|
||||||
|
u32 columns = static_cast<u32>(std::ceil(axes.w));
|
||||||
|
u32 rows = static_cast<u32>(std::ceil(axes.h));
|
||||||
|
if (columns == 0u || rows == 0u) return false;
|
||||||
|
// **The grid is the axes rect in device pixels, up to `kDensityCellCap`.** Beyond it the rect is
|
||||||
|
// scaled down *uniformly*, so the cells stay square in device pixels and the picture keeps the
|
||||||
|
// axes' aspect: a cap on one axis alone would stretch a cell, and a density smeared in one
|
||||||
|
// direction only reads as a bug in the data rather than as a coarse image. The cells are then no
|
||||||
|
// longer one device pixel across, which is why the renderer magnifies the image with a linear
|
||||||
|
// filter instead of a point sample -- see `Renderer::draw_density_grid()`.
|
||||||
|
//
|
||||||
|
// The floor of the product is what makes the cap hold: `floor(w*s) * floor(h*s) <= w*s * h*s
|
||||||
|
// <= cap` for the `s` chosen, and the two `max(1, ...)` keep a very thin axes rect from
|
||||||
|
// rounding to nothing.
|
||||||
|
const u64 pixels = static_cast<u64>(columns) * static_cast<u64>(rows);
|
||||||
|
if (pixels > static_cast<u64>(kDensityCellCap)) {
|
||||||
|
const f64 scale = std::sqrt(static_cast<f64>(kDensityCellCap) / static_cast<f64>(pixels));
|
||||||
|
columns = std::max(1u, static_cast<u32>(static_cast<f64>(columns) * scale));
|
||||||
|
rows = std::max(1u, static_cast<u32>(static_cast<f64>(rows) * scale));
|
||||||
|
}
|
||||||
|
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
|
||||||
|
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
|
||||||
|
// accumulation gets its own empty grid here while the grids already sized keep what they held.
|
||||||
|
if (density_grids_.size() != traces_.size()) density_grids_.resize(traces_.size());
|
||||||
|
// The per-sweep dedupe's stamps are one per grid **row** and shared by every trace's deposit:
|
||||||
|
// the key a cell is deduped against carries the trace's number (see
|
||||||
|
// `deposit_density_segment()`), so a stamp left by one trace's sweep never matches another's, and
|
||||||
|
// a zeroed stamp -- every key has a non-zero trace number in its high half -- matches nothing.
|
||||||
|
if (density_stamp_.size() != rows) density_stamp_.assign(rows, 0u);
|
||||||
|
bool usable = false;
|
||||||
|
for (usize i = 0u; i < traces_.size(); ++i) {
|
||||||
|
DensityGrid& grid = density_grids_[i];
|
||||||
|
if (!traces_[i].visible()) {
|
||||||
|
// A trace with nothing to draw gets no image: the grid is per *trace*, and a slot with no
|
||||||
|
// series is not one. Dropping it here is also what keeps the display's memory proportional
|
||||||
|
// to the traces actually on screen rather than to a plot's fixed trace slots -- a row of four
|
||||||
|
// signals out of eight slots holds four grids, not eight.
|
||||||
|
if (!grid.cells_.empty()) {
|
||||||
|
grid.cells_.clear();
|
||||||
|
grid.cells_.shrink_to_fit();
|
||||||
|
grid.columns_ = 0u;
|
||||||
|
grid.rows_ = 0u;
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (columns != grid.columns_ || rows != grid.rows_) {
|
||||||
|
// A grid of another size is another image, and there is nothing honest to carry over from
|
||||||
|
// one to the other: the cells are device pixels, so a resize moves every one of them. Drop
|
||||||
|
// what it held rather than resample it -- the display restarts, which is what a scope does
|
||||||
|
// when its screen changes.
|
||||||
|
grid.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
|
||||||
|
grid.columns_ = columns;
|
||||||
|
grid.rows_ = rows;
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
usable = usable || !grid.cells_.empty();
|
||||||
|
}
|
||||||
|
return usable;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::accumulate_density() {
|
||||||
|
if (!density_.enabled) return;
|
||||||
|
if (!size_density_grids()) return;
|
||||||
|
// The fade before the deposit, never after: under `PerAcquisition` the acquisition just drawn
|
||||||
|
// is the brightest one and each older one is the factor down, which is what "the N-th back is
|
||||||
|
// gone" means. TimeConstant fades in `decay_density()` instead, because its fade is the clock's
|
||||||
|
// and not the acquisition's.
|
||||||
|
if (density_.decay == DensityDecay::PerAcquisition) {
|
||||||
|
const f32 factor = std::clamp(density_.per_acquisition, 0.0f, 1.0f);
|
||||||
|
for (DensityGrid& grid : density_grids_) {
|
||||||
|
if (grid.cells_.empty()) continue;
|
||||||
|
for (f32& cell : grid.cells_) cell *= factor;
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **Every visible trace of the acquisition on screen, the newest included.** The current
|
||||||
|
// acquisition is deposited here like any other, so it is inside the image and fades with it --
|
||||||
|
// there is no second, solid trace drawn over the density (see `Renderer::draw_traces()`), which
|
||||||
|
// is what a report of "the last acquisition never fades" turned out to be. One grid each, so two
|
||||||
|
// traces that agree read as two colours at full strength where they overlap and not as one
|
||||||
|
// blended one.
|
||||||
|
const usize n = std::min(density_grids_.size(), traces_.size());
|
||||||
|
for (usize i = 0u; i < n; ++i) deposit_density_trace(traces_[i], density_grids_[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::deposit_density_trace(const Trace& trace, DensityGrid& grid) {
|
||||||
|
if (!trace.visible()) return;
|
||||||
|
const data::Series& series = *trace.series;
|
||||||
|
const u64 n = series.size();
|
||||||
|
if (!(series.dx() > 0.0) || n == 0u) return;
|
||||||
|
|
||||||
|
// The same index window `Renderer::draw_traces()` maps, by the same calls: `neighbours()` on
|
||||||
|
// the series' own stamps -- never the `(x - x0()) / dx()` grid, which is a nominal spacing and
|
||||||
|
// would lose the segments entering and leaving the frame -- with one sample of slack on each
|
||||||
|
// side and clamped to the series.
|
||||||
|
const Range window = x_range(trace.axis_x);
|
||||||
|
u64 first = 0u;
|
||||||
|
u64 last = n;
|
||||||
|
u64 i0 = 0u;
|
||||||
|
u64 i1 = 0u;
|
||||||
|
if (series.neighbours(window.min, i0, i1)) first = i0 > 0u ? i0 - 1u : 0u;
|
||||||
|
if (series.neighbours(window.max, i0, i1)) last = std::min(n, i1 + 1u);
|
||||||
|
if (last <= first) return;
|
||||||
|
|
||||||
|
// The same level gate the renderer uses, and for the same reason: a window denser than the
|
||||||
|
// pixel grid is a *reduction*, and the density is a reduction of it too -- one pass per cell
|
||||||
|
// the column's own envelope crosses. The levels agree because the ask is the same ask.
|
||||||
|
const f64 samples_per_pixel =
|
||||||
|
static_cast<f64>(last - first) / static_cast<f64>(layout_.axes.w);
|
||||||
|
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
||||||
|
? series.pyramid().level_for(n, samples_per_pixel)
|
||||||
|
: 0u;
|
||||||
|
|
||||||
|
density_points_.clear();
|
||||||
|
series.gather(level, first, last, window.min, density_points_, trace.mode);
|
||||||
|
if (density_points_.size() < 2u) return;
|
||||||
|
|
||||||
|
// A fresh stamp for this trace's part of this acquisition: the key a cell is deduped against is
|
||||||
|
// `(this, column)`, so every trace of a plot deposits once per cell it covers even where they
|
||||||
|
// overlap, and a cell one trace crosses twice in one sweep deposits once.
|
||||||
|
++density_trace_;
|
||||||
|
const Transform tx = x_transform(trace.axis_x);
|
||||||
|
const Transform ty = y_transform(trace.axis_y);
|
||||||
|
f32 px = 0.0f;
|
||||||
|
f32 py = 0.0f;
|
||||||
|
u32 run = 0u; // points in the current unbroken run
|
||||||
|
// A run's *first* point is not deposited on its own: the segment that leaves it already puts
|
||||||
|
// ink in that cell, and depositing both would count the pen's arrival twice -- a single pass
|
||||||
|
// would read as two in the cell every trace starts in, which is exactly the cell a peak
|
||||||
|
// reading is taken from. A run of one point -- a lone visible sample, or one isolated by a
|
||||||
|
// gap -- is deposited as a degenerate segment instead, because a place a trace *was* is ink
|
||||||
|
// however short its stay.
|
||||||
|
const auto flush_run = [&]() {
|
||||||
|
if (run == 1u) deposit_density_segment(px, py, px, py, grid);
|
||||||
|
};
|
||||||
|
for (usize k = 0; k + 1u < density_points_.size(); k += 2u) {
|
||||||
|
// x is gathered relative to the visible range's minimum (see `Series::gather()`), so the
|
||||||
|
// absolute x is reconstructed in f64 before it is mapped -- the same reconstruction, and the
|
||||||
|
// same reason, as the staging paths.
|
||||||
|
const f64 x_abs = window.min + static_cast<f64>(density_points_[k]);
|
||||||
|
const f64 y_abs = static_cast<f64>(density_points_[k + 1u]);
|
||||||
|
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) {
|
||||||
|
flush_run(); // a NaN is a gap: it joins nothing, so the trace is deposited in pieces
|
||||||
|
run = 0u;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const f32 sx = static_cast<f32>(tx.to_screen_x(x_abs));
|
||||||
|
const f32 sy = static_cast<f32>(ty.to_screen_y(y_abs));
|
||||||
|
if (!std::isfinite(sx) || !std::isfinite(sy)) {
|
||||||
|
flush_run();
|
||||||
|
run = 0u;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (run > 0u) deposit_density_segment(px, py, sx, sy, grid);
|
||||||
|
px = sx;
|
||||||
|
py = sy;
|
||||||
|
++run;
|
||||||
|
}
|
||||||
|
flush_run();
|
||||||
|
++grid.revision_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept {
|
||||||
|
const u32 columns = grid.columns_;
|
||||||
|
const u32 rows = grid.rows_;
|
||||||
|
if (columns == 0u || rows == 0u) return;
|
||||||
|
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that
|
||||||
|
if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
|
||||||
|
const Rect& axes = layout_.axes;
|
||||||
|
// **The image's footprint is the axes rect rounded up to whole device pixels** -- the rect
|
||||||
|
// `size_density_grids()` counted the cells on -- so `sx`/`sy` are exactly **1** while that rect is
|
||||||
|
// under `kDensityCellCap`: a cell *is* a device pixel and this is the arithmetic the density has
|
||||||
|
// always used. Past the cap they are a fraction of one, and the cell index is read through the
|
||||||
|
// scale rather than straight off the pixel. (The rounding is why the footprint, not the
|
||||||
|
// fractional rect, is the reference: a cell is defined on whole pixels, and a grid of `ceil(w)` of
|
||||||
|
// them covers the rect the axes was laid out with.)
|
||||||
|
const f32 sx = axes.w > 0.0f ? static_cast<f32>(columns) / std::ceil(axes.w) : 1.0f;
|
||||||
|
const f32 sy = axes.h > 0.0f ? static_cast<f32>(rows) / std::ceil(axes.h) : 1.0f;
|
||||||
|
const f32 lo_x = std::min(x0, x1);
|
||||||
|
const f32 hi_x = std::max(x0, x1);
|
||||||
|
i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
|
||||||
|
i32 c1 = static_cast<i32>(std::floor((hi_x - axes.x) * sx));
|
||||||
|
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
|
||||||
|
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
|
||||||
|
// keeps ink off the edge column when a trace leaves the frame sideways.
|
||||||
|
c0 = std::max(c0, 0);
|
||||||
|
c1 = std::min(c1, static_cast<i32>(columns) - 1);
|
||||||
|
if (c1 < c0) return;
|
||||||
|
|
||||||
|
const f32 span = hi_x - lo_x;
|
||||||
|
const f32 denom = x1 - x0;
|
||||||
|
const i32 last_row = static_cast<i32>(rows) - 1;
|
||||||
|
// The segment's y span inside one column's own slice of x: the two ends of the slice taken
|
||||||
|
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
|
||||||
|
// x), so the deposit is a continuous trace and not a dotted one.
|
||||||
|
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
|
||||||
|
const f32 ca = axes.x + static_cast<f32>(column) / sx;
|
||||||
|
f32 ya = y0;
|
||||||
|
f32 yb = y1;
|
||||||
|
if (span > 0.0f && denom != 0.0f) {
|
||||||
|
const f32 a = std::max(ca, lo_x);
|
||||||
|
const f32 b = std::min(ca + 1.0f / sx, hi_x);
|
||||||
|
ya = y0 + (a - x0) / denom * (y1 - y0);
|
||||||
|
yb = y0 + (b - x0) / denom * (y1 - y0);
|
||||||
|
}
|
||||||
|
out_lo = std::min(ya, yb);
|
||||||
|
out_hi = std::max(ya, yb);
|
||||||
|
};
|
||||||
|
for (i32 c = c0; c <= c1; ++c) {
|
||||||
|
f32 y_lo = 0.0f;
|
||||||
|
f32 y_hi = 0.0f;
|
||||||
|
column_y_span(c, y_lo, y_hi);
|
||||||
|
// A column whose slice of the segment lies wholly outside the axes deposits nothing: without
|
||||||
|
// this, a trace above the top of the plot would paint a band along the top row, which is the
|
||||||
|
// one thing a density must not do -- it would say the trace was there.
|
||||||
|
if (!(y_hi >= axes.y) || !(y_lo <= axes.bottom())) continue;
|
||||||
|
const f32 top = std::max(y_lo, axes.y);
|
||||||
|
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
|
||||||
|
// rather than one past the end.
|
||||||
|
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
|
||||||
|
i32 r0 = std::clamp(static_cast<i32>(std::floor((top - axes.y) * sy)), 0, last_row);
|
||||||
|
i32 r1 = std::clamp(static_cast<i32>(std::floor((bottom - axes.y) * sy)), 0, last_row);
|
||||||
|
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
|
||||||
|
// of times by one acquisition's own samples, and counting each crossing would put a single
|
||||||
|
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
|
||||||
|
// and a cell's value would mean "how much the beam moved here" instead of "how many
|
||||||
|
// acquisitions were here" -- which is what a persistence display is. The stamp is per
|
||||||
|
// *(trace, column)*, so two sweeps that agree on a cell still count twice, and the two segments
|
||||||
|
// that share a column's boundary do not.
|
||||||
|
const u64 key = (static_cast<u64>(density_trace_) << 32) | static_cast<u64>(static_cast<u32>(c));
|
||||||
|
f32* column_cells = grid.cells_.data() + static_cast<usize>(c);
|
||||||
|
for (i32 r = r0; r <= r1; ++r) {
|
||||||
|
const usize row = static_cast<usize>(r);
|
||||||
|
if (density_stamp_[row] == key) continue; // this sweep has already put its ink here
|
||||||
|
density_stamp_[row] = key;
|
||||||
|
column_cells[row * static_cast<usize>(columns)] += 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DensityGrid& Plot::empty_density_grid() noexcept {
|
||||||
|
static const DensityGrid empty{};
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -512,6 +824,19 @@ void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
|
|||||||
x_axes_[axis].link_member = linked;
|
x_axes_[axis].link_member = linked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_y_link_member(u32 axis, bool linked) noexcept {
|
||||||
|
if (axis >= y_axes_.size()) return;
|
||||||
|
y_axes_[axis].link_member = linked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plot::y_data_bounds(u32 axis, Range& out) const {
|
||||||
|
return trace_bounds_union(/*x_side=*/false, axis, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plot::fit_y_bounds(u32 axis, Range bounds) {
|
||||||
|
return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);
|
||||||
|
}
|
||||||
|
|
||||||
XFollow Plot::x_follow(u32 axis) const {
|
XFollow Plot::x_follow(u32 axis) const {
|
||||||
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
||||||
}
|
}
|
||||||
@@ -694,7 +1019,8 @@ void Plot::autoscale_axes() {
|
|||||||
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
|
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
|
||||||
}
|
}
|
||||||
for (u32 i = 0; i < y_axes_.size(); ++i) {
|
for (u32 i = 0; i < y_axes_.size(); ++i) {
|
||||||
if (y_axes_[i].auto_scale) fit_y_axis(i);
|
// A shared-y member is fitted by its figure, against every cell's traces.
|
||||||
|
if (y_axes_[i].auto_scale && !y_axes_[i].link_member) fit_y_axis(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,13 +1317,18 @@ const PlotLayout& Plot::layout() {
|
|||||||
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
|
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
|
||||||
x_height = text_->height() + 2.0f * pad;
|
x_height = text_->height() + 2.0f * pad;
|
||||||
}
|
}
|
||||||
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the
|
// A row of a `Figure` that hands its x axis to the row below (which has the same
|
||||||
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
|
// columns, so the same x extents) reserves no strip: the seed and
|
||||||
// the fixed point below both start from zero, and `axes` then reaches the viewport's
|
// the fixed point below both start from zero, and `axes` then reaches the viewport's
|
||||||
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
|
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
|
||||||
// the strip wants and this overrides the want, not the measurement.
|
// the strip wants and this overrides the want, not the measurement.
|
||||||
const bool x_labels = x_strip_has_labels(x_strip_);
|
const bool x_labels = x_strip_has_labels(x_strip_);
|
||||||
if (!x_labels) x_height = 0.0f;
|
if (!x_labels) x_height = 0.0f;
|
||||||
|
// The y strip likewise: a cell that gives its y axis to a neighbour (`YStrip::None`) reserves
|
||||||
|
// no label width, no title column and no share of the figure's gutter, so every y-strip
|
||||||
|
// quantity below collapses to zero and `axes` starts at the viewport's left edge.
|
||||||
|
const bool y_none = y_strip_ == YStrip::None;
|
||||||
|
if (y_none) y_width = 0.0f;
|
||||||
// The title strips, though, are needed before the loop and never change, unlike
|
// The title strips, though, are needed before the loop and never change, unlike
|
||||||
// the label strips: a strip holds exactly one text line per titled axis and no
|
// the label strips: a strip holds exactly one text line per titled axis and no
|
||||||
// tick label can change that. Nothing at all is reserved without a text backend
|
// tick label can change that. Nothing at all is reserved without a text backend
|
||||||
@@ -1011,7 +1342,7 @@ const PlotLayout& Plot::layout() {
|
|||||||
// no title reserves no row for a title either, and one that reserves the title and no
|
// no title reserves no row for a title either, and one that reserves the title and no
|
||||||
// labels still gets its row -- below where the labels would have been.
|
// labels still gets its row -- below where the labels would have been.
|
||||||
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
|
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
|
||||||
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
const f32 y_title_w = text_ != nullptr && !y_none ? y_title_strip_width() : 0.0f;
|
||||||
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
||||||
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
||||||
: 0.0f;
|
: 0.0f;
|
||||||
@@ -1032,17 +1363,26 @@ const PlotLayout& Plot::layout() {
|
|||||||
// -- and it is applied before the title column below, so the pair still fits the cap
|
// -- and it is applied before the title column below, so the pair still fits the cap
|
||||||
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
|
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
|
||||||
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
|
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
|
||||||
const f32 y_floor = std::min(min_y_gutter_, y_budget);
|
const f32 y_floor = y_none ? 0.0f : std::min(min_y_gutter_, y_budget);
|
||||||
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
|
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
|
||||||
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 =
|
||||||
@@ -1056,7 +1396,8 @@ const PlotLayout& Plot::layout() {
|
|||||||
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
||||||
// The same caps as above, so this compares like with like and the loop still
|
// The same caps as above, so this compares like with like and the loop still
|
||||||
// reaches its fixed point on the second pass.
|
// reaches its fixed point on the second pass.
|
||||||
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
const f32 need_y =
|
||||||
|
y_none ? 0.0f : std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
||||||
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
||||||
// point has to be zero for a row that gave its strip away.
|
// point has to be zero for a row that gave its strip away.
|
||||||
const f32 need_x =
|
const f32 need_x =
|
||||||
@@ -1080,11 +1421,16 @@ const PlotLayout& Plot::layout() {
|
|||||||
// measured from the *placed* live windows, which is what makes a region's data
|
// measured from the *placed* live windows, which is what makes a region's data
|
||||||
// interval map to the pixel columns the frame will actually use.
|
// interval map to the pixel columns the frame will actually use.
|
||||||
build_regions();
|
build_regions();
|
||||||
|
build_invalid_spans();
|
||||||
build_legend();
|
build_legend();
|
||||||
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
||||||
// its rows are the interpolated values at a data x -- so it wants the transforms the
|
// its rows are the sample values nearest a data x -- so it wants the transforms the
|
||||||
// settled layout pins, exactly as the legend and the regions do.
|
// settled layout pins, exactly as the legend and the regions do.
|
||||||
build_crosshair();
|
build_crosshair();
|
||||||
|
// The box-zoom frame is not measured, only carried: the host already has it in viewport pixels from
|
||||||
|
// the controller that produced it, and re-deriving it here would be a second rounding of the same
|
||||||
|
// rectangle -- and a second place for it to disagree with what the gesture is doing.
|
||||||
|
layout_.selection = selection_;
|
||||||
return layout_;
|
return layout_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,18 +1693,53 @@ void Plot::build_regions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::build_invalid_spans() {
|
||||||
|
layout_.invalid_spans.clear();
|
||||||
|
if (invalid_spans_.empty()) return;
|
||||||
|
|
||||||
|
const Rect& axes = layout_.axes;
|
||||||
|
// The primary x axis' transform, taken here for `build_regions()`' reason: after the live
|
||||||
|
// windows have been placed and the strips have settled, so these are the columns the frame
|
||||||
|
// draws with and a band is anchored to the data by construction rather than by a second
|
||||||
|
// mapping that would have to be kept in step with this one.
|
||||||
|
const Transform tx = x_transform(0u);
|
||||||
|
// A hole can be narrower than a column -- one missing sample in a window of hundreds of
|
||||||
|
// thousands -- and a fill rounded to zero width would draw nothing, losing exactly the case
|
||||||
|
// the band is most needed for. One device pixel is the floor.
|
||||||
|
const f32 min_w = std::max(1.0f, style_.device(1.0f));
|
||||||
|
|
||||||
|
for (const InvalidSpan& span : invalid_spans_) {
|
||||||
|
const f64 a = std::min(span.x0, span.x1);
|
||||||
|
const f64 b = std::max(span.x0, span.x1);
|
||||||
|
if (!std::isfinite(a) || !std::isfinite(b)) continue;
|
||||||
|
const f32 lo = static_cast<f32>(tx.to_screen_x(a));
|
||||||
|
const f32 hi = static_cast<f32>(tx.to_screen_x(b));
|
||||||
|
// Scrolled out of the window: nothing to shade. Tested before the floor is applied, so a
|
||||||
|
// span off the left edge is dropped rather than pinned to it as a one-pixel band that
|
||||||
|
// accuses the samples at the window's start.
|
||||||
|
if (hi < axes.x || lo > axes.right()) continue;
|
||||||
|
const f32 first = std::clamp(lo, axes.x, axes.right());
|
||||||
|
const f32 last = std::clamp(hi, axes.x, axes.right());
|
||||||
|
const f32 w = std::min(std::max(last - first, min_w), axes.w);
|
||||||
|
// Widening a band at the right edge would run it over the axis line, so the floor is taken
|
||||||
|
// out of the left instead: the span's own edge is kept wherever there is room for it.
|
||||||
|
const f32 x = std::clamp(first, axes.x, axes.right() - w);
|
||||||
|
layout_.invalid_spans.push_back(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- crosshair (spec §15) --------------------------------------------------
|
// --- crosshair (spec §15) --------------------------------------------------
|
||||||
|
|
||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
||||||
CrosshairSpan span;
|
CrosshairSpan span;
|
||||||
const u64 n = series.size();
|
const u64 n = series.size();
|
||||||
// No samples and no finite query: nothing to interpolate between.
|
// No samples and no finite query: nothing to bracket.
|
||||||
if (n == 0u || !std::isfinite(x)) return span;
|
if (n == 0u || !std::isfinite(x)) return span;
|
||||||
|
|
||||||
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
||||||
// the window is closed at both ends -- the oldest and the newest sample are
|
// the window is closed at both ends -- the oldest and the newest sample are
|
||||||
// samples too -- and a query outside it has no neighbours to interpolate
|
// samples too -- and a query outside it has no neighbours at all. Answering
|
||||||
// between. Answering with the nearest sample's value instead is the clamped lie
|
// with the nearest sample's value instead is the clamped lie
|
||||||
// this refuses: the readout would report a number as if it were under the
|
// this refuses: the readout would report a number as if it were under the
|
||||||
// pointer when the series does not reach that far. The series owns where its
|
// pointer when the series does not reach that far. The series owns where its
|
||||||
// samples are, so an XY series is bracketed by its own x array exactly, where
|
// samples are, so an XY series is bracketed by its own x array exactly, where
|
||||||
@@ -1371,8 +1752,8 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|||||||
// A query that landed **on** a sample is a span of one, whichever side the
|
// A query that landed **on** a sample is a span of one, whichever side the
|
||||||
// series' bracket reported: a sample at the query is the answer, and the two
|
// series' bracket reported: a sample at the query is the answer, and the two
|
||||||
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
||||||
// on sample 0 would read as an interpolation with no width. Decided on the
|
// on sample 0 would read as a span with a width it does not have. Decided on
|
||||||
// samples' own coordinates, so it holds for any `neighbours()` implementation.
|
// the samples' own coordinates, so it holds for any `neighbours()` impl.
|
||||||
if (series.x_at(i0) == x) {
|
if (series.x_at(i0) == x) {
|
||||||
span.begin = span.end = i0;
|
span.begin = span.end = i0;
|
||||||
span.t = 0.0;
|
span.t = 0.0;
|
||||||
@@ -1387,16 +1768,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i0 == i1) {
|
if (i0 == i1) {
|
||||||
// The series bracketed a single sample for a query that is not on it: nothing
|
// The series bracketed a single sample for a query that is not on it: there
|
||||||
// to interpolate with, and the sample is the honest answer.
|
// is no second sample to choose between, and this one is the honest answer.
|
||||||
span.begin = span.end = i0;
|
span.begin = span.end = i0;
|
||||||
span.t = 0.0;
|
span.t = 0.0;
|
||||||
} else {
|
} else {
|
||||||
span.begin = i0;
|
span.begin = i0;
|
||||||
span.end = i1;
|
span.end = i1;
|
||||||
// `t` from the samples' own x coordinates, so the interpolation is exact on
|
// `t` from the samples' own x coordinates, so "which is nearer" is exact on
|
||||||
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
||||||
// interpolates nothing and reads the earlier sample.
|
// reads the earlier sample.
|
||||||
const f64 xa = series.x_at(i0);
|
const f64 xa = series.x_at(i0);
|
||||||
const f64 xb = series.x_at(i1);
|
const f64 xb = series.x_at(i1);
|
||||||
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
||||||
@@ -1410,6 +1791,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 +1827,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,14 +1843,33 @@ 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]);
|
||||||
|
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
|
||||||
|
// The signal the samples imply *at* this x, so the point is the query's own x: that is
|
||||||
|
// where an interpolated number lives, and the marker on the polyline there is honest
|
||||||
|
// about it.
|
||||||
|
out_x = x;
|
||||||
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
|
||||||
|
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
|
||||||
|
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
|
||||||
|
// comes back through `x_at()` rather than from the gathered pairs, which are relative to 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;
|
||||||
|
}
|
||||||
|
|
||||||
void Plot::build_crosshair() {
|
void Plot::build_crosshair() {
|
||||||
CrosshairLayout& crosshair = layout_.crosshair;
|
CrosshairLayout& crosshair = layout_.crosshair;
|
||||||
@@ -1481,6 +1893,14 @@ void Plot::build_crosshair() {
|
|||||||
const auto inside = [](f32 v, f32 lo, f32 hi) {
|
const auto inside = [](f32 v, f32 lo, f32 hi) {
|
||||||
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
|
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
|
||||||
};
|
};
|
||||||
|
crosshair.y_axis_badge = Rect{};
|
||||||
|
crosshair.y_axis_text.clear();
|
||||||
|
crosshair.x_axis_badge = Rect{};
|
||||||
|
crosshair.x_axis_text.clear();
|
||||||
|
if (!crosshair_.active) {
|
||||||
|
crosshair.rows.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The two lines, each a stroke box held *inside* the axes by half its own width. The
|
// The two lines, each a stroke box held *inside* the axes by half its own width. The
|
||||||
// renderer stages the stroke across the box's centre line, so this is the whole placement
|
// renderer stages the stroke across the box's centre line, so this is the whole placement
|
||||||
@@ -1511,6 +1931,58 @@ void Plot::build_crosshair() {
|
|||||||
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
|
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The axis badges, when asked for (`Crosshair::axis_labels`): the values the two lines
|
||||||
|
// meet the axes at, written in the strips the tick labels are written in and in each
|
||||||
|
// axis' own format, so a badge reads exactly like the tick beside it. Placed here rather
|
||||||
|
// than by the renderer, for the readout panel's reason -- one placement rule, one place to
|
||||||
|
// read it -- and left empty whenever there is nothing honest to place: no text backend to
|
||||||
|
// measure with, the line they belong to not drawn (a vertical-only crosshair has no y),
|
||||||
|
// or their strip absent or too small (a row whose x strip went to the row below, a y
|
||||||
|
// gutter collapsed to a title). The x badge is held to the axes' own columns as well, so
|
||||||
|
// a crosshair in the leftmost cell of a split row cannot badge into its neighbour's.
|
||||||
|
if (crosshair_.axis_labels && text_ != nullptr) {
|
||||||
|
const f32 badge_pad = style_.device(4.0f);
|
||||||
|
const f32 badge_h = text_->height() + 2.0f * badge_pad;
|
||||||
|
if (crosshair_.horizontal && layout_.y_label_area.w > 0.0f &&
|
||||||
|
layout_.y_label_area.h >= badge_h) {
|
||||||
|
char text[64];
|
||||||
|
y_axis(0u).format_value(crosshair_.y, text, sizeof text);
|
||||||
|
crosshair.y_axis_text.assign(text);
|
||||||
|
const f32 w = text_->measure(crosshair.y_axis_text.c_str()) + 2.0f * badge_pad;
|
||||||
|
// Right-aligned on the axis line -- where the y ticks end -- and **centred** on the
|
||||||
|
// horizontal line's own height, the way the x badge is centred on its column: the badge
|
||||||
|
// names the line it sits beside, and a badge whose top edge *is* that line reads as
|
||||||
|
// belonging to the tick below it. A badge is as wide as its own text and is capped by
|
||||||
|
// nothing else, so it
|
||||||
|
// is held within the **left strips' union**: the label strip first, the title strip
|
||||||
|
// beside it when there is one, and never over the data or off the viewport's left
|
||||||
|
// edge. A `std::clamp` on an inverted pair is undefined, which is exactly the state
|
||||||
|
// "the strip is narrower than the badge" produces -- hence the ordered min/max chain.
|
||||||
|
const f32 left = layout_.y_title_area.w > 0.0f ? layout_.y_title_area.x
|
||||||
|
: layout_.y_label_area.x;
|
||||||
|
f32 bx = layout_.y_label_area.right() - w;
|
||||||
|
if (bx < left) bx = left;
|
||||||
|
bx = std::min(bx, axes.x - w);
|
||||||
|
if (bx < left) bx = left; // a badge wider than the union stays at its left edge
|
||||||
|
const f32 at = inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)) - 0.5f * badge_h,
|
||||||
|
layout_.y_label_area.y + badge_pad,
|
||||||
|
layout_.y_label_area.bottom() - badge_pad - badge_h);
|
||||||
|
crosshair.y_axis_badge = Rect{bx, at, w, badge_h};
|
||||||
|
}
|
||||||
|
if (crosshair_.vertical && layout_.x_label_area.w > 0.0f &&
|
||||||
|
layout_.x_label_area.h >= badge_h) {
|
||||||
|
char text[64];
|
||||||
|
x_axis(0u).format_value(crosshair_.x, text, sizeof text);
|
||||||
|
crosshair.x_axis_text.assign(text);
|
||||||
|
const f32 w = text_->measure(crosshair.x_axis_text.c_str()) + 2.0f * badge_pad;
|
||||||
|
const f32 column =
|
||||||
|
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
|
||||||
|
const f32 x = inside(column - 0.5f * w, layout_.x_label_area.x,
|
||||||
|
layout_.x_label_area.right() - w);
|
||||||
|
crosshair.x_axis_badge = Rect{x, layout_.x_label_area.y, w, badge_h};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The rows: one per *visible* trace -- a trace with no series or no samples is not under
|
// The rows: one per *visible* trace -- a trace with no series or no samples is not under
|
||||||
// the pointer, so it has nothing to say -- named or not, because the readout is about what
|
// the pointer, so it has nothing to say -- named or not, because the readout is about what
|
||||||
// the pointer is over and not about identity. The vector is filled in place and its entries
|
// the pointer is over and not about identity. The vector is filled in place and its entries
|
||||||
@@ -1524,17 +1996,23 @@ void Plot::build_crosshair() {
|
|||||||
row.trace = i;
|
row.trace = i;
|
||||||
row.valid = false;
|
row.valid = false;
|
||||||
row.listed = false;
|
row.listed = false;
|
||||||
|
row.x = 0.0;
|
||||||
row.y = 0.0;
|
row.y = 0.0;
|
||||||
row.marker = Vec2{};
|
row.marker = Vec2{};
|
||||||
row.swatch = Rect{};
|
row.swatch = Rect{};
|
||||||
row.label_box = Rect{};
|
row.label_box = Rect{};
|
||||||
row.value_box = Rect{};
|
row.value_box = Rect{};
|
||||||
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
||||||
// The value: the interpolated number where the trace has a sample at this x, and the em dash
|
// The value where the trace has one at this x, and the em dash where it does not. The row is
|
||||||
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
|
// listed either way, so the absence has to be *said* -- a blank value cell reads as a value
|
||||||
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
|
// that has not arrived -- and the dash is measured into the panel's width like any other
|
||||||
// width like any other value, so the row's boxes are placed by one rule.
|
// value, so the row's boxes are placed by one rule.
|
||||||
if (crosshair_value(i, crosshair_.x, row.y)) {
|
//
|
||||||
|
// The **point**, not just the number: `crosshair_point()` reports the x the value belongs to
|
||||||
|
// as well, which in the default `NearestSample` mode is the quoted sample's own and not the
|
||||||
|
// pointer's. Both coordinates come from that one call, so the mark and the text cannot
|
||||||
|
// disagree about which datum is being read (see `CrosshairRow::marker`).
|
||||||
|
if (crosshair_point(i, crosshair_.x, row.x, row.y)) {
|
||||||
row.valid = true;
|
row.valid = true;
|
||||||
char value[32];
|
char value[32];
|
||||||
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
||||||
@@ -1543,7 +2021,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
|
||||||
|
|||||||
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
|
|||||||
program_.set_uniform("u_tex_size", texels);
|
program_.set_uniform("u_tex_size", texels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool DensityProgram::create(Status* st) noexcept {
|
||||||
|
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
|
||||||
|
if (program_.id() == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
|
||||||
|
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
|
||||||
|
program_.use();
|
||||||
|
program_.set_uniform_int("u_density", 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
|
||||||
|
|
||||||
|
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
|
||||||
|
program_.set_uniform("u_origin", origin);
|
||||||
|
program_.set_uniform("u_size", size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DensityProgram::set_color(Color color) const noexcept {
|
||||||
|
program_.set_uniform("u_color", color);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
+914
-76
File diff suppressed because it is too large.
Load diff
@@ -235,4 +235,48 @@ void main() {
|
|||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
|
const char* const kDensityVS = R"(#version 330 core
|
||||||
|
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
|
||||||
|
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
|
||||||
|
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
|
||||||
|
uniform vec2 u_viewport; // render target size, pixels
|
||||||
|
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
|
||||||
|
uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
|
||||||
|
|
||||||
|
out vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
|
||||||
|
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
|
||||||
|
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
|
||||||
|
vec2 px = u_origin + corner * u_size;
|
||||||
|
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
|
||||||
|
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
|
||||||
|
gl_Position = vec4(ndc, 0.0, 1.0);
|
||||||
|
// uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
|
||||||
|
// between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
|
||||||
|
// pixel, the sampling all but a point sample) and a linear magnification of the image past it.
|
||||||
|
v_uv = corner;
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
const char* const kDensityFS = R"(#version 330 core
|
||||||
|
in vec2 v_uv; // cell coordinate in [0,1] across the quad
|
||||||
|
|
||||||
|
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
|
||||||
|
uniform vec4 u_color;
|
||||||
|
|
||||||
|
out vec4 o_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// The cell's count was already turned into a luminance on the CPU, where the count, the
|
||||||
|
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
|
||||||
|
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
|
||||||
|
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
|
||||||
|
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
|
||||||
|
float ink = texture(u_density, v_uv).r;
|
||||||
|
o_color = vec4(u_color.rgb, u_color.a * ink);
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
|
|||||||
|
|
||||||
inline int run_all() {
|
inline int run_all() {
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
|
const char* only = std::getenv("PLOT_TEST_FILTER");
|
||||||
for (auto& c : cases()) {
|
for (auto& c : cases()) {
|
||||||
|
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
|
||||||
try {
|
try {
|
||||||
c.fn();
|
c.fn();
|
||||||
std::printf("[ ok ] %s\n", c.name);
|
std::printf("[ ok ] %s\n", c.name);
|
||||||
|
|||||||
@@ -2403,3 +2403,54 @@ PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
|
|||||||
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
|
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
|
||||||
PLOT_CHECK(c.grabbed_cursor() == normal);
|
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) {
|
||||||
|
// What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the
|
||||||
|
// x range only, so what its release will change is a *band* across the plot -- and a pointer dragged
|
||||||
|
// straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the
|
||||||
|
// gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the
|
||||||
|
// other single-axis mask, and gets the other frame.
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::Rect axes = p.layout().axes;
|
||||||
|
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
|
||||||
|
c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
|
||||||
|
// Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a
|
||||||
|
// full-width band derived from it would claim a range that does not exist yet.
|
||||||
|
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
|
||||||
|
c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false});
|
||||||
|
const plot::Rect ctrl = c.selection_preview();
|
||||||
|
PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6);
|
||||||
|
// The gesture's own geometry is untouched: the widening happens in the preview, so the range the
|
||||||
|
// release computes is still the one the pointer described.
|
||||||
|
PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6);
|
||||||
|
c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||||
|
|
||||||
|
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
|
||||||
|
c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false});
|
||||||
|
const plot::Rect both = c.selection_preview();
|
||||||
|
PLOT_CHECK_NEAR(both.x, 100.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(both.y, 50.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(both.w, 300.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(both.h, 100.0, 1e-6);
|
||||||
|
c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
|
||||||
|
|
||||||
|
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false});
|
||||||
|
c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false});
|
||||||
|
const plot::Rect alt = c.selection_preview();
|
||||||
|
PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6);
|
||||||
|
c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true});
|
||||||
|
// Closed: a frame on a finished gesture would claim the old selection is still open.
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||||
|
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
|
||||||
|
}
|
||||||
+610
-8
@@ -16,6 +16,7 @@
|
|||||||
#include <plot/render/embedded_mono_font.hpp>
|
#include <plot/render/embedded_mono_font.hpp>
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -425,6 +426,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The other side of the same band. `axes` reached the viewport's right edge, which left
|
||||||
|
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
|
||||||
|
/// ask for an empty band there. One value for every row, like the left gutter, because
|
||||||
|
/// the rows' axes have to end on a common column as well as start on one.
|
||||||
|
PLOT_TEST(figure_rows_share_one_right_gutter) {
|
||||||
|
plot::Figure figure = different_magnitude_figure();
|
||||||
|
figure.layout();
|
||||||
|
const plot::f32 flush = figure.plot(0u).layout().axes.right();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
||||||
|
// The default is the layout as it always was: nothing between the data and the frame.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
|
||||||
|
|
||||||
|
figure.set_right_gutter(30.0f);
|
||||||
|
figure.layout();
|
||||||
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& rl = figure.plot(row).layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
|
||||||
|
1e-4);
|
||||||
|
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
|
||||||
|
// so a host can balance the two sides without the one moving when it sets the other.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
||||||
|
// ... and the x strip follows the data area rather than the viewport, or the shared
|
||||||
|
// axis would be labelled over a band that holds no data.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
|
||||||
|
1e-4);
|
||||||
|
}
|
||||||
|
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
|
||||||
|
// squeeze `axes` below the floor `Plot::layout()` guarantees.
|
||||||
|
figure.set_right_gutter(10.0f * kW);
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||||
|
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
|
||||||
|
}
|
||||||
|
// Negative means "none", like the gutter and the row gap, and the data area goes back
|
||||||
|
// to the viewport's edge.
|
||||||
|
figure.set_right_gutter(-5.0f);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
|
||||||
|
static_cast<plot::f64>(flush), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
||||||
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
||||||
/// row that has no sibling to line up with.
|
/// row that has no sibling to line up with.
|
||||||
@@ -877,8 +924,8 @@ namespace {
|
|||||||
|
|
||||||
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
|
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
|
||||||
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
|
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
|
||||||
/// sample at different rates are the point -- one x, three different interpolations -- and the
|
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
|
||||||
/// last row is the case a readout must not invent a value for.
|
/// grid -- and the last row is the case a readout must not invent a value for.
|
||||||
plot::Figure crosshair_figure() {
|
plot::Figure crosshair_figure() {
|
||||||
plot::Figure figure(4u);
|
plot::Figure figure(4u);
|
||||||
figure.set_viewport(whole_figure());
|
figure.set_viewport(whole_figure());
|
||||||
@@ -906,7 +953,7 @@ plot::Figure crosshair_figure() {
|
|||||||
|
|
||||||
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
|
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
|
||||||
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
|
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
|
||||||
/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and
|
/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
|
||||||
/// each row's series is read on its own grid -- while a row whose series does not reach that x
|
/// each row's series is read on its own grid -- while a row whose series does not reach that x
|
||||||
/// reports no value at all instead of a fabricated one.
|
/// reports no value at all instead of a fabricated one.
|
||||||
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
||||||
@@ -930,14 +977,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
|||||||
PLOT_CHECK(first.rows[0].label == "fast");
|
PLOT_CHECK(first.rows[0].label == "fast");
|
||||||
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
|
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
|
||||||
|
|
||||||
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2
|
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
|
||||||
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
|
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
|
||||||
// case about the rows rather than about one series read four times.
|
// number for the same x, which is what makes this case about the rows rather than about one
|
||||||
|
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
|
||||||
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
|
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
|
||||||
PLOT_CHECK(second.rows.size() == 1u);
|
PLOT_CHECK(second.rows.size() == 1u);
|
||||||
PLOT_CHECK(second.rows[0].valid);
|
PLOT_CHECK(second.rows[0].valid);
|
||||||
PLOT_CHECK(second.rows[0].label == "slow");
|
PLOT_CHECK(second.rows[0].label == "slow");
|
||||||
PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9);
|
PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
|
||||||
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
|
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
|
||||||
|
|
||||||
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
|
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
|
||||||
@@ -972,7 +1020,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
|||||||
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
|
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
|
||||||
}
|
}
|
||||||
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5
|
// Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
|
||||||
|
// and a readout quotes one of them.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
|
||||||
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
|
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
|
||||||
|
|
||||||
// On *every* row at once, and only the rows free it.
|
// On *every* row at once, and only the rows free it.
|
||||||
@@ -1092,3 +1142,555 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
|
|||||||
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
|
||||||
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ cell grid
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
|
||||||
|
/// order), with an x and a y axis on every plot unless `axes` is off.
|
||||||
|
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
|
||||||
|
figure.set_viewport(whole_figure());
|
||||||
|
if (axes) {
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
|
||||||
|
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_all_ones_equals_rows) {
|
||||||
|
plot::Figure split = split_figure({1, 1, 1});
|
||||||
|
plot::Figure plain = figure_with_axes(3u);
|
||||||
|
const plot::FigureLayout& sl = split.layout();
|
||||||
|
const plot::FigureLayout& pl = plain.layout();
|
||||||
|
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
|
||||||
|
PLOT_CHECK(sl.cells.size() == 3u);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_tile_exactly) {
|
||||||
|
plot::Figure figure = split_figure({1, 2, 1});
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
|
||||||
|
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
|
||||||
|
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
|
||||||
|
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cell_weights_and_floor) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
||||||
|
PLOT_CHECK(figure.has_cell_weights(0u));
|
||||||
|
{
|
||||||
|
const auto& c = figure.layout().cells;
|
||||||
|
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
|
||||||
|
}
|
||||||
|
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
|
||||||
|
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
|
||||||
|
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
|
||||||
|
const std::vector<plot::f32> zero{1.0f, 0.0f};
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0u, three));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(5u, w));
|
||||||
|
figure.clear_cell_weights(0u);
|
||||||
|
PLOT_CHECK(!figure.has_cell_weights(0u));
|
||||||
|
const auto& c = figure.layout().cells;
|
||||||
|
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_narrow_degrades) {
|
||||||
|
plot::Figure figure = split_figure({4}, false);
|
||||||
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
|
||||||
|
for (plot::u32 i = 0; i < 4u; ++i) {
|
||||||
|
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
}
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const plot::Rect& c : fl.cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
|
||||||
|
plot::Figure figure = split_figure({2, 1});
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
|
||||||
|
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.set_crosshair(4.0, true);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK(figure.plot(i).crosshair().active);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.clear_crosshair();
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
|
||||||
|
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
|
||||||
|
plot::Figure figure = split_figure({1, 1, 1});
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_common_y_gutter) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
give_magnitude_row(figure.plot(0u), 0u);
|
||||||
|
give_magnitude_row(figure.plot(1u), 1u);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
|
||||||
|
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
|
||||||
|
PLOT_CHECK(g0 > 0.0);
|
||||||
|
PLOT_CHECK_NEAR(g0, g1, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
|
||||||
|
plot::Figure figure = split_figure({2, 1});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
// The unsplit row is its own thing.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The horizontal line is one *value*, and a split row's cells share one y scale: the line
|
||||||
|
/// spans the whole row instead of stopping at the divider between two cells that read the same
|
||||||
|
/// numbers. The rows of the figure still do not share y, so no other row gets one.
|
||||||
|
PLOT_TEST(figure_crosshair_horizontal_spans_a_split_rows_cells) {
|
||||||
|
plot::Figure figure = split_figure({2, 1});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
// The pointer in the *second* cell of the split row, half-way down it.
|
||||||
|
const plot::Rect cell1 = figure.layout().cells[1];
|
||||||
|
const plot::Transform ty = figure.plot(1u).y_transform(0u);
|
||||||
|
const plot::f32 y = static_cast<plot::f32>(ty.to_data_y(cell1.y + 0.5f * cell1.h));
|
||||||
|
figure.set_crosshair(50.0, 1u, y, true);
|
||||||
|
figure.layout();
|
||||||
|
const plot::CrosshairLayout& left = figure.plot(0u).layout().crosshair;
|
||||||
|
const plot::CrosshairLayout& right = figure.plot(1u).layout().crosshair;
|
||||||
|
const plot::CrosshairLayout& other = figure.plot(2u).layout().crosshair;
|
||||||
|
// Both cells of the row carry the line, at one screen row: they share the y scale, so the
|
||||||
|
// row's own y is the same value's height in each.
|
||||||
|
PLOT_CHECK(left.horizontal.w > 0.0f);
|
||||||
|
PLOT_CHECK(right.horizontal.w > 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(left.horizontal.y + 0.5f * left.horizontal.h,
|
||||||
|
right.horizontal.y + 0.5f * right.horizontal.h, 0.75);
|
||||||
|
// The unsplit row keeps the shared-x vertical line and no horizontal one -- a line there
|
||||||
|
// would mean a value on a scale that row does not have.
|
||||||
|
PLOT_CHECK(other.vertical.w > 0.0f);
|
||||||
|
PLOT_CHECK(other.horizontal.h == 0.0f);
|
||||||
|
// The spread does not touch the x: both cells keep the one shared column.
|
||||||
|
PLOT_CHECK(left.vertical.w > 0.0f);
|
||||||
|
PLOT_CHECK(right.vertical.w > 0.0f);
|
||||||
|
// Without `set_share_row_y` the cells are separate scales again, and the line is the
|
||||||
|
// hovered cell's alone.
|
||||||
|
figure.set_share_row_y(false);
|
||||||
|
figure.set_crosshair(50.0, 1u, y, true);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w > 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().crosshair.horizontal.h == 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
|
||||||
|
plot::Figure figure = split_figure({3});
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true); // cell 2 has no trace
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
const plot::Range r0 = figure.plot(0u).y_range(0u);
|
||||||
|
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
|
||||||
|
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
|
||||||
|
for (plot::u32 i = 1; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.layout(); // manual holds: no fight with the plots' own autoscale
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.share_row_y());
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({1, 3}, false);
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
|
||||||
|
for (plot::u32 i = 2; i < 4u; ++i) {
|
||||||
|
const plot::PlotLayout& l = figure.plot(i).layout();
|
||||||
|
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
|
||||||
|
PLOT_CHECK(l.axes.w > 0.0f);
|
||||||
|
}
|
||||||
|
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
|
||||||
|
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
|
||||||
|
plot::Figure figure = split_figure({3}, false);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(8.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
const double w0 = figure.plot(0u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
||||||
|
figure.layout();
|
||||||
|
const double a0 = figure.plot(0u).layout().axes.w;
|
||||||
|
const double a1 = figure.plot(1u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
|
||||||
|
plot::Figure figure = split_figure({4}, false);
|
||||||
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
|
||||||
|
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const plot::Rect& c : figure.layout().cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
give_magnitude_row(figure.plot(0u), 0u);
|
||||||
|
give_magnitude_row(figure.plot(1u), 1u);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- x axes follow the columns
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
|
||||||
|
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
|
||||||
|
/// a 35 px right gutter.
|
||||||
|
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
|
||||||
|
plot::Figure figure = split_figure(cells, false);
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
|
||||||
|
figure.set_col_gap(6.0f);
|
||||||
|
figure.set_right_gutter(35.0f);
|
||||||
|
figure.set_share_row_y(share_y);
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
|
||||||
|
const plot::PlotLayout& l = figure.plot(plot_index).layout();
|
||||||
|
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
|
||||||
|
const plot::PlotLayout& l = figure.plot(plot_index).layout();
|
||||||
|
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
|
||||||
|
plot::Figure figure = host_like_figure({2, 1});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 0u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
||||||
|
// ... and the other way round.
|
||||||
|
plot::Figure other = host_like_figure({1, 2});
|
||||||
|
other.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(other, 0u));
|
||||||
|
PLOT_CHECK(shows_x_axis(other, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(other, 2u));
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u));
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 3u));
|
||||||
|
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
|
||||||
|
// serves them.
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
for (plot::u32 c = 0; c < 2u; ++c) {
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
|
||||||
|
const plot::PlotLayout& up = figure.plot(c).layout();
|
||||||
|
const plot::PlotLayout& down = figure.plot(2u + c).layout();
|
||||||
|
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
|
||||||
|
}
|
||||||
|
// The same without row-shared y (every cell has its own y strip).
|
||||||
|
plot::Figure own_y = host_like_figure({2, 2}, false);
|
||||||
|
own_y.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
|
||||||
|
for (plot::u32 c = 0; c < 2u; ++c) {
|
||||||
|
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
|
||||||
|
const std::vector<plot::f32> one_three{1.0f, 3.0f};
|
||||||
|
const std::vector<plot::f32> two_six{2.0f, 6.0f};
|
||||||
|
const std::vector<plot::f32> halves{1.0f, 1.0f};
|
||||||
|
{ // 1:3 over 1:3
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
|
||||||
|
}
|
||||||
|
{ // 1:3 over 2:6 -- normalised, so the same columns
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
||||||
|
}
|
||||||
|
{ // 1:3 over 1:1
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(1u, halves));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
||||||
|
}
|
||||||
|
{ // 1:3 over (no weights = equal)
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
|
||||||
|
}
|
||||||
|
{ // explicit 1:1 over (no weights = equal)
|
||||||
|
plot::Figure figure = host_like_figure({2, 2});
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, halves));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
||||||
|
}
|
||||||
|
{ // a different number of cells never matches, whatever the weights
|
||||||
|
plot::Figure figure = host_like_figure({3, 2});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
|
||||||
|
plot::Figure figure = host_like_figure({1, 1, 1});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u));
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
||||||
|
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
|
||||||
|
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
|
||||||
|
plot::Figure figure = host_like_figure({2, 2, 1});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 4u));
|
||||||
|
}
|
||||||
|
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
|
||||||
|
plot::Figure figure = host_like_figure({2, 2, 2});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
|
||||||
|
}
|
||||||
|
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
|
||||||
|
plot::Figure figure = host_like_figure({1, 2, 2});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 0u));
|
||||||
|
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
|
||||||
|
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
|
||||||
|
}
|
||||||
|
{ // the figure's x_axis_strip is still the last plot's label area
|
||||||
|
plot::Figure figure = host_like_figure({2, 1});
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
|
||||||
|
plot::Figure figure = host_like_figure({2, 2, 1});
|
||||||
|
figure.set_x_labels_only_on_bottom(false);
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------- the gap between cells of a row
|
||||||
|
|
||||||
|
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
|
||||||
|
for (const plot::u32 n : {2u, 3u, 4u}) {
|
||||||
|
plot::Figure figure = host_like_figure({n});
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
|
||||||
|
for (plot::u32 k = 0; k + 1u < n; ++k) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
|
||||||
|
6.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
|
||||||
|
// Only the last cell holds the right gutter.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
// Equal weights give equal axes widths, the first cell included.
|
||||||
|
for (plot::u32 k = 1; k < n; ++k) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
|
||||||
|
}
|
||||||
|
// The cells tile the row exactly.
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
|
||||||
|
// The right gutter is still there, on the last cell.
|
||||||
|
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
|
||||||
|
// The y gutter still sits on the first cell alone.
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
|
||||||
|
plot::Figure figure = host_like_figure({1, 2});
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(1u, w));
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
const plot::Rect a0 = figure.plot(1u).layout().axes;
|
||||||
|
const plot::Rect a1 = figure.plot(2u).layout().axes;
|
||||||
|
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
|
||||||
|
// The unsplit row above keeps its own right gutter on its one plot.
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
|
||||||
|
plot::Figure figure = host_like_figure({3});
|
||||||
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const plot::Rect& c : fl.cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
|
||||||
|
plot::Figure figure = host_like_figure({2}, false);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
|
||||||
|
}
|
||||||
|
// An unsplit row is unchanged by sharing being on.
|
||||||
|
plot::Figure unsplit = host_like_figure({1, 1});
|
||||||
|
const plot::FigureLayout& ul = unsplit.layout();
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
#include <glfw/glfw_host.hpp>
|
#include <glfw/glfw_host.hpp>
|
||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
|
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
|
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
|
||||||
@@ -315,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
|
|||||||
PLOT_CHECK(host.row_controller(0u) == nullptr);
|
PLOT_CHECK(host.row_controller(0u) == nullptr);
|
||||||
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
|
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(glfw_host_routes_by_cell) {
|
||||||
|
// A figure of two rows, the first of one cell and the second of two side by side: the
|
||||||
|
// pointer's x as well as its y picks the plot, and the controllers are indexed by the
|
||||||
|
// flattened plot.
|
||||||
|
std::vector<plot::u32> cells{1, 2};
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 300, 600});
|
||||||
|
figure.set_col_gap(6.0f);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
}
|
||||||
|
plot::glfw::Host host;
|
||||||
|
host.set_figure(&figure);
|
||||||
|
const std::vector<plot::Rect> c = figure.layout().cells;
|
||||||
|
PLOT_CHECK(c.size() == 3u);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK(host.row_controller(i) != nullptr);
|
||||||
|
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
|
||||||
|
PLOT_CHECK(&host.controller_at(static_cast<plot::f32>(cx), static_cast<plot::f32>(cy)) ==
|
||||||
|
host.row_controller(i));
|
||||||
|
}
|
||||||
|
PLOT_CHECK(host.row_controller(3u) == nullptr);
|
||||||
|
// in the column gap: the nearest cell wins
|
||||||
|
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1));
|
||||||
|
PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <plot/data/detail/merge_count.hpp>
|
#include <plot/data/detail/merge_count.hpp>
|
||||||
#include <plot/data/minmax_pyramid.hpp>
|
#include <plot/data/minmax_pyramid.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
#include <random>
|
#include <random>
|
||||||
|
|
||||||
using plot::data::kBucket;
|
using plot::data::kBucket;
|
||||||
@@ -342,3 +343,69 @@ PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
|
|||||||
p.append(1.0f);
|
p.append(1.0f);
|
||||||
PLOT_CHECK(p.level_count() >= 1);
|
PLOT_CHECK(p.level_count() >= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_folds_a_backfilled_sample_into_every_covering_level) {
|
||||||
|
// A hole is appended as NaN -- which the pyramid already stores as an empty aggregate --
|
||||||
|
// and amended later when the packet that owned it finally arrives. The amendment has to
|
||||||
|
// reach every level that covers the index, because the renderer reads whichever level the
|
||||||
|
// zoom picked: repairing level 0 alone would make a trace whole when zoomed in and still
|
||||||
|
// broken when zoomed out.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(8 * kBucket);
|
||||||
|
for (u64 i = 0; i < 8 * kBucket; ++i) {
|
||||||
|
// One NaN, at an index comfortably inside the first bucket of every level.
|
||||||
|
p.append(i == 5 ? std::numeric_limits<f32>::quiet_NaN() : 1.0f);
|
||||||
|
}
|
||||||
|
// Before: every covering bucket aggregates kBucket-1 ones and knows nothing of index 5.
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == kBucket - 1);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 1.0);
|
||||||
|
|
||||||
|
p.amend(5, 7.0f);
|
||||||
|
|
||||||
|
// Level 0 gained the sample.
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == kBucket);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 7.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket_sum(0, 0), f64(kBucket - 1) + 7.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket_sumsq(0, 0), f64(kBucket - 1) + 49.0);
|
||||||
|
// And so did every coarser level that covers it.
|
||||||
|
for (u32 l = 1; l < p.level_count(); ++l) {
|
||||||
|
if (p.level_size(l) == 0) continue;
|
||||||
|
const u64 span = u64{kBucket} << l;
|
||||||
|
const u64 b = 5 / span;
|
||||||
|
if (b < p.level_base(l) || b - p.level_base(l) >= p.level_size(l)) continue;
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(l, b).max, 7.0);
|
||||||
|
PLOT_CHECK(p.bucket_count(l, b) == span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_makes_an_all_nan_bucket_present_again) {
|
||||||
|
// A bucket whose every sample was non-finite is stored with count 0, which every reader
|
||||||
|
// treats as absent. One amendment has to make it present -- min and max become the value
|
||||||
|
// rather than widening from a meaningless zero, which is the case a plain fold gets wrong.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(4 * kBucket);
|
||||||
|
const f32 nan = std::numeric_limits<f32>::quiet_NaN();
|
||||||
|
for (u64 i = 0; i < 2 * kBucket; ++i) p.append(i < kBucket ? nan : 1.0f);
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == 0);
|
||||||
|
|
||||||
|
p.amend(3, -2.0f);
|
||||||
|
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == 1);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).min, -2.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, -2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_outside_the_window_is_a_no_op) {
|
||||||
|
// An index the window has already dropped, or one not yet appended, has nowhere to go.
|
||||||
|
// Silently ignoring it is what lets the caller amend without first proving the ring still
|
||||||
|
// holds the slot -- a race it cannot win, because the window slides on the other thread.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(4 * kBucket);
|
||||||
|
for (u64 i = 0; i < 4 * kBucket; ++i) p.append(1.0f);
|
||||||
|
p.drop_before(2 * kBucket);
|
||||||
|
const u64 before = p.bucket_count(0, 2);
|
||||||
|
p.amend(5, 99.0f); // dropped
|
||||||
|
p.amend(100 * kBucket, 99.0f); // never appended
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 2) == before);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 2).max, 1.0);
|
||||||
|
}
|
||||||
+613
-36
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
|
|||||||
}
|
}
|
||||||
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||||
++gather_calls_;
|
++gather_calls_;
|
||||||
samples_read_ += end - begin;
|
samples_read_ += end - begin;
|
||||||
gather_impl(level, begin, end, out, mode);
|
gather_impl(level, begin, end, x_origin, out, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
u64 gather_calls() const noexcept { return gather_calls_; }
|
u64 gather_calls() const noexcept { return gather_calls_; }
|
||||||
@@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
|
/// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
|
||||||
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
|
/// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
|
||||||
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
|
/// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
|
||||||
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
|
/// this refuses, and what it used to return. The corners of the rule are all covered: a query
|
||||||
/// one sample), and a one-sample series, whose window is that sample alone.
|
/// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
|
||||||
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
/// the window (where the span collapses onto one sample), and a one-sample series.
|
||||||
|
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
|
||||||
CrosshairScene scene;
|
CrosshairScene scene;
|
||||||
const u32 trace = scene.plot.add_trace(
|
const u32 trace = scene.plot.add_trace(
|
||||||
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
||||||
|
|
||||||
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
|
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
|
||||||
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
|
// (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
|
||||||
// not only the value is what pins the convention -- a nearest-sample lookup would report the
|
// both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
|
||||||
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
|
// bracketing is unchanged and only what is built from it differs.
|
||||||
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
|
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
|
||||||
PLOT_CHECK(half.valid);
|
PLOT_CHECK(half.valid);
|
||||||
PLOT_CHECK(half.begin == 1u);
|
PLOT_CHECK(half.begin == 1u);
|
||||||
@@ -1986,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
|
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
|
||||||
|
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
|
// Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and
|
||||||
|
// 20 are `tens` values, where an interpolation would have answered 11 and 19.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
// Exactly halfway is the tie, and it reads the *earlier* sample -- the bias
|
||||||
|
// `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer.
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
||||||
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
|
||||||
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
|
// A quarter of the way through another pair: sample 2, not 22.5.
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
|
||||||
PLOT_CHECK_NEAR(y, 22.5, 1e-9);
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
// Just short of the next sample is that sample, so the readout steps rather than ramps.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
||||||
|
|
||||||
// The ends of the window are samples too, so a query exactly on one reads that sample --
|
// The ends of the window are samples too, so a query exactly on one reads that sample --
|
||||||
// and reads it *as* a span of one, with nothing to its left or right to interpolate with.
|
// and reads it *as* a span of one, with no neighbour on the far side.
|
||||||
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
|
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
|
||||||
PLOT_CHECK(first.valid);
|
PLOT_CHECK(first.valid);
|
||||||
PLOT_CHECK(first.begin == 0u);
|
PLOT_CHECK(first.begin == 0u);
|
||||||
@@ -2008,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
|
||||||
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
||||||
|
|
||||||
// Outside the window there is no value at all -- not the nearest sample's, not the end of
|
// Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
|
||||||
// the window's, which are the clamped lies the header refuses -- and a series with no
|
// series *brackets*; extending it past the ends would answer with the first or last sample for
|
||||||
// samples has no span at any x.
|
// any x whatever, which is the clamped lie the header refuses. A series with no samples has no
|
||||||
|
// span at any x.
|
||||||
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
||||||
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
|
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
|
||||||
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
||||||
@@ -2023,7 +2036,7 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
|
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
|
||||||
|
|
||||||
// A one-sample series: the sample itself is the only query it answers, and it answers it
|
// A one-sample series: the sample itself is the only query it answers, and it answers it
|
||||||
// without a neighbour to interpolate against.
|
// with no second sample to be nearer to.
|
||||||
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
|
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
|
||||||
plot::Plot one;
|
plot::Plot one;
|
||||||
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
@@ -2039,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
|
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
|
||||||
|
|
||||||
// The readout's row is the same lookup: the number a user reads off the panel is the value
|
// The readout's row is the same lookup: the number a user reads off the panel is the value
|
||||||
// this test computed by hand, not a second derivation of it.
|
// this test computed by hand, not a second derivation of it. x = 1.5 is the tie, so the row
|
||||||
|
// reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed
|
||||||
|
// `"15"`, a number the series does not contain.
|
||||||
scene.plot.set_crosshair(1.5, true);
|
scene.plot.set_crosshair(1.5, true);
|
||||||
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
||||||
PLOT_CHECK(crosshair.active);
|
PLOT_CHECK(crosshair.active);
|
||||||
@@ -2049,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
// the figure's case pins).
|
// the figure's case pins).
|
||||||
PLOT_CHECK(crosshair.rows.size() == 1u);
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
||||||
PLOT_CHECK(crosshair.rows[0].valid);
|
PLOT_CHECK(crosshair.rows[0].valid);
|
||||||
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
|
||||||
PLOT_CHECK(crosshair.rows[0].value == "15");
|
PLOT_CHECK(crosshair.rows[0].value == "10");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark is **snapped to the sample the row quotes**, which is what makes it say *which*
|
||||||
|
/// value is being read. Placing it at the pointer's x with the sample's y put it on neither the
|
||||||
|
/// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on
|
||||||
|
/// nothing.
|
||||||
|
///
|
||||||
|
/// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong
|
||||||
|
/// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query,
|
||||||
|
/// and that `row.marker` is that position mapped through the trace's own axes. The pixel column
|
||||||
|
/// is computed from the transform the plot already exposes, so this checks the two agree rather
|
||||||
|
/// than re-deriving the mapping.
|
||||||
|
PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) {
|
||||||
|
CrosshairScene scene;
|
||||||
|
const u32 trace = scene.plot.add_trace(
|
||||||
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||||
|
|
||||||
|
// A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as
|
||||||
|
// y = 20 -- so the mark lands on the datum and not a fifth of a division to its left.
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 20.0, 1e-9);
|
||||||
|
// The tie takes the earlier sample, and takes *its* x with it: the same bias in both
|
||||||
|
// coordinates, so the mark and the number can never name different samples.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 10.0, 1e-9);
|
||||||
|
// `crosshair_value()` is the same call with the x dropped, so the two cannot disagree.
|
||||||
|
f64 y_only = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only));
|
||||||
|
PLOT_CHECK_NEAR(y_only, py, 1e-12);
|
||||||
|
// A failing lookup leaves both outputs alone, as the value form does.
|
||||||
|
PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||||
|
|
||||||
|
// And through the layout: the row carries the snapped x, and the marker is that x through the
|
||||||
|
// trace's own transform -- not the crosshair's column, which is where it used to be.
|
||||||
|
scene.plot.set_crosshair(1.8, true);
|
||||||
|
scene.plot.layout();
|
||||||
|
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||||
|
PLOT_CHECK(row.valid);
|
||||||
|
PLOT_CHECK_NEAR(row.x, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(row.y, 20.0, 1e-9);
|
||||||
|
const f32 sample_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(2.0));
|
||||||
|
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||||
|
PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(row.marker.y,
|
||||||
|
static_cast<f32>(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3);
|
||||||
|
// The two columns are far apart at this scale, so the check above is not satisfied by both
|
||||||
|
// being nearly the same number.
|
||||||
|
PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f);
|
||||||
|
// The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved.
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x +
|
||||||
|
0.5f * scene.plot.layout().crosshair.vertical.w,
|
||||||
|
pointer_col, 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the
|
||||||
|
/// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a
|
||||||
|
/// continuous quantity sampled finely enough that the line between two samples is a model it
|
||||||
|
/// believes. `NearestSample` is the default because that line is an artefact of plotting for most
|
||||||
|
/// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers
|
||||||
|
/// both rather than picking for the caller.
|
||||||
|
///
|
||||||
|
/// Every number here is one the *other* mode refuses, so the two are pinned apart rather than
|
||||||
|
/// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value,
|
||||||
|
/// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the
|
||||||
|
/// polyline, and nowhere else.
|
||||||
|
PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) {
|
||||||
|
CrosshairScene scene;
|
||||||
|
const u32 trace = scene.plot.add_trace(
|
||||||
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample);
|
||||||
|
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated);
|
||||||
|
|
||||||
|
f64 y = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 11.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 19.0, 1e-9);
|
||||||
|
// A query *on* a sample reads that sample back exactly in this mode too: the span collapses to
|
||||||
|
// one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y));
|
||||||
|
PLOT_CHECK(y == 20.0);
|
||||||
|
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0);
|
||||||
|
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0);
|
||||||
|
PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12);
|
||||||
|
// Out of the window is still no value: interpolating needs two samples to be between.
|
||||||
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
||||||
|
|
||||||
|
// The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the
|
||||||
|
// number is -- the opposite placement from `NearestSample`, and the mode's visible half.
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.8, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 18.0, 1e-9);
|
||||||
|
scene.plot.set_crosshair(1.8, true);
|
||||||
|
scene.plot.layout();
|
||||||
|
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||||
|
PLOT_CHECK_NEAR(row.x, 1.8, 1e-12);
|
||||||
|
PLOT_CHECK(row.value == "18");
|
||||||
|
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||||
|
PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3);
|
||||||
|
|
||||||
|
// Switching back is a plain state change, with no second placement to keep in step: the next
|
||||||
|
// layout reports the sample again.
|
||||||
|
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample);
|
||||||
|
scene.plot.layout();
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is
|
||||||
|
/// snapped onto one of them: at the same size it reads as one more sample rather than as the one
|
||||||
|
/// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is
|
||||||
|
/// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is
|
||||||
|
/// the regression, and nothing else in the library would notice.
|
||||||
|
PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) {
|
||||||
|
const plot::render::Style style;
|
||||||
|
PLOT_CHECK(style.crosshair_marker_px > style.marker_px);
|
||||||
|
// And heavier than the crosshair's own lines, which are chrome and recede on purpose.
|
||||||
|
PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
||||||
@@ -2080,14 +2223,16 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
|
|||||||
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
|
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
|
||||||
PLOT_CHECK(crosshair.rows[1].label == "#1");
|
PLOT_CHECK(crosshair.rows[1].label == "#1");
|
||||||
|
|
||||||
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
|
// Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
|
||||||
// and the value's box -- all placed by the plot, all inside the panel it measured.
|
// box and the value's box -- all placed by the plot, all inside the panel it measured. x = 1.5
|
||||||
|
// is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an
|
||||||
|
// interpolating readout showed.
|
||||||
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
|
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
|
||||||
for (const plot::CrosshairRow& row : crosshair.rows) {
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
||||||
PLOT_CHECK(row.valid);
|
PLOT_CHECK(row.valid);
|
||||||
PLOT_CHECK(row.listed);
|
PLOT_CHECK(row.listed);
|
||||||
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
|
||||||
PLOT_CHECK(row.value == "15");
|
PLOT_CHECK(row.value == "10");
|
||||||
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
|
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
|
||||||
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
|
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
|
||||||
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
|
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
|
||||||
@@ -2148,7 +2293,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
|
|||||||
PLOT_CHECK(on.rows[0].listed);
|
PLOT_CHECK(on.rows[0].listed);
|
||||||
PLOT_CHECK(on.rows[1].listed);
|
PLOT_CHECK(on.rows[1].listed);
|
||||||
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
|
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
|
||||||
PLOT_CHECK(on.rows[0].value == "15");
|
PLOT_CHECK(on.rows[0].value == "10");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
|
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
|
||||||
@@ -2253,11 +2398,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
|||||||
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
||||||
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
|
||||||
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them.
|
// A query between two samples: 1234 % 100 = 34, the next is 35, and 1234.5 is the tie, which
|
||||||
|
// reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are
|
||||||
|
// still read: the bound below is on how much data the lookup touches, not on which end it
|
||||||
|
// then reports.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
const u64 before_reads = series.samples_read();
|
const u64 before_reads = series.samples_read();
|
||||||
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
|
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
|
||||||
PLOT_CHECK_NEAR(y, 34.5, 1e-9);
|
PLOT_CHECK_NEAR(y, 34.0, 1e-9);
|
||||||
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
|
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
|
||||||
PLOT_CHECK(series.gather_calls() <= 1u);
|
PLOT_CHECK(series.gather_calls() <= 1u);
|
||||||
|
|
||||||
@@ -2288,7 +2436,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
|||||||
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
||||||
PLOT_CHECK(crosshair.rows.size() == 1u);
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
||||||
PLOT_CHECK(crosshair.rows[0].valid);
|
PLOT_CHECK(crosshair.rows[0].valid);
|
||||||
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9);
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.0, 1e-9);
|
||||||
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
||||||
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
||||||
// And the readout is not vacuous: the series it read two samples of really does hold a
|
// And the readout is not vacuous: the series it read two samples of really does hold a
|
||||||
@@ -2323,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
|
|||||||
}
|
}
|
||||||
plot::Range y_bounds() const override { return {0.0, 20.0}; }
|
plot::Range y_bounds() const override { return {0.0, 20.0}; }
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||||
gather_impl(level, begin, end, out, mode);
|
gather_impl(level, begin, end, x_origin, out, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
|
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
|
||||||
@@ -2511,9 +2659,12 @@ PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The XY routing, crosshair half: the span brackets the samples the *array*
|
/// The XY routing, crosshair half: the span brackets the samples the *array*
|
||||||
/// names, and the interpolation fraction is measured on the samples' own positions
|
/// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
|
||||||
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
|
/// wide gap, where a uniform-grid guess would have read samples that do not exist at
|
||||||
/// samples that do not exist at the positions it assumed.
|
/// the positions it assumed. The gap is what makes the value worth asserting too:
|
||||||
|
/// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a
|
||||||
|
/// real choice to get right, and it only has that choice because `t` came from the
|
||||||
|
/// array.
|
||||||
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
||||||
XyScene scene;
|
XyScene scene;
|
||||||
const u32 trace = scene.plot.add_trace(
|
const u32 trace = scene.plot.add_trace(
|
||||||
@@ -2526,7 +2677,11 @@ PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
|||||||
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
|
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
|
||||||
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap
|
||||||
|
// And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a
|
||||||
|
// fixed preference for one end.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
|
||||||
// On a sample, at both ends of the array's window, and outside it: the same
|
// On a sample, at both ends of the array's window, and outside it: the same
|
||||||
// closed-window rule a uniform series follows, on the array's own coordinates.
|
// closed-window rule a uniform series follows, on the array's own coordinates.
|
||||||
@@ -2634,3 +2789,425 @@ PLOT_TEST(an_annotation_can_be_moved_by_id) {
|
|||||||
p.set_annotation(42u, other);
|
p.set_annotation(42u, other);
|
||||||
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
|
||||||
|
// The box-zoom frame is the host's to set -- the plot has no controller and learns nothing about
|
||||||
|
// pointers -- so what it owes is to carry it: out through `layout()` unchanged, because it is
|
||||||
|
// already in viewport pixels and a second derivation would be a second rounding of the same
|
||||||
|
// rectangle, and gone when the host takes it off, because a frame left behind claims the selection
|
||||||
|
// is still open.
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
PLOT_CHECK(p.layout().selection.w == 0.0f && p.layout().selection.h == 0.0f); // nothing swept
|
||||||
|
p.set_selection_rect({10.0f, 20.0f, 30.0f, 40.0f});
|
||||||
|
PLOT_CHECK_NEAR(p.selection_rect().x, 10.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(p.layout().selection.y, 20.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(p.layout().selection.w, 30.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(p.layout().selection.h, 40.0, 1e-6);
|
||||||
|
p.clear_selection_rect();
|
||||||
|
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- phosphor density -------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A two-sample flat series: one segment across the whole window at y = 0, which is the
|
||||||
|
/// simplest geometry a density can be read against -- one cell per column and nothing else.
|
||||||
|
std::vector<f32> flat_pair() { return std::vector<f32>{0.0f, 0.0f}; }
|
||||||
|
|
||||||
|
/// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is
|
||||||
|
/// the one `accumulate_density()` sizes the grid to, so a test reads both together.
|
||||||
|
struct DensityFixture {
|
||||||
|
plot::data::ArraySeries series;
|
||||||
|
plot::Plot plot;
|
||||||
|
plot::u32 ax = 0;
|
||||||
|
plot::u32 ay = 0;
|
||||||
|
|
||||||
|
explicit DensityFixture(std::vector<f32> y = flat_pair(), plot::f64 dx = 1.0)
|
||||||
|
: series(std::move(y), 0.0, dx) {
|
||||||
|
plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
|
||||||
|
ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u);
|
||||||
|
plot.set_x_range(ax, {0.0, 1.0});
|
||||||
|
plot.set_y_range(ay, {-1.0, 1.0});
|
||||||
|
plot.layout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
|
||||||
|
/// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
|
||||||
|
/// the ones the API's memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has
|
||||||
|
/// done and whatever the window is.
|
||||||
|
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
|
||||||
|
DensityFixture f;
|
||||||
|
const plot::Rect axes = f.plot.layout().axes;
|
||||||
|
// The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
|
||||||
|
// *scaled down* -- the rule the library states, written out here so the assertion is arithmetic
|
||||||
|
// rather than a recorded number. A grid under the cap is the rect itself, which the 320x240 leg
|
||||||
|
// at the end reads.
|
||||||
|
const auto capped = [](plot::u32 w, plot::u32 h) {
|
||||||
|
std::pair<plot::u32, plot::u32> out{w, h};
|
||||||
|
const plot::u64 pixels = static_cast<plot::u64>(w) * static_cast<plot::u64>(h);
|
||||||
|
if (pixels > static_cast<plot::u64>(plot::kDensityCellCap)) {
|
||||||
|
const plot::f64 s = std::sqrt(static_cast<plot::f64>(plot::kDensityCellCap) /
|
||||||
|
static_cast<plot::f64>(pixels));
|
||||||
|
out.first = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(w) * s));
|
||||||
|
out.second = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(h) * s));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const auto [columns, rows] =
|
||||||
|
capped(static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
|
||||||
|
// The row a device y lands in is read through the grid's cells-per-*whole*-pixel, which is what
|
||||||
|
// the deposit uses (see `deposit_density_segment()`): 1 while the grid is the rect.
|
||||||
|
const plot::f32 sy = static_cast<plot::f32>(rows) / std::ceil(axes.h);
|
||||||
|
PLOT_CHECK(static_cast<plot::u64>(columns) * rows <= plot::kDensityCellCap);
|
||||||
|
|
||||||
|
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
|
||||||
|
// deposits nothing at all.
|
||||||
|
PLOT_CHECK(!f.plot.density().enabled);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
|
||||||
|
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
PLOT_CHECK(f.plot.density().enabled);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
|
||||||
|
const plot::DensityGrid& grid = f.plot.density_grid(0u);
|
||||||
|
PLOT_CHECK(grid.columns() == columns);
|
||||||
|
PLOT_CHECK(grid.rows() == rows);
|
||||||
|
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
|
||||||
|
// `4 bytes * cells`, and never more than the cap: the whole point of the bound.
|
||||||
|
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
|
||||||
|
PLOT_CHECK(bytes <= static_cast<plot::usize>(plot::kDensityCellCap) * sizeof(plot::f32));
|
||||||
|
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
|
||||||
|
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
|
||||||
|
static_cast<double>(grid.peak()));
|
||||||
|
|
||||||
|
// One flat trace over the whole window lights every column exactly once: one segment, no
|
||||||
|
// column reached twice, so the ink *is* the width and the peak is one pass.
|
||||||
|
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(columns), 1.0);
|
||||||
|
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
|
||||||
|
|
||||||
|
// ... and all of it in one row, the one y = 0 maps to.
|
||||||
|
const plot::Transform ty = f.plot.y_transform(f.ay);
|
||||||
|
const int row = static_cast<int>(std::floor((ty.to_screen_y(0.0) - axes.y) * sy));
|
||||||
|
plot::u32 lit = 0;
|
||||||
|
for (plot::u32 r = 0; r < grid.rows(); ++r) {
|
||||||
|
for (plot::u32 c = 0; c < grid.columns(); ++c) {
|
||||||
|
if (grid.at(c, r) <= 0.0f) continue;
|
||||||
|
++lit;
|
||||||
|
PLOT_CHECK(static_cast<int>(r) == row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PLOT_CHECK(lit == columns);
|
||||||
|
|
||||||
|
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
|
||||||
|
// grid of that size and nothing carried over from the old one -- and that rect is under the
|
||||||
|
// cap, so the grid is it *exactly*: one cell a device pixel, no scaling at all.
|
||||||
|
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
|
||||||
|
f.plot.layout();
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
const plot::DensityGrid& small = f.plot.density_grid(0u);
|
||||||
|
const plot::Rect small_axes = f.plot.layout().axes;
|
||||||
|
PLOT_CHECK(small.columns() == static_cast<plot::u32>(std::ceil(small_axes.w)));
|
||||||
|
PLOT_CHECK(small.rows() == static_cast<plot::u32>(std::ceil(small_axes.h)));
|
||||||
|
PLOT_CHECK(small.cell_count() <= plot::kDensityCellCap);
|
||||||
|
PLOT_CHECK(small.columns() < columns);
|
||||||
|
PLOT_CHECK_NEAR(small.ink(), static_cast<double>(small.columns()), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap is what keeps the resolution from following the window: a grid is never more than
|
||||||
|
/// `kDensityCellCap` cells, whatever the axes rect is, so the memory a display holds and the frame
|
||||||
|
/// pass over it are numbers and not functions of how wide the window was dragged. The scaling is
|
||||||
|
/// **uniform**, so a cell stays square and the picture keeps the axes' aspect -- the reading that
|
||||||
|
/// separates this from a cap on one axis, which would stretch every cell by the other one's ratio.
|
||||||
|
PLOT_TEST(a_density_grid_is_capped_whatever_the_window) {
|
||||||
|
DensityFixture f;
|
||||||
|
f.plot.set_viewport({0.0f, 0.0f, 3840.0f, 2160.0f});
|
||||||
|
f.plot.layout();
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
|
||||||
|
const plot::DensityGrid& grid = f.plot.density_grid(0u);
|
||||||
|
const plot::Rect axes = f.plot.layout().axes;
|
||||||
|
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) * grid.rows() <= plot::kDensityCellCap);
|
||||||
|
PLOT_CHECK(grid.cell_count() * sizeof(plot::f32) <= 1'048'576u);
|
||||||
|
// ... and it is as large as the cap allows: flooring a uniform scale costs at most the two
|
||||||
|
// columns and rows the truncation drops, so the grid is within a hair of the whole budget.
|
||||||
|
PLOT_CHECK(static_cast<plot::f64>(grid.cell_count()) >
|
||||||
|
0.99 * static_cast<plot::f64>(plot::kDensityCellCap));
|
||||||
|
// Uniform: the grid's aspect is the axes rect's, to the rounding of the two floors.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(grid.columns()) / grid.rows(),
|
||||||
|
static_cast<plot::f64>(axes.w) / axes.h, 0.01);
|
||||||
|
|
||||||
|
// A cap on the *cells* also returns a full sweep's worth of ink: one flat pass over the window
|
||||||
|
// is one per column however fine or coarse the grid is.
|
||||||
|
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
|
||||||
|
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
|
||||||
|
std::printf("[density] capped grid=%ux%u cells=%zu bytes=%zu of a %ux%u axes\n", grid.columns(),
|
||||||
|
grid.rows(), grid.cell_count(), grid.cell_count() * sizeof(plot::f32),
|
||||||
|
static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
|
||||||
|
/// deposited, so the sweep on screen is the brightest and the one behind it is the factor
|
||||||
|
/// down: the schedule a phosphor's afterglow has, and the reading that says which way round
|
||||||
|
/// the halving goes.
|
||||||
|
PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
|
||||||
|
DensityFixture f;
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
cfg.decay = plot::DensityDecay::PerAcquisition;
|
||||||
|
cfg.per_acquisition = 0.5f;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
|
||||||
|
// of one pass *is* the width: one segment across the window, one cell per column.
|
||||||
|
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
|
||||||
|
const double one = f.plot.density_grid(0u).ink();
|
||||||
|
PLOT_CHECK_NEAR(one, width, 1.0);
|
||||||
|
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
const double two = f.plot.density_grid(0u).ink();
|
||||||
|
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
|
||||||
|
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
const double three = f.plot.density_grid(0u).ink();
|
||||||
|
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3);
|
||||||
|
|
||||||
|
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
|
||||||
|
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3);
|
||||||
|
|
||||||
|
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
|
||||||
|
cfg.per_acquisition = 0.0f;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3);
|
||||||
|
|
||||||
|
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
|
||||||
|
three, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time constant is the other question: the image fades while you watch, by the frame's own
|
||||||
|
/// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is
|
||||||
|
/// no clock in the library, which is what makes the reading reproducible.
|
||||||
|
PLOT_TEST(a_density_fades_by_its_time_constant) {
|
||||||
|
DensityFixture f;
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
cfg.decay = plot::DensityDecay::TimeConstant;
|
||||||
|
cfg.time_constant_s = 2.0f;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
const double full = f.plot.density_grid(0u).ink();
|
||||||
|
|
||||||
|
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
|
||||||
|
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
|
||||||
|
f.plot.decay_density(0.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
|
||||||
|
f.plot.decay_density(-1.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
|
||||||
|
|
||||||
|
// Quarter of a time constant: exp(-0.25).
|
||||||
|
f.plot.decay_density(0.5);
|
||||||
|
const double quarter = full * std::exp(-0.25);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4);
|
||||||
|
|
||||||
|
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
|
||||||
|
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f);
|
||||||
|
|
||||||
|
// A non-positive constant freezes the grid rather than fading it.
|
||||||
|
cfg.time_constant_s = 0.0f;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
const double frozen = f.plot.density_grid(0u).ink();
|
||||||
|
f.plot.decay_density(100.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6);
|
||||||
|
|
||||||
|
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
|
||||||
|
// changes nothing.
|
||||||
|
cfg.decay = plot::DensityDecay::PerAcquisition;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
const double before = f.plot.density_grid(0u).ink();
|
||||||
|
f.plot.decay_density(1.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
|
||||||
|
|
||||||
|
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
|
||||||
|
frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching off drops the image at once -- not at the next acquisition, which a trigger that
|
||||||
|
/// has stopped firing never sends -- and turning it back on starts from an empty grid.
|
||||||
|
PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
|
||||||
|
DensityFixture f;
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
|
||||||
|
|
||||||
|
f.plot.clear_density();
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
|
||||||
|
// The clear keeps the allocation -- the grid is still the screen's -- so the next
|
||||||
|
// accumulation does not have to reallocate it.
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
|
||||||
|
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
|
||||||
|
|
||||||
|
cfg.enabled = false;
|
||||||
|
f.plot.set_density(cfg);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
|
||||||
|
|
||||||
|
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
|
||||||
|
f.plot.accumulate_density();
|
||||||
|
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
|
||||||
|
/// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that
|
||||||
|
/// joins nothing.
|
||||||
|
PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
|
||||||
|
// y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes.
|
||||||
|
DensityFixture off(std::vector<f32>{5.0f, 5.0f});
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
off.plot.set_density(cfg);
|
||||||
|
off.plot.accumulate_density();
|
||||||
|
PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9);
|
||||||
|
|
||||||
|
// ... and the same series inside the axis deposits one cell per column, in two rows: the
|
||||||
|
// first half at y = -1 and the second at y = +1, joined by a diagonal.
|
||||||
|
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
|
||||||
|
split.plot.set_density(cfg);
|
||||||
|
split.plot.accumulate_density();
|
||||||
|
const plot::DensityGrid& grid = split.plot.density_grid(0u);
|
||||||
|
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
|
||||||
|
const plot::Transform ty = split.plot.y_transform(split.ay);
|
||||||
|
const plot::Rect axes = split.plot.layout().axes;
|
||||||
|
// The row a device y lands in is the grid's own mapping: the axis rect scaled by the grid's
|
||||||
|
// cells-per-pixel (1 while the rect is under `kDensityCellCap`, less past it).
|
||||||
|
const plot::f32 sy =
|
||||||
|
axes.h > 0.0f ? static_cast<plot::f32>(grid.rows()) / std::ceil(axes.h) : 1.0f;
|
||||||
|
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
|
||||||
|
// the bottom edge -- lands in the last row rather than one past the end.
|
||||||
|
const auto row_of = [&](plot::f64 y) {
|
||||||
|
return static_cast<plot::u32>(std::clamp(
|
||||||
|
static_cast<int>(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0,
|
||||||
|
static_cast<int>(grid.rows()) - 1));
|
||||||
|
};
|
||||||
|
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
|
||||||
|
PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f);
|
||||||
|
// Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping.
|
||||||
|
PLOT_CHECK(row_of(1.0) < row_of(-1.0));
|
||||||
|
|
||||||
|
// A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and
|
||||||
|
// the columns where the gap is stay empty -- where the same series with the sample filled in
|
||||||
|
// lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0.
|
||||||
|
const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast<double>(g.at(column, r));
|
||||||
|
return sum;
|
||||||
|
};
|
||||||
|
DensityFixture whole(std::vector<f32>{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25);
|
||||||
|
whole.plot.set_density(cfg);
|
||||||
|
whole.plot.accumulate_density();
|
||||||
|
DensityFixture gap(
|
||||||
|
std::vector<f32>{0.0f, 0.0f, std::numeric_limits<f32>::quiet_NaN(), 0.0f, 0.0f}, 0.25);
|
||||||
|
gap.plot.set_density(cfg);
|
||||||
|
gap.plot.accumulate_density();
|
||||||
|
|
||||||
|
const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u);
|
||||||
|
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
|
||||||
|
const plot::Transform tx = gap.plot.x_transform(gap.ax);
|
||||||
|
const plot::Rect gap_axes = gap.plot.layout().axes;
|
||||||
|
const plot::f32 gsx = gap_axes.w > 0.0f
|
||||||
|
? static_cast<plot::f32>(gap_grid.columns()) / std::ceil(gap_axes.w)
|
||||||
|
: 1.0f;
|
||||||
|
const plot::u32 column = static_cast<plot::u32>(std::clamp(
|
||||||
|
static_cast<int>(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0,
|
||||||
|
static_cast<int>(gap_grid.columns()) - 1));
|
||||||
|
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
|
||||||
|
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
|
||||||
|
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
|
||||||
|
|
||||||
|
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
|
||||||
|
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
|
||||||
|
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
|
||||||
|
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
|
||||||
|
/// trace's ink and not the other's is the reading that says the images are not shared.
|
||||||
|
PLOT_TEST(a_density_holds_one_grid_per_trace) {
|
||||||
|
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, 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({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
|
||||||
|
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
|
||||||
|
p.set_x_range(ax, {0.0, 1.0});
|
||||||
|
p.set_y_range(ay, {-1.0, 1.0});
|
||||||
|
p.layout();
|
||||||
|
|
||||||
|
plot::DensityConfig cfg;
|
||||||
|
cfg.enabled = true;
|
||||||
|
p.set_density(cfg);
|
||||||
|
p.accumulate_density();
|
||||||
|
|
||||||
|
PLOT_CHECK(p.trace_count() == 2u);
|
||||||
|
const plot::DensityGrid& g0 = p.density_grid(0u);
|
||||||
|
const plot::DensityGrid& g1 = p.density_grid(1u);
|
||||||
|
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
|
||||||
|
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
|
||||||
|
// widths in a shared grid).
|
||||||
|
PLOT_CHECK(g0.cell_count() > 0u);
|
||||||
|
PLOT_CHECK(g1.cell_count() == g0.cell_count());
|
||||||
|
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
|
||||||
|
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
|
||||||
|
|
||||||
|
const plot::Transform ty = p.y_transform(ay);
|
||||||
|
const plot::Rect axes = p.layout().axes;
|
||||||
|
const plot::f32 sy =
|
||||||
|
axes.h > 0.0f ? static_cast<plot::f32>(g0.rows()) / std::ceil(axes.h) : 1.0f;
|
||||||
|
const auto row_of = [&](plot::f64 y) {
|
||||||
|
return static_cast<plot::u32>(std::floor((ty.to_screen_y(y) - axes.y) * sy));
|
||||||
|
};
|
||||||
|
const plot::u32 lo_row = row_of(-0.5);
|
||||||
|
const plot::u32 hi_row = row_of(0.5);
|
||||||
|
PLOT_CHECK(lo_row != hi_row);
|
||||||
|
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
|
||||||
|
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
|
||||||
|
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
|
||||||
|
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
|
||||||
|
|
||||||
|
// An out-of-range trace reads an empty image rather than anything borrowed.
|
||||||
|
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
|
||||||
|
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
|
||||||
|
|
||||||
|
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
|
||||||
|
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
|
||||||
|
}
|
||||||
+1379
-15
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,43 @@ PLOT_TEST(ring_clear_resets) {
|
|||||||
PLOT_CHECK_NEAR(r[0], 42.0);
|
PLOT_CHECK_NEAR(r[0], 42.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_clear_to_resumes_the_absolute_count) {
|
||||||
|
// A recompute: a derived ring is emptied and refilled from the absolute origin its source
|
||||||
|
// retains, so it has to come back numbered as the source is. `clear()` restarts at 0, which
|
||||||
|
// leaves a refilled ring reporting a produced() a whole window behind an identically fed one.
|
||||||
|
plot::data::RingBuffer<f32> src(8);
|
||||||
|
plot::data::RingBuffer<f32> derived(8);
|
||||||
|
for (int i = 0; i < 20; ++i) {
|
||||||
|
src.push(f32(i));
|
||||||
|
derived.push(f32(i) * 2.0f);
|
||||||
|
}
|
||||||
|
PLOT_CHECK(src.base() == 12);
|
||||||
|
|
||||||
|
derived.clear_to(src.base());
|
||||||
|
for (u64 i = 0; i < src.size(); ++i) derived.push(src[i] * 3.0f);
|
||||||
|
|
||||||
|
// Numbered as the source: same produced(), same base(), and the retained windows pair up.
|
||||||
|
PLOT_CHECK(derived.produced() == src.produced());
|
||||||
|
PLOT_CHECK(derived.base() == src.base());
|
||||||
|
PLOT_CHECK(derived.size() == src.size());
|
||||||
|
PLOT_CHECK_NEAR(derived[0], 36.0); // 12 * 3
|
||||||
|
PLOT_CHECK_NEAR(derived[7], 57.0); // 19 * 3
|
||||||
|
// And the absolute reader agrees with the windowed one, which is what a snapshot reads.
|
||||||
|
f32 at = 0.0f;
|
||||||
|
derived.copy_from(19, 1, &at);
|
||||||
|
PLOT_CHECK_NEAR(at, 57.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_clear_to_zero_is_clear) {
|
||||||
|
plot::data::RingBuffer<f32> r(8);
|
||||||
|
for (int i = 0; i < 5; ++i) r.push(f32(i));
|
||||||
|
r.clear_to(0);
|
||||||
|
PLOT_CHECK(r.size() == 0);
|
||||||
|
PLOT_CHECK(r.produced() == 0);
|
||||||
|
r.push(42.0f);
|
||||||
|
PLOT_CHECK_NEAR(r[0], 42.0);
|
||||||
|
}
|
||||||
|
|
||||||
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
|
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
|
||||||
// The capacity is rounded up so slot addressing stays a mask, and capacity()
|
// The capacity is rounded up so slot addressing stays a mask, and capacity()
|
||||||
// reports the rounded value -- the one callers must size for (R4).
|
// reports the rounded value -- the one callers must size for (R4).
|
||||||
@@ -35,3 +72,27 @@ PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
|
|||||||
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
|
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
|
||||||
PLOT_CHECK_NEAR(r[127], 999.0); // newest
|
PLOT_CHECK_NEAR(r[127], 999.0); // newest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_amend_rewrites_a_retained_slot) {
|
||||||
|
// Backfill: a slot pushed as a placeholder is rewritten once the data that belongs there
|
||||||
|
// arrives. The index is absolute, because that is the only coordinate a late packet can
|
||||||
|
// compute -- a retained-window index would have moved by the time it got here.
|
||||||
|
plot::data::RingBuffer<f32> r(8);
|
||||||
|
for (int i = 0; i < 6; ++i) r.push(f32(i));
|
||||||
|
PLOT_CHECK(r.amend(3, 42.0f));
|
||||||
|
PLOT_CHECK_NEAR(r[3], 42.0);
|
||||||
|
PLOT_CHECK(r.produced() == 6); // an amend is not a push
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_amend_refuses_an_index_the_ring_no_longer_holds) {
|
||||||
|
// The window slides on the producer, so a late amend may arrive after its slot was
|
||||||
|
// overwritten. Writing it anyway would corrupt whatever sample now owns that slot, so the
|
||||||
|
// bounds test is the whole safety of the operation and the caller reads the result.
|
||||||
|
plot::data::RingBuffer<f32> r(8);
|
||||||
|
for (int i = 0; i < 20; ++i) r.push(f32(i));
|
||||||
|
PLOT_CHECK(r.base() == 12);
|
||||||
|
PLOT_CHECK(!r.amend(3, 42.0f)); // aged out
|
||||||
|
PLOT_CHECK(!r.amend(20, 42.0f)); // never produced
|
||||||
|
PLOT_CHECK_NEAR(r[0], 12.0); // and nothing moved
|
||||||
|
PLOT_CHECK_NEAR(r[7], 19.0);
|
||||||
|
}
|
||||||
+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
|
||||||
@@ -72,9 +73,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
|
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
|
||||||
// The crosshair asks neighbours() at an arbitrary x and interpolates between
|
// The crosshair asks neighbours() at an arbitrary x and reports whichever of
|
||||||
// the two samples it gets back. On a non-uniform grid that needs real
|
// the two samples it gets back is nearer. On a non-uniform grid that needs
|
||||||
// searches: a uniform-grid formula would land megabytes off here.
|
// real searches: a uniform-grid formula would land megabytes off here.
|
||||||
auto x = spread_x(1000);
|
auto x = spread_x(1000);
|
||||||
const std::vector<f64> xs = x;
|
const std::vector<f64> xs = x;
|
||||||
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
||||||
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
|
|||||||
const u64 span = u64{kBucket} << level;
|
const u64 span = u64{kBucket} << level;
|
||||||
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
|
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, b, e, out, plot::data::GatherMode::Envelope);
|
s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
|
||||||
PLOT_CHECK(out.size() >= 8u);
|
PLOT_CHECK(out.size() >= 8u);
|
||||||
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
|
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
|
||||||
// both the range's extremes (the lead/trail raw spans carry them).
|
// both the range's extremes (the lead/trail raw spans carry them).
|
||||||
|
|||||||
Reference in new issue
Block a user