Compare commits
11
Commits
3ad02e5120
...
2cb31cbb91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb31cbb91 | ||
|
|
69f0083fc0 | ||
|
|
2b4b316ad8 | ||
|
|
25c3381551 | ||
|
|
73f610b136 | ||
|
|
e5b7faadec | ||
|
|
7d8c453232 | ||
|
|
f79a97be9b | ||
|
|
1b8b55d3ee | ||
|
|
738205293e | ||
|
|
6a6915e64e |
No files matched your search
@@ -58,6 +58,20 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
||||
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
|
||||
(`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`,
|
||||
@@ -196,12 +210,12 @@ documentation.
|
||||
```sh
|
||||
cmake --preset linux-debug
|
||||
cmake --build build/debug
|
||||
ctest --test-dir build/debug --output-on-failure # 377 cases, 0 failed
|
||||
ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
|
||||
|
||||
tools/coverage.sh # configure+build+run+capture+floor check
|
||||
```
|
||||
|
||||
The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** 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
|
||||
`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
|
||||
|
||||
+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_`
|
||||
return;
|
||||
}
|
||||
const u32 rows = figure_->row_count();
|
||||
row_controllers_.reserve(rows);
|
||||
for (u32 i = 0; i < rows; ++i) {
|
||||
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||
// flattened plot index the figure uses.
|
||||
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)));
|
||||
}
|
||||
// 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 {
|
||||
// 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).
|
||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
||||
// instead of paying this again, which is the whole point of the cache (R7). The rects
|
||||
// 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 {
|
||||
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
||||
// frame stale, which is the documented worst case.
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
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, y);
|
||||
const u32 row = row_in(rows, x, y);
|
||||
last_row_ = row;
|
||||
input::Controller& controller = *row_controllers_[row];
|
||||
// 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,
|
||||
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_;
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
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.
|
||||
if (released && button == captured_button_) captured_ = false;
|
||||
} else {
|
||||
row = row_in(rows, y);
|
||||
row = row_in(rows, x, y);
|
||||
if (pressed) {
|
||||
captured_ = true;
|
||||
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 {
|
||||
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;
|
||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
||||
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
|
||||
// row whose rect is nearest. Selection by distance rather than by "the first that
|
||||
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
|
||||
// tile the figure at the default gap of 0, so this is unreachable until a host asks
|
||||
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
|
||||
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
|
||||
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||
// pixel to the rect). Selection by distance rather than by "the first that matches" is
|
||||
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
|
||||
// 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;
|
||||
f32 best = std::numeric_limits<f32>::infinity();
|
||||
double best = std::numeric_limits<double>::infinity();
|
||||
for (u32 i = 0; i < rows.size(); ++i) {
|
||||
const f32 top = rows[i].y;
|
||||
const f32 bottom = rows[i].y + rows[i].h;
|
||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
||||
const Rect& r = rows[i];
|
||||
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||
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) {
|
||||
best = distance;
|
||||
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;
|
||||
|
||||
+19
-15
@@ -121,18 +121,21 @@ class Host {
|
||||
/// `plot_` is then untouched by any of this.
|
||||
void set_figure(Figure* figure) noexcept;
|
||||
Figure* figure() noexcept { return figure_; }
|
||||
/// One figure row's controller -- the one a gesture on that row reaches -- or
|
||||
/// null while no figure is set or the row does not exist. A host reads a drag
|
||||
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||
/// 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
|
||||
/// not the one figure gestures go to).
|
||||
input::Controller* row_controller(u32 row) noexcept;
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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()`,
|
||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
||||
/// inside no cell -- the row or column gap a host asked for with
|
||||
/// `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.
|
||||
///
|
||||
/// 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_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
|
||||
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
|
||||
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
|
||||
/// that has just laid the figure out does not make it lay out again for this.
|
||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
||||
/// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
|
||||
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
|
||||
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
|
||||
/// -- and taking the rects rather than reading them, so a caller that has just laid the figure
|
||||
/// 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:
|
||||
/// 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,
|
||||
bool released) noexcept;
|
||||
|
||||
/// The row the pointer's events currently go to: the captured row while a drag owns
|
||||
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
||||
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
||||
/// hover or a grab it last published.
|
||||
/// The plot (flattened index) the pointer's events currently go to: the captured one
|
||||
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
|
||||
/// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
|
||||
/// drawing a hover or a grab it last published.
|
||||
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
||||
|
||||
/// Spreads the hover over a whole `Figure` (spec §15).
|
||||
@@ -292,7 +296,7 @@ class Host {
|
||||
u32 captured_row_ = 0u;
|
||||
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
|
||||
/// `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
|
||||
|
||||
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
|
||||
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
||||
return;
|
||||
}
|
||||
const u32 rows = figure_->row_count();
|
||||
row_controllers_.reserve(rows);
|
||||
for (u32 i = 0; i < rows; ++i) {
|
||||
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||
// flattened plot index the figure uses.
|
||||
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)));
|
||||
}
|
||||
// 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 {
|
||||
// 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.
|
||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
||||
// instead of paying this again, which is the whole point of the cache. The rects 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* 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
|
||||
// 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_;
|
||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||
const u32 row = row_in(row_rects_, y);
|
||||
const u32 row = row_in(row_rects_, x, y);
|
||||
input::Controller& controller = *row_controllers_[row];
|
||||
controller.set_viewport(row_rects_[row]);
|
||||
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 {
|
||||
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
||||
(void)x;
|
||||
return row_in(row_rects_, to_device(x, y).y);
|
||||
const Vec2 p = to_device(x, 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 (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
|
||||
@@ -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
|
||||
// open for ever -- its controller never sees the release that ends it -- and let a
|
||||
// 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
|
||||
// guard keeps a stale index from reaching past the controller list.
|
||||
if (row >= row_controllers_.size()) row = 0;
|
||||
@@ -314,23 +317,26 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
||||
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;
|
||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
||||
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
|
||||
// whose rect is nearest. By distance rather than by "the first that matches": the
|
||||
// rows tile the figure at the default gap of 0, so this is unreachable until a host
|
||||
// asks for a gap, and a gesture in a visible gap must not default to row 0.
|
||||
// The cell the pixel is in, or -- when it is in none of them, which is the row or
|
||||
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||
// pixel to the rect). By distance rather than by "the first that matches": a gesture
|
||||
// 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;
|
||||
f32 best = std::numeric_limits<f32>::infinity();
|
||||
double best = std::numeric_limits<double>::infinity();
|
||||
for (u32 i = 0; i < rows.size(); ++i) {
|
||||
const f32 top = rows[i].y;
|
||||
const f32 bottom = rows[i].y + rows[i].h;
|
||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
||||
const Rect& r = rows[i];
|
||||
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||
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) {
|
||||
best = distance;
|
||||
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;
|
||||
@@ -341,7 +347,7 @@ u32 PlotArea::pointer_row() const noexcept {
|
||||
// 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.
|
||||
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 {
|
||||
@@ -616,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
|
||||
// own position *before* the event is dispatched (see `target_controller()`). Harmless
|
||||
// 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_button_ = button;
|
||||
press_mods_ = mods;
|
||||
@@ -634,7 +640,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
|
||||
e.down = true;
|
||||
e.pressed = true;
|
||||
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_));
|
||||
}
|
||||
|
||||
@@ -643,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
|
||||
// claimed rather than the row the pointer ended over -- the controller that owns the
|
||||
// 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;
|
||||
|
||||
input::PointerEvent e;
|
||||
@@ -748,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)));
|
||||
// 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.
|
||||
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_));
|
||||
}
|
||||
|
||||
@@ -795,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_);
|
||||
// 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.
|
||||
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_));
|
||||
}
|
||||
|
||||
@@ -834,7 +840,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
||||
e.dx = static_cast<f32>(scroll_notches(dx, unit));
|
||||
e.dy = static_cast<f32>(notches);
|
||||
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_));
|
||||
return TRUE; // consumed: the plot zoomed
|
||||
}
|
||||
@@ -858,7 +864,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
|
||||
e.down = false;
|
||||
e.time_s = PlotArea::pointer_time_s(
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
@@ -260,15 +260,17 @@ class PlotArea {
|
||||
Figure* figure() 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
|
||||
/// while no figure is set or the row does not exist. A host reads a drag mode or a
|
||||
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||
/// 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.
|
||||
input::Controller* row_controller(u32 row) noexcept;
|
||||
|
||||
/// 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
|
||||
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
|
||||
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data
|
||||
/// while no figure is set, and otherwise the controller of the figure plot (cell)
|
||||
/// that pixel lands in -- x and y both pick it, as a row may hold several cells side
|
||||
/// 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
|
||||
/// is what keeps the controller's hit-test geometry in step with the layout between
|
||||
/// two frames.
|
||||
@@ -277,10 +279,10 @@ class PlotArea {
|
||||
/// press capture, so a host may call it freely.
|
||||
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
|
||||
/// context menu can name the row a pointer is over without converting anything. The
|
||||
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
|
||||
/// context menu can name the plot a pointer is over without converting anything. The
|
||||
/// 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;
|
||||
|
||||
/// 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.
|
||||
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*
|
||||
/// 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.
|
||||
///
|
||||
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
|
||||
/// geometry the last frame drew.
|
||||
input::Controller& target_controller(f32 y) noexcept;
|
||||
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
|
||||
/// one when it is in none (a row gap, a margin outside the figure). Selection by
|
||||
/// distance rather than "the first that matches" is what makes a pixel in a visible
|
||||
/// gap belong to the row it is *near* instead of to row 0.
|
||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
||||
input::Controller& target_controller(f32 x, f32 y) noexcept;
|
||||
/// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
|
||||
/// to: the cell it is in, or the nearest one when it is in none (a row or column gap,
|
||||
/// a margin outside the figure). Selection by distance rather than "the first that
|
||||
/// matches" is what makes a pixel in a visible gap belong to the cell it is *near*
|
||||
/// 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,
|
||||
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
|
||||
/// position otherwise. 0 with no rows.
|
||||
@@ -457,7 +460,8 @@ class PlotArea {
|
||||
/// `set_figure()` is called, and the single-plot path reads neither.
|
||||
Figure* figure_ = nullptr;
|
||||
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
|
||||
/// is set, which is the state the routing falls back to `controller_` on.
|
||||
std::vector<Rect> row_rects_;
|
||||
|
||||
@@ -41,8 +41,8 @@ ctest --test-dir build/debug --output-on-failure
|
||||
|
||||
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
|
||||
`plot_umbrella_check`. The `plot_tests` binary itself runs **377 cases** in the `linux-debug`
|
||||
preset; its harness prints one line per case and a final `377 cases, 0 failed` summary line.
|
||||
`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 `396 cases, 0 failed` summary line.
|
||||
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.
|
||||
|
||||
@@ -50,7 +50,7 @@ The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`
|
||||
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 `377 cases, 0 failed`
|
||||
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
|
||||
@@ -126,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
|
||||
live numbers.
|
||||
|
||||
**One thing to know about the coverage preset:** it builds **360 of the 377 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
|
||||
`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)
|
||||
|
||||
@@ -327,6 +327,116 @@ way**, against the 191 the sparse frame gave before. Across the whole sweep from
|
||||
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
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/density.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
|
||||
@@ -102,12 +102,16 @@ struct Axis {
|
||||
/// group stops following or the row leaves it.
|
||||
bool live_owner = false;
|
||||
f64 live_owner_newest = 0.0;
|
||||
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
|
||||
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
|
||||
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
|
||||
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
|
||||
/// than letting a later call put the member's axis back into a state the group
|
||||
/// cannot drive (see `Plot::set_x_follow()`).
|
||||
/// Set while this axis is a member of a `Figure` link group. On an x axis that is
|
||||
/// an x link group (`Figure::link_x()` writes it, `unlink_row()` clears it): the
|
||||
/// group owns the shared x window *and* its follow state -- `Figure::link_x()`
|
||||
/// refuses a `Live` member joiner -- so `Plot::set_x_follow(Live)` on this axis is
|
||||
/// refused after linking too, rather than letting a later call put the member's
|
||||
/// 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;
|
||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||
std::vector<Tick> ticks;
|
||||
|
||||
@@ -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
|
||||
+126
-30
@@ -27,13 +27,21 @@ struct FigureLayout {
|
||||
/// 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.
|
||||
std::vector<Rect> rows;
|
||||
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
|
||||
/// `PlotLayout::x_label_area`, 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
|
||||
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
|
||||
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its 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
|
||||
/// 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
|
||||
/// which case it is simply the bottom row's own strip, one of several.
|
||||
/// reserves a label strip. A row above whose columns differ from the row below keeps
|
||||
/// 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
|
||||
/// 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
|
||||
/// short to hold it, so the rows never lose their height to a gap.
|
||||
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.
|
||||
@@ -59,12 +71,15 @@ struct FigureLayout {
|
||||
/// **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
|
||||
/// 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
|
||||
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
|
||||
/// strips, so those labels and that title are drawn once, from the bottom row's own
|
||||
/// ticks and transform. That places the 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.
|
||||
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
|
||||
/// with the *same columns* (the same number of cells and the same normalised cell
|
||||
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
|
||||
/// keeps its own label and title strips, so those labels and that title are drawn once,
|
||||
/// from its own ticks and transform. A row whose columns differ from the row below keeps
|
||||
/// 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
|
||||
/// 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
|
||||
/// its own plot was given.
|
||||
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()); }
|
||||
/// One row's plot. A row 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 row can only ever affect itself.
|
||||
Plot& plot(u32 row) noexcept;
|
||||
const Plot& plot(u32 row) const noexcept;
|
||||
/// The number of figure rows -- the bands stacked top to bottom -- which equals
|
||||
/// `plot_count()` for a figure no row of which is split.
|
||||
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
|
||||
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
|
||||
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
@@ -119,6 +151,21 @@ class Figure {
|
||||
void clear_row_weights() noexcept { row_weights_.clear(); }
|
||||
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
|
||||
/// 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
|
||||
@@ -143,14 +190,17 @@ class Figure {
|
||||
/// 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 the shared x axis is the figure's: every row reserves no x label strip
|
||||
/// and one shared axis is drawn under the last row (the default), or every row
|
||||
/// draws its own labels and the figure draws nothing extra.
|
||||
/// 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; }
|
||||
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
||||
|
||||
@@ -195,17 +245,18 @@ class Figure {
|
||||
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
||||
void set_crosshair(f64 x, bool active);
|
||||
|
||||
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
|
||||
/// shared x goes to every row -- 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*
|
||||
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
|
||||
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
|
||||
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
|
||||
/// the horizontal line is drawn **only in `hovered_row`** -- the **flattened plot index**, not
|
||||
/// the figure-row index; they differ when a row holds several cells -- where its y is a value of
|
||||
/// *that* plot's own scale. 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
|
||||
/// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
|
||||
/// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
|
||||
/// state the single-coordinate overload produces.
|
||||
///
|
||||
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
||||
@@ -226,9 +277,10 @@ class Figure {
|
||||
/// How many groups there are, excluding the unlinked group 0.
|
||||
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,
|
||||
/// so the row is on the group's window from this call rather than from the next
|
||||
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
|
||||
/// Moves one plot into a link group and applies that group's range to it at once; `row`
|
||||
/// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
|
||||
/// 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.
|
||||
///
|
||||
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
||||
@@ -242,7 +294,7 @@ class Figure {
|
||||
/// per-row window. `XFollow::Off` is the group's to set
|
||||
/// (`set_x_link_follow()`), not the axis'.
|
||||
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;
|
||||
|
||||
/// Sets a group's shared range and pushes it to every member.
|
||||
@@ -269,6 +321,22 @@ class Figure {
|
||||
/// 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;
|
||||
|
||||
/// 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:
|
||||
/// 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
|
||||
@@ -325,7 +393,35 @@ class Figure {
|
||||
/// a following group's window, push both to all members.
|
||||
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<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
|
||||
/// without reading out of bounds. Owned rather than a static, so one figure's
|
||||
/// stray row can never write into another's.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/density.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
#include <plot/plot/measure.hpp>
|
||||
@@ -39,6 +40,13 @@ namespace plot {
|
||||
/// are for a host that wants one without the other.
|
||||
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()`
|
||||
/// 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.
|
||||
@@ -237,6 +245,12 @@ class Plot {
|
||||
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
||||
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
|
||||
/// 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
|
||||
@@ -434,6 +448,20 @@ class Plot {
|
||||
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
|
||||
/// figure never has a linked axis.
|
||||
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
|
||||
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
||||
@@ -583,6 +611,85 @@ class Plot {
|
||||
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
|
||||
/// 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
|
||||
@@ -929,6 +1036,26 @@ class Plot {
|
||||
/// 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
|
||||
/// interval covers no sample. The one place the region arithmetic lives.
|
||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||
@@ -960,6 +1087,8 @@ class Plot {
|
||||
std::string x_note_;
|
||||
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
||||
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
|
||||
/// none, which is what a plot standing on its own keeps.
|
||||
f32 min_y_gutter_ = 0.0f;
|
||||
@@ -977,6 +1106,27 @@ class Plot {
|
||||
std::vector<Annotation> annotations_;
|
||||
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()`
|
||||
/// 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
|
||||
|
||||
@@ -84,4 +84,37 @@ class TextProgram {
|
||||
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
|
||||
@@ -48,6 +48,18 @@ struct FrameStats {
|
||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||
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.
|
||||
@@ -93,8 +105,8 @@ class Renderer {
|
||||
Renderer(const Renderer&) = delete;
|
||||
Renderer& operator=(const Renderer&) = delete;
|
||||
|
||||
/// Destroys every GL object this renderer created: both programs, both vertex
|
||||
/// arrays and their instance buffers, and the glyph atlas texture. The
|
||||
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
|
||||
/// their instance buffers, the glyph atlas texture and the density's own texture. The
|
||||
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
|
||||
/// called again on the same object.
|
||||
///
|
||||
@@ -226,12 +238,13 @@ class Renderer {
|
||||
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
||||
/// single-plot hosts.
|
||||
|
||||
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
|
||||
/// that row's normal path: when the figure says labels belong on the bottom only
|
||||
/// (the default), the rows above reserve no x chrome and the bottom row stages its
|
||||
/// own labels and title once, inside its own strip -- the rect
|
||||
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
|
||||
/// its own chrome and each draws its own labels; there is then no shared axis.
|
||||
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
|
||||
/// columns), so it is drawn by that row's normal path: when the figure says labels
|
||||
/// belong on the bottom only (the default), the rows above that match the row below
|
||||
/// reserve no x chrome and the bottom row stages its own labels and title once,
|
||||
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
|
||||
/// 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
|
||||
/// the figure computes its layout, so the strips the rows measure and the strokes
|
||||
@@ -513,6 +526,27 @@ class Renderer {
|
||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||
|
||||
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
|
||||
@@ -679,6 +713,27 @@ class Renderer {
|
||||
render::TextAtlas atlas_;
|
||||
render::LineProgram line_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::TextVertexArray text_vao_;
|
||||
|
||||
|
||||
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
|
||||
extern const char* const kTextVS;
|
||||
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
|
||||
+323
-38
@@ -23,6 +23,88 @@ namespace {
|
||||
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
|
||||
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
|
||||
/// 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
|
||||
@@ -41,18 +123,72 @@ f64 from_link_space(f64 v, bool log) noexcept {
|
||||
|
||||
} // namespace
|
||||
|
||||
Figure::Figure(u32 rows) : plots_(rows) {}
|
||||
|
||||
Plot& Figure::plot(u32 row) noexcept {
|
||||
return row < plots_.size() ? plots_[row] : fallback_;
|
||||
Figure::Figure(u32 rows) : plots_(rows) {
|
||||
const std::vector<u32> ones(rows, 1u);
|
||||
build_cells(std::span<const u32>(ones));
|
||||
}
|
||||
|
||||
const Plot& Figure::plot(u32 row) const noexcept {
|
||||
return row < plots_.size() ? plots_[row] : fallback_;
|
||||
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
|
||||
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) {
|
||||
if (weights.size() != plots_.size()) {
|
||||
if (weights.size() != cells_per_row_.size()) {
|
||||
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
||||
}
|
||||
f32 total = 0.0f;
|
||||
@@ -330,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
|
||||
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() {
|
||||
for (XLink& group : links_) {
|
||||
if (group.rows.empty()) continue;
|
||||
@@ -369,6 +580,8 @@ void Figure::sync_x_links() {
|
||||
const FigureLayout& Figure::layout() {
|
||||
const u32 rows = row_count();
|
||||
layout_.rows.assign(rows, Rect{});
|
||||
layout_.cells.clear();
|
||||
layout_.col_gap = 0.0f;
|
||||
layout_.row_gap = row_gap_;
|
||||
layout_.x_axis_strip = Rect{};
|
||||
if (rows == 0u) {
|
||||
@@ -377,12 +590,13 @@ const FigureLayout& Figure::layout() {
|
||||
}
|
||||
|
||||
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
|
||||
// nothing is taken off the figure for it. That is what keeps the axis reachable --
|
||||
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
|
||||
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
|
||||
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
|
||||
// leaving a band no one owns.
|
||||
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
|
||||
// the strip of any row whose columns differ from the row below), so nothing is taken off
|
||||
// the figure for it. That is what keeps the axis reachable -- its strip is a real
|
||||
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
|
||||
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
|
||||
// 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;
|
||||
// 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
|
||||
@@ -441,25 +655,59 @@ const FigureLayout& Figure::layout() {
|
||||
// short to hold the one asked for.
|
||||
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) {
|
||||
Plot& p = plots_[row];
|
||||
p.set_viewport(layout_.rows[row]);
|
||||
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
|
||||
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
|
||||
// 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.
|
||||
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
||||
// Before either pass below, and the same value for every row: nothing is measured on
|
||||
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
|
||||
// on a common column because they are all told the one width.
|
||||
p.set_right_gutter(right_gutter_);
|
||||
const u32 n = cells_per_row_[row];
|
||||
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
|
||||
floor_scale, &layout_.cells[row_first_[row]]);
|
||||
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
|
||||
}
|
||||
|
||||
// Which rows carry their own x strip, decided structurally and before the first
|
||||
// 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
|
||||
// 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.
|
||||
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
|
||||
// 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&)`
|
||||
@@ -486,26 +734,63 @@ const FigureLayout& Figure::layout() {
|
||||
// `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
|
||||
// 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_;
|
||||
for (u32 row = 0; row < rows; ++row) {
|
||||
plots_[row].set_min_y_gutter(0.0f);
|
||||
const PlotLayout& row_layout = plots_[row].layout();
|
||||
//
|
||||
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
|
||||
// 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);
|
||||
}
|
||||
// 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
|
||||
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
||||
// also what each would have done alone.
|
||||
for (u32 row = 0; row < rows; ++row) {
|
||||
plots_[row].set_min_y_gutter(gutter);
|
||||
plots_[row].layout();
|
||||
for (Plot& p : plots_) {
|
||||
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
|
||||
p.layout();
|
||||
}
|
||||
|
||||
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
|
||||
// row's own label area -- the rect the controller hit-tests and the renderer stages
|
||||
// its labels in, read back here rather than derived again, so a host and the frame
|
||||
// cannot disagree about where the shared axis is. It is zero-sized only when there
|
||||
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
|
||||
// row's own strip, one of several.
|
||||
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
|
||||
// controller hit-tests and the renderer stages its labels in, read back here rather than
|
||||
// derived again, so a host and the frame cannot disagree about where it is. Rows above
|
||||
// that carry their own strip (their columns differ from the row below, or every row is
|
||||
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
|
||||
// when there are no rows at all.
|
||||
layout_.x_axis_strip = plots_.back().layout().x_label_area;
|
||||
return layout_;
|
||||
}
|
||||
|
||||
+327
-6
@@ -348,6 +348,307 @@ 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 {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||
}
|
||||
@@ -518,6 +819,19 @@ void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
|
||||
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 {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
||||
}
|
||||
@@ -700,7 +1014,8 @@ void Plot::autoscale_axes() {
|
||||
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,13 +1312,18 @@ const PlotLayout& Plot::layout() {
|
||||
y_width = y_axes_.empty() ? 0.0f : 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
|
||||
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
|
||||
// A row of a `Figure` that hands its x axis to the row below (which has the same
|
||||
// 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
|
||||
// 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.
|
||||
const bool x_labels = x_strip_has_labels(x_strip_);
|
||||
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 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
|
||||
@@ -1017,7 +1337,7 @@ const PlotLayout& Plot::layout() {
|
||||
// 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.
|
||||
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
|
||||
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
||||
: 0.0f;
|
||||
@@ -1038,7 +1358,7 @@ const PlotLayout& Plot::layout() {
|
||||
// -- 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.
|
||||
// `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);
|
||||
x_height = std::min(x_height, x_budget);
|
||||
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
||||
@@ -1071,7 +1391,8 @@ const PlotLayout& Plot::layout() {
|
||||
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
||||
// The same caps as above, so this compares like with like and the loop still
|
||||
// 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
|
||||
// point has to be zero for a row that gave its strip away.
|
||||
const f32 need_x =
|
||||
|
||||
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
|
||||
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
|
||||
+151
-13
@@ -193,14 +193,23 @@ Renderer::~Renderer() {
|
||||
void Renderer::release_gl() noexcept {
|
||||
ready_ = false;
|
||||
// Assigning fresh objects releases every GL name through the handles'
|
||||
// destructors: both programs, both vertex arrays and their instance buffers,
|
||||
// and the atlas texture. Safe to call more than once; every assignment is a
|
||||
// no-op while the renderer is uninitialised.
|
||||
// destructors: all three programs, the vertex arrays and their instance
|
||||
// buffers, the glyph atlas texture and the density's own texture. Safe to call
|
||||
// more than once; every assignment is a no-op while the renderer is
|
||||
// uninitialised.
|
||||
atlas_ = render::TextAtlas{};
|
||||
line_program_ = render::LineProgram{};
|
||||
text_program_ = render::TextProgram{};
|
||||
density_program_ = render::DensityProgram{};
|
||||
line_vao_ = render::LineVertexArray{};
|
||||
text_vao_ = render::TextVertexArray{};
|
||||
density_vao_ = gl::VertexArray{};
|
||||
density_texture_ = gl::Texture{};
|
||||
density_pixels_.clear();
|
||||
density_w_ = 0;
|
||||
density_h_ = 0;
|
||||
density_uploaded_grid_ = nullptr;
|
||||
density_uploaded_revision_ = 0;
|
||||
lines_.clear();
|
||||
glyphs_under_.clear();
|
||||
glyphs_over_.clear();
|
||||
@@ -252,10 +261,10 @@ bool Renderer::init(Status* st) noexcept {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!line_program_.create(st) || !text_program_.create(st)) {
|
||||
if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
|
||||
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
ready_ = true;
|
||||
@@ -462,7 +471,7 @@ void Renderer::draw(Figure& figure) {
|
||||
// Every row is prepared before the figure lays itself out, because a row's strips
|
||||
// are measured from the metrics this installs: a row measuring with one backend and
|
||||
// drawing with another is the defect the propagation point exists to prevent.
|
||||
const u32 rows = figure.row_count();
|
||||
const u32 rows = figure.plot_count();
|
||||
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
|
||||
|
||||
// The figure's one layout for the frame. It hands every row its rect and its
|
||||
@@ -471,10 +480,11 @@ void Renderer::draw(Figure& figure) {
|
||||
// than laying each row out a second time. Nothing draws before this: a row's
|
||||
// viewport is the figure's to set.
|
||||
//
|
||||
// The figure's one x axis needs no pass of its own: it is the bottom row's own, so
|
||||
// that row stages it in its own strip through `draw_tick_labels()` like any other
|
||||
// plot's axis -- and every row above it reserved no x chrome, which is what keeps
|
||||
// the labels from appearing once per row without a second staging here.
|
||||
// A shared x axis needs no pass of its own: it is the bottom row's own (of a run of rows
|
||||
// with the same columns), so that row stages it in its own strip through
|
||||
// `draw_tick_labels()` like any other plot's axis -- and every row of the run above it
|
||||
// reserved no x chrome, which is what keeps the labels from appearing once per row without
|
||||
// a second staging here. A row whose columns differ stages its own.
|
||||
figure.layout();
|
||||
for (u32 row = 0; row < rows; ++row) {
|
||||
Plot& plot = figure.plot(row);
|
||||
@@ -519,6 +529,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||
// The holes over the grid and under the data: a band is chrome of the *background*, not an
|
||||
// overlay, so everything a frame draws inside the axes is drawn on top of it.
|
||||
draw_invalid_spans(layout);
|
||||
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
|
||||
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
|
||||
// under them for the same reason they are under the data.
|
||||
draw_density(plot, layout);
|
||||
clear_clip();
|
||||
draw_axis_lines(plot);
|
||||
set_clip(layout.axes);
|
||||
@@ -865,8 +879,8 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||
const f32 line_h = font_backend_.height();
|
||||
|
||||
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
|
||||
// that reserves no x chrome, because the figure draws one shared axis in the bottom
|
||||
// row's own strip instead -- in which case it has no strip to put them in and their
|
||||
// that reserves no x chrome, because the row below has the same columns and draws the
|
||||
// axis in its own strip instead -- in which case it has no strip to put them in and their
|
||||
// absence is the point. A plot with no strip would otherwise stage its labels just
|
||||
// above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the
|
||||
// strip's single text line, which is the one rule for a plot's labels and a figure's
|
||||
@@ -889,9 +903,14 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
||||
// inside the strip at the two extremes (a label centred on the top tick would
|
||||
// otherwise hang above the plot area).
|
||||
//
|
||||
// Unless the plot gave its y strip away (`YStrip::None`, a later cell of a split row that
|
||||
// shares one y axis): its label area is then zero wide, and labels right-aligned against it
|
||||
// would be drawn in the neighbouring cell. Keyed on the area rather than the mode so a strip
|
||||
// the layout clamped to nothing stages nothing either.
|
||||
const f32 top_limit =
|
||||
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h);
|
||||
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||
for (u32 i = 0; layout.y_label_area.w > 0.0f && i < plot.y_axis_count(); ++i) {
|
||||
const Transform transform = plot.y_transform(i);
|
||||
for (const Tick& tick : plot.y_axis(i).ticks) {
|
||||
if (!tick.major || tick.label.empty()) continue;
|
||||
@@ -1357,6 +1376,14 @@ u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32
|
||||
}
|
||||
|
||||
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
// **No solid trace while the density is on.** The trace ink *is* the density image (see
|
||||
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
|
||||
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
|
||||
// its own faint image -- the "persistent image + last acquisition" a report described, where the
|
||||
// newest acquisition never faded because it was never only the image. The traces still exist --
|
||||
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
|
||||
// as lines.
|
||||
if (plot.density().enabled) return;
|
||||
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
||||
// would flatten every trace onto a single row -- a frame that looks like a
|
||||
// rendering choice rather than the collapsed plot it is.
|
||||
@@ -1740,6 +1767,117 @@ void Renderer::draw_invalid_spans(const PlotLayout& layout) {
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
||||
const DensityConfig& config = plot.density();
|
||||
if (!config.enabled) {
|
||||
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
|
||||
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
|
||||
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
|
||||
// happens to match.
|
||||
density_uploaded_grid_ = nullptr;
|
||||
return;
|
||||
}
|
||||
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
|
||||
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
|
||||
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
|
||||
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
|
||||
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
|
||||
// was deposited into nothing (its grid is empty), so it is skipped.
|
||||
for (u32 i = 0u; i < plot.trace_count(); ++i) {
|
||||
const Trace& trace = plot.trace(i);
|
||||
if (!trace.visible()) continue;
|
||||
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
|
||||
const PlotLayout& layout) {
|
||||
const u32 columns = grid.columns();
|
||||
const u32 rows = grid.rows();
|
||||
if (columns == 0u || rows == 0u) return;
|
||||
|
||||
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
|
||||
// applied. The conversion lives here rather than in the fragment shader so that a cell's
|
||||
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
|
||||
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
|
||||
//
|
||||
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
|
||||
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
|
||||
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
|
||||
// re-fills the one texture once per trace, which is what a figure of several rows already cost
|
||||
// before the density was per trace.
|
||||
const bool resized = columns != density_w_ || rows != density_h_;
|
||||
if (resized) {
|
||||
if (!density_texture_.create_r8(columns, rows, nullptr)) {
|
||||
gl_error_ = true;
|
||||
return;
|
||||
}
|
||||
// **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
|
||||
// bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
|
||||
// between two of them is between their two ink levels -- which is exactly what a linear tap
|
||||
// returns, and what a `GL_NEAREST` fetch cannot (it would show the grid's own steps as blocks
|
||||
// once the image is scaled up, `kDensityCellCap` cells spread across a wider axes rect). The
|
||||
// nearest tap was right only while a cell *was* a device pixel and every fragment landed in
|
||||
// its own; minification interpolates for the same reason, so a plot whose rect is under the
|
||||
// cap (cell and pixel one and the same, up to the rounding of `ceil`) reads as it always did.
|
||||
density_texture_.set_filter(gl::GL_LINEAR, gl::GL_LINEAR);
|
||||
density_w_ = columns;
|
||||
density_h_ = rows;
|
||||
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
|
||||
}
|
||||
if (resized || density_uploaded_grid_ != &grid || grid.revision() != density_uploaded_revision_) {
|
||||
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
|
||||
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
|
||||
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
|
||||
// bits, so quantising the count first and lifting the quantised value costs nothing a
|
||||
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
|
||||
u8 lut[256];
|
||||
for (u32 i = 0u; i < 256u; ++i) {
|
||||
const f32 v = static_cast<f32>(i) / 255.0f;
|
||||
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
|
||||
}
|
||||
density_pixels_.resize(grid.cell_count());
|
||||
const f32* cells = grid.cells().data();
|
||||
for (usize i = 0; i < density_pixels_.size(); ++i) {
|
||||
const f32 v = cells[i] / full;
|
||||
const u32 quantised =
|
||||
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
|
||||
density_pixels_[i] = lut[std::min(quantised, 255u)];
|
||||
}
|
||||
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
|
||||
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
|
||||
// its own upload, since the two share a context and a frame.
|
||||
gl::GLint alignment = 4;
|
||||
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
|
||||
density_texture_.upload_r8(columns, rows, density_pixels_.data());
|
||||
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
|
||||
density_uploaded_grid_ = &grid;
|
||||
density_uploaded_revision_ = grid.revision();
|
||||
// += and not =: a figure draws a density per trace per row, and what this field reports is the
|
||||
// frame's upload, not the last one's.
|
||||
stats_.density_upload_bytes += columns * rows;
|
||||
}
|
||||
|
||||
// The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
|
||||
// the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
|
||||
// `kDensityCellCap` it is exactly `columns` by `rows` pixels -- one texel a pixel, the mapping
|
||||
// this has always had -- and past the cap the grid is smaller and the image is magnified back
|
||||
// over it. The uv still runs 0..1 over the quad, so a fragment between two cells is a linear
|
||||
// blend of them: the filter the texture was created with.
|
||||
density_program_.bind();
|
||||
density_program_.set_viewport({viewport_.w, viewport_.h});
|
||||
density_program_.set_rect({layout.axes.x, layout.axes.y},
|
||||
{std::ceil(layout.axes.w), std::ceil(layout.axes.h)});
|
||||
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
|
||||
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
|
||||
density_program_.set_color(tint);
|
||||
density_texture_.bind();
|
||||
density_vao_.bind();
|
||||
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
|
||||
stats_.density_cells += columns * rows;
|
||||
++stats_.draw_calls;
|
||||
}
|
||||
|
||||
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
|
||||
const HotHandles hot = plot.hot_handles();
|
||||
const f32 edge_w = style_.device(style_.line_width_px);
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <plot/render/embedded_mono_font.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
@@ -1141,3 +1142,517 @@ 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.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));
|
||||
}
|
||||
|
||||
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 <plot/plot/figure.hpp>
|
||||
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
/// 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.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));
|
||||
}
|
||||
@@ -2808,3 +2808,406 @@ PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
|
||||
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);
|
||||
}
|
||||
@@ -5059,6 +5059,52 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
/// A split row that shares one y axis draws it once, on its first cell: every later cell is
|
||||
/// `YStrip::None`, reserves no y strip, and the renderer must stage no y tick labels for it.
|
||||
/// Staged text is not observable through `FrameStats`, so this reads the pixels. The column gap
|
||||
/// is wide and empty chrome, and a label staged for the second cell would be right-aligned
|
||||
/// against that cell's (zero-width) label area, i.e. land in the gap: the gap holds no ink.
|
||||
PLOT_TEST(figure_shared_y_second_cell_stages_no_y_labels) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::u32> cells{2u};
|
||||
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
for (plot::u32 i = 0; i < 2u; ++i) {
|
||||
plot::Plot& p = figure.plot(i);
|
||||
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
}
|
||||
figure.set_col_gap(80.0f);
|
||||
figure.set_share_row_y(true);
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
const plot::Color ink = r.style().axis_text;
|
||||
|
||||
const plot::PlotLayout& first = figure.plot(0u).layout();
|
||||
const plot::PlotLayout& second = figure.plot(1u).layout();
|
||||
PLOT_CHECK(second.y_label_area.w == 0.0f);
|
||||
PLOT_CHECK(layout.cells[1].x - layout.cells[0].right() > 70.0f);
|
||||
// The first cell still draws the row's one axis ...
|
||||
PLOT_CHECK(scan_rect_ink(ctx, first.y_label_area, ink).pixels > 0u);
|
||||
// ... and nothing is staged in the gap on the second cell's left.
|
||||
// Inset a few pixels each side: the cells' own frame lines sit on the gap's two edges.
|
||||
const plot::Rect gap{layout.cells[0].right() + 4.0f, first.axes.y,
|
||||
layout.cells[1].x - layout.cells[0].right() - 8.0f, first.axes.h};
|
||||
PLOT_CHECK(scan_rect_ink(ctx, gap, ink).pixels == 0u);
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the axes clip (spec §13)
|
||||
|
||||
// The reported defect this section is about: a trace is staged in *data* space and
|
||||
@@ -7519,3 +7565,333 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
|
||||
PLOT_CHECK(outside_after == outside_before);
|
||||
p.clear_selection_rect();
|
||||
}
|
||||
|
||||
/// The phosphor density **is the trace**, in the trace's own colour: it covers the axes rect in
|
||||
/// `Trace::color`, the newest acquisition is deposited into it like every other -- so it fades, and
|
||||
/// no solid polyline is drawn over the ink -- and it costs an upload on the frame a grid moved and
|
||||
/// nothing on the frame it did not.
|
||||
///
|
||||
/// Three things are read off the *screen*, because neither the grid's counts nor a tint alone can
|
||||
/// make them: that a saturated cell is the trace's own colour and not a monochrome wash; that a
|
||||
/// cell a quarter of the way up the scale is dimmer *and the same hue*; and that the acquisition
|
||||
/// just deposited fades -- which is only true if it is *inside* the image and not a stroke drawn
|
||||
/// on top of its own faint ghost (the "persistent image + last acquisition" report).
|
||||
PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
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});
|
||||
const plot::Color trace_color = plot::Color::hex(0x00E070);
|
||||
PLOT_CHECK(p.add_trace({&s, trace_color, 1.5f, ax, ay, "flat"}) == 0u);
|
||||
p.set_x_range(ax, {0.0, 1.0});
|
||||
p.set_y_range(ay, {-1.0, 1.0});
|
||||
|
||||
// The baseline: no density at all, and no grid to draw one from.
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
PLOT_CHECK(r.stats().density_cells == 0u);
|
||||
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
|
||||
const std::vector<plot::u8> without = read_frame(ctx);
|
||||
|
||||
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
||||
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
|
||||
};
|
||||
const plot::Transform tx = p.x_transform(ax);
|
||||
const plot::Transform ty = p.y_transform(ay);
|
||||
const plot::u32 mid_x = static_cast<plot::u32>(tx.to_screen_x(0.5));
|
||||
const plot::u32 trace_row = static_cast<plot::u32>(ty.to_screen_y(0.0));
|
||||
// A row well away from the sweep, and away from the trace in the no-density frame: the
|
||||
// background to read every cell against, rather than a guessed one.
|
||||
const plot::u32 blank_row = trace_row + 40u < kH ? trace_row + 40u : trace_row - 40u;
|
||||
|
||||
// **The newest acquisition is in the image, and it fades.** A time fade from the first deposit:
|
||||
// one time constant must dim the same pixel, which a stroke drawn over the density could not do.
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
cfg.decay = plot::DensityDecay::TimeConstant;
|
||||
cfg.time_constant_s = 1.0f; // the ceiling stays the library's one sweep -- the trace's own
|
||||
p.set_density(cfg);
|
||||
p.accumulate_density(); // the acquisition on screen, deposited like any other
|
||||
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
dump_if_requested(r, "density.ppm");
|
||||
const plot::FrameStats first = r.stats();
|
||||
const std::vector<plot::u8> fresh = read_frame(ctx);
|
||||
|
||||
// The image is drawn -- it changed pixels -- and every pixel it changed is inside the axes:
|
||||
// a density reserves no layout space and paints nothing over a label strip.
|
||||
const plot::Rect axes = p.layout().axes;
|
||||
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0u, hi_y = 0u, differing = 0u;
|
||||
for (plot::u32 y = 0u; y < kH; ++y) {
|
||||
for (plot::u32 x = 0u; x < kW; ++x) {
|
||||
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
||||
if (fresh[at] == without[at] && fresh[at + 1u] == without[at + 1u] &&
|
||||
fresh[at + 2u] == without[at + 2u]) {
|
||||
continue;
|
||||
}
|
||||
++differing;
|
||||
lo_x = std::min(lo_x, x);
|
||||
lo_y = std::min(lo_y, y);
|
||||
hi_x = std::max(hi_x, x);
|
||||
hi_y = std::max(hi_y, y);
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(differing > 0u);
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
|
||||
|
||||
// One trace, so one grid and one upload of one byte a cell -- and the *same* grid the plot
|
||||
// reports, so the picture and the numbers are one derivation.
|
||||
const plot::DensityGrid& grid = p.density_grid(0u);
|
||||
PLOT_CHECK(first.density_cells == static_cast<plot::u32>(grid.cell_count()));
|
||||
PLOT_CHECK(first.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
|
||||
|
||||
// **The colour**: the cell the sweep is in is the trace's own colour at full strength -- one
|
||||
// sweep is the ceiling, so a saturated cell *is* the trace -- and not the near-white monochrome
|
||||
// the density used to draw in. The trace has no red and plenty of green and blue; a monochrome
|
||||
// wash at full ink had all three high and near equal.
|
||||
const int bg_g = channel(fresh, mid_x, blank_row, 1);
|
||||
const int cell_r = channel(fresh, mid_x, trace_row, 0);
|
||||
const int cell_g = channel(fresh, mid_x, trace_row, 1);
|
||||
const int cell_b = channel(fresh, mid_x, trace_row, 2);
|
||||
PLOT_CHECK(cell_r < 40); // the trace's colour has no red in it
|
||||
PLOT_CHECK(cell_g > 150); // ... and its green is the ink
|
||||
PLOT_CHECK(cell_g > cell_b && cell_b > cell_r); // the hue is the trace's, not a neutral
|
||||
PLOT_CHECK(cell_g > bg_g + 40);
|
||||
// A saturated cell **is** the trace's colour: one sweep at the ceiling draws the trace's own
|
||||
// rgba, so the pixel matches it exactly rather than merely resembling it.
|
||||
const plot::u8* cell_px = fresh.data() + (plot::usize(trace_row) * kW + mid_x) * 4u;
|
||||
PLOT_CHECK(pixel_is(cell_px, trace_color));
|
||||
|
||||
// One time constant of the same pixel: dimmer, and still the trace's hue. This is the assertion
|
||||
// that the current acquisition is inside the density -- the fade `exp(-dt/tau)`.
|
||||
const double before = grid.ink();
|
||||
p.decay_density(1.0); // dt = tau, so the whole image is e^-1 of what it was
|
||||
const double after = grid.ink();
|
||||
PLOT_CHECK_NEAR(after / before, std::exp(-1.0), 1e-4);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> faded = read_frame(ctx);
|
||||
const int faded_g = channel(faded, mid_x, trace_row, 1);
|
||||
const int faded_r = channel(faded, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(faded_g < cell_g - 20); // the newest acquisition faded
|
||||
PLOT_CHECK(faded_r < 40); // and it faded *as the trace's colour*, not toward a neutral
|
||||
PLOT_CHECK(faded_g > bg_g); // ... to more than the background: still on screen
|
||||
|
||||
// **No solid trace over the image.** Move the series to y = +0.5 and draw *without* depositing:
|
||||
// the plot draws no polyline while the density is on, so the +0.5 row is background and the only
|
||||
// ink on screen is the sweep already in the image. A trace drawn over its own ghost -- the
|
||||
// reported "persistent image + last acquisition" -- would put its colour at +0.5 here.
|
||||
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> moved = read_frame(ctx);
|
||||
const plot::u32 moved_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
|
||||
PLOT_CHECK(moved_row != trace_row);
|
||||
// At +0.5 there is only the grid: the frame with the density is *identical* to the frame without
|
||||
// one there, so nothing was drawn at the moved trace's own row -- no stroke over the image. (The
|
||||
// row the sweep is in still holds its ink, so the image itself did not go away.)
|
||||
PLOT_CHECK(channel(moved, mid_x, moved_row, 1) == channel(without, mid_x, moved_row, 1));
|
||||
PLOT_CHECK(channel(moved, mid_x, moved_row, 0) == channel(without, mid_x, moved_row, 0));
|
||||
PLOT_CHECK(channel(moved, mid_x, trace_row, 1) > bg_g + 20); // the image is still there
|
||||
s.reset(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
||||
|
||||
// **The steady state and a quarter of it, in the signal's colour.** Many acquisitions of a
|
||||
// per-acquisition fade settle at `1/(1-f)` sweeps, which the one-sweep ceiling clamps to the
|
||||
// trace's colour; a decay to a quarter of that ink is dimmer and keeps the hue.
|
||||
plot::DensityConfig steady_cfg = cfg;
|
||||
steady_cfg.decay = plot::DensityDecay::PerAcquisition;
|
||||
steady_cfg.per_acquisition = 0.5f;
|
||||
p.set_density(steady_cfg);
|
||||
for (int i = 0; i < 24; ++i) p.accumulate_density();
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> steady = read_frame(ctx);
|
||||
const int steady_g = channel(steady, mid_x, trace_row, 1);
|
||||
const int steady_r = channel(steady, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(steady_g > bg_g + 60);
|
||||
PLOT_CHECK(steady_r < 40);
|
||||
// A quarter of the ink: `dt = tau*ln(4)` is one step of the time fade to a quarter.
|
||||
plot::DensityConfig quarter_cfg = steady_cfg;
|
||||
quarter_cfg.decay = plot::DensityDecay::TimeConstant;
|
||||
quarter_cfg.time_constant_s = 1.0f;
|
||||
p.set_density(quarter_cfg);
|
||||
const double steady_ink = grid.ink();
|
||||
p.decay_density(std::log(4.0));
|
||||
PLOT_CHECK_NEAR(grid.ink() / steady_ink, 0.25, 1e-6);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> quarter = read_frame(ctx);
|
||||
const int quarter_g = channel(quarter, mid_x, trace_row, 1);
|
||||
const int quarter_r = channel(quarter, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(steady_g > quarter_g + 20);
|
||||
PLOT_CHECK(quarter_g > bg_g + 10);
|
||||
PLOT_CHECK(quarter_r < 40); // still the signal's colour, not a neutral wash
|
||||
|
||||
// A frame with nothing new to deposit: the grid did not move, so the conversion and the upload
|
||||
// are skipped -- the frame costs the one draw call -- and the picture is bit-identical.
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
PLOT_CHECK(r.stats().density_cells > 0u);
|
||||
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
|
||||
const std::vector<plot::u8> again = read_frame(ctx);
|
||||
PLOT_CHECK(again == quarter);
|
||||
|
||||
std::printf("[density gl] cells=%u uploaded=%u differing=%u trace row=%u blank row=%u"
|
||||
" axes=%.0f,%.0f %.0fx%.0f\n",
|
||||
first.density_cells, first.density_upload_bytes, differing, trace_row, blank_row,
|
||||
axes.x, axes.y, axes.w, axes.h);
|
||||
std::printf("[density gl] green: bg=%d full=%d faded=%d steady=%d quarter=%d"
|
||||
" (fresh ink %.0f -> %.0f)\n",
|
||||
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A grid **past `kDensityCellCap`** is the axes rect scaled down, and the frame magnifies the image
|
||||
/// back over that rect: this is the screen-side half of the bound --
|
||||
/// `a_density_grid_is_capped_whatever_the_window` reads the counts of a capped grid, and the two
|
||||
/// agree only if the deposit and the draw use the same footprint, the axes rect in *whole* device
|
||||
/// pixels.
|
||||
///
|
||||
/// What is read here is what a wrong scale moves: the image covers the rect's own columns rather
|
||||
/// than the smaller grid's (the assertion a quad drawn at the grid's size fails), it stays inside
|
||||
/// the rect, and the sweep is on the row its own transform maps y = 0 to, in the trace's colour.
|
||||
PLOT_TEST(a_capped_density_is_magnified_over_the_axes_rect) {
|
||||
const plot::u32 w = 900, h = 720;
|
||||
plottest::HeadlessContext ctx(w, h);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
|
||||
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});
|
||||
const plot::Color colour = plot::Color::hex(0x00E070);
|
||||
PLOT_CHECK(p.add_trace({&s, colour, 1.5f, ax, ay, "flat"}) == 0u);
|
||||
p.set_x_range(ax, {0.0, 1.0});
|
||||
p.set_y_range(ay, {-1.0, 1.0});
|
||||
|
||||
const plot::Rect frame{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
|
||||
r.begin_frame(w, h, frame);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> without = read_frame(ctx);
|
||||
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
p.set_density(cfg);
|
||||
p.accumulate_density();
|
||||
const plot::DensityGrid& grid = p.density_grid(0u);
|
||||
const plot::Rect axes = p.layout().axes;
|
||||
// The rect is past the cap, so the grid is smaller than it and the frame has to scale the image
|
||||
// up -- and the one flat sweep still lights every column, once.
|
||||
PLOT_CHECK(grid.cell_count() > 0u);
|
||||
PLOT_CHECK(grid.cell_count() <= plot::kDensityCellCap);
|
||||
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) < static_cast<plot::u64>(std::ceil(axes.w)));
|
||||
PLOT_CHECK(static_cast<plot::u64>(grid.rows()) < static_cast<plot::u64>(std::ceil(axes.h)));
|
||||
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
|
||||
|
||||
r.begin_frame(w, h, frame);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const plot::FrameStats stats = r.stats();
|
||||
PLOT_CHECK(stats.density_cells == static_cast<plot::u32>(grid.cell_count()));
|
||||
PLOT_CHECK(stats.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
|
||||
const std::vector<plot::u8> capped = read_frame(ctx);
|
||||
|
||||
const auto differs = [&](plot::u32 x, plot::u32 y) {
|
||||
const plot::usize k = (plot::usize(y) * w + x) * 4u;
|
||||
return capped[k] != without[k] || capped[k + 1u] != without[k + 1u] ||
|
||||
capped[k + 2u] != without[k + 2u];
|
||||
};
|
||||
const auto at = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
||||
return static_cast<int>(f[(plot::usize(y) * w + x) * 4u + static_cast<plot::usize>(c)]);
|
||||
};
|
||||
|
||||
// **The image covers the rect's columns, not the grid's.** A quad drawn at the grid's own size --
|
||||
// the temptation once a cell is no longer a pixel -- would paint the left `columns` of the rect
|
||||
// and leave the rest of it blank, which this counts.
|
||||
plot::u32 changed_cols = 0;
|
||||
plot::u32 lo_x = w, hi_x = 0, lo_y = h, hi_y = 0;
|
||||
for (plot::u32 x = 0; x < w; ++x) {
|
||||
bool any = false;
|
||||
for (plot::u32 y = 0; y < h; ++y) {
|
||||
if (!differs(x, y)) continue;
|
||||
any = true;
|
||||
lo_x = std::min(lo_x, x);
|
||||
hi_x = std::max(hi_x, x);
|
||||
lo_y = std::min(lo_y, y);
|
||||
hi_y = std::max(hi_y, y);
|
||||
}
|
||||
if (any) ++changed_cols;
|
||||
}
|
||||
PLOT_CHECK(changed_cols > 0u);
|
||||
PLOT_CHECK(changed_cols + 2u >= static_cast<plot::u32>(axes.w));
|
||||
// ... and it stays inside the rect it is an image of.
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
|
||||
|
||||
// The sweep is on the row its own transform maps y = 0 to, in the trace's own colour: the cell's
|
||||
// one sweep is spread over the block of device pixels it was magnified into, so what is asserted
|
||||
// is that the ink is *there* -- the brightest row of the rect is that row -- and that its hue is
|
||||
// the trace's rather than the background's.
|
||||
const plot::u32 mid_x = static_cast<plot::u32>(p.x_transform(ax).to_screen_x(0.5));
|
||||
const plot::u32 trace_row = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.0));
|
||||
// The background to read the ink against is a row of the *without* frame away from the sweep's
|
||||
// own: the trace itself is drawn there (the density is off in that frame), so the trace's own row
|
||||
// is the trace's colour and not a background at all.
|
||||
const plot::u32 blank_row = trace_row + 40u < h ? trace_row + 40u : trace_row - 40u;
|
||||
const int bg_g = at(without, mid_x, blank_row, 1);
|
||||
int band_g = 0;
|
||||
int band_y = 0;
|
||||
for (plot::u32 y = static_cast<plot::u32>(axes.y); y < static_cast<plot::u32>(axes.bottom()); ++y) {
|
||||
const int g = at(capped, mid_x, y, 1);
|
||||
if (g > band_g) {
|
||||
band_g = g;
|
||||
band_y = static_cast<int>(y);
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(band_g > bg_g + 40); // the sweep is on screen at all
|
||||
PLOT_CHECK(at(capped, mid_x, trace_row, 0) < 40); // ... in the trace's own hue ...
|
||||
PLOT_CHECK(at(capped, mid_x, trace_row, 1) > bg_g + 40);
|
||||
PLOT_CHECK(std::abs(band_y - static_cast<int>(trace_row)) <= 2);
|
||||
|
||||
std::printf("[density gl] capped grid=%ux%u cells=%zu of a %.0fx%.0f axes: changed cols=%u"
|
||||
" brightest row=%d (transform says %u), green=%d against bg=%d\n",
|
||||
grid.columns(), grid.rows(), grid.cell_count(), axes.w, axes.h, changed_cols, band_y,
|
||||
trace_row, band_g, bg_g);
|
||||
ctx.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user